Compare commits
1637 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 62b200c5d9 | |||
| c2ed772f34 | |||
| bbce6d4ad0 | |||
| 45f6a0ec02 | |||
| 6b681278e0 | |||
| 2281ff06c7 | |||
| 572a81fd4e | |||
| 5fd7df69fd | |||
| 3401c6305e | |||
| 269f9ac52c | |||
| 4712171d43 | |||
| 632c7b91f4 | |||
| f72dd79dff | |||
| 4d109c0481 | |||
| c046b77223 | |||
| 56ba3b5e5f | |||
| fa99247cb7 | |||
| 653fd37c08 | |||
| 9d053beafb | |||
| f33c451a19 | |||
| 9543148887 | |||
| 029ee4ed2f | |||
| 688e826e9d | |||
| deea0c54d4 | |||
| 028f62aa9c | |||
| d3aef9c1d1 | |||
| 72edce511a | |||
| 37fade8f7a | |||
| 0d2aa6738c | |||
| e3e0e5cba8 | |||
| e40de189c3 | |||
| c9c99f7b2a | |||
| e6400918c8 | |||
| d08557ad0e | |||
| ba84dce7a7 | |||
| 7044725bf1 | |||
| 1812966fe6 | |||
| a9bac25c9b | |||
| 5c967f11f0 | |||
| 1b5f080495 | |||
| 50a27fa3f6 | |||
| f8ed53c1b9 | |||
| 2163830a54 | |||
| cae5e3b99f | |||
| cc0a7941ea | |||
| 18901c0e2d | |||
| d4ea239185 | |||
| 813c6aab13 | |||
| f606e131a7 | |||
| ccefa61b3d | |||
| 606cae411f | |||
| 901e4012cc | |||
| d03b667194 | |||
| d30954167e | |||
| 0ee514ea15 | |||
| 7e60792be8 | |||
| 244a325394 | |||
| 53df16a7ca | |||
| 420576da09 | |||
| d5a9d8ffdb | |||
| 1873ad1a02 | |||
| 9dec238f41 | |||
| b93a018dc1 | |||
| 6b1d5bf7db | |||
| 4580866281 | |||
| 5b743772ac | |||
| 0ecf08e8e6 | |||
| 7ca53d30b2 | |||
| 18a0ba1981 | |||
| 11a35ed589 | |||
| eb5aa12d7f | |||
| 1065b67073 | |||
| 0d7b278003 | |||
| 05093a9d49 | |||
| 87f9b2b72c | |||
| 2d4833d199 | |||
| 610e08e690 | |||
| 1192224c15 | |||
| 4c3a9928e7 | |||
| 433a4359e6 | |||
| f1854b5120 | |||
| 6961b0c2f5 | |||
| 8b26f30e37 | |||
| 071724949f | |||
| 28898aa1db | |||
| 11fae19e33 | |||
| 13b9dd0262 | |||
| b47520c938 | |||
| f6869b9e1c | |||
| 96046a5d1f | |||
| 524d0b278b | |||
| 6e2348eb06 | |||
| e4b57e6ca3 | |||
| 9640e558cd | |||
| 07b13d1374 | |||
| 7e4389abd9 | |||
| 0f424e7f0d | |||
| 455e5735ff | |||
| 6577d2ae3c | |||
| 56ed543dfb | |||
| 5549e3a398 | |||
| 2ff7c111af | |||
| a7af072ca7 | |||
| a4a455f31e | |||
| 99d55b4314 | |||
| 811e2155a6 | |||
| ab7a49351d | |||
| efdfba0575 | |||
| af9b5f6ca4 | |||
| 65144b9a3d | |||
| 621f57d702 | |||
| a0444fbeee | |||
| cff81eea14 | |||
| 5738c90721 | |||
| a229231c0c | |||
| 6bf5bd97b5 | |||
| 35c50a7c60 | |||
| 042e6584eb | |||
| bcce1b7ea8 | |||
| 73181f9e33 | |||
| b0a7b6c7cd | |||
| 09744818dc | |||
| f93b3109b9 | |||
| 48d4836f0a | |||
| 5d4f70e943 | |||
| 9e05197a9a | |||
| 11671e884d | |||
| dcce818678 | |||
| f6c23bc9a0 | |||
| 15eca895fb | |||
| d5d5dd7855 | |||
| c79f5fd8a5 | |||
| 409e40f3b7 | |||
| 67a83cb164 | |||
| 908bdc7c86 | |||
| b1c2bd3d64 | |||
| ffd317aff0 | |||
| 84a10afea1 | |||
| 32036ef64d | |||
| a8b8036311 | |||
| b813716f7c | |||
| 7bd6061a59 | |||
| 7682a6e708 | |||
| fe9c592107 | |||
| 9ff24dc446 | |||
| 3036711fb4 | |||
| 53363f293b | |||
| e9791984ee | |||
| ddca96a60e | |||
| be3607dd4d | |||
| 6000a7a60f | |||
| cc64c9f9d8 | |||
| 5c699d956c | |||
| e1757e5ac5 | |||
| 31167234be | |||
| 807638ca04 | |||
| ff6b78252c | |||
| 93bdcaab7f | |||
| d06c580bbc | |||
| a5b32b356c | |||
| d117e666fd | |||
| 09da94b2ab | |||
| 9ebf5919a2 | |||
| 9c46452a4d | |||
| b34536491b | |||
| a7c1e240c1 | |||
| 44618d3d73 | |||
| 1f92af64f0 | |||
| 5bcd081e88 | |||
| b141622e75 | |||
| f96bdee71d | |||
| 0af70d3298 | |||
| e044c59627 | |||
| cce1c902e5 | |||
| d845474644 | |||
| cd67aba2ad | |||
| 1e47162357 | |||
| 230400846f | |||
| ebb29ad04b | |||
| 2cd603357d | |||
| bee26838e1 | |||
| 5b0572879d | |||
| 6e86275dce | |||
| 03a007b9b6 | |||
| dadb215ce0 | |||
| a1e3e12c6b | |||
| 4274c817d3 | |||
| 5abedc15dc | |||
| 947c9639e8 | |||
| c4cdf4a834 | |||
| 44b9bfee68 | |||
| ac9d43892b | |||
| 9f62f8eff9 | |||
| 13dd400795 | |||
| 37343a4114 | |||
| 3df6a4048a | |||
| c9fb87b571 | |||
| 44ca1d507d | |||
| 30b236548a | |||
| 21b7d1c3fb | |||
| 8d5ea66ecc | |||
| b5ed10689d | |||
| a55cdfd7fa | |||
| 39a4c10ac9 | |||
| c542cd4d7d | |||
| 01de338a65 | |||
| f23f7b1983 | |||
| a349ab62ec | |||
| e620010f10 | |||
| 70509355de | |||
| f25654ead7 | |||
| f1741d4dac | |||
| f3245d092b | |||
| a039a8600e | |||
| ee56f4a7a2 | |||
| c40b6ca7f4 | |||
| 2c0e1e498b | |||
| 0262ea31eb | |||
| 4b671d7fb0 | |||
| d8f9419eb9 | |||
| aa65bab486 | |||
| 849c3967fd | |||
| 83562cf7d8 | |||
| 2631a44410 | |||
| ddfea43b79 | |||
| 68f19ffa5f | |||
| 8800d42c32 | |||
| 416a8a7cb2 | |||
| 7088f249a5 | |||
| a2301cc980 | |||
| af545404e8 | |||
| 2e96f19476 | |||
| e3f26b7f75 | |||
| f45c98a6a7 | |||
| a565a3c909 | |||
| b5e3dd6c06 | |||
| 928f592d9c | |||
| 8ee8edcd36 | |||
| 1e128348e5 | |||
| 6ef5655b7d | |||
| f6ba5329ce | |||
| 93cef0d580 | |||
| 797b088cc8 | |||
| 6d23d3510f | |||
| f2d7d0af43 | |||
| e55c0461db | |||
| 8eca511a53 | |||
| f20e46dee0 | |||
| b79f22f4fe | |||
| 3287dc77e2 | |||
| 78a08b35e7 | |||
| e86196999a | |||
| 4d50339041 | |||
| edc5a2c0f2 | |||
| 598b88b1f0 | |||
| c22b9f8ff5 | |||
| 3c654ab495 | |||
| 2f0fabea7a | |||
| 099b14efc3 | |||
| ee42dee366 | |||
| deae081cb3 | |||
| 6479f14d3d | |||
| 60707c3868 | |||
| e78d8e1ae6 | |||
| fee0d0aed9 | |||
| 178abc77ce | |||
| 7001f97d96 | |||
| 55432e61ff | |||
| 4ee993ef3b | |||
| 64d471bb9b | |||
| 4dfcdcb0b2 | |||
| 8e69a84e7a | |||
| 8ec643d882 | |||
| f4d6192c80 | |||
| c0aa2b85fc | |||
| 61a376fb6d | |||
| 8786cb5180 | |||
| c305ef1360 | |||
| f662ce0b7a | |||
| 71af9345a5 | |||
| a819a19c77 | |||
| c65fad06b7 | |||
| 9e6e1931b1 | |||
| 3b22273f5a | |||
| fc5ff1782b | |||
| 4d3b3d984d | |||
| 514976561f | |||
| fb4998d21b | |||
| 0feec978d3 | |||
| 5f1c39aba5 | |||
| 17973619de | |||
| 478d7a2d2d | |||
| f2af0be1e1 | |||
| d0725f5098 | |||
| 646d614d94 | |||
| 4c337ef5e9 | |||
| 2b633b8566 | |||
| 6b16454217 | |||
| b7086deeac | |||
| f021afb6a4 | |||
| 99622bd3d6 | |||
| 50a76519ea | |||
| 1e806054ab | |||
| 89d7f335fc | |||
| 7a664ec4ec | |||
| a8a4d029f8 | |||
| 501b5dce76 | |||
| e9b3504370 | |||
| 7b20c78e73 | |||
| a343ce69aa | |||
| d52ce400fb | |||
| 0ee574eaaa | |||
| 74c4392b6d | |||
| d844c330e9 | |||
| b50cb78fa6 | |||
| 26c138f42c | |||
| 0ec7e65926 | |||
| 3588cc4c03 | |||
| da8c7749c8 | |||
| 79fe999e77 | |||
| 439c65ad6d | |||
| 81b3aa5ac1 | |||
| c4beb9ae4d | |||
| 9d286d8378 | |||
| 19e7a43fe3 | |||
| ab59e2deac | |||
| 043f22e6ec | |||
| 4abb6af31e | |||
| a17ba4a81f | |||
| bc8a6847e3 | |||
| 18f97f9df2 | |||
| 4a204d8d89 | |||
| 062c6c2364 | |||
| 477716ef67 | |||
| ef973df7c9 | |||
| c40bb6a4d5 | |||
| 20e942dccd | |||
| 598cbc4d11 | |||
| 70a3d5af07 | |||
| 9cabb1afbd | |||
| 4267224f59 | |||
| 02d910f53c | |||
| e81b7b5b9f | |||
| 17e0a8eec1 | |||
| eb322c9d41 | |||
| 751af92c21 | |||
| fb4962a41a | |||
| daf6598599 | |||
| e1e4f71f3a | |||
| a2fa7ec9c4 | |||
| 5cd37b74b4 | |||
| beed7e83f2 | |||
| a7726edca6 | |||
| eed0c21c41 | |||
| 094a43157e | |||
| 4033958bf5 | |||
| f18784ecc1 | |||
| 418494b7f3 | |||
| 343753ee2a | |||
| 1ceda066c4 | |||
| 3c13dea55f | |||
| 6ced2cbf7c | |||
| b41f16a736 | |||
| 42fa02a887 | |||
| 2ce87cdac0 | |||
| e4864d3871 | |||
| b6e0052013 | |||
| 9ef83a59c7 | |||
| 325724ff85 | |||
| 998bfa0656 | |||
| e476df5e7d | |||
| 83a3601cdb | |||
| 996dcc4b23 | |||
| 04304f8283 | |||
| 3418f73390 | |||
| b07b6c8960 | |||
| 4f2cf37d73 | |||
| 408e017f2f | |||
| 62b42266e8 | |||
| 80e9e23965 | |||
| c73154aeb1 | |||
| 66c4786ec2 | |||
| 143f5a2085 | |||
| 699c7df798 | |||
| d3de7b95aa | |||
| 2dc6f76da9 | |||
| 63ccf6b553 | |||
| f49ffe3cb0 | |||
| c1b578350d | |||
| 48e4af41ae | |||
| 5e915f9c40 | |||
| 70b5f91f82 | |||
| 792df08c78 | |||
| 032b5d3580 | |||
| 8f93e43bb3 | |||
| 04f95e648b | |||
| 511b8eb407 | |||
| a6e6dd255d | |||
| c3c53d4056 | |||
| 2f700d9a4c | |||
| e4da9f5afe | |||
| 3f919813f2 | |||
| 4063ffc163 | |||
| 0d08b89853 | |||
| 6c9da364d0 | |||
| c1614e8241 | |||
| 75a458f2be | |||
| 602291736f | |||
| 9f2d15e590 | |||
| 5e88201d47 | |||
| 598b8bd1cd | |||
| 9186a44860 | |||
| 61e3dae708 | |||
| 94d46299d0 | |||
| 7070c05f2f | |||
| 0b7038cc65 | |||
| 4882d04ece | |||
| 4b2d34491e | |||
| 79d8230821 | |||
| c7e3305a76 | |||
| 81feccf0d2 | |||
| 9666bee006 | |||
| 44d54057d0 | |||
| beb4251688 | |||
| 598395cd38 | |||
| 0a6913f5d0 | |||
| fa36458303 | |||
| 3f96f88027 | |||
| 131ab714ba | |||
| 333d0c933a | |||
| 2a5c0e05cc | |||
| 4bda9da860 | |||
| 4c579cf862 | |||
| 1b74ce7ac0 | |||
| 29e3625c7b | |||
| a41b9381a1 | |||
| b4980a968c | |||
| ea91751217 | |||
| 1ceffc3391 | |||
| 3de31427a3 | |||
| 4abb9c2ea6 | |||
| e7bfbe77c2 | |||
| 776282ed6b | |||
| d1621684df | |||
| ba183e71e1 | |||
| aac34d4fad | |||
| 8e28e4ecbf | |||
| 48665aa1ad | |||
| f34968bcf5 | |||
| 4a5c1ed582 | |||
| 6d87ab08e2 | |||
| 5ae18bf4f9 | |||
| 1bac12259d | |||
| 434dc408c3 | |||
| 6601ee3b12 | |||
| fde1731365 | |||
| 7725952776 | |||
| e18ee08b70 | |||
| 5aaaeb426c | |||
| 1f55a0cbd8 | |||
| 4ad026b398 | |||
| d36825da52 | |||
| bf2715c2be | |||
| 80953a0148 | |||
| bb9a08d00d | |||
| 3e8fa7cba7 | |||
| da8b88b6b2 | |||
| 4bd21a1ccb | |||
| 29f7586b93 | |||
| 39b6b725f5 | |||
| 631c68029a | |||
| 93aefc127f | |||
| 19ac88b560 | |||
| 99e775a283 | |||
| 7fc05b96a2 | |||
| 9b811da43d | |||
| 4199b609b4 | |||
| 958e6d8519 | |||
| 9dea22ab05 | |||
| 3dc7c6b36f | |||
| 44e76f36b4 | |||
| 010333a190 | |||
| ba833a265a | |||
| e0bf156272 | |||
| a654e21b27 | |||
| de6f149e3b | |||
| 29893b89b3 | |||
| 7783e9ed20 | |||
| d93d1ed48a | |||
| 32c461e93b | |||
| d88e6153c1 | |||
| 7b980ae4d4 | |||
| b249d37bab | |||
| fa34e081cc | |||
| 9f795d7256 | |||
| c8d7d6be43 | |||
| c31124eb14 | |||
| e999b7a8f8 | |||
| 49353a5ec5 | |||
| 229fbdd306 | |||
| 4562dd08dc | |||
| f24f4ea8f9 | |||
| 6338d38ab6 | |||
| 527d93c6b4 | |||
| 3f5f8d9f57 | |||
| 245c913ba1 | |||
| 7d3ef52f03 | |||
| 6cd7556bc5 | |||
| 123f0594a3 | |||
| 652cebc7d0 | |||
| a4cb9a8923 | |||
| eb954fb10d | |||
| 845eab6f53 | |||
| 4fe20db497 | |||
| c40d503f6e | |||
| 1ea843bde4 | |||
| 9ed5d70250 | |||
| f6209b97e2 | |||
| 5221ad6da7 | |||
| cd0bded428 | |||
| ff5fddf353 | |||
| 28b29ed086 | |||
| 765b2b1d69 | |||
| 599a434faa | |||
| a2abee986d | |||
| d57c0712b0 | |||
| 4166d78e87 | |||
| 5322291402 | |||
| dc2ffd758d | |||
| b73cdb2e7d | |||
| 3a83e5d519 | |||
| 3c0cdcadc0 | |||
| 0a23ef8b5d | |||
| c8a38ac709 | |||
| 31d15fadbe | |||
| 3fc481e302 | |||
| 803605c318 | |||
| 1d138e3b4b | |||
| 5b6c5326b6 | |||
| 60a1c303da | |||
| b9f32da7b8 | |||
| fde0b1d8bf | |||
| cf07004fcd | |||
| b41b52df84 | |||
| 9632dd170a | |||
| 9dc334dea9 | |||
| 0741079450 | |||
| 562df0f48f | |||
| 3b87c078f4 | |||
| fc091665ff | |||
| 3f2842a9a3 | |||
| f2418c81d7 | |||
| 3c85797cc9 | |||
| 9187bf0b83 | |||
| 5a2381d9dd | |||
| 5fb8bb0dac | |||
| 0d55da18d4 | |||
| 73148d65bb | |||
| e81438e49f | |||
| e247d8095e | |||
| 46ddb36c79 | |||
| a87fee906f | |||
| 888d94131e | |||
| 63dd018756 | |||
| db38571646 | |||
| 8111d96a20 | |||
| a1b5b7c03c | |||
| 91e95b1ef2 | |||
| 1491f35f5e | |||
| 9bb127dda7 | |||
| c4348b0cb2 | |||
| a3fc0c7f96 | |||
| c7387068cc | |||
| 658ce390e2 | |||
| f0b6f66be6 | |||
| 304812e14f | |||
| 92d8a05393 | |||
| d96d98b8f4 | |||
| 0d059187ec | |||
| 1b73b0b861 | |||
| 29f8d6b981 | |||
| 7826de9d29 | |||
| 78c56e4f28 | |||
| 3ef7736e85 | |||
| 73e6194551 | |||
| 7ceed3dfbc | |||
| 7a0c2dc261 | |||
| 5807c4d97f | |||
| a689607e98 | |||
| b6b3e27408 | |||
| ac30bd6e51 | |||
| 174fc4f72b | |||
| 047ec982f4 | |||
| e427f37f0e | |||
| 810ac1fcfa | |||
| 5ee3cc6712 | |||
| 5ad3d5697e | |||
| 874ab093d5 | |||
| fb668859b0 | |||
| be7a2d7f41 | |||
| 154b6b9f74 | |||
| 23c91386dc | |||
| 741b6ce0d9 | |||
| 600c2f6061 | |||
| 359de2dbe0 | |||
| 84eec4655a | |||
| 7e8c69a02d | |||
| 730d47f2f7 | |||
| 5afb74e606 | |||
| 8a21547668 | |||
| 3ee3044270 | |||
| 782dc24eba | |||
| 475b96178e | |||
| 4beba53675 | |||
| efb7cad993 | |||
| 7b7705866d | |||
| b7e06d51ea | |||
| 95476276ac | |||
| 2347e10458 | |||
| 85051f1340 | |||
| 42c6e70ebe | |||
| b9fe83e7a8 | |||
| 841108623f | |||
| 8ce221e41b | |||
| f8c41ab39f | |||
| 79e7fd175e | |||
| fbcf755591 | |||
| 6168a47e24 | |||
| d788114be3 | |||
| 497814f80c | |||
| 7297edf16f | |||
| 714407eb46 | |||
| dd3523ddd7 | |||
| 7739de5db9 | |||
| 99c08026ee | |||
| 19f7ea70f0 | |||
| 49050c042d | |||
| 18ccff5759 | |||
| 9f6f646e77 | |||
| b8c0d8ef79 | |||
| 2ccd41bfb9 | |||
| fa64b51d4a | |||
| f5ac194008 | |||
| 816cf0141b | |||
| 7baabc6d2c | |||
| 37d1c7338b | |||
| 404ea9d838 | |||
| 98c5c5827c | |||
| 79525284b1 | |||
| c14ea7afdf | |||
| c437753d64 | |||
| 441cc35e5a | |||
| dc03144773 | |||
| 992921b24c | |||
| 28f38dca46 | |||
| 53155ccef0 | |||
| ba6f0a1aab | |||
| 2d89d06bcb | |||
| 54ff50ce68 | |||
| 22aa8cdd6c | |||
| 06b0195d74 | |||
| a99b4ded7f | |||
| 2405a0e778 | |||
| 84544b1e84 | |||
| 95fce39502 | |||
| 99c5b26241 | |||
| 6e07e49c84 | |||
| 0bcfea9d20 | |||
| 2658331fd2 | |||
| 2ab49cc545 | |||
| a39fe5ff3b | |||
| 01578b4e34 | |||
| 95718c889d | |||
| 6279cc9ec1 | |||
| f7fb9034ef | |||
| 15f3af2020 | |||
| 97288ed6ce | |||
| 5e168c2561 | |||
| 358b3f96ae | |||
| c0d9c3808a | |||
| 7404bb8e64 | |||
| 93eccd7dcf | |||
| 05d9d41860 | |||
| c47c41548f | |||
| 013d1980a3 | |||
| df9f4a23b4 | |||
| c41da47a48 | |||
| e7214ad8df | |||
| d6671de842 | |||
| aad218db5d | |||
| 724ba1e271 | |||
| 97d554f638 | |||
| c5a7655d26 | |||
| 403e896e3e | |||
| 1a15f43cad | |||
| 399b460c53 | |||
| acc0362180 | |||
| 00db93e03f | |||
| d1997794c8 | |||
| aa1ebe69f2 | |||
| 4e7f5f56f1 | |||
| 28cb7359ce | |||
| 91c272d21c | |||
| 3c00125e83 | |||
| f359848a2f | |||
| 989769e5e8 | |||
| 0f2f1b6211 | |||
| ffe8f4acc6 | |||
| edb09777de | |||
| 5262c7863e | |||
| 54256826fe | |||
| 3d3c224b3a | |||
| 049eccb872 | |||
| 269828c79e | |||
| b4e25ae66d | |||
| b20dd74d23 | |||
| bc3e2ec358 | |||
| 6133a6d6d8 | |||
| 46a16c04e6 | |||
| 8469b3b26f | |||
| 2ed04f57fe | |||
| b19bac679a | |||
| 3c33d5982c | |||
| 5b934eeb87 | |||
| 795d96f8d5 | |||
| a8e7119b4a | |||
| 38569ff7fc | |||
| e404557d62 | |||
| 96cbc75a5e | |||
| c989af6cf0 | |||
| 4eac9d03ea | |||
| 6292009b0b | |||
| 3272be967d | |||
| 1c015da440 | |||
| 0d047cc956 | |||
| e682070b85 | |||
| 9f08694d9b | |||
| 70f0db73e5 | |||
| 9dc8f44379 | |||
| 59f7ccd723 | |||
| 0710e95a6d | |||
| 4d1b5e3919 | |||
| 0cc2cb92dd | |||
| dba4d168f7 | |||
| d87ac7843c | |||
| 040535b004 | |||
| c8acd2c0b1 | |||
| d67fecea6e | |||
| 61f80f9ee6 | |||
| 9da8f9a5d1 | |||
| f381468d5a | |||
| 6ae97266e4 | |||
| 66060f345c | |||
| c61f568170 | |||
| dcd108bda3 | |||
| 9d89f98987 | |||
| ca7b959fce | |||
| 4a30793595 | |||
| 35e2d53f0f | |||
| 503efa4572 | |||
| b0c33d9dff | |||
| 012b156b46 | |||
| 25d0d3bf59 | |||
| 0f1babc82b | |||
| e2b93ea785 | |||
| b1cedfa81e | |||
| 701ee36f6a | |||
| 4e5db86434 | |||
| f45e9e657c | |||
| 4936fcdb1e | |||
| 374e05c422 | |||
| 9c00798373 | |||
| db82fce925 | |||
| acaa28e476 | |||
| f297ce5809 | |||
| 3dc3fc5f67 | |||
| 4884fc4418 | |||
| adc17842ec | |||
| daa48b0b7c | |||
| 17c0362df3 | |||
| 29b9a63fc9 | |||
| 2a9fae160e | |||
| 0c49a1e3bd | |||
| e896c41be1 | |||
| 187250fa24 | |||
| 9035b18584 | |||
| 4534d78978 | |||
| f4ab0e982c | |||
| 3e7c6629a6 | |||
| 3ea17331fe | |||
| 1057fcc271 | |||
| 5a31c36097 | |||
| 1677a69bba | |||
| 315c49165d | |||
| aae70e7ec0 | |||
| 5cb9e13ca7 | |||
| 0187010f94 | |||
| 2c2ed21e59 | |||
| f8b2ccec40 | |||
| e858dc582d | |||
| dd737f4b46 | |||
| f0bc238b6d | |||
| af55424850 | |||
| 902534baff | |||
| 6daa630040 | |||
| 0b2b86673b | |||
| 6aa5b58208 | |||
| 4430201cd2 | |||
| 7c7963a83e | |||
| e2202cd2d8 | |||
| a931be83bc | |||
| 7350bea345 | |||
| 9b1e39dbb4 | |||
| 15cd118845 | |||
| d58dff047c | |||
| a2f83c896c | |||
| 6ef77c731c | |||
| 29b0f61958 | |||
| e944b2ecdd | |||
| 41819c46a3 | |||
| 13f391a6f0 | |||
| 85a3d44f2c | |||
| 0792392058 | |||
| ff5083ada0 | |||
| 62841677bc | |||
| 1761cf53a2 | |||
| a771efc5fa | |||
| ed049da76a | |||
| 5d1d357a2e | |||
| 30d0706a1c | |||
| e9667e1266 | |||
| 73109483e7 | |||
| a9c1acf204 | |||
| 81c4f5814c | |||
| c595f6d781 | |||
| 24bb6b1d3d | |||
| 49eeb6020d | |||
| 7c272bd2a2 | |||
| cfbd865937 | |||
| fe472f33ef | |||
| 8d6b3d650f | |||
| 3b0d5b5eb7 | |||
| 875e8a99bd | |||
| 6c19d81844 | |||
| ba535a931f | |||
| 45dca5218d | |||
| da3cb9971b | |||
| b39270dc1e | |||
| ae8a7d0de9 | |||
| 2d501415bf | |||
| da639ccaac | |||
| a352770e2d | |||
| e3e1899466 | |||
| e67288e623 | |||
| 4019e49b07 | |||
| cd8711f3bc | |||
| 0d119379de | |||
| aa2b6ff112 | |||
| 3482f7dc98 | |||
| 16c321f114 | |||
| a81e7f3c44 | |||
| d7cc001521 | |||
| eb11962231 | |||
| 9f73b8f159 | |||
| 873a4abe24 | |||
| 56bc584f5e | |||
| 2a9f2f3c2e | |||
| ee719cdd39 | |||
| a571b57b30 | |||
| 5ee7a23bea | |||
| fe159ea195 | |||
| 8fcdf6176b | |||
| 715166bbca | |||
| 1d58072c70 | |||
| d667cde699 | |||
| 4cd8889c38 | |||
| 93896f6fb7 | |||
| 3b3f0387bb | |||
| 2875c9af95 | |||
| 93ef1bfccc | |||
| a886af1d87 | |||
| d731ff3ae6 | |||
| d44864637d | |||
| 674ee34ec6 | |||
| a93eeda243 | |||
| 80fd92e2a1 | |||
| d4ff2da473 | |||
| 9b7b271580 | |||
| e1b340966a | |||
| 7ec4c331af | |||
| 3102d596ee | |||
| af56dc546e | |||
| 15d47499fa | |||
| 53a34d0470 | |||
| 3ee675cefe | |||
| d98c7bdc03 | |||
| bb4f1ebed6 | |||
| c8f73ea23b | |||
| 8292b12787 | |||
| 0f518e3c35 | |||
| 1c2f67d43d | |||
| a5560a3123 | |||
| 1332096360 | |||
| 80381a6375 | |||
| acf92bd005 | |||
| da4f8a3a19 | |||
| 3a332192e3 | |||
| 1fdb1d87cc | |||
| b99aa55d7a | |||
| de20da2dad | |||
| 9444f0a68b | |||
| 48fd223a28 | |||
| 0845efe419 | |||
| 57b7ba91bc | |||
| 97af8a4892 | |||
| d6f237e289 | |||
| aba7109b35 | |||
| d3ec71052e | |||
| 1be63f396b | |||
| 9308742146 | |||
| b32241082d | |||
| 1f8504d685 | |||
| 97c5c48150 | |||
| afe84dc46a | |||
| ffafd42f03 | |||
| 7dca715c91 | |||
| 7695e1d8dd | |||
| 84b86d1db7 | |||
| bae3ef6460 | |||
| 97c6ec8875 | |||
| d33128dc26 | |||
| 10bdecabb6 | |||
| de88f530c8 | |||
| fb511b7596 | |||
| 322665ce91 | |||
| baeca1fcfb | |||
| 095b98c36a | |||
| 29bb7e7608 | |||
| e3d137efba | |||
| 207e915393 | |||
| 614e629a2b | |||
| f35de5c749 | |||
| c1623bd4df | |||
| 8690da5017 | |||
| 696adcdc24 | |||
| 2756bd06c1 | |||
| 4893f6ea00 | |||
| 35a7348197 | |||
| cdd6333d0a | |||
| 54399b5b5d | |||
| f6b192cc1e | |||
| cd231b90d8 | |||
| 87fe788358 | |||
| 3e9bd21ea8 | |||
| b6d4029797 | |||
| ec65e96148 | |||
| 926f1f971f | |||
| 5d69fad73f | |||
| a796761023 | |||
| 5d1338e485 | |||
| ce25a167f1 | |||
| 1c44969580 | |||
| b6e04e3ede | |||
| 84c26be703 | |||
| d201160722 | |||
| e112361b43 | |||
| 3e69795c9d | |||
| b11baf2e5d | |||
| 233770b553 | |||
| 187db73798 | |||
| 0e3fc6f682 | |||
| d11e3a4ac4 | |||
| d3b4ca3e66 | |||
| f37fbbfb8b | |||
| 52b7aac424 | |||
| d42f3f8f0c | |||
| 91b5c7c9bc | |||
| 48feebc092 | |||
| 14e2d66d96 | |||
| 10d844a195 | |||
| bbf91ae5d6 | |||
| cb82eda49a | |||
| bc1dbb1c27 | |||
| 9496a7f1ce | |||
| 7241fa31b4 | |||
| fed7216436 | |||
| ffe7d7c4c6 | |||
| f430ac8d6c | |||
| 70dfd7c9a3 | |||
| ed3140932b | |||
| 3cd2bd6ce8 | |||
| 982bf45fc4 | |||
| aaba8569fc | |||
| 4111e15eb9 | |||
| 2012478f26 | |||
| 88869d3239 | |||
| f3c2549b18 | |||
| 57e3b839d0 | |||
| faf3f43413 | |||
| 52e5bb3386 | |||
| 89405f6670 | |||
| 73111c4139 | |||
| 04e9c5db8c | |||
| 69278902de | |||
| efa95b0858 | |||
| 660128cd5c | |||
| ef1e052e47 | |||
| 0b346bc343 | |||
| 2272eaf833 | |||
| 4adee98bce | |||
| cbdb2c0705 | |||
| 4f438aabbf | |||
| b6ccc06963 | |||
| 5b89a15bfc | |||
| 5596ae551d | |||
| 1360df592a | |||
| 13684ff83c | |||
| ae88f7870e | |||
| 810b6da60c | |||
| 7bdf3e08f9 | |||
| fdad2a087f | |||
| c437a8c426 | |||
| ef861e6d1d | |||
| 928a008688 | |||
| 638a124adb | |||
| c2a63ae9bb | |||
| 28cf31e6e7 | |||
| 3cf416167d | |||
| ebf03923a0 | |||
| 82797d2421 | |||
| 52b6be946c | |||
| dc46724d7b | |||
| ed7d43b6a9 | |||
| 6f3fc51278 | |||
| a446acc282 | |||
| d987d639ab | |||
| e7e180e468 | |||
| 76770f82cd | |||
| 4079d4fd7c | |||
| ac48178369 | |||
| c2e9f038ee | |||
| 70220d9829 | |||
| b9a1f378ec | |||
| f6bc090a98 | |||
| be519f3932 | |||
| 0a46f77555 | |||
| 0e6cc0c7e5 | |||
| 11cd425162 | |||
| aa269688d6 | |||
| 4c9e94768e | |||
| 581157fa82 | |||
| e748e479cc | |||
| 5c9e4eea1e | |||
| 0c1189b233 | |||
| 5ec9b82b4a | |||
| c84ec533da | |||
| fb80c6ad7a | |||
| 2e3bfff6a4 | |||
| e96ce30891 | |||
| 10de5b2e5f | |||
| 1966081239 | |||
| b48d806d53 | |||
| 97784d74e7 | |||
| c42e92b07d | |||
| 2c52943b54 | |||
| 4ccb1902cb | |||
| 349b0572cd | |||
| 87fae8a9eb | |||
| a77a097f47 | |||
| a84d81143e | |||
| d9cee50ef3 | |||
| 0fc414e5e9 | |||
| e18f20ce4c | |||
| c12af4060c | |||
| 9992ea0dee | |||
| b8310f1c5d | |||
| 78f66af702 | |||
| 4d7564094e | |||
| 370f4694d1 | |||
| fc7c740691 | |||
| 8e1f955519 | |||
| aa3d16d981 | |||
| 5447f0e4df | |||
| 97294df208 | |||
| c6f53629da | |||
| fcba907658 | |||
| f481df7b8d | |||
| 81079a35d9 | |||
| bda344c382 | |||
| c4ebc396af | |||
| 4cdbf1231b | |||
| 92db58a9f6 | |||
| 5f07f47308 | |||
| 2132ae79a6 | |||
| bda7834a4f | |||
| 7693f313c4 | |||
| d2200a64e0 | |||
| a5c46ab837 | |||
| a389282e23 | |||
| 1f90b5b739 | |||
| 2c59500046 | |||
| a59a056e12 | |||
| 235364013b | |||
| 1049ac6eac | |||
| 04dc7af25c | |||
| f62ea3ad04 | |||
| 14c75f2cd9 | |||
| 9ae68b9653 | |||
| b7ab4c4568 | |||
| 7d0b3a0c87 | |||
| 0d38f7f290 | |||
| 12e5ef4231 | |||
| f3aa1f7414 | |||
| f2eaec6e02 | |||
| 0654a3ed55 | |||
| 9a27138d96 | |||
| b3c9f71c02 | |||
| e9c9b957db | |||
| 29cdf6fa48 | |||
| 8466a8e21e | |||
| 1523b6b8a8 | |||
| 33205e1008 | |||
| 537af385f8 | |||
| 7259b0a850 | |||
| 11fbfda6bf | |||
| 4f0353d0fb | |||
| a605d68d73 | |||
| 237b7fbf1b | |||
| 4a7e21f6b4 | |||
| b7017573b8 | |||
| a98b087c5d | |||
| 161c840136 | |||
| 4dd2abc202 | |||
| cc0e9f61a7 | |||
| 21a658f1f4 | |||
| b99f391c2a | |||
| 9abe25b91a | |||
| 2531fc6dac | |||
| e5551cb179 | |||
| 4728b7a8b7 | |||
| 2863921e15 | |||
| b93668edfe | |||
| 8ae91b8c31 | |||
| 894a23d701 | |||
| 3598906cbd | |||
| 75c12e2d4b | |||
| d6e0519a3d | |||
| 41efc71626 | |||
| 6e3d97f472 | |||
| 9d58d02522 | |||
| fe86396b21 | |||
| 97994f7632 | |||
| 33d63457b3 | |||
| ed36d9e953 | |||
| 9a478d74d2 | |||
| 72d72544a4 | |||
| 4bbbe81182 | |||
| a0af0c2492 | |||
| ce7d3e4702 | |||
| 1bb4ca8541 | |||
| ea65445772 | |||
| 972db8fcea | |||
| a3c12631f0 | |||
| 3cadfd08d8 | |||
| 104f3de013 | |||
| 713b41bd52 | |||
| 253093fa2f | |||
| f36af5af64 | |||
| 97b6c0e44d | |||
| c4f6dabd4d | |||
| d1c8aeb25d | |||
| 6e1cb2e0fe | |||
| da9762f60e | |||
| 27affdec14 | |||
| 433a19e46a | |||
| da9db9d3d1 | |||
| ed4b0eba2f | |||
| 615aecf80f | |||
| 4622d1a610 | |||
| f1b80d8f57 | |||
| e76e303383 | |||
| 97133c3fcb | |||
| 450610b6e6 | |||
| 4dc3fd92cc | |||
| f4b5e7c044 | |||
| 6c0b2a468d | |||
| e33f724f1b | |||
| b0d5562917 | |||
| eecf7a2194 | |||
| 54fd8a0332 | |||
| b6ca91980b | |||
| 6af7e2d749 | |||
| 86d334c204 | |||
| 585a4fa449 | |||
| 7438073e7e | |||
| 6e808ae35a | |||
| 99ec64e852 | |||
| eeac63c0a5 | |||
| 5d5a3c3301 | |||
| 31e9730236 | |||
| 69b32a02ff | |||
| a222df8176 | |||
| 7f4c99be60 | |||
| ccff657a62 | |||
| fb258499e1 | |||
| 79c6d6c742 | |||
| 80d9d5480c | |||
| 958a553922 | |||
| a44bbc3513 | |||
| d7f2f4a3e7 | |||
| 073566a23e | |||
| 590aecfcf1 | |||
| 77ab52310e | |||
| 2c2ccddbe4 | |||
| 87062db9d5 | |||
| b74701dbc5 | |||
| a88db8830b | |||
| 36cd83c796 | |||
| a039c93c05 | |||
| 57b4ade3be | |||
| 87ce6cfa98 | |||
| 6a99c8c81d | |||
| 46e8188d5a | |||
| 3c990df1fe | |||
| 8969bc5aa6 | |||
| 45e8ca8d42 | |||
| 39930153c9 | |||
| 5890e46db3 | |||
| c5c06a08ba | |||
| 2a0e677a89 | |||
| 8f7a968dc9 | |||
| 20cfc50448 | |||
| 4bf019ec7e | |||
| 57fe45484c | |||
| 7744f4ed76 | |||
| a43e81e229 | |||
| f6ad7e250b | |||
| 0f2b0482ec | |||
| 21d850d39e | |||
| d3f2e42301 | |||
| df68154f10 | |||
| f8ebf03afd | |||
| 23f8b97319 | |||
| d712054353 | |||
| 58da896b14 | |||
| 8db57bda6e | |||
| 53f29ec710 | |||
| 43a8fc0e86 | |||
| 7f2adb068e | |||
| 218ae9f9bf | |||
| 350c03874d | |||
| 575c0e5bf9 | |||
| 3a890ba2c7 | |||
| 2a345f4869 | |||
| 658ebbd84d | |||
| 0c54ade367 | |||
| f16ba64026 | |||
| d72127aaeb | |||
| 40e0b1291c | |||
| 0fae7f0166 | |||
| 6f916a4c32 | |||
| 41979d5389 | |||
| 0d51205bd6 | |||
| 416dd52a30 | |||
| 850e45b9a5 | |||
| 040d2ca7f6 | |||
| e173072622 | |||
| 991dd80382 | |||
| 5fc5d02134 | |||
| 0f51256add | |||
| a78860dbc4 | |||
| f655a3c52d | |||
| b69aebd5be | |||
| 769a7c391f | |||
| 9a4d55aa36 | |||
| e776acfbab | |||
| b4f58286b4 | |||
| 59779cc931 | |||
| 567bcecc80 | |||
| 0ca87dc29b | |||
| 6e0824e357 | |||
| 9a8a620658 | |||
| eb1db3120d | |||
| 98a9225c32 | |||
| 1b8fb766a8 | |||
| c28ef3ec3b | |||
| fab3f0630c | |||
| d2499ad157 | |||
| 724a37bbf4 | |||
| c5f1c30b1c | |||
| e90363df71 | |||
| 0932008619 | |||
| a24e00ad5a | |||
| fab938055f | |||
| b2026b0dac | |||
| c1c742084e | |||
| 7140f590cf | |||
| dcc26ad666 | |||
| 70f1ecad49 | |||
| 03c7383b54 | |||
| d3734971cc | |||
| cff8358b3e | |||
| 42d691c4ce | |||
| 33dcbe8b5a | |||
| 980c7a4390 | |||
| 1b7f881d5a | |||
| 8635e2cf67 | |||
| e36bc8bab2 | |||
| 693da7733f | |||
| 6b6128d92d | |||
| 64586a44b4 | |||
| 2d28ecca32 | |||
| 92f3edb337 | |||
| 32f8edecdd | |||
| 14b061cd28 | |||
| aeb90cbdd2 | |||
| e2a0b627b2 | |||
| de5eb0d914 |
@@ -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,177 @@
|
||||
import requests, json
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
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"
|
||||
import requests
|
||||
|
||||
# Base path to build the full URL for the installable scripts
|
||||
# ---------- Config ----------
|
||||
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
|
||||
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
|
||||
|
||||
# Output file where the consolidated helper scripts cache will be stored
|
||||
OUTPUT_FILE = Path("json/helpers_cache.json")
|
||||
# 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)
|
||||
# ----------------------------
|
||||
|
||||
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
|
||||
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:
|
||||
raw = requests.get(url).json()
|
||||
if not isinstance(raw, dict):
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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}"
|
||||
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
|
||||
|
||||
|
||||
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.")
|
||||
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,81 @@
|
||||
name: Build ProxMenux Monitor AppImage
|
||||
|
||||
on:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build Next.js app
|
||||
working-directory: AppImage
|
||||
run: npm run build
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
working-directory: AppImage
|
||||
run: chmod +x scripts/build_appimage.sh
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
retention-days: 30
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
run: |
|
||||
cd AppImage/dist
|
||||
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
|
||||
echo "Generated SHA256:"
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage and checksum to /AppImage folder in main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git fetch origin main
|
||||
git checkout main
|
||||
|
||||
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
|
||||
|
||||
# Copy new files
|
||||
cp AppImage/dist/*.AppImage AppImage/
|
||||
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
|
||||
|
||||
git add AppImage/*.AppImage AppImage/*.sha256
|
||||
git commit -m "Update AppImage build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin main
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Build ProxMenux Monitor AppImage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths: [ 'AppImage/**' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths: [ 'AppImage/**' ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build Next.js app
|
||||
working-directory: AppImage
|
||||
run: npm run build
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
working-directory: AppImage
|
||||
run: chmod +x scripts/build_appimage.sh
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
retention-days: 30
|
||||
@@ -51,3 +51,5 @@ Thumbs.db
|
||||
!guides/
|
||||
!web/
|
||||
|
||||
# GitHub authentication
|
||||
.github/auth.sh
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
f35de512c1a19843d15a9a3263a5104759d041ffc9d01249450babe0b0c3f889 ProxMenux-1.0.1.AppImage
|
||||
@@ -0,0 +1,812 @@
|
||||
# ProxMenux Monitor
|
||||
|
||||
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)
|
||||
- [Contributing](#contributing)
|
||||
- [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>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen2.png" alt="Storage Management" width="800"/>
|
||||
<br/>
|
||||
<em>Storage Management - Visual representation of disk usage and health</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen3.png" alt="Network Monitoring" width="800"/>
|
||||
<br/>
|
||||
<em>Network Monitoring - Real-time traffic graphs and interface statistics</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen4.png" alt="Virtual Machines & LXC" width="800"/>
|
||||
<br/>
|
||||
<em>VMs & LXC Containers - Comprehensive view with resource usage and controls</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen5.png" alt="Hardware Information" width="800"/>
|
||||
<br/>
|
||||
<em>Hardware Information - Detailed specs for CPU, GPU, and PCIe devices</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen6.png" alt="System Logs" width="800"/>
|
||||
<br/>
|
||||
<em>System Logs - Real-time monitoring with filtering and search</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **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, 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 v4 with custom Proxmox-inspired theme
|
||||
- **Charts**: Recharts for data visualization
|
||||
- **UI Components**: Radix UI primitives with shadcn/ui
|
||||
- **Backend**: Flask (Python) server for system data collection
|
||||
- **Packaging**: AppImage for easy distribution and deployment
|
||||
|
||||
## Installation
|
||||
|
||||
**ProxMenux Monitor is integrated into [ProxMenux](https://proxmenux.com) and comes enabled by default.** No manual installation is required if you're using ProxMenux.
|
||||
|
||||
The monitor automatically starts when ProxMenux is installed and runs as a systemd service on your Proxmox server.
|
||||
|
||||
### Accessing the Dashboard
|
||||
|
||||
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
|
||||
|
||||
**Example Nginx configuration:**
|
||||
```nginx
|
||||
location /proxmenux-monitor/ {
|
||||
proxy_pass http://localhost:8008/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
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
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies: `npm install`
|
||||
3. Run development server: `npm run dev`
|
||||
4. Build AppImage: `./build_appimage.sh`
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**.
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format
|
||||
- Adapt — remix, transform, and build upon the material
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made
|
||||
- NonCommercial — You may not use the material for commercial purposes
|
||||
|
||||
For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/).
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For support, feature requests, or bug reports, please visit:
|
||||
- GitHub Issues: [github.com/your-repo/issues](https://github.com/your-repo/issues)
|
||||
- Documentation: [github.com/your-repo/wiki](https://github.com/your-repo/wiki)
|
||||
|
||||
---
|
||||
|
||||
**ProxMenux Monitor** - Made with ❤️ for the Proxmox community
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ProxmoxDashboard } from "../../components/proxmox-dashboard"
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<ProxmoxDashboard />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ===================== */
|
||||
/* Light Mode (default) */
|
||||
/* ===================== */
|
||||
:root {
|
||||
--background: oklch(1 0 0); /* blanco */
|
||||
--foreground: oklch(0.145 0 0); /* casi negro */
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: var(--foreground);
|
||||
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
--primary: oklch(0.205 0 0); /* gris oscuro */
|
||||
--primary-foreground: oklch(0.985 0 0); /* blanco */
|
||||
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: var(--primary);
|
||||
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0); /* gris medio */
|
||||
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: var(--primary);
|
||||
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.145 0 0);
|
||||
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: var(--border);
|
||||
--ring: oklch(0.708 0 0);
|
||||
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
|
||||
--radius: 0.625rem;
|
||||
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: var(--foreground);
|
||||
--sidebar-primary: var(--primary);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: var(--primary);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--ring);
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Dark Mode (gris) */
|
||||
/* ===================== */
|
||||
.dark {
|
||||
--background: oklch(0.22 0 0); /* gris oscuro */
|
||||
--foreground: oklch(0.97 0 0); /* blanco/gris claro */
|
||||
|
||||
--card: oklch(0.24 0 0);
|
||||
--card-foreground: var(--foreground);
|
||||
|
||||
--popover: var(--card);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
--primary: oklch(0.83 0 0); /* casi blanco */
|
||||
--primary-foreground: var(--background);
|
||||
|
||||
--secondary: oklch(0.28 0 0);
|
||||
--secondary-foreground: oklch(0.92 0 0);
|
||||
|
||||
--muted: oklch(0.26 0 0);
|
||||
--muted-foreground: oklch(0.72 0 0);
|
||||
|
||||
--accent: oklch(0.28 0 0);
|
||||
--accent-foreground: var(--primary);
|
||||
|
||||
--destructive: oklch(0.53 0.25 27);
|
||||
--destructive-foreground: oklch(0.9 0 0);
|
||||
|
||||
--border: oklch(0.34 0 0);
|
||||
--input: var(--border);
|
||||
--ring: oklch(0.55 0 0);
|
||||
|
||||
--chart-1: oklch(0.60 0.20 255);
|
||||
--chart-2: oklch(0.70 0.16 165);
|
||||
--chart-3: oklch(0.76 0.19 70);
|
||||
--chart-4: oklch(0.63 0.25 305);
|
||||
--chart-5: oklch(0.66 0.24 20);
|
||||
|
||||
--sidebar: oklch(0.24 0 0);
|
||||
--sidebar-foreground: var(--foreground);
|
||||
--sidebar-primary: var(--chart-1);
|
||||
--sidebar-primary-foreground: var(--foreground);
|
||||
--sidebar-accent: oklch(0.28 0 0);
|
||||
--sidebar-accent-foreground: var(--foreground);
|
||||
--sidebar-border: var(--border);
|
||||
--sidebar-ring: var(--ring);
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Base layer */
|
||||
/* ===================== */
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Foco accesible */
|
||||
:is(button,[role="button"],a,input,select,textarea,[tabindex]:not([tabindex="-1"])):focus {
|
||||
@apply outline-none;
|
||||
}
|
||||
:is(button,[role="button"],a,input,select,textarea,[tabindex]:not([tabindex="-1"])):focus-visible {
|
||||
@apply ring-2;
|
||||
--tw-ring-color: var(--ring);
|
||||
--tw-ring-opacity: 0.5; /* equivalente al /50 */
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Ajustes para Charts */
|
||||
/* ===================== */
|
||||
@layer components {
|
||||
/* Recharts axis */
|
||||
.recharts-cartesian-axis-tick tspan {
|
||||
fill: var(--muted-foreground);
|
||||
}
|
||||
.recharts-cartesian-axis-line,
|
||||
.recharts-cartesian-grid line {
|
||||
stroke: var(--border);
|
||||
}
|
||||
|
||||
/* Chart.js axis */
|
||||
.chartjs-render-monitor text {
|
||||
fill: var(--muted-foreground);
|
||||
}
|
||||
.chartjs-render-monitor line {
|
||||
stroke: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Hardware from "@/components/hardware"
|
||||
|
||||
export default function HardwarePage() {
|
||||
return <Hardware />
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { ThemeProvider } from "../components/theme-provider"
|
||||
import { Suspense } from "react"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "ProxMenux Monitor",
|
||||
description: "Proxmox System Dashboard and Monitor",
|
||||
generator: "v0.app",
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
{ url: "/icon.svg", type: "image/svg+xml" },
|
||||
{ url: "/icon.png", type: "image/png", sizes: "32x32" },
|
||||
],
|
||||
shortcut: "/favicon.ico",
|
||||
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
},
|
||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#2b2f36" },
|
||||
],
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${GeistSans.variable} ${GeistMono.variable} antialiased bg-background text-foreground`}>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem disableTransitionOnChange>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +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() {
|
||||
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,257 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent } from "./ui/dialog"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Shield, Lock, User, AlertCircle } 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)
|
||||
|
||||
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">
|
||||
{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="password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</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="password"
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleSetupAuth} className="w-full bg-blue-500 hover:bg-blue-600" disabled={loading}>
|
||||
{loading ? "Setting up..." : "Setup Authentication"}
|
||||
</Button>
|
||||
<Button onClick={() => setStep("choice")} variant="ghost" className="w-full" disabled={loading}>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"
|
||||
import { Cpu } from "@/components/icons/cpu" // Added import for Cpu
|
||||
import type { PCIDevice } from "../types/hardware" // Fixed import to use relative path instead of alias
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
function GPUCard({ device }: { device: PCIDevice }) {
|
||||
const hasMonitoring = device.gpu_temperature !== undefined || device.gpu_utilization !== undefined
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5" />
|
||||
{device.device}
|
||||
</CardTitle>
|
||||
<CardDescription>{device.vendor}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Slot</div>
|
||||
<div className="font-medium">{device.slot}</div>
|
||||
</div>
|
||||
{device.driver && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Driver</div>
|
||||
<div className="font-medium">{device.driver}</div>
|
||||
</div>
|
||||
)}
|
||||
{device.gpu_driver_version && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Driver Version</div>
|
||||
<div className="font-medium">{device.gpu_driver_version}</div>
|
||||
</div>
|
||||
)}
|
||||
{device.gpu_memory && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Memory</div>
|
||||
<div className="font-medium">{device.gpu_memory}</div>
|
||||
</div>
|
||||
)}
|
||||
{device.gpu_compute_capability && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Compute Capability</div>
|
||||
<div className="font-medium">{device.gpu_compute_capability}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMonitoring && (
|
||||
<div className="space-y-3 pt-4 border-t">
|
||||
<h4 className="text-sm font-semibold">Real-time Monitoring</h4>
|
||||
|
||||
{device.gpu_temperature !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Temperature</span>
|
||||
<span className="font-medium">{device.gpu_temperature}°C</span>
|
||||
</div>
|
||||
<Progress value={(device.gpu_temperature / 100) * 100} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_utilization !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">GPU Utilization</span>
|
||||
<span className="font-medium">{device.gpu_utilization}%</span>
|
||||
</div>
|
||||
<Progress value={device.gpu_utilization} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_memory_used && device.gpu_memory_total && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Memory Usage</span>
|
||||
<span className="font-medium">
|
||||
{device.gpu_memory_used} / {device.gpu_memory_total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(Number.parseInt(device.gpu_memory_used) / Number.parseInt(device.gpu_memory_total)) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_power_draw && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Power Draw</span>
|
||||
<span className="font-medium">{device.gpu_power_draw}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_clock_speed && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">GPU Clock</span>
|
||||
<span className="font-medium">{device.gpu_clock_speed}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.gpu_memory_clock && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Memory Clock</span>
|
||||
<span className="font-medium">{device.gpu_memory_clock}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
"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
|
||||
[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) {
|
||||
return (
|
||||
<div
|
||||
key={detailKey}
|
||||
className="flex items-start justify-between gap-2 text-xs pl-3 border-l-2 border-muted py-1"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{detailKey}:</span>
|
||||
{detailValue.reason && (
|
||||
<span className="ml-1 text-muted-foreground">{detailValue.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
{(status === "WARNING" || status === "CRITICAL") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent"
|
||||
onClick={(e) => handleAcknowledge(detailKey, e)}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
<span className="text-xs">Dismiss</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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.1</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
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
|
||||
vmName: string
|
||||
vmType: "qemu" | "lxc"
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const TIMEFRAME_OPTIONS = [
|
||||
{ value: "hour", label: "1 Hour" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
{ value: "year", label: "1 Year" },
|
||||
]
|
||||
|
||||
const CustomCPUTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomMemoryTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value} GB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomDiskTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value} MB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomNetworkTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value} MB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function MetricsView({ vmid, vmName, vmType, onBack }: MetricsViewProps) {
|
||||
const [timeframe, setTimeframe] = useState("week")
|
||||
const [data, setData] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hiddenDiskLines, setHiddenDiskLines] = useState<string[]>([])
|
||||
const [hiddenNetworkLines, setHiddenNetworkLines] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchMetrics()
|
||||
}, [vmid, timeframe])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
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)
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "month") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: item.time,
|
||||
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
|
||||
memory: item.mem ? Number(((item.mem / item.maxmem) * 100).toFixed(2)) : 0,
|
||||
memoryGB: item.mem ? Number((item.mem / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
maxMemoryGB: item.maxmem ? Number((item.maxmem / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
netin: item.netin ? Number((item.netin / 1024 / 1024).toFixed(2)) : 0,
|
||||
netout: item.netout ? Number((item.netout / 1024 / 1024).toFixed(2)) : 0,
|
||||
diskread: item.diskread ? Number((item.diskread / 1024 / 1024).toFixed(2)) : 0,
|
||||
diskwrite: item.diskwrite ? Number((item.diskwrite / 1024 / 1024).toFixed(2)) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
setData(transformedData)
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Error loading metrics")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatXAxisTick = (tick: any) => {
|
||||
return tick
|
||||
}
|
||||
|
||||
const renderAllCharts = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[400px]">
|
||||
<p className="text-red-500">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[400px]">
|
||||
<p className="text-muted-foreground">No data available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tickInterval = Math.ceil(data.length / 8)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* CPU Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">CPU Usage</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
label={{ value: "%", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomCPUTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="CPU %"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Memory Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Memory Usage</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
label={{ value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomMemoryTooltip />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryGB"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Memory GB"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Disk I/O Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Disk I/O</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
label={{ value: "MB", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomDiskTooltip />} />
|
||||
<Legend content={renderDiskLegend} verticalAlign="top" />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskread"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Read"
|
||||
hide={hiddenDiskLines.includes("diskread")}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="diskwrite"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Write"
|
||||
hide={hiddenDiskLines.includes("diskwrite")}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Network I/O Chart */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Network I/O</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
tickFormatter={formatXAxisTick}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor" }}
|
||||
label={{ value: "MB", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomNetworkTooltip />} />
|
||||
<Legend content={renderNetworkLegend} verticalAlign="top" />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="netin"
|
||||
stroke="#10b981"
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Download"
|
||||
hide={hiddenNetworkLines.includes("netin")}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="netout"
|
||||
stroke="#3b82f6"
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
name="Upload"
|
||||
hide={hiddenNetworkLines.includes("netout")}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleDiskLegendClick = (dataKey: string) => {
|
||||
setHiddenDiskLines((prev) => {
|
||||
if (prev.includes(dataKey)) {
|
||||
return prev.filter((key) => key !== dataKey)
|
||||
} else {
|
||||
return [...prev, dataKey]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleNetworkLegendClick = (dataKey: string) => {
|
||||
setHiddenNetworkLines((prev) => {
|
||||
if (prev.includes(dataKey)) {
|
||||
return prev.filter((key) => key !== dataKey)
|
||||
} else {
|
||||
return [...prev, dataKey]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const renderDiskLegend = (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div className="flex justify-center gap-6 pb-2">
|
||||
{payload.map((entry: any) => (
|
||||
<button
|
||||
key={entry.dataKey}
|
||||
onClick={() => handleDiskLegendClick(entry.dataKey)}
|
||||
className={`flex items-center gap-2 cursor-pointer transition-opacity hover:opacity-100 ${
|
||||
hiddenDiskLines.includes(entry.dataKey) ? "opacity-40" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-sm">{entry.value}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderNetworkLegend = (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div className="flex justify-center gap-6 pb-2">
|
||||
{payload.map((entry: any) => (
|
||||
<button
|
||||
key={entry.dataKey}
|
||||
onClick={() => handleNetworkLegendClick(entry.dataKey)}
|
||||
className={`flex items-center gap-2 cursor-pointer transition-opacity hover:opacity-100 ${
|
||||
hiddenNetworkLines.includes(entry.dataKey) ? "opacity-40" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-sm">{entry.value}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full max-h-[90vh]">
|
||||
{/* Fixed Header */}
|
||||
<div className="p-6 pb-4 border-b shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Metrics - {vmName}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
VMID: {vmid} • Type: {vmType.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content with all charts */}
|
||||
<div className="flex-1 overflow-y-auto p-6 min-h-0">{renderAllCharts()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Wifi, Zap } from "lucide-react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
|
||||
interface NetworkCardProps {
|
||||
interface_: {
|
||||
name: string
|
||||
type: string
|
||||
status: string
|
||||
speed: number
|
||||
duplex?: string
|
||||
mtu?: number
|
||||
mac_address: string | null
|
||||
addresses: Array<{
|
||||
ip: string
|
||||
netmask: string
|
||||
}>
|
||||
bytes_sent?: number
|
||||
bytes_recv?: number
|
||||
bridge_physical_interface?: string
|
||||
bridge_bond_slaves?: string[]
|
||||
vmid?: number
|
||||
vm_name?: string
|
||||
vm_type?: string
|
||||
}
|
||||
timeframe: "hour" | "day" | "week" | "month" | "year"
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const getInterfaceTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case "physical":
|
||||
return { color: "bg-blue-500/10 text-blue-500 border-blue-500/20", label: "Physical" }
|
||||
case "bridge":
|
||||
return { color: "bg-green-500/10 text-green-500 border-green-500/20", label: "Bridge" }
|
||||
case "bond":
|
||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "Bond" }
|
||||
case "vlan":
|
||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "VLAN" }
|
||||
case "vm_lxc":
|
||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
||||
case "virtual":
|
||||
return { color: "bg-orange-500/10 text-orange-500 border-orange-500/20", label: "Virtual" }
|
||||
default:
|
||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
||||
}
|
||||
}
|
||||
|
||||
const getVMTypeBadge = (vmType: string | undefined) => {
|
||||
if (vmType === "lxc") {
|
||||
return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC" }
|
||||
} else if (vmType === "vm") {
|
||||
return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM" }
|
||||
}
|
||||
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 [trafficData, setTrafficData] = useState<{ received: number; sent: number }>({
|
||||
received: 0,
|
||||
sent: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrafficData = async () => {
|
||||
try {
|
||||
const data = await fetchApi(`/api/network/${interface_.name}/metrics?timeframe=${timeframe}`)
|
||||
|
||||
if (data.data && data.data.length > 0) {
|
||||
const lastPoint = data.data[data.data.length - 1]
|
||||
const firstPoint = data.data[0]
|
||||
|
||||
const receivedGB = Math.max(0, (lastPoint.netin || 0) - (firstPoint.netin || 0))
|
||||
const sentGB = Math.max(0, (lastPoint.netout || 0) - (firstPoint.netout || 0))
|
||||
|
||||
setTrafficData({
|
||||
received: receivedGB,
|
||||
sent: sentGB,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch traffic data for card:", error)
|
||||
setTrafficData({ received: 0, sent: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
if (interface_.status.toLowerCase() === "up" && interface_.vm_type !== "vm") {
|
||||
fetchTrafficData()
|
||||
|
||||
const interval = setInterval(fetchTrafficData, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [interface_.name, interface_.status, interface_.vm_type, timeframe])
|
||||
|
||||
const getTimeframeLabel = () => {
|
||||
switch (timeframe) {
|
||||
case "hour":
|
||||
return "Last Hour"
|
||||
case "day":
|
||||
return "Last 24 Hours"
|
||||
case "week":
|
||||
return "Last 7 Days"
|
||||
case "month":
|
||||
return "Last 30 Days"
|
||||
case "year":
|
||||
return "Last Year"
|
||||
default:
|
||||
return "Last 24 Hours"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border hover:bg-white/5 transition-colors cursor-pointer" onClick={onClick}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* First row: Icon, Name, Type Badge, Status */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Wifi className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 flex-wrap">
|
||||
<div className="font-medium text-foreground">{interface_.name}</div>
|
||||
{vmTypeBadge ? (
|
||||
<Badge variant="outline" className={vmTypeBadge.color}>
|
||||
{vmTypeBadge.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className={typeBadge.color}>
|
||||
{typeBadge.label}
|
||||
</Badge>
|
||||
)}
|
||||
{interface_.vm_name && (
|
||||
<div className="text-sm text-muted-foreground truncate">→ {interface_.vm_name}</div>
|
||||
)}
|
||||
{interface_.type === "bridge" && interface_.bridge_physical_interface && (
|
||||
<div className="text-sm text-blue-500 font-medium flex items-center gap-1 flex-wrap break-all">
|
||||
→ {interface_.bridge_physical_interface}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
interface_.status === "up"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
>
|
||||
{interface_.status.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Second row: Details - Responsive layout */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{interface_.type === "vm_lxc" ? "VMID" : "IP Address"}
|
||||
</div>
|
||||
<div className="font-medium text-foreground font-mono text-sm truncate">
|
||||
{interface_.type === "vm_lxc"
|
||||
? (interface_.vmid ?? "N/A")
|
||||
: interface_.addresses.length > 0
|
||||
? interface_.addresses[0].ip
|
||||
: "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">Speed</div>
|
||||
<div className="font-medium text-foreground flex items-center gap-1 text-xs">
|
||||
<Zap className="h-3 w-3" />
|
||||
{formatSpeed(interface_.speed)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="text-muted-foreground text-xs">{getTimeframeLabel()}</div>
|
||||
<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-blue-500">↑ {formatStorage(trafficData.sent * 1024 * 1024 * 1024)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-green-500">↓ {formatBytes(interface_.bytes_recv)}</span>
|
||||
{" / "}
|
||||
<span className="text-blue-500">↑ {formatBytes(interface_.bytes_sent)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{interface_.mac_address && (
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="text-muted-foreground text-xs">MAC</div>
|
||||
<div className="font-medium text-foreground font-mono text-xs truncate">{interface_.mac_address}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
interface NetworkMetricsData {
|
||||
time: string
|
||||
timestamp: number
|
||||
netIn: number
|
||||
netOut: number
|
||||
}
|
||||
|
||||
interface NetworkTrafficChartProps {
|
||||
timeframe: string
|
||||
interfaceName?: string
|
||||
onTotalsCalculated?: (totals: { received: number; sent: number }) => void
|
||||
refreshInterval?: number // En milisegundos, por defecto 60000 (60 segundos)
|
||||
}
|
||||
|
||||
const CustomNetworkTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value.toFixed(3)} GB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function NetworkTrafficChart({
|
||||
timeframe,
|
||||
interfaceName,
|
||||
onTotalsCalculated,
|
||||
refreshInterval = 60000,
|
||||
}: NetworkTrafficChartProps) {
|
||||
const [data, setData] = useState<NetworkMetricsData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isInitialLoad, setIsInitialLoad] = useState(true)
|
||||
const [visibleLines, setVisibleLines] = useState({
|
||||
netIn: true,
|
||||
netOut: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setIsInitialLoad(true)
|
||||
fetchMetrics()
|
||||
}, [timeframe, interfaceName])
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(() => {
|
||||
fetchMetrics()
|
||||
}, refreshInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [timeframe, interfaceName, refreshInterval])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (isInitialLoad) {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const apiPath = interfaceName
|
||||
? `/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
|
||||
: `/api/node/metrics?timeframe=${timeframe}`
|
||||
|
||||
console.log("[v0] Fetching network metrics from:", apiPath)
|
||||
|
||||
const result = await fetchApi<any>(apiPath)
|
||||
|
||||
if (!result.data || !Array.isArray(result.data)) {
|
||||
throw new Error("Invalid data format received from server")
|
||||
}
|
||||
|
||||
if (result.data.length === 0) {
|
||||
setData([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const transformedData = result.data.map((item: any, index: number) => {
|
||||
const date = new Date(item.time * 1000)
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "year") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
let intervalSeconds = 60
|
||||
if (index > 0) {
|
||||
intervalSeconds = item.time - result.data[index - 1].time
|
||||
}
|
||||
|
||||
const netInBytes = (item.netin || 0) * intervalSeconds
|
||||
const netOutBytes = (item.netout || 0) * intervalSeconds
|
||||
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: item.time,
|
||||
netIn: Number((netInBytes / 1024 / 1024 / 1024).toFixed(4)),
|
||||
netOut: Number((netOutBytes / 1024 / 1024 / 1024).toFixed(4)),
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
if (onTotalsCalculated) {
|
||||
onTotalsCalculated({ received: totalReceived, sent: totalSent })
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[v0] Error fetching network metrics:", err)
|
||||
setError(err.message || "Error loading metrics")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tickInterval = Math.ceil(data.length / 8)
|
||||
|
||||
const handleLegendClick = (dataKey: string) => {
|
||||
setVisibleLines((prev) => ({
|
||||
...prev,
|
||||
[dataKey as keyof typeof prev]: !prev[dataKey as keyof typeof prev],
|
||||
}))
|
||||
}
|
||||
|
||||
const renderLegend = (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div className="flex justify-center gap-4 pb-2 flex-wrap">
|
||||
{payload.map((entry: any, index: number) => {
|
||||
const isVisible = visibleLines[entry.dataKey as keyof typeof visibleLines]
|
||||
return (
|
||||
<div
|
||||
key={`legend-${index}`}
|
||||
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleLegendClick(entry.dataKey)}
|
||||
style={{ opacity: isVisible ? 1 : 0.4 }}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-sm text-foreground">{entry.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading && isInitialLoad) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
||||
<p className="text-muted-foreground text-sm">Network metrics not available yet</p>
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<p className="text-muted-foreground text-sm">No network metrics available</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 80 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={{ value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
domain={[0, "auto"]}
|
||||
/>
|
||||
<Tooltip content={<CustomNetworkTooltip />} />
|
||||
<Legend verticalAlign="top" height={36} content={renderLegend} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="netIn"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Received"
|
||||
hide={!visibleLines.netIn}
|
||||
isAnimationActive={true}
|
||||
animationDuration={300}
|
||||
animationEasing="ease-in-out"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="netOut"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="Sent"
|
||||
hide={!visibleLines.netOut}
|
||||
isAnimationActive={true}
|
||||
animationDuration={300}
|
||||
animationEasing="ease-in-out"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
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" },
|
||||
{ value: "day", label: "24 Hours" },
|
||||
{ value: "week", label: "7 Days" },
|
||||
{ value: "month", label: "30 Days" },
|
||||
]
|
||||
|
||||
interface NodeMetricsData {
|
||||
time: string
|
||||
timestamp: number
|
||||
cpu: number
|
||||
load: number
|
||||
memoryTotal: number
|
||||
memoryUsed: number
|
||||
memoryFree: number
|
||||
memoryZfsArc: number
|
||||
}
|
||||
|
||||
const CustomCpuTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const CustomMemoryTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
<p className="text-sm font-semibold text-white mb-2">{label}</p>
|
||||
<div className="space-y-1.5">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value} GB</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function NodeMetricsCharts() {
|
||||
const [timeframe, setTimeframe] = useState("day")
|
||||
const [data, setData] = useState<NodeMetricsData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const [visibleLines, setVisibleLines] = useState({
|
||||
cpu: { cpu: true, load: true },
|
||||
memory: { memoryTotal: true, memoryUsed: true, memoryZfsArc: true, memoryFree: true },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
console.log("[v0] NodeMetricsCharts component mounted")
|
||||
fetchMetrics()
|
||||
}, [timeframe])
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
console.log("[v0] fetchMetrics called with timeframe:", timeframe)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await fetchApi<any>(`/api/node/metrics?timeframe=${timeframe}`)
|
||||
|
||||
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)
|
||||
|
||||
if (!result.data || !Array.isArray(result.data)) {
|
||||
console.error("[v0] Invalid data format - data is not an array:", result)
|
||||
throw new Error("Invalid data format received from server")
|
||||
}
|
||||
|
||||
if (result.data.length === 0) {
|
||||
console.warn("[v0] No data points received")
|
||||
setData([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[v0] First data point sample:", result.data[0])
|
||||
console.log("[v0] First data point loadavg field:", result.data[0]?.loadavg)
|
||||
console.log("[v0] loadavg type:", typeof result.data[0]?.loadavg)
|
||||
console.log("[v0] loadavg is array:", Array.isArray(result.data[0]?.loadavg))
|
||||
if (result.data[0]?.loadavg) {
|
||||
console.log("[v0] loadavg length:", result.data[0].loadavg.length)
|
||||
console.log("[v0] loadavg[0]:", result.data[0].loadavg[0])
|
||||
}
|
||||
|
||||
const transformedData = result.data.map((item: any) => {
|
||||
const date = new Date(item.time * 1000)
|
||||
let timeLabel = ""
|
||||
|
||||
if (timeframe === "hour") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "day") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else if (timeframe === "week") {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
} else {
|
||||
timeLabel = date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: item.time,
|
||||
cpu: item.cpu ? Number((item.cpu * 100).toFixed(2)) : 0,
|
||||
load: item.loadavg
|
||||
? typeof item.loadavg === "number"
|
||||
? Number(item.loadavg.toFixed(2))
|
||||
: Array.isArray(item.loadavg) && item.loadavg.length > 0
|
||||
? Number(item.loadavg[0].toFixed(2))
|
||||
: 0
|
||||
: 0,
|
||||
memoryTotal: item.memtotal ? Number((item.memtotal / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
memoryUsed: item.memused ? Number((item.memused / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
memoryFree: item.memfree ? Number((item.memfree / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
memoryZfsArc: item.zfsarc ? Number((item.zfsarc / 1024 / 1024 / 1024).toFixed(2)) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
setData(transformedData)
|
||||
} catch (err: any) {
|
||||
console.error("[v0] Error fetching node metrics:", err)
|
||||
console.error("[v0] Error message:", err.message)
|
||||
console.error("[v0] Error stack:", err.stack)
|
||||
setError(err.message || "Error loading metrics")
|
||||
} finally {
|
||||
console.log("[v0] fetchMetrics finally block - setting loading to false")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tickInterval = Math.ceil(data.length / 8)
|
||||
|
||||
const handleLegendClick = (chartType: "cpu" | "memory", dataKey: string) => {
|
||||
setVisibleLines((prev) => ({
|
||||
...prev,
|
||||
[chartType]: {
|
||||
...prev[chartType],
|
||||
[dataKey as keyof (typeof prev)[typeof chartType]]:
|
||||
!prev[chartType][dataKey as keyof (typeof prev)[typeof chartType]],
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
const renderLegend = (chartType: "cpu" | "memory") => (props: any) => {
|
||||
const { payload } = props
|
||||
return (
|
||||
<div className="flex justify-center gap-4 pb-2 flex-wrap">
|
||||
{payload.map((entry: any, index: number) => {
|
||||
const isVisible = visibleLines[chartType][entry.dataKey as keyof (typeof visibleLines)[typeof chartType]]
|
||||
return (
|
||||
<div
|
||||
key={`legend-${index}`}
|
||||
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleLegendClick(chartType, entry.dataKey)}
|
||||
style={{ opacity: isVisible ? 1 : 0.4 }}
|
||||
>
|
||||
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-sm text-foreground">{entry.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
console.log("[v0] Render state - loading:", loading, "error:", error, "data length:", data.length)
|
||||
|
||||
if (loading) {
|
||||
console.log("[v0] Rendering loading state")
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.log("[v0] Rendering error state:", error)
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
||||
<p className="text-muted-foreground text-sm">Metrics data not available yet</p>
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col items-center justify-center h-[300px] gap-2">
|
||||
<p className="text-muted-foreground text-sm">Metrics data not available yet</p>
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
console.log("[v0] Rendering no data state")
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<p className="text-muted-foreground text-sm">No metrics data available</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-center h-[300px]">
|
||||
<p className="text-muted-foreground text-sm">No metrics data available</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
console.log("[v0] Rendering charts with", data.length, "data points")
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Timeframe Selector */}
|
||||
<div className="flex justify-end">
|
||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TIMEFRAME_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* CPU Usage + Load Average Chart */}
|
||||
<Card className="bg-card border-border">
|
||||
<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 className="px-0 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "CPU %", angle: -90, position: "insideLeft", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "Load", angle: 90, position: "insideRight", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomCpuTooltip />} />
|
||||
<Legend verticalAlign="top" height={36} content={renderLegend("cpu")} />
|
||||
<Area
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.3}
|
||||
name="CPU %"
|
||||
hide={!visibleLines.cpu.cpu}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="load"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Load Avg"
|
||||
hide={!visibleLines.cpu.load}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory Usage Chart */}
|
||||
<Card className="bg-card border-border">
|
||||
<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 className="px-0 pr-2 md:px-6">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 0, right: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="currentColor" className="text-border" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={tickInterval}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={
|
||||
isMobile ? undefined : { value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }
|
||||
}
|
||||
domain={[0, "dataMax"]}
|
||||
/>
|
||||
<Tooltip content={<CustomMemoryTooltip />} />
|
||||
<Legend verticalAlign="top" height={36} content={renderLegend("memory")} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryTotal"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="#3b82f6"
|
||||
fillOpacity={0.1}
|
||||
name="Total"
|
||||
hide={!visibleLines.memory.memoryTotal}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryUsed"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="#10b981"
|
||||
fillOpacity={0.3}
|
||||
name="Used"
|
||||
hide={!visibleLines.memory.memoryUsed}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryZfsArc"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
fill="#f59e0b"
|
||||
fillOpacity={0.3}
|
||||
name="ZFS ARC"
|
||||
hide={!visibleLines.memory.memoryZfsArc}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memoryFree"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2}
|
||||
fill="#06b6d4"
|
||||
fillOpacity={0.3}
|
||||
name="Available"
|
||||
hide={!visibleLines.memory.memoryFree}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent } from "./ui/dialog"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
Sparkles,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
Network,
|
||||
Box,
|
||||
Cpu,
|
||||
FileText,
|
||||
Rocket,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
interface OnboardingSlide {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
image?: string
|
||||
icon: React.ReactNode
|
||||
gradient: string
|
||||
}
|
||||
|
||||
const slides: OnboardingSlide[] = [
|
||||
{
|
||||
id: 0,
|
||||
title: "Welcome to ProxMenux Monitor!",
|
||||
description:
|
||||
"Your new monitoring tool for Proxmox. Discover all the features that will help you manage and supervise your infrastructure efficiently.",
|
||||
icon: <Sparkles className="h-16 w-16" />,
|
||||
gradient: "from-blue-500 via-purple-500 to-pink-500",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: "System Overview",
|
||||
description:
|
||||
"Monitor your server's status in real-time: CPU, memory, temperature, system load and more. Everything in an intuitive and easy-to-understand dashboard.",
|
||||
image: "/images/onboarding/imagen1.png",
|
||||
icon: <LayoutDashboard className="h-12 w-12" />,
|
||||
gradient: "from-blue-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Storage Management",
|
||||
description:
|
||||
"Visualize the status of all your disks and volumes. Detailed information on capacity, usage, SMART health, temperature and performance of each storage device.",
|
||||
image: "/images/onboarding/imagen2.png",
|
||||
icon: <HardDrive className="h-12 w-12" />,
|
||||
gradient: "from-cyan-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Network Metrics",
|
||||
description:
|
||||
"Monitor network traffic in real-time. Bandwidth statistics, active interfaces, transfer speeds and historical usage graphs.",
|
||||
image: "/images/onboarding/imagen3.png",
|
||||
icon: <Network className="h-12 w-12" />,
|
||||
gradient: "from-teal-500 to-green-500",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Virtual Machines & Containers",
|
||||
description:
|
||||
"Manage all your VMs and LXC containers from one place. Status, allocated resources, current usage and quick controls for each virtual machine.",
|
||||
image: "/images/onboarding/imagen4.png",
|
||||
icon: <Box className="h-12 w-12" />,
|
||||
gradient: "from-green-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Hardware Information",
|
||||
description:
|
||||
"Complete details of your server hardware: CPU, RAM, GPU, disks, network, UPS and more. Technical specifications, models, serial numbers and status of each component.",
|
||||
image: "/images/onboarding/imagen5.png",
|
||||
icon: <Cpu className="h-12 w-12" />,
|
||||
gradient: "from-emerald-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "System Logs",
|
||||
description:
|
||||
"Access system logs in real-time. Filter by event type, search for specific errors and keep complete track of your server activity. Download the displayed logs for further analysis.",
|
||||
image: "/images/onboarding/imagen6.png",
|
||||
icon: <FileText className="h-12 w-12" />,
|
||||
gradient: "from-blue-500 to-indigo-500",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Ready for the Future!",
|
||||
description:
|
||||
"ProxMenux Monitor is prepared to receive updates and improvements that will be added gradually, improving the user experience and being able to execute ProxMenux functions from the web panel.",
|
||||
icon: <Rocket className="h-16 w-16" />,
|
||||
gradient: "from-indigo-500 via-purple-500 to-pink-500",
|
||||
},
|
||||
]
|
||||
|
||||
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")
|
||||
if (!hasSeenOnboarding) {
|
||||
setOpen(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentSlide < slides.length - 1) {
|
||||
setDirection("next")
|
||||
setCurrentSlide(currentSlide + 1)
|
||||
} else {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentSlide > 0) {
|
||||
setDirection("prev")
|
||||
setCurrentSlide(currentSlide - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontShowAgain) {
|
||||
localStorage.setItem("proxmenux-onboarding-seen", "true")
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleDotClick = (index: number) => {
|
||||
setDirection(index > currentSlide ? "next" : "prev")
|
||||
setCurrentSlide(index)
|
||||
}
|
||||
|
||||
const slide = slides[currentSlide]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
|
||||
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
||||
<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-48 md:h-64 bg-gradient-to-br ${slide.gradient} flex items-center justify-center overflow-hidden`}
|
||||
>
|
||||
<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">
|
||||
{slide.image ? (
|
||||
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
|
||||
<Image
|
||||
src={slide.image || "/placeholder.svg"}
|
||||
alt={slide.title}
|
||||
width={600}
|
||||
height={400}
|
||||
className="rounded-lg shadow-2xl object-cover max-h-36 md:max-h-48"
|
||||
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")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="fallback-icon hidden">{slide.icon}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-pulse">{slide.icon}</div>
|
||||
)}
|
||||
</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="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-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>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 py-2 md:py-4">
|
||||
{slides.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleDotClick(index)}
|
||||
className={`transition-all duration-300 rounded-full ${
|
||||
index === currentSlide
|
||||
? "w-8 h-2.5 bg-blue-500 shadow-lg shadow-blue-500/50"
|
||||
: "w-2.5 h-2.5 bg-muted-foreground/60 hover:bg-muted-foreground/80 border border-muted-foreground/40"
|
||||
}`}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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 text-sm"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<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 text-sm"
|
||||
>
|
||||
Skip
|
||||
</Button>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<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 text-sm"
|
||||
>
|
||||
Get Started!
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
|
||||
import { SystemOverview } from "./system-overview"
|
||||
import { StorageOverview } from "./storage-overview"
|
||||
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 {
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Server,
|
||||
Menu,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
NetworkIcon,
|
||||
Box,
|
||||
Cpu,
|
||||
FileText,
|
||||
SettingsIcon,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
|
||||
|
||||
interface SystemStatus {
|
||||
status: "healthy" | "warning" | "critical"
|
||||
uptime: string
|
||||
lastUpdate: string
|
||||
serverName: string
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
interface FlaskSystemData {
|
||||
hostname: string
|
||||
node_id: string
|
||||
uptime: string
|
||||
cpu_usage: number
|
||||
memory_usage: number
|
||||
temperature: number
|
||||
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("en-US", { hour12: false }),
|
||||
serverName: "Loading...",
|
||||
nodeId: "Loading...",
|
||||
})
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [isServerConnected, setIsServerConnected] = useState(true)
|
||||
const [componentKey, setComponentKey] = useState(0)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
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 () => {
|
||||
try {
|
||||
const data: FlaskSystemInfo = await fetchApi("/api/system-info")
|
||||
|
||||
const uptimeValue =
|
||||
data.uptime && typeof data.uptime === "string" && data.uptime.trim() !== "" ? data.uptime : "N/A"
|
||||
|
||||
const backendStatus = data.health?.status?.toUpperCase() || "OK"
|
||||
let healthStatus: "healthy" | "warning" | "critical"
|
||||
|
||||
if (backendStatus === "CRITICAL") {
|
||||
healthStatus = "critical"
|
||||
} else if (backendStatus === "WARNING") {
|
||||
healthStatus = "warning"
|
||||
} else {
|
||||
healthStatus = "healthy"
|
||||
}
|
||||
|
||||
setSystemStatus({
|
||||
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)
|
||||
|
||||
setIsServerConnected(false)
|
||||
setSystemStatus((prev) => ({
|
||||
...prev,
|
||||
status: "critical",
|
||||
serverName: "Server Offline",
|
||||
nodeId: "Server Offline",
|
||||
uptime: "N/A",
|
||||
lastUpdate: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Siempre fetch inicial
|
||||
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 (
|
||||
systemStatus.serverName &&
|
||||
systemStatus.serverName !== "Loading..." &&
|
||||
systemStatus.serverName !== "Server Offline"
|
||||
) {
|
||||
document.title = `${systemStatus.serverName} - ProxMenux Monitor`
|
||||
} else {
|
||||
document.title = "ProxMenux Monitor"
|
||||
}
|
||||
}, [systemStatus.serverName])
|
||||
|
||||
useEffect(() => {
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let lastPosition = window.scrollY
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentScrollY = window.scrollY
|
||||
const delta = currentScrollY - lastPosition
|
||||
|
||||
if (currentScrollY < 50) {
|
||||
setShowNavigation(true)
|
||||
} else if (delta > 2) {
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
hideTimeout = setTimeout(() => setShowNavigation(false), 20)
|
||||
} else if (delta < -2) {
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
setShowNavigation(true)
|
||||
}
|
||||
|
||||
lastPosition = currentScrollY
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true })
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll)
|
||||
if (hideTimeout) clearTimeout(hideTimeout)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshData = async () => {
|
||||
setIsRefreshing(true)
|
||||
await fetchSystemData()
|
||||
setComponentKey((prev) => prev + 1)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
switch (systemStatus.status) {
|
||||
case "healthy":
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
case "critical":
|
||||
return <XCircle className="h-4 w-4 text-red-500" />
|
||||
}
|
||||
}, [systemStatus.status])
|
||||
|
||||
const statusColor = useMemo(() => {
|
||||
switch (systemStatus.status) {
|
||||
case "healthy":
|
||||
return "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
case "warning":
|
||||
return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||
case "critical":
|
||||
return "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
}
|
||||
}, [systemStatus.status])
|
||||
|
||||
const getActiveTabLabel = () => {
|
||||
switch (activeTab) {
|
||||
case "overview":
|
||||
return "Overview"
|
||||
case "storage":
|
||||
return "Storage"
|
||||
case "network":
|
||||
return "Network"
|
||||
case "vms":
|
||||
return "VMs & LXCs"
|
||||
case "hardware":
|
||||
return "Hardware"
|
||||
case "logs":
|
||||
return "System Logs"
|
||||
case "settings":
|
||||
return "Settings"
|
||||
default:
|
||||
return "Navigation Menu"
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="container mx-auto">
|
||||
<div className="flex items-center space-x-2 text-red-500 mb-2">
|
||||
<XCircle className="h-5 w-5" />
|
||||
<span className="font-medium">ProxMenux Server Connection Failed</span>
|
||||
</div>
|
||||
<div className="text-sm text-red-500/80 space-y-1 ml-7">
|
||||
<p>• Check that the monitor.service is running correctly.</p>
|
||||
<p>• The ProxMenux server should start automatically on port 8008</p>
|
||||
<p>
|
||||
• Try accessing:{" "}
|
||||
<a href={getApiUrl("/api/health")} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
{getApiUrl("/api/health")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">
|
||||
{/* Logo and Title */}
|
||||
<div className="flex items-center space-x-2 md:space-x-3 min-w-0">
|
||||
<div className="w-16 h-16 md:w-10 md:h-10 relative flex items-center justify-center bg-primary/10 flex-shrink-0">
|
||||
<Image
|
||||
src="/images/proxmenux-logo.png"
|
||||
alt="ProxMenux Logo"
|
||||
width={64}
|
||||
height={64}
|
||||
className="object-contain md:w-10 md:h-10"
|
||||
priority
|
||||
onError={(e) => {
|
||||
console.log("[v0] Logo failed to load, using fallback icon")
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.parentElement?.querySelector(".fallback-icon")
|
||||
if (fallback) {
|
||||
fallback.classList.remove("hidden")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Server className="h-8 w-8 md:h-6 md:w-6 text-primary absolute fallback-icon hidden" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">ProxMenux Monitor</h1>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">Proxmox System Dashboard</p>
|
||||
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Server className="h-3 w-3" />
|
||||
<span className="truncate">Node: {systemStatus.serverName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden lg:flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-foreground">Node: {systemStatus.serverName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
{statusIcon}
|
||||
<span className="ml-1 capitalize">{systemStatus.status}</span>
|
||||
</Badge>
|
||||
|
||||
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Uptime: {systemStatus.uptime || "N/A"}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
refreshData()
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="border-border/50 bg-transparent hover:bg-secondary"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex lg:hidden items-center gap-2">
|
||||
<Badge variant="outline" className={`${statusColor} text-xs px-2`}>
|
||||
{statusIcon}
|
||||
<span className="ml-1 capitalize hidden sm:inline">{systemStatus.status}</span>
|
||||
</Badge>
|
||||
|
||||
<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>
|
||||
|
||||
<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 || "N/A"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`sticky z-40 bg-background
|
||||
top-[120px] md:top-[76px]
|
||||
transition-all duration-700 ease-[cubic-bezier(0.4,0,0.2,1)]
|
||||
${showNavigation ? "translate-y-0 opacity-100" : "-translate-y-[120%] opacity-0 pointer-events-none"}
|
||||
`}
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6 pt-4 md:pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||
<TabsList className="hidden md:grid w-full grid-cols-7 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"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="storage"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Storage
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="network"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Network
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="vms"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
VMs & LXCs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="hardware"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Hardware
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="logs"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
System Logs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<div className="md:hidden">
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`w-full justify-between border-border ${
|
||||
activeTab ? "bg-blue-500/10 text-blue-500" : "bg-card"
|
||||
}`}
|
||||
>
|
||||
<span>{getActiveTabLabel()}</span>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</div>
|
||||
<SheetContent side="top" className="bg-card border-border">
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("overview")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "overview"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Overview</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("storage")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "storage"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("network")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "network"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<NetworkIcon className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("vms")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "vms"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Box className="h-5 w-5" />
|
||||
<span>VMs & LXCs</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("hardware")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "hardware"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("logs")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "logs"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>System Logs</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>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6">
|
||||
<TabsContent value="overview" className="space-y-4 md:space-y-6 mt-0">
|
||||
<SystemOverview key={`overview-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="storage" className="space-y-4 md:space-y-6 mt-0">
|
||||
<StorageOverview key={`storage-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="network" className="space-y-4 md:space-y-6 mt-0">
|
||||
<NetworkMetrics key={`network-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vms" className="space-y-4 md:space-y-6 mt-0">
|
||||
<VirtualMachines key={`vms-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="hardware" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Hardware key={`hardware-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
|
||||
<SystemLogs key={`logs-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Settings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.0.1</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 hover:underline transition-colors"
|
||||
>
|
||||
Support and contribute to the project
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<HealthStatusModal open={showHealthModal} onOpenChange={setShowHealthModal} getApiUrl={getApiUrl} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent } from "./ui/dialog"
|
||||
import { X, Sparkles, Link2, Shield, Zap, HardDrive, Gauge, Wrench, Settings } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.0.1" // 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">
|
||||
<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 { 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,
|
||||
} from "lucide-react"
|
||||
import { APP_VERSION } from "./release-notes-modal"
|
||||
import { getApiUrl, fetchApi } from "../lib/api-config"
|
||||
import { TwoFactorSetup } from "./two-factor-setup"
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus()
|
||||
loadProxmenuxTools()
|
||||
}, [])
|
||||
|
||||
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],
|
||||
}))
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon } 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 },
|
||||
{ name: "System Logs", href: "/logs", icon: FileText },
|
||||
{ name: "Settings", href: "/settings", icon: SettingsIcon },
|
||||
]
|
||||
|
||||
// ... existing code ...
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
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
|
||||
used: number
|
||||
available: number
|
||||
disks: DiskInfo[]
|
||||
}
|
||||
|
||||
interface DiskInfo {
|
||||
name: string
|
||||
mountpoint: string
|
||||
fstype: string
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
usage_percent: number
|
||||
health: string
|
||||
temperature: number
|
||||
}
|
||||
|
||||
const fetchStorageData = async (): Promise<StorageData | null> => {
|
||||
try {
|
||||
console.log("[v0] Fetching storage data from Flask server...")
|
||||
const response = await fetch("/api/storage", {
|
||||
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()
|
||||
console.log("[v0] Successfully fetched storage data from Flask:", data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch storage data from Flask server:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function StorageMetrics() {
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const result = await fetchStorageData()
|
||||
|
||||
if (!result) {
|
||||
setError("Flask server not available. Please ensure the server is running.")
|
||||
} else {
|
||||
setStorageData(result)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
const interval = setInterval(fetchData, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-lg font-medium text-foreground mb-2">Loading storage data...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !storageData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-red-500/10 border-red-500/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<div>
|
||||
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div>
|
||||
<div className="text-sm">
|
||||
{error || "Unable to connect to the Flask server. Please ensure the server is running and try again."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const usagePercent = storageData.total > 0 ? (storageData.used / storageData.total) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Overview 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">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Total Storage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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">
|
||||
{formatStorage(storageData.used)} used • {formatStorage(storageData.available)} available
|
||||
</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">Used Storage</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Archive className="h-5 w-5 mr-2" />
|
||||
Available
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Available space</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
Disks
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{storageData.disks.length}</div>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{storageData.disks.filter((d) => d.health === "healthy").length} Healthy
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Storage devices</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Disk Details */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Database className="h-5 w-5 mr-2" />
|
||||
Storage Devices
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.disks.map((disk, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-border bg-card/50"
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{disk.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{disk.fstype} • {disk.mountpoint}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{formatStorage(disk.used)} / {formatStorage(disk.total)}
|
||||
</div>
|
||||
<Progress value={disk.usage_percent} className="w-24 mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-muted-foreground">Temp</div>
|
||||
<div className="text-sm font-medium text-foreground">{disk.temperature}°C</div>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
disk.health === "healthy"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
|
||||
}
|
||||
>
|
||||
{disk.health === "healthy" ? (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{disk.health}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network } from "lucide-react"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
|
||||
interface SystemData {
|
||||
cpu_usage: number
|
||||
memory_usage: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
temperature: number
|
||||
uptime: string
|
||||
load_average: number[]
|
||||
hostname: string
|
||||
node_id: string
|
||||
timestamp: string
|
||||
cpu_cores?: number
|
||||
cpu_threads?: number
|
||||
proxmox_version?: string
|
||||
kernel_version?: string
|
||||
available_updates?: number
|
||||
}
|
||||
|
||||
interface VMData {
|
||||
vmid: number
|
||||
name: string
|
||||
status: string
|
||||
cpu: number
|
||||
mem: number
|
||||
maxmem: number
|
||||
disk: number
|
||||
maxdisk: number
|
||||
uptime: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface StorageData {
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
disk_count: number
|
||||
disks: Array<{
|
||||
name: string
|
||||
mountpoint: string
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
usage_percent: number
|
||||
}>
|
||||
}
|
||||
|
||||
interface NetworkData {
|
||||
interfaces: Array<{
|
||||
name: string
|
||||
status: string
|
||||
addresses: Array<{ ip: string; netmask: string }>
|
||||
}>
|
||||
traffic: {
|
||||
bytes_sent: number
|
||||
bytes_recv: number
|
||||
packets_sent: number
|
||||
packets_recv: number
|
||||
}
|
||||
physical_active_count?: number
|
||||
physical_total_count?: number
|
||||
bridge_active_count?: number
|
||||
bridge_total_count?: number
|
||||
physical_interfaces?: Array<{
|
||||
name: string
|
||||
status: string
|
||||
addresses: Array<{ ip: string; netmask: string }>
|
||||
}>
|
||||
bridge_interfaces?: Array<{
|
||||
name: string
|
||||
status: string
|
||||
addresses: Array<{ ip: string; netmask: string }>
|
||||
}>
|
||||
}
|
||||
|
||||
interface ProxmoxStorageData {
|
||||
storage: Array<{
|
||||
name: string
|
||||
type: string
|
||||
status: string
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
percent: number
|
||||
}>
|
||||
}
|
||||
|
||||
const fetchSystemData = async (): Promise<SystemData | null> => {
|
||||
try {
|
||||
const data = await fetchApi<SystemData>("/api/system")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch system data:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVMData = async (): Promise<VMData[]> => {
|
||||
try {
|
||||
const data = await fetchApi<any>("/api/vms")
|
||||
return Array.isArray(data) ? data : data.vms || []
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch VM data:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStorageData = async (): Promise<StorageData | null> => {
|
||||
try {
|
||||
const data = await fetchApi<StorageData>("/api/storage/summary")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Storage API not available (this is normal if not configured)")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNetworkData = async (): Promise<NetworkData | null> => {
|
||||
try {
|
||||
const data = await fetchApi<NetworkData>("/api/network/summary")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Network API not available (this is normal if not configured)")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorageData | null> => {
|
||||
try {
|
||||
const data = await fetchApi<ProxmoxStorageData>("/api/proxmox-storage")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Proxmox storage API not available")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 [loadingStates, setLoadingStates] = useState({
|
||||
system: true,
|
||||
vms: true,
|
||||
storage: true,
|
||||
network: true,
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [networkTimeframe, setNetworkTimeframe] = useState("day")
|
||||
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
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 }))),
|
||||
])
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fetchAllData()
|
||||
|
||||
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)
|
||||
|
||||
return () => {
|
||||
clearInterval(systemInterval)
|
||||
clearInterval(vmInterval)
|
||||
clearInterval(storageInterval)
|
||||
clearInterval(networkInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isInitialLoading = loadingStates.system && !systemData
|
||||
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-lg font-medium text-foreground mb-2">Connecting to ProxMenux Monitor...</div>
|
||||
<div className="text-sm text-muted-foreground">Fetching real-time system data</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="bg-card border-border animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
<div className="h-4 bg-muted rounded w-1/2 mb-4"></div>
|
||||
<div className="h-8 bg-muted rounded w-3/4 mb-2"></div>
|
||||
<div className="h-2 bg-muted rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-muted rounded w-2/3"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !systemData) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-red-500/10 border-red-500/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<AlertCircle className="h-6 w-6" />
|
||||
<div>
|
||||
<div className="font-semibold text-lg mb-1">Flask Server Not Available</div>
|
||||
<div className="text-sm">
|
||||
{error || "Unable to connect to the Flask server. Please ensure the server is running and try again."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const vmStats = {
|
||||
total: vmData.length,
|
||||
running: vmData.filter((vm) => vm.status === "running").length,
|
||||
stopped: vmData.filter((vm) => vm.status === "stopped").length,
|
||||
lxc: vmData.filter((vm) => vm.type === "lxc").length,
|
||||
vms: vmData.filter((vm) => vm.type === "qemu" || !vm.type).length,
|
||||
}
|
||||
|
||||
const getTemperatureStatus = (temp: number) => {
|
||||
if (temp === 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" }
|
||||
if (temp < 60) return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
if (temp < 75) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
}
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
if (!seconds || seconds === 0) return "Stopped"
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
return (bytes / 1024 ** 3).toFixed(2)
|
||||
}
|
||||
|
||||
const formatStorage = (sizeInGB: number): string => {
|
||||
if (sizeInGB < 1) {
|
||||
return `${(sizeInGB * 1024).toFixed(1)} MB`
|
||||
} else if (sizeInGB > 999) {
|
||||
return `${(sizeInGB / 1024).toFixed(2)} TB`
|
||||
} else {
|
||||
return `${sizeInGB.toFixed(2)} GB`
|
||||
}
|
||||
}
|
||||
|
||||
const tempStatus = getTemperatureStatus(systemData.temperature)
|
||||
|
||||
const localStorage = proxmoxStorageData?.storage.find((s) => s.name === "local")
|
||||
|
||||
const vmLxcStorages = proxmoxStorageData?.storage.filter(
|
||||
(s) =>
|
||||
(s.type === "lvm" || s.type === "lvmthin" || s.type === "zfspool" || s.type === "btrfs" || s.type === "dir") &&
|
||||
s.type !== "nfs" &&
|
||||
s.type !== "cifs" &&
|
||||
s.type !== "iscsi" &&
|
||||
s.name !== "local",
|
||||
)
|
||||
|
||||
const vmLxcStorageTotal = vmLxcStorages?.reduce((acc, s) => acc + s.total, 0) || 0
|
||||
const vmLxcStorageUsed = vmLxcStorages?.reduce((acc, s) => acc + s.used, 0) || 0
|
||||
const vmLxcStorageAvailable = vmLxcStorages?.reduce((acc, s) => acc + s.available, 0) || 0
|
||||
const vmLxcStoragePercent = vmLxcStorageTotal > 0 ? (vmLxcStorageUsed / vmLxcStorageTotal) * 100 : 0
|
||||
|
||||
const getLoadStatus = (load: number, cores: number) => {
|
||||
if (load < cores) {
|
||||
return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" }
|
||||
} else if (load < cores * 1.5) {
|
||||
return { status: "Moderate", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" }
|
||||
} else {
|
||||
return { status: "High", color: "bg-red-500/10 text-red-500 border-red-500/20" }
|
||||
}
|
||||
}
|
||||
|
||||
const systemAlerts = []
|
||||
if (systemData.available_updates && systemData.available_updates > 0) {
|
||||
systemAlerts.push({
|
||||
type: "warning",
|
||||
message: `${systemData.available_updates} updates available`,
|
||||
})
|
||||
}
|
||||
if (vmStats.stopped > 0) {
|
||||
systemAlerts.push({
|
||||
type: "info",
|
||||
message: `${vmStats.stopped} VM${vmStats.stopped > 1 ? "s" : ""} stopped`,
|
||||
})
|
||||
}
|
||||
if (systemData.temperature > 75) {
|
||||
systemAlerts.push({
|
||||
type: "warning",
|
||||
message: "High temperature detected",
|
||||
})
|
||||
}
|
||||
if (localStorage && localStorage.percent > 90) {
|
||||
systemAlerts.push({
|
||||
type: "warning",
|
||||
message: "System storage almost full",
|
||||
})
|
||||
}
|
||||
|
||||
const loadStatus = getLoadStatus(systemData.load_average[0], systemData.cpu_cores || 8)
|
||||
|
||||
const getTimeframeLabel = (timeframe: string): string => {
|
||||
switch (timeframe) {
|
||||
case "hour":
|
||||
return "1h"
|
||||
case "day":
|
||||
return "24h"
|
||||
case "week":
|
||||
return "7d"
|
||||
case "month":
|
||||
return "30d"
|
||||
case "year":
|
||||
return "1y"
|
||||
default:
|
||||
return timeframe
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{systemData.cpu_usage}%</div>
|
||||
<Progress value={systemData.cpu_usage} className="mt-2 [&>div]:bg-blue-500" />
|
||||
<p className="text-xs text-muted-foreground mt-2">Real-time usage</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">Memory Usage</CardTitle>
|
||||
<MemoryStick className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{systemData.memory_used.toFixed(1)} GB</div>
|
||||
<Progress value={systemData.memory_usage} className="mt-2 [&>div]:bg-blue-500" />
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
<span className="text-green-500 font-medium">{systemData.memory_usage.toFixed(1)}%</span> of{" "}
|
||||
{systemData.memory_total} GB
|
||||
</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>
|
||||
<Thermometer className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{systemData.temperature === 0 ? "N/A" : `${systemData.temperature}°C`}
|
||||
</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<Badge variant="outline" className={tempStatus.color}>
|
||||
{tempStatus.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{systemData.temperature === 0 ? "No sensor available" : "Live temperature reading"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<NodeMetricsCharts />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<HardDrive className="h-5 w-5 mr-2" />
|
||||
Storage Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingStates.storage ? (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-6 bg-muted rounded w-full"></div>
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-4 bg-muted rounded w-2/3"></div>
|
||||
</div>
|
||||
) : storageData ? (
|
||||
<div className="space-y-4">
|
||||
{(() => {
|
||||
const totalCapacity = (vmLxcStorageTotal || 0) + (localStorage?.total || 0)
|
||||
const totalUsed = (vmLxcStorageUsed || 0) + (localStorage?.used || 0)
|
||||
const totalAvailable = (vmLxcStorageAvailable || 0) + (localStorage?.available || 0)
|
||||
const totalPercent = totalCapacity > 0 ? (totalUsed / totalCapacity) * 100 : 0
|
||||
|
||||
return totalCapacity > 0 ? (
|
||||
<div className="space-y-2 pb-4 border-b-2 border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span>
|
||||
<span className="text-lg font-bold text-foreground">{formatStorage(totalCapacity)}</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={totalPercent}
|
||||
className="mt-2 h-3 [&>div]:bg-gradient-to-r [&>div]:from-blue-500 [&>div]:to-purple-500"
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Used: <span className="font-semibold text-foreground">{formatStorage(totalUsed)}</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Free: <span className="font-semibold text-green-500">{formatStorage(totalAvailable)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-muted-foreground">{totalPercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total Capacity:</span>
|
||||
<span className="text-lg font-semibold text-foreground">{storageData.total} TB</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Physical Disks:</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{storageData.disk_count} disk{storageData.disk_count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<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>
|
||||
</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)}
|
||||
</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)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{vmLxcStoragePercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
{vmLxcStorages.length > 1 && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{vmLxcStorages.length} storage volume{vmLxcStorages.length > 1 ? "s" : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">No VM/LXC storage configured</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localStorage && (
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
</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)}
|
||||
</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)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{localStorage.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">Storage data not available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Network className="h-5 w-5 mr-2" />
|
||||
Network Overview
|
||||
</div>
|
||||
<Select value={networkTimeframe} onValueChange={setNetworkTimeframe}>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hour">1 Hour</SelectItem>
|
||||
<SelectItem value="day">24 Hours</SelectItem>
|
||||
<SelectItem value="week">7 Days</SelectItem>
|
||||
<SelectItem value="month">30 Days</SelectItem>
|
||||
<SelectItem value="year">1 Year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{(networkData.physical_active_count || 0) + (networkData.bridge_active_count || 0)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{networkData.physical_interfaces && networkData.physical_interfaces.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{networkData.physical_interfaces
|
||||
.filter((iface) => iface.status === "up")
|
||||
.map((iface) => (
|
||||
<Badge
|
||||
key={iface.name}
|
||||
variant="outline"
|
||||
className="bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||||
>
|
||||
{iface.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{networkData.bridge_interfaces && networkData.bridge_interfaces.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{networkData.bridge_interfaces
|
||||
.filter((iface) => iface.status === "up")
|
||||
.map((iface) => (
|
||||
<Badge
|
||||
key={iface.name}
|
||||
variant="outline"
|
||||
className="bg-green-500/10 text-green-500 border-green-500/20"
|
||||
>
|
||||
{iface.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border space-y-2">
|
||||
<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)}
|
||||
<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)}
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">Network data not available</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Server className="h-5 w-5 mr-2" />
|
||||
System Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Uptime:</span>
|
||||
<span className="text-foreground">{systemData.uptime}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Proxmox Version:</span>
|
||||
<span className="text-foreground">{systemData.proxmox_version || "N/A"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Kernel:</span>
|
||||
<span className="text-foreground font-mono text-sm">{systemData.kernel_version || "Linux"}</span>
|
||||
</div>
|
||||
{systemData.available_updates !== undefined && systemData.available_updates > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Available Updates:</span>
|
||||
<Badge variant="outline" className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">
|
||||
{systemData.available_updates} packages
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<Zap className="h-5 w-5 mr-2" />
|
||||
System Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">Load Average (1m):</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold text-foreground font-mono">
|
||||
{systemData.load_average[0].toFixed(2)}
|
||||
</span>
|
||||
<Badge variant="outline" className={loadStatus.color}>
|
||||
{loadStatus.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">CPU Threads:</span>
|
||||
<span className="text-lg font-semibold text-foreground">{systemData.cpu_threads || "N/A"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pb-3 border-b border-border">
|
||||
<span className="text-sm text-muted-foreground">Physical Disks:</span>
|
||||
<span className="text-lg font-semibold text-foreground">{storageData?.disk_count || "N/A"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Network Interfaces:</span>
|
||||
<span className="text-lg font-semibold text-foreground">
|
||||
{networkData?.physical_total_count || networkData?.physical_interfaces?.length || "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import type { ThemeProviderProps } from "next-themes"
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { Button } from "./ui/button"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
console.log("[v0] Current theme:", theme)
|
||||
const newTheme = theme === "light" ? "dark" : "light"
|
||||
console.log("[v0] Switching to theme:", newTheme)
|
||||
setTheme(newTheme)
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="border-border bg-transparent w-9 h-9">
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={handleThemeToggle} className="border-border bg-transparent w-9 h-9">
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -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,28 @@
|
||||
import type * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
},
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
|
||||
)
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"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}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70")
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-secondary", className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", className)} {...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = DialogPrimitive.Root
|
||||
|
||||
const SheetTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const SheetClose = DialogPrimitive.Close
|
||||
|
||||
const SheetPortal = DialogPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<React.ElementRef<typeof DialogPrimitive.Content>, SheetContentProps>(
|
||||
({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...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-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
),
|
||||
)
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
|
||||
))
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
))
|
||||
SheetDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -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,21 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
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`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
trailingSlash: true,
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
experimental: {
|
||||
esmExternals: 'loose',
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
fs: false,
|
||||
net: false,
|
||||
tls: false,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.0.1",
|
||||
"description": "Proxmox System Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"export": "next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.1",
|
||||
"@radix-ui/react-avatar": "1.1.2",
|
||||
"@radix-ui/react-checkbox": "1.1.3",
|
||||
"@radix-ui/react-collapsible": "1.1.2",
|
||||
"@radix-ui/react-context-menu": "2.2.4",
|
||||
"@radix-ui/react-dialog": "1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||
"@radix-ui/react-hover-card": "1.1.4",
|
||||
"@radix-ui/react-label": "2.1.1",
|
||||
"@radix-ui/react-menubar": "1.1.4",
|
||||
"@radix-ui/react-navigation-menu": "1.2.3",
|
||||
"@radix-ui/react-popover": "1.1.4",
|
||||
"@radix-ui/react-progress": "1.1.1",
|
||||
"@radix-ui/react-radio-group": "1.2.2",
|
||||
"@radix-ui/react-scroll-area": "1.2.2",
|
||||
"@radix-ui/react-select": "2.1.4",
|
||||
"@radix-ui/react-separator": "1.1.1",
|
||||
"@radix-ui/react-slider": "1.2.2",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-switch": "1.1.2",
|
||||
"@radix-ui/react-tabs": "1.1.2",
|
||||
"@radix-ui/react-toast": "1.2.4",
|
||||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"geist": "^1.3.1",
|
||||
"input-otp": "1.4.1",
|
||||
"lucide-react": "^0.454.0",
|
||||
"next": "15.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19",
|
||||
"react-day-picker": "9.8.0",
|
||||
"react-dom": "^19",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^1.7.4",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.9",
|
||||
"zod": "3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#0d597f"><path d="M23.25 38.81v-6.745l-4.855 4.864c.474.333.968.635 1.48.906.463.243.87.434 1.303.58s.782.24 1.13.304.66.093.95.096m24.822-.562c.045.037.092.07.142.1a2.77 2.77 0 0 0 .385.203 2.93 2.93 0 0 0 .637.194c.296.06.598.088.9.087.3 0 .608-.03.955-.087a7.24 7.24 0 0 0 1.138-.301 9.96 9.96 0 0 0 1.32-.579c.52-.274 1.02-.58 1.503-.918l-3.685-3.6-12.21-12.258-5.356 5.356-7.23-7.455-18.14 17.935a13.82 13.82 0 0 0 1.5.918c.47.246.91.434 1.317.58a7.18 7.18 0 0 0 1.135.301 5.53 5.53 0 0 0 .955.087c.302.001.604-.028.9-.087a3.29 3.29 0 0 0 .637-.194 2.49 2.49 0 0 0 .385-.197l.145-.104 8.193-8.193 2.924-2.808 8.106 8.106 2.837 2.912a1.29 1.29 0 0 0 .145.101 2.52 2.52 0 0 0 .385.2c.206.085.42.15.637.194.255.052.556.087.903.087.3 0 .608-.03.955-.087a6.89 6.89 0 0 0 1.138-.301 9.95 9.95 0 0 0 1.32-.579c.52-.274 1.02-.58 1.503-.918l-6.508-6.37 1.2-1.2 5.63 5.63 3.283 3.254m-.07-33.96l15.998 27.714L48.003 59.71H15.996L-.002 31.997 15.996 4.283z"/><path d="M38.02 30.65l-4.262-4.256.304-.304 4.3 4.244z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256" version="1.0">
|
||||
<defs>
|
||||
<linearGradient xlink:href="#a" id="d" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-.39377 0 0 .39375 978.34969 416.9815)" x1="541.33502" y1="104.50665" x2="606.91248" y2="303.14029"/>
|
||||
<linearGradient gradientUnits="userSpaceOnUse" id="a" y2="129.3468" x2="112.49853" y1="6.1372099" x1="112.49854" gradientTransform="translate(287 -83)">
|
||||
<stop offset="0" style="stop-color:#fff;stop-opacity:0"/>
|
||||
<stop offset="1" style="stop-color:#fff;stop-opacity:.27450982"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="b">
|
||||
<stop style="stop-color:#00bdec" offset="0"/>
|
||||
<stop style="stop-color:#40bfde" offset="1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="c">
|
||||
<stop style="stop-color:#6e6e6e" offset="0"/>
|
||||
<stop style="stop-color:#4d4d4d" offset="1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path style="fill:#1793d1" d="M128 0c-11.39482 27.937051-18.31337 46.237163-31 73.34375 7.7785 8.245207 17.33826 17.811753 32.84375 28.65625-16.66992-6.859577-28.03357-13.728504-36.53125-20.875C77.076039 115.00489 51.621645 163.24639 0 256c40.562707-23.41756 72.007597-37.86167 101.3125-43.375-1.25376-5.40435-1.923505-11.27752-1.875-17.375l.03125-1.28125c.64379-25.99398 14.16934-45.98224 30.1875-44.625 16.01815 1.35723 28.48754 23.53727 27.84375 49.53125-.12127 4.89622-.6905 9.60082-1.65625 13.96875C184.83328 218.51691 215.98162 232.89667 256 256c-7.89193-14.52962-14.96051-27.61983-21.6875-40.09375-10.59609-8.21269-21.64301-18.89743-44.1875-30.46875 15.4958 4.02645 26.60184 8.6825 35.25 13.875C156.97985 71.972668 151.45422 55.040376 128 0z" transform="matrix(1 0 0 1 -.000002 4e-8)"/>
|
||||
<path style="fill:#fff;fill-opacity:.16568047" d="M818.22607 548.55277c-41.18143-55.89508-50.72685-100.94481-53.14467-111.70015 21.96737 50.6686 21.81733 51.28995 53.14467 111.70015z" transform="matrix(1.34737 0 0 1.34737 -902.40019 -586.944907)"/>
|
||||
<path style="fill:url(#d);fill-opacity:1" d="M765.09805 436.43495c-1.05641 2.59705-2.08559 5.1172-3.06152 7.51465-1.08115 2.65585-2.10928 5.19128-3.13111 7.677-1.02174 2.48575-2.03439 4.91156-3.03833 7.30591-1.00398 2.39446-2.01068 4.76169-3.03833 7.14355-1.02758 2.38177-2.06156 4.78845-3.15429 7.23633-1.09273 2.44796-2.23335 4.94504-3.43262 7.53784-1.19937 2.59282-2.45641 5.27815-3.80371 8.09448-.18662.39008-.41312.83402-.60303 1.22925 5.75521 6.09563 12.84133 13.14976 24.28345 21.15234-12.34021-5.07792-20.76511-10.15751-27.06665-15.44677-.32717.66791-.61387 1.26431-.95093 1.94824-.44365.90024-.97632 1.92315-1.43799 2.85278-.80967 1.66032-1.65574 3.36576-2.52807 5.12574-.33524.66652-.62948 1.24283-.97413 1.92504-5.50733 11.05265-12.33962 24.28304-21.12915 40.72754 24.09557-13.57581 50.08533-33.16242 97.29615-16.30493-2.36708-4.48319-4.54319-8.68756-6.58692-12.64038-2.0437-3.95294-3.94246-7.6555-5.70556-11.15601-1.76297-3.50043-3.39212-6.80069-4.917-9.92675-1.52486-3.12599-2.93832-6.0765-4.26757-8.90625-1.32934-2.8297-2.58106-5.55264-3.75733-8.16407-1.17634-2.6114-2.29708-5.11315-3.36304-7.58422-1.06607-2.4712-2.08657-4.89718-3.08471-7.30591-.99823-2.4088-1.97267-4.81178-2.94556-7.23633-.34772-.86638-.69553-1.7689-1.0437-2.64404-2.66339-6.25269-5.3982-12.73163-8.55835-20.15503z" transform="matrix(1.34737 0 0 1.34737 -902.40019 -586.944907)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,86 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 10.0, SVG Export Plug-In . SVG Version: 3.0.0 Build 77) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd" [
|
||||
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
|
||||
<!ENTITY ns_extend "http://ns.adobe.com/Extensibility/1.0/">
|
||||
<!ENTITY ns_ai "http://ns.adobe.com/AdobeIllustrator/10.0/">
|
||||
<!ENTITY ns_graphs "http://ns.adobe.com/Graphs/1.0/">
|
||||
<!ENTITY ns_vars "http://ns.adobe.com/Variables/1.0/">
|
||||
<!ENTITY ns_imrep "http://ns.adobe.com/ImageReplacement/1.0/">
|
||||
<!ENTITY ns_sfw "http://ns.adobe.com/SaveForWeb/1.0/">
|
||||
<!ENTITY ns_custom "http://ns.adobe.com/GenericCustomNamespace/1.0/">
|
||||
<!ENTITY ns_adobe_xpath "http://ns.adobe.com/XPath/1.0/">
|
||||
<!ENTITY ns_svg "http://www.w3.org/2000/svg">
|
||||
<!ENTITY ns_xlink "http://www.w3.org/1999/xlink">
|
||||
]>
|
||||
<svg
|
||||
xmlns:x="&ns_extend;" xmlns:i="&ns_ai;" xmlns:graph="&ns_graphs;" i:viewOrigin="262 450" i:rulerOrigin="0 0" i:pageBounds="0 792 612 0"
|
||||
xmlns="&ns_svg;" xmlns:xlink="&ns_xlink;" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
|
||||
width="87.041" height="108.445" viewBox="0 0 87.041 108.445" overflow="visible" enable-background="new 0 0 87.041 108.445"
|
||||
xml:space="preserve">
|
||||
<metadata>
|
||||
<variableSets xmlns="&ns_vars;">
|
||||
<variableSet varSetName="binding1" locked="none">
|
||||
<variables></variables>
|
||||
<v:sampleDataSets xmlns="&ns_custom;" xmlns:v="&ns_vars;"></v:sampleDataSets>
|
||||
</variableSet>
|
||||
</variableSets>
|
||||
<sfw xmlns="&ns_sfw;">
|
||||
<slices></slices>
|
||||
<sliceSourceBounds y="341.555" x="262" width="87.041" height="108.445" bottomLeftOrigin="true"></sliceSourceBounds>
|
||||
</sfw>
|
||||
</metadata>
|
||||
<g id="Layer_1" i:layer="yes" i:dimmedPercent="50" i:rgbTrio="#4F008000FFFF">
|
||||
<g>
|
||||
<path i:knockout="Off" fill="#A80030" d="M51.986,57.297c-1.797,0.025,0.34,0.926,2.686,1.287
|
||||
c0.648-0.506,1.236-1.018,1.76-1.516C54.971,57.426,53.484,57.434,51.986,57.297"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M61.631,54.893c1.07-1.477,1.85-3.094,2.125-4.766c-0.24,1.192-0.887,2.221-1.496,3.307
|
||||
c-3.359,2.115-0.316-1.256-0.002-2.537C58.646,55.443,61.762,53.623,61.631,54.893"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M65.191,45.629c0.217-3.236-0.637-2.213-0.924-0.978
|
||||
C64.602,44.825,64.867,46.932,65.191,45.629"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M45.172,1.399c0.959,0.172,2.072,0.304,1.916,0.533
|
||||
C48.137,1.702,48.375,1.49,45.172,1.399"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M47.088,1.932l-0.678,0.14l0.631-0.056L47.088,1.932"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M76.992,46.856c0.107,2.906-0.85,4.316-1.713,6.812l-1.553,0.776
|
||||
c-1.271,2.468,0.123,1.567-0.787,3.53c-1.984,1.764-6.021,5.52-7.313,5.863c-0.943-0.021,0.639-1.113,0.846-1.541
|
||||
c-2.656,1.824-2.131,2.738-6.193,3.846l-0.119-0.264c-10.018,4.713-23.934-4.627-23.751-17.371
|
||||
c-0.107,0.809-0.304,0.607-0.526,0.934c-0.517-6.557,3.028-13.143,9.007-15.832c5.848-2.895,12.704-1.707,16.893,2.197
|
||||
c-2.301-3.014-6.881-6.209-12.309-5.91c-5.317,0.084-10.291,3.463-11.951,7.131c-2.724,1.715-3.04,6.611-4.227,7.507
|
||||
C31.699,56.271,36.3,61.342,44.083,67.307c1.225,0.826,0.345,0.951,0.511,1.58c-2.586-1.211-4.954-3.039-6.901-5.277
|
||||
c1.033,1.512,2.148,2.982,3.589,4.137c-2.438-0.826-5.695-5.908-6.646-6.115c4.203,7.525,17.052,13.197,23.78,10.383
|
||||
c-3.113,0.115-7.068,0.064-10.566-1.229c-1.469-0.756-3.467-2.322-3.11-2.615c9.182,3.43,18.667,2.598,26.612-3.771
|
||||
c2.021-1.574,4.229-4.252,4.867-4.289c-0.961,1.445,0.164,0.695-0.574,1.971c2.014-3.248-0.875-1.322,2.082-5.609l1.092,1.504
|
||||
c-0.406-2.696,3.348-5.97,2.967-10.234c0.861-1.304,0.961,1.403,0.047,4.403c1.268-3.328,0.334-3.863,0.66-6.609
|
||||
c0.352,0.923,0.814,1.904,1.051,2.878c-0.826-3.216,0.848-5.416,1.262-7.285c-0.408-0.181-1.275,1.422-1.473-2.377
|
||||
c0.029-1.65,0.459-0.865,0.625-1.271c-0.324-0.186-1.174-1.451-1.691-3.877c0.375-0.57,1.002,1.478,1.512,1.562
|
||||
c-0.328-1.929-0.893-3.4-0.916-4.88c-1.49-3.114-0.527,0.415-1.736-1.337c-1.586-4.947,1.316-1.148,1.512-3.396
|
||||
c2.404,3.483,3.775,8.881,4.404,11.117c-0.48-2.726-1.256-5.367-2.203-7.922c0.73,0.307-1.176-5.609,0.949-1.691
|
||||
c-2.27-8.352-9.715-16.156-16.564-19.818c0.838,0.767,1.896,1.73,1.516,1.881c-3.406-2.028-2.807-2.186-3.295-3.043
|
||||
c-2.775-1.129-2.957,0.091-4.795,0.002c-5.23-2.774-6.238-2.479-11.051-4.217l0.219,1.023c-3.465-1.154-4.037,0.438-7.782,0.004
|
||||
c-0.228-0.178,1.2-0.644,2.375-0.815c-3.35,0.442-3.193-0.66-6.471,0.122c0.808-0.567,1.662-0.942,2.524-1.424
|
||||
c-2.732,0.166-6.522,1.59-5.352,0.295c-4.456,1.988-12.37,4.779-16.811,8.943l-0.14-0.933c-2.035,2.443-8.874,7.296-9.419,10.46
|
||||
l-0.544,0.127c-1.059,1.793-1.744,3.825-2.584,5.67c-1.385,2.36-2.03,0.908-1.833,1.278c-2.724,5.523-4.077,10.164-5.246,13.97
|
||||
c0.833,1.245,0.02,7.495,0.335,12.497c-1.368,24.704,17.338,48.69,37.785,54.228c2.997,1.072,7.454,1.031,11.245,1.141
|
||||
c-4.473-1.279-5.051-0.678-9.408-2.197c-3.143-1.48-3.832-3.17-6.058-5.102l0.881,1.557c-4.366-1.545-2.539-1.912-6.091-3.037
|
||||
l0.941-1.229c-1.415-0.107-3.748-2.385-4.386-3.646l-1.548,0.061c-1.86-2.295-2.851-3.949-2.779-5.23l-0.5,0.891
|
||||
c-0.567-0.973-6.843-8.607-3.587-6.83c-0.605-0.553-1.409-0.9-2.281-2.484l0.663-0.758c-1.567-2.016-2.884-4.6-2.784-5.461
|
||||
c0.836,1.129,1.416,1.34,1.99,1.533c-3.957-9.818-4.179-0.541-7.176-9.994l0.634-0.051c-0.486-0.732-0.781-1.527-1.172-2.307
|
||||
l0.276-2.75C4.667,58.121,6.719,47.409,7.13,41.534c0.285-2.389,2.378-4.932,3.97-8.92l-0.97-0.167
|
||||
c1.854-3.234,10.586-12.988,14.63-12.486c1.959-2.461-0.389-0.009-0.772-0.629c4.303-4.453,5.656-3.146,8.56-3.947
|
||||
c3.132-1.859-2.688,0.725-1.203-0.709c5.414-1.383,3.837-3.144,10.9-3.846c0.745,0.424-1.729,0.655-2.35,1.205
|
||||
c4.511-2.207,14.275-1.705,20.617,1.225c7.359,3.439,15.627,13.605,15.953,23.17l0.371,0.1
|
||||
c-0.188,3.802,0.582,8.199-0.752,12.238L76.992,46.856"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M32.372,59.764l-0.252,1.26c1.181,1.604,2.118,3.342,3.626,4.596
|
||||
C34.661,63.502,33.855,62.627,32.372,59.764"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M35.164,59.654c-0.625-0.691-0.995-1.523-1.409-2.352
|
||||
c0.396,1.457,1.207,2.709,1.962,3.982L35.164,59.654"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M84.568,48.916l-0.264,0.662c-0.484,3.438-1.529,6.84-3.131,9.994
|
||||
C82.943,56.244,84.088,52.604,84.568,48.916"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M45.527,0.537C46.742,0.092,48.514,0.293,49.803,0c-1.68,0.141-3.352,0.225-5.003,0.438
|
||||
L45.527,0.537"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M2.872,23.219c0.28,2.592-1.95,3.598,0.494,1.889
|
||||
C4.676,22.157,2.854,24.293,2.872,23.219"/>
|
||||
<path i:knockout="Off" fill="#A80030" d="M0,35.215c0.563-1.728,0.665-2.766,0.88-3.766C-0.676,33.438,0.164,33.862,0,35.215"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32" preserveAspectRatio="xMidYMid"><path d="M32 16c0 8.836-7.164 16-16 16S0 24.836 0 16 7.164 0 16 0s16 7.164 16 16z" fill="#dd4814"/><path d="M5.12 13.864c-1.18 0-2.137.956-2.137 2.137s.956 2.136 2.137 2.136S7.257 17.18 7.257 16 6.3 13.864 5.12 13.864zm15.252 9.71c-1.022.6-1.372 1.896-.782 2.917s1.895 1.372 2.917.782 1.372-1.895.782-2.917-1.896-1.37-2.917-.782zM9.76 16a6.23 6.23 0 0 1 2.653-5.105L10.852 8.28a9.3 9.3 0 0 0-3.838 5.394C7.69 14.224 8.12 15.06 8.12 16s-.432 1.776-1.106 2.326c.577 2.237 1.968 4.146 3.838 5.395l1.562-2.616A6.23 6.23 0 0 1 9.761 16zM16 9.76a6.24 6.24 0 0 1 6.215 5.687l3.044-.045a9.25 9.25 0 0 0-2.757-6.019c-.812.307-1.75.26-2.56-.208a2.99 2.99 0 0 1-1.461-2.118C17.7 6.84 16.86 6.72 16 6.72c-1.477 0-2.873.347-4.113.96l1.484 2.66c.8-.372 1.69-.58 2.628-.58zm0 12.48c-.94 0-1.83-.21-2.628-.58l-1.484 2.66c1.24.614 2.636.96 4.113.96a9.28 9.28 0 0 0 2.479-.338c.14-.858.65-1.648 1.46-2.118s1.75-.514 2.56-.207a9.25 9.25 0 0 0 2.757-6.019l-3.045-.045A6.24 6.24 0 0 1 16 22.24zm4.372-13.813c1.022.6 2.328.24 2.917-.78s.24-2.328-.78-2.918-2.328-.24-2.918.783-.24 2.327.782 2.917z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,23 @@
|
||||
# Onboarding Images
|
||||
|
||||
Place your screenshot images here with the following names:
|
||||
|
||||
- `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
|
||||
|
||||
## Image Guidelines
|
||||
|
||||
- **Format**: PNG or JPG
|
||||
- **Recommended size**: 1200x800px or similar aspect ratio
|
||||
- **Quality**: High quality screenshots showing the main features of each section
|
||||
- **Content**: Capture the full section with representative data
|
||||
|
||||
## Notes
|
||||
|
||||
- The last slide (Future Updates) doesn't need an image as it uses an icon
|
||||
- If an image fails to load, the component will show a fallback icon
|
||||
- Images should be optimized for web (compressed but still high quality)
|
||||
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "ProxMenux Monitor",
|
||||
"short_name": "ProxMenux",
|
||||
"description": "Proxmox System Dashboard and Monitor",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#2b2f36",
|
||||
"theme_color": "#2b2f36",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/proxmenux-logo.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ProxMenux Monitor AppImage Entry Point
|
||||
# This script is executed when the AppImage is run
|
||||
|
||||
# Get the directory where this AppImage is mounted
|
||||
APPDIR="$(dirname "$(readlink -f "${0}")")"
|
||||
|
||||
export PATH="${APPDIR}/usr/bin:${PATH}"
|
||||
export LD_LIBRARY_PATH="${APPDIR}/usr/lib/x86_64-linux-gnu:${APPDIR}/usr/lib:${APPDIR}/lib/x86_64-linux-gnu:${APPDIR}/lib:${LD_LIBRARY_PATH}"
|
||||
export PYTHONPATH="${APPDIR}/usr/lib/python3/dist-packages:${APPDIR}/usr/lib/python3/site-packages:${PYTHONPATH}"
|
||||
|
||||
# Change to the AppImage directory
|
||||
cd "${APPDIR}"
|
||||
|
||||
# Check for translation argument
|
||||
if [[ "$1" == "--translate" ]]; then
|
||||
echo "🌐 Starting ProxMenux Translation Service..."
|
||||
exec python3 "${APPDIR}/usr/bin/translate_cli.py" "${@:2}"
|
||||
else
|
||||
echo "🚀 Starting ProxMenux Monitor Dashboard..."
|
||||
echo ""
|
||||
|
||||
echo "🔧 Hardware monitoring tools:"
|
||||
[ -x "${APPDIR}/usr/bin/ipmitool" ] && echo " ✅ ipmitool available" || echo " ⚠️ ipmitool not available"
|
||||
[ -x "${APPDIR}/usr/bin/sensors" ] && echo " ✅ sensors available" || echo " ⚠️ sensors not available"
|
||||
[ -x "${APPDIR}/usr/bin/upsc" ] && echo " ✅ upsc available" || echo " ⚠️ upsc not available"
|
||||
|
||||
if [ -x "${APPDIR}/usr/bin/ipmitool" ]; then
|
||||
if ldd "${APPDIR}/usr/bin/ipmitool" 2>/dev/null | grep -q "libfreeipmi.so.17 => not found"; then
|
||||
echo " ⚠️ libfreeipmi.so.17 not found - ipmitool may not work"
|
||||
elif ldd "${APPDIR}/usr/bin/ipmitool" 2>/dev/null | grep -q "libfreeipmi.so.17"; then
|
||||
echo " ✅ libfreeipmi.so.17 loaded successfully"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Start the Flask server
|
||||
exec python3 "${APPDIR}/usr/bin/flask_server.py"
|
||||
fi
|
||||
@@ -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"
|
||||
@@ -0,0 +1,476 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ProxMenux Monitor AppImage Builder
|
||||
# This script creates a single AppImage with Flask server, Next.js dashboard, and translation support
|
||||
|
||||
set -e
|
||||
|
||||
WORK_DIR="/tmp/proxmenux_build"
|
||||
APP_DIR="$WORK_DIR/ProxMenux.AppDir"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DIST_DIR="$SCRIPT_DIR/../dist"
|
||||
APPIMAGE_ROOT="$SCRIPT_DIR/.."
|
||||
|
||||
VERSION=$(node -p "require('$APPIMAGE_ROOT/package.json').version")
|
||||
APPIMAGE_NAME="ProxMenux-${VERSION}.AppImage"
|
||||
|
||||
echo "🚀 Building ProxMenux Monitor AppImage v${VERSION} with hardware monitoring tools..."
|
||||
|
||||
# Clean and create work directory
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$APP_DIR"
|
||||
mkdir -p "$DIST_DIR"
|
||||
|
||||
# Download appimagetool if not exists
|
||||
if [ ! -f "$WORK_DIR/appimagetool" ]; then
|
||||
echo "📥 Downloading appimagetool..."
|
||||
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" -O "$WORK_DIR/appimagetool"
|
||||
chmod +x "$WORK_DIR/appimagetool"
|
||||
fi
|
||||
|
||||
# Create directory structure
|
||||
mkdir -p "$APP_DIR/usr/bin"
|
||||
mkdir -p "$APP_DIR/usr/lib/python3/dist-packages"
|
||||
mkdir -p "$APP_DIR/usr/share/applications"
|
||||
mkdir -p "$APP_DIR/usr/share/icons/hicolor/256x256/apps"
|
||||
mkdir -p "$APP_DIR/web"
|
||||
|
||||
echo "🔨 Building Next.js application..."
|
||||
cd "$APPIMAGE_ROOT"
|
||||
if [ ! -f "package.json" ]; then
|
||||
echo "❌ Error: package.json not found in AppImage directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install dependencies if node_modules doesn't exist
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "📦 Installing dependencies..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
echo "🏗️ Building Next.js static export..."
|
||||
npm run export
|
||||
|
||||
echo "🔍 Checking export results..."
|
||||
if [ -d "out" ]; then
|
||||
echo "✅ Export directory found"
|
||||
echo "📁 Contents of out directory:"
|
||||
ls -la out/
|
||||
if [ -f "out/index.html" ]; then
|
||||
echo "✅ index.html found in out directory"
|
||||
else
|
||||
echo "❌ index.html NOT found in out directory"
|
||||
echo "📁 Looking for HTML files:"
|
||||
find out/ -name "*.html" -type f || echo "No HTML files found"
|
||||
fi
|
||||
else
|
||||
echo "❌ Error: Next.js export failed - out directory not found"
|
||||
echo "📁 Current directory contents:"
|
||||
ls -la
|
||||
echo "📁 Looking for any build outputs:"
|
||||
find . -name "*.html" -type f 2>/dev/null || echo "No HTML files found anywhere"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Return to script directory
|
||||
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"
|
||||
|
||||
echo "📋 Adding translation support..."
|
||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ProxMenux translate CLI
|
||||
stdin JSON -> {"text":"...", "dest_lang":"es", "context":"...", "cache_file":"/usr/local/share/proxmenux/cache.json"}
|
||||
stdout JSON -> {"success":true,"text":"..."} or {"success":false,"error":"..."}
|
||||
"""
|
||||
import sys, json, re
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure embedded site-packages are discoverable
|
||||
HERE = Path(__file__).resolve().parents[2] # .../AppDir
|
||||
DIST = HERE / "usr" / "lib" / "python3" / "dist-packages"
|
||||
SITE = HERE / "usr" / "lib" / "python3" / "site-packages"
|
||||
for p in (str(DIST), str(SITE)):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
# Python 3.13 compat: inline 'cgi' shim
|
||||
try:
|
||||
import cgi
|
||||
except Exception:
|
||||
import types, html
|
||||
def _parse_header(value: str):
|
||||
value = str(value or "")
|
||||
parts = [p.strip() for p in value.split(";")]
|
||||
if not parts:
|
||||
return "", {}
|
||||
key = parts[0].lower()
|
||||
params = {}
|
||||
for item in parts[1:]:
|
||||
if not item:
|
||||
continue
|
||||
if "=" in item:
|
||||
k, v = item.split("=", 1)
|
||||
k = k.strip().lower()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
params[k] = v
|
||||
else:
|
||||
params[item.strip().lower()] = ""
|
||||
return key, params
|
||||
cgi = types.SimpleNamespace(parse_header=_parse_header, escape=html.escape)
|
||||
|
||||
try:
|
||||
from googletrans import Translator
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": f"ImportError: {e}"}))
|
||||
sys.exit(0)
|
||||
|
||||
def load_json_stdin():
|
||||
try:
|
||||
return json.load(sys.stdin)
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": f"Invalid JSON input: {e}"}))
|
||||
sys.exit(0)
|
||||
|
||||
def ensure_cache(path: Path):
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not path.exists():
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
json.loads(path.read_text(encoding="utf-8") or "{}")
|
||||
except Exception:
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
|
||||
def read_cache(path: Path):
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8") or "{}")
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def write_cache(path: Path, cache: dict):
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(path)
|
||||
|
||||
def clean_translated(s: str) -> str:
|
||||
s = re.sub(r'^.*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)', '', s, flags=re.IGNORECASE | re.DOTALL).strip()
|
||||
s = re.sub(r'^.*?(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?:', '', s, flags=re.IGNORECASE | re.DOTALL).strip()
|
||||
return s.strip()
|
||||
|
||||
def main():
|
||||
req = load_json_stdin()
|
||||
text = req.get("text", "")
|
||||
dest = req.get("dest_lang", "en") or "en"
|
||||
context = req.get("context", "")
|
||||
cache_file = Path(req.get("cache_file", "")) if req.get("cache_file") else None
|
||||
|
||||
if dest == "en":
|
||||
print(json.dumps({"success": True, "text": text}))
|
||||
return
|
||||
|
||||
cache = {}
|
||||
if cache_file:
|
||||
ensure_cache(cache_file)
|
||||
cache = read_cache(cache_file)
|
||||
if text in cache and (dest in cache[text] or "notranslate" in cache[text]):
|
||||
found = cache[text].get(dest) or cache[text].get("notranslate")
|
||||
print(json.dumps({"success": True, "text": found}))
|
||||
return
|
||||
|
||||
try:
|
||||
full = (context + " " + text).strip() if context else text
|
||||
tr = Translator()
|
||||
result = tr.translate(full, dest=dest).text
|
||||
result = clean_translated(result)
|
||||
|
||||
if cache_file:
|
||||
cache.setdefault(text, {})
|
||||
cache[text][dest] = result
|
||||
write_cache(cache_file, cache)
|
||||
|
||||
print(json.dumps({"success": True, "text": result}))
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PYEOF
|
||||
|
||||
chmod +x "$APP_DIR/usr/bin/translate_cli.py"
|
||||
|
||||
# Copy Next.js build
|
||||
echo "📋 Copying web dashboard..."
|
||||
if [ -d "$APPIMAGE_ROOT/out" ]; then
|
||||
mkdir -p "$APP_DIR/web"
|
||||
echo "📁 Copying from $APPIMAGE_ROOT/out to $APP_DIR/web"
|
||||
cp -r "$APPIMAGE_ROOT/out"/* "$APP_DIR/web/"
|
||||
|
||||
if [ -f "$APP_DIR/web/index.html" ]; then
|
||||
echo "✅ index.html copied successfully to $APP_DIR/web/"
|
||||
else
|
||||
echo "❌ index.html NOT found after copying"
|
||||
echo "📁 Contents of $APP_DIR/web:"
|
||||
ls -la "$APP_DIR/web/" || echo "Directory is empty or doesn't exist"
|
||||
fi
|
||||
|
||||
if [ -d "$APPIMAGE_ROOT/public" ]; then
|
||||
cp -r "$APPIMAGE_ROOT/public"/* "$APP_DIR/web/" 2>/dev/null || true
|
||||
fi
|
||||
cp "$APPIMAGE_ROOT/package.json" "$APP_DIR/web/"
|
||||
|
||||
echo "✅ Next.js static export copied successfully"
|
||||
else
|
||||
echo "❌ Error: Next.js export not found even after building"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy AppRun script
|
||||
echo "📋 Copying AppRun script..."
|
||||
if [ -f "$SCRIPT_DIR/AppRun" ]; then
|
||||
cp "$SCRIPT_DIR/AppRun" "$APP_DIR/AppRun"
|
||||
chmod +x "$APP_DIR/AppRun"
|
||||
echo "✅ AppRun script copied successfully"
|
||||
else
|
||||
echo "❌ Error: AppRun script not found at $SCRIPT_DIR/AppRun"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create desktop file
|
||||
cat > "$APP_DIR/proxmenux-monitor.desktop" << EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=ProxMenux Monitor
|
||||
Comment=Proxmox System Monitoring Dashboard with Translation Support
|
||||
Exec=AppRun
|
||||
Icon=proxmenux-monitor
|
||||
Categories=System;Monitor;
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
EOF
|
||||
|
||||
# Copy desktop file to applications directory
|
||||
cp "$APP_DIR/proxmenux-monitor.desktop" "$APP_DIR/usr/share/applications/"
|
||||
|
||||
# Download and set icon
|
||||
echo "🎨 Setting up icon..."
|
||||
if [ -f "$APPIMAGE_ROOT/public/images/proxmenux-logo.png" ]; then
|
||||
cp "$APPIMAGE_ROOT/public/images/proxmenux-logo.png" "$APP_DIR/proxmenux-monitor.png"
|
||||
else
|
||||
wget -q "https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logo.png" -O "$APP_DIR/proxmenux-monitor.png" || {
|
||||
echo "⚠️ Could not download logo, creating placeholder..."
|
||||
convert -size 256x256 xc:blue -fill white -gravity center -pointsize 24 -annotate +0+0 "PM" "$APP_DIR/proxmenux-monitor.png" 2>/dev/null || {
|
||||
echo "⚠️ ImageMagick not available, skipping icon creation"
|
||||
}
|
||||
}
|
||||
fi
|
||||
|
||||
if [ -f "$APP_DIR/proxmenux-monitor.png" ]; then
|
||||
cp "$APP_DIR/proxmenux-monitor.png" "$APP_DIR/usr/share/icons/hicolor/256x256/apps/"
|
||||
fi
|
||||
|
||||
echo "📦 Installing Python dependencies..."
|
||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
|
||||
flask \
|
||||
flask-cors \
|
||||
psutil \
|
||||
requests \
|
||||
PyJWT \
|
||||
pyotp \
|
||||
segno \
|
||||
googletrans==4.0.0-rc1 \
|
||||
httpx==0.13.3 \
|
||||
httpcore==0.9.1 \
|
||||
beautifulsoup4
|
||||
|
||||
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
|
||||
from typing import Tuple, Dict
|
||||
try:
|
||||
from html import escape as _html_escape
|
||||
except Exception:
|
||||
def _html_escape(s, quote=True): return s
|
||||
|
||||
__all__ = ["parse_header", "escape"]
|
||||
|
||||
def escape(s, quote=True):
|
||||
return _html_escape(s, quote=quote)
|
||||
|
||||
def parse_header(value: str) -> Tuple[str, Dict[str, str]]:
|
||||
if not isinstance(value, str):
|
||||
value = str(value or "")
|
||||
parts = [p.strip() for p in value.split(";")]
|
||||
if not parts:
|
||||
return "", {}
|
||||
key = parts[0].lower()
|
||||
params: Dict[str, str] = {}
|
||||
for item in parts[1:]:
|
||||
if not item:
|
||||
continue
|
||||
if "=" in item:
|
||||
k, v = item.split("=", 1)
|
||||
k = k.strip().lower()
|
||||
v = v.strip().strip('"').strip("'")
|
||||
params[k] = v
|
||||
else:
|
||||
params[item.strip().lower()] = ""
|
||||
return key, params
|
||||
PYEOF
|
||||
|
||||
echo "🔧 Installing hardware monitoring tools..."
|
||||
mkdir -p "$WORK_DIR/debs"
|
||||
cd "$WORK_DIR/debs"
|
||||
|
||||
echo "📥 Downloading hardware monitoring tools (dynamic via APT)..."
|
||||
|
||||
dl_pkg() {
|
||||
local out="$1"; shift
|
||||
local pkg deb_file
|
||||
for pkg in "$@"; do
|
||||
echo " - trying: $pkg"
|
||||
if apt-get download -y "$pkg" >/dev/null 2>&1; then
|
||||
deb_file="$(ls -1 ${pkg}_*.deb 2>/dev/null | head -n1)"
|
||||
if [ -n "$deb_file" ] && [ -f "$deb_file" ]; then
|
||||
mv "$deb_file" "$out"
|
||||
echo " ✅ downloaded: $pkg -> $out"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
echo " ↻ retry with sudo apt-get update && download"
|
||||
sudo apt-get update -qq || true
|
||||
for pkg in "$@"; do
|
||||
echo " - trying (sudo): $pkg"
|
||||
if sudo apt-get download -y "$pkg" >/dev/null 2>&1; then
|
||||
deb_file="$(ls -1 ${pkg}_*.deb 2>/dev/null | head -n1)"
|
||||
if [ -n "$deb_file" ] && [ -f "$deb_file" ]; then
|
||||
mv "$deb_file" "$out"
|
||||
echo " ✅ downloaded (sudo): $pkg -> $out"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
echo " ⚠️ none of the candidates could be downloaded for $out"
|
||||
return 1
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
echo "📦 Extracting .deb packages into AppDir..."
|
||||
extracted_count=0
|
||||
shopt -s nullglob
|
||||
for deb in *.deb; do
|
||||
echo " -> $deb"
|
||||
if file "$deb" | grep -q "Debian binary package"; then
|
||||
dpkg-deb -x "$deb" "$APP_DIR" && extracted_count=$((extracted_count + 1))
|
||||
else
|
||||
echo " ⚠️ $deb is not a valid .deb, skipping"
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
if [ $extracted_count -eq 0 ]; then
|
||||
echo "⚠️ No packages extracted; hardware/GPU monitoring may be unavailable"
|
||||
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"
|
||||
cp -r "$APP_DIR/bin/"* "$APP_DIR/usr/bin/" 2>/dev/null || true
|
||||
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 ' ')"
|
||||
echo " missing: $missing"
|
||||
case "$missing" in
|
||||
libupsclient.so.6) need_pkg="libupsclient6" ;;
|
||||
libupsclient.so.5) need_pkg="libupsclient5" ;;
|
||||
libupsclient.so.4) need_pkg="libupsclient4" ;;
|
||||
*) need_pkg="" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$need_pkg" ]; then
|
||||
echo " downloading: $need_pkg"
|
||||
dl_pkg "libupsclient_autofix.deb" "$need_pkg" || true
|
||||
if [ -f "libupsclient_autofix.deb" ]; then
|
||||
dpkg-deb -x "libupsclient_autofix.deb" "$APP_DIR"
|
||||
echo " re-checking ldd for upsc..."
|
||||
if ldd "$APP_DIR/usr/bin/upsc" | grep -q 'not found'; then
|
||||
echo "❌ upsc still has unresolved libs:"
|
||||
ldd "$APP_DIR/usr/bin/upsc" | grep 'not found' || true
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ could not download $need_pkg automatically"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "❌ unknown missing library for upsc: $missing"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Sanity check OK (ipmitool/upsc ready; libfreeipmi present)"
|
||||
|
||||
# Info rápida
|
||||
[ -x "$APP_DIR/usr/bin/sensors" ] && echo " • sensors: OK" || echo " • sensors: missing"
|
||||
[ -x "$APP_DIR/usr/bin/ipmitool" ] && echo " • ipmitool: OK" || echo " • ipmitool: missing"
|
||||
[ -x "$APP_DIR/usr/bin/upsc" ] && echo " • upsc: OK" || echo " • upsc: missing"
|
||||
[ -x "$APP_DIR/usr/bin/nvidia-smi" ] && echo " • nvidia-smi: OK" || echo " • nvidia-smi: missing"
|
||||
[ -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"
|
||||
export NO_CLEANUP=1
|
||||
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||
ARCH=x86_64 ./appimagetool --no-appstream --verbose "$APP_DIR" "$APPIMAGE_NAME"
|
||||
|
||||
# Move to dist directory
|
||||
mv "$APPIMAGE_NAME" "$DIST_DIR/"
|
||||
|
||||
echo "✅ Unified AppImage created: $DIST_DIR/$APPIMAGE_NAME"
|
||||
echo ""
|
||||
echo "📋 Usage:"
|
||||
echo " Dashboard: ./$APPIMAGE_NAME"
|
||||
echo " Translation: ./$APPIMAGE_NAME --translate"
|
||||
echo ""
|
||||
echo "🚀 Installation:"
|
||||
echo " sudo cp $DIST_DIR/$APPIMAGE_NAME /usr/local/bin/proxmenux-monitor"
|
||||
echo " sudo chmod +x /usr/local/bin/proxmenux-monitor"
|
||||
@@ -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,369 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import subprocess
|
||||
import re
|
||||
import os
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
def run_command(cmd: List[str]) -> str:
|
||||
"""Run a command and return its output."""
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
return result.stdout
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
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)
|
||||
rapl_path = '/sys/class/powercap/intel-rapl/intel-rapl:0/energy_uj'
|
||||
|
||||
if os.path.exists(rapl_path):
|
||||
try:
|
||||
with open(rapl_path, 'r') as f:
|
||||
energy_uj = int(f.read().strip())
|
||||
|
||||
# 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'
|
||||
}
|
||||
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()
|
||||
@@ -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,65 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{ts,tsx,js,jsx}",
|
||||
"./components/**/*.{ts,tsx,js,jsx}",
|
||||
"./pages/**/*.{ts,tsx,js,jsx}",
|
||||
"./src/**/*.{ts,tsx,js,jsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
|
||||
card: "var(--card)",
|
||||
"card-foreground": "var(--card-foreground)",
|
||||
|
||||
popover: "var(--popover)",
|
||||
"popover-foreground": "var(--popover-foreground)",
|
||||
|
||||
primary: "var(--primary)",
|
||||
"primary-foreground": "var(--primary-foreground)",
|
||||
|
||||
secondary: "var(--secondary)",
|
||||
"secondary-foreground": "var(--secondary-foreground)",
|
||||
|
||||
muted: "var(--muted)",
|
||||
"muted-foreground": "var(--muted-foreground)",
|
||||
|
||||
accent: "var(--accent)",
|
||||
"accent-foreground": "var(--accent-foreground)",
|
||||
|
||||
destructive: "var(--destructive)",
|
||||
"destructive-foreground": "var(--destructive-foreground)",
|
||||
|
||||
border: "var(--border)",
|
||||
input: "var(--input)",
|
||||
ring: "var(--ring)",
|
||||
|
||||
"chart-1": "var(--chart-1)",
|
||||
"chart-2": "var(--chart-2)",
|
||||
"chart-3": "var(--chart-3)",
|
||||
"chart-4": "var(--chart-4)",
|
||||
"chart-5": "var(--chart-5)",
|
||||
|
||||
sidebar: "var(--sidebar)",
|
||||
"sidebar-foreground": "var(--sidebar-foreground)",
|
||||
"sidebar-primary": "var(--sidebar-primary)",
|
||||
"sidebar-primary-foreground": "var(--sidebar-primary-foreground)",
|
||||
"sidebar-accent": "var(--sidebar-accent)",
|
||||
"sidebar-accent-foreground": "var(--sidebar-accent-foreground)",
|
||||
"sidebar-border": "var(--sidebar-border)",
|
||||
"sidebar-ring": "var(--sidebar-ring)",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
xl: "calc(var(--radius) + 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "es6"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
|
||||
export interface Temperature {
|
||||
name: string
|
||||
original_name?: string
|
||||
current: number
|
||||
high?: number
|
||||
critical?: number
|
||||
adapter?: string
|
||||
}
|
||||
|
||||
export interface PowerMeter {
|
||||
name: string
|
||||
watts: number
|
||||
adapter?: string
|
||||
}
|
||||
|
||||
export interface NetworkInterface {
|
||||
name: string
|
||||
type: string
|
||||
speed?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface StorageDevice {
|
||||
name: string
|
||||
type: string
|
||||
size?: string
|
||||
model?: string
|
||||
driver?: string
|
||||
interface?: string
|
||||
serial?: string
|
||||
family?: string
|
||||
firmware?: string
|
||||
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 {
|
||||
slot: string
|
||||
type: string
|
||||
device: string
|
||||
vendor: string
|
||||
class: string
|
||||
driver?: string
|
||||
kernel_module?: string
|
||||
irq?: string
|
||||
memory_address?: string
|
||||
link_speed?: string
|
||||
capabilities?: string[]
|
||||
gpu_memory?: string
|
||||
gpu_driver_version?: string
|
||||
gpu_cuda_version?: string
|
||||
gpu_compute_capability?: string
|
||||
gpu_power_draw?: string
|
||||
gpu_temperature?: number
|
||||
gpu_utilization?: number
|
||||
gpu_memory_used?: string
|
||||
gpu_memory_total?: string
|
||||
gpu_clock_speed?: string
|
||||
gpu_memory_clock?: string
|
||||
}
|
||||
|
||||
export interface Fan {
|
||||
name: string
|
||||
original_name?: string
|
||||
speed: number
|
||||
unit: string
|
||||
adapter?: string
|
||||
}
|
||||
|
||||
export interface PowerSupply {
|
||||
name: string
|
||||
watts: number
|
||||
status?: string
|
||||
}
|
||||
|
||||
export interface UPS {
|
||||
name: string
|
||||
host?: string
|
||||
is_remote?: boolean
|
||||
connection_type?: string
|
||||
status: string
|
||||
model?: string
|
||||
manufacturer?: string
|
||||
serial?: string
|
||||
device_type?: string
|
||||
firmware?: string
|
||||
driver?: string
|
||||
battery_charge?: string
|
||||
battery_charge_raw?: number
|
||||
battery_voltage?: string
|
||||
battery_date?: string
|
||||
time_left?: string
|
||||
time_left_seconds?: number
|
||||
load_percent?: string
|
||||
load_percent_raw?: number
|
||||
input_voltage?: string
|
||||
input_frequency?: string
|
||||
output_voltage?: string
|
||||
output_frequency?: string
|
||||
real_power?: string
|
||||
apparent_power?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface GPU {
|
||||
slot: string
|
||||
name: string
|
||||
vendor: string
|
||||
type: string
|
||||
pci_class?: string
|
||||
pci_driver?: string
|
||||
pci_kernel_module?: string
|
||||
driver_version?: string
|
||||
memory_total?: string
|
||||
memory_used?: string
|
||||
memory_free?: string
|
||||
temperature?: number
|
||||
power_draw?: string
|
||||
power_limit?: string
|
||||
utilization_gpu?: number
|
||||
utilization_memory?: number
|
||||
clock_graphics?: string
|
||||
clock_memory?: string
|
||||
engine_render?: number
|
||||
engine_blitter?: number
|
||||
engine_video?: number
|
||||
engine_video_enhance?: number
|
||||
pcie_gen?: string
|
||||
pcie_width?: string
|
||||
fan_speed?: number
|
||||
fan_unit?: string
|
||||
processes?: Array<{
|
||||
pid: string
|
||||
name: string
|
||||
memory: string
|
||||
}>
|
||||
has_monitoring_tool?: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface DiskHardwareInfo {
|
||||
type?: string
|
||||
driver?: string
|
||||
interface?: string
|
||||
model?: string
|
||||
serial?: string
|
||||
family?: string
|
||||
firmware?: string
|
||||
rotation_rate?: string
|
||||
form_factor?: string
|
||||
sata_version?: string
|
||||
}
|
||||
|
||||
export interface NetworkHardwareInfo {
|
||||
driver?: string
|
||||
kernel_modules?: string
|
||||
subsystem?: string
|
||||
max_link_speed?: string
|
||||
max_link_width?: string
|
||||
current_link_speed?: string
|
||||
current_link_width?: string
|
||||
interface_name?: string
|
||||
interface_speed?: string
|
||||
mac_address?: string
|
||||
}
|
||||
|
||||
export interface HardwareData {
|
||||
cpu?: {
|
||||
model?: string
|
||||
cores_per_socket?: number
|
||||
sockets?: number
|
||||
total_threads?: number
|
||||
l3_cache?: string
|
||||
virtualization?: string
|
||||
}
|
||||
motherboard?: {
|
||||
manufacturer?: string
|
||||
model?: string
|
||||
bios?: {
|
||||
vendor?: string
|
||||
version?: string
|
||||
date?: string
|
||||
}
|
||||
}
|
||||
memory_modules?: Array<{
|
||||
slot: string
|
||||
size?: string
|
||||
type?: string
|
||||
speed?: string
|
||||
manufacturer?: string
|
||||
}>
|
||||
temperatures?: Temperature[]
|
||||
power_meter?: PowerMeter
|
||||
network_cards?: NetworkInterface[]
|
||||
storage_devices?: StorageDevice[]
|
||||
pci_devices?: PCIDevice[]
|
||||
gpus?: GPU[]
|
||||
fans?: Fan[]
|
||||
power_supplies?: PowerSupply[]
|
||||
ups?: UPS | UPS[]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,3 +1,243 @@
|
||||
## 2025-09-04
|
||||
|
||||
### New version v1.1.7
|
||||
|
||||
### Added
|
||||
|
||||
- **ProxMenux Monitor**
|
||||
Your new monitoring tool for Proxmox. Discover all the features that will help you manage and supervise your infrastructure efficiently.
|
||||
|
||||
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
|
||||
- Shows a clear warning that breaking changes may occur with future GUI updates
|
||||
- If the GUI fails to load, the user can revert changes via SSH from the post-install menu using the **"Uninstall Options → Restore Banner"** tool
|
||||
|
||||
Special thanks to **@eryonki** for providing the improved method.
|
||||
|
||||
---
|
||||
|
||||
### Improved
|
||||
|
||||
- **CORAL TPU Installer Updated for PVE 9**
|
||||
The CORAL TPU driver installer now supports both **Proxmox VE 8 and VE 9**, ensuring compatibility with the latest kernels and udev rules.
|
||||
|
||||
- **Log2RAM Installation & Integration**
|
||||
- Log2RAM installation is now idempotent and can be safely run multiple times.
|
||||
- Automatically adjusts `journald` configuration to align with the size and behavior of Log2RAM.
|
||||
- Ensures journaling is correctly tuned to avoid overflows or RAM exhaustion on low-memory systems.
|
||||
|
||||
- **Network Optimization Function (LXC + NFS)**
|
||||
Improved to prevent “martian source” warnings in setups where **LXC containers share storage with VMs** over NFS within the same server.
|
||||
|
||||
- **APT Upgrade Progress**
|
||||
When running full system upgrades via ProxMenux, a **real-time progress bar** is now displayed, giving the user clear visibility into the update process.
|
||||
|
||||
---
|
||||
|
||||
### Fixed
|
||||
|
||||
- Other small improvements and fixes to optimize runtime performance and eliminate minor bugs.
|
||||
|
||||
|
||||
|
||||
## 2025-01-10
|
||||
|
||||
### New version v1.1.6
|
||||
|
||||

|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **New Menu: Mount and Share Manager**
|
||||
Introduced a comprehensive new menu for managing shared resources between Proxmox host and LXC containers:
|
||||
|
||||
**Host Configuration Options:**
|
||||
- **Configure NFS Shared on Host** - Add, view, and remove NFS shared resources on the Proxmox server with automatic export management
|
||||
- **Configure Samba Shared on Host** - Add, view, and remove Samba/CIFS shared resources on the Proxmox server with share configuration
|
||||
- **Configure Local Shared on Host** - Create and manage local shared directories with proper permissions on the Proxmox host
|
||||
|
||||
**LXC Integration Options:**
|
||||
- **Configure LXC Mount Points (Host ↔ Container)** - **Core feature** that enables mounting host directories into LXC containers with automatic permission handling. Includes the ability to **view existing mount points** for each container in a clear, organized way and **remove mount points** with proper verification that the process completed successfully. Especially optimized for **unprivileged containers** where UID/GID mapping is critical.
|
||||
- **Configure NFS Client in LXC** - Set up NFS client inside privileged containers
|
||||
- **Configure Samba Client in LXC** - Set up Samba client inside privileged containers
|
||||
- **Configure NFS Server in LXC** - Install NFS server inside privileged containers
|
||||
- **Configure Samba Server in LXC** - Install Samba server inside privileged containers
|
||||
|
||||
**Documentation & Support:**
|
||||
- **Help & Info (commands)** - Comprehensive guides with step-by-step manual instructions for all sharing scenarios
|
||||
|
||||
The entire system is built around the **LXC Mount Points** functionality, which automatically detects filesystem types, handles permission mapping between host and container users, and provides seamless integration for both privileged and unprivileged containers.
|
||||
|
||||
---
|
||||
|
||||
### Improved
|
||||
|
||||
- **Log2RAM Auto-Detection Enhancement**
|
||||
In the automatic post-install script, the Log2RAM installation function now prompts the user when automatic disk ssd/m2 detection fails.
|
||||
This ensures Log2RAM can still be installed on systems where automatic disk detection doesn't work properly.
|
||||
|
||||
---
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Proxmox Update Repository Verification**
|
||||
Fixed an issue in the Proxmox update function where empty repository source files would cause errors during conflict verification. The function now properly handles empty `/etc/apt/sources.list.d/` files without throwing false warnings.
|
||||
|
||||
Thanks to **@JF_Car** for reporting this issue.
|
||||
|
||||
---
|
||||
|
||||
### Acknowledgments
|
||||
|
||||
Special thanks to **@JF_Car**, **@ghosthvj**, and **@jonatanc** for their testing, valuable feedback, and suggestions that helped refine the shared resources functionality and improve the overall user experience.
|
||||
|
||||
|
||||
|
||||
## 2025-08-20
|
||||
|
||||
### New version v1.1.5
|
||||
|
||||
### Added
|
||||
|
||||
- **New Script: Upgrade PVE 8 to PVE 9**
|
||||
Added a full upgrade tool located under `Utilities and Tools`. It provides:
|
||||
1. **Automatic upgrade** from PVE 8 to 9
|
||||
2. **Interactive upgrade** with step-by-step confirmations
|
||||
3. **Check-only mode** using `check-pve8to9`
|
||||
4. **Manual instructions** shown in order for users who prefer to upgrade manually
|
||||
|
||||
- **New Tools in System Utilities**
|
||||
- [`s-tui`](https://github.com/amanusk/s-tui): Terminal-based CPU monitoring with graphs
|
||||
- [`intel-gpu-tools`](https://gitlab.freedesktop.org/drm/igt-gpu-tools): Useful for Intel GPU diagnostics
|
||||
|
||||
---
|
||||
|
||||
### Improved
|
||||
|
||||
- **APT Upgrade Handling**
|
||||
The PVE upgrade function now blocks the process if any package prompts for manual confirmation. This avoids partial upgrades and ensures consistency.
|
||||
|
||||
- **Network Optimization (sysctl)**
|
||||
- Obsolete kernel parameters removed (e.g., `tcp_tw_recycle`, `nf_conntrack_helper`) to prevent warnings in **Proxmox 9 / kernel 6.14**
|
||||
- Now generates only valid, up-to-date sysctl parameters
|
||||
|
||||
- **AMD CPU Patch Handling**
|
||||
- Now applies correct `idle=nomwait` and KVM options (`ignore_msrs=1`, `report_ignored_msrs=0`)
|
||||
- Expected warning is now documented and safely handled for stability with Ryzen/EPYC
|
||||
|
||||
- **Timezone & NTP Fixes**
|
||||
- Automatically detects timezone using public IP geolocation
|
||||
- Falls back to UTC if detection fails
|
||||
- Restarts Postfix after timezone set → resolves `/var/spool/postfix/etc/localtime` mismatch warning
|
||||
|
||||
- **Repository & Package Installer Logic**
|
||||
- Now verifies that working repositories exist before installing any package
|
||||
- If none are available, adds a fallback **Debian stable** repository
|
||||
- Replaces deprecated `mlocate` with `plocate` (compatible with Debian 13 and Proxmox 9)
|
||||
|
||||
- **Improved Logs and User Feedback**
|
||||
- Actions that fail now provide precise messages (instead of falsely marking as success)
|
||||
- Helps users clearly understand what's been applied or skipped
|
||||
|
||||
|
||||
|
||||
## 2025-08-06
|
||||
|
||||
### New version v1.1.4
|
||||
|
||||
### Added
|
||||
|
||||
- **Proxmox 9 Compatibility Preparation**
|
||||
This version prepares **ProxMenux** for the upcoming **Proxmox VE 9**:
|
||||
- The function to add the official Proxmox repositories now supports the new `.sources` format used in Proxmox 9, while maintaining backward compatibility with Proxmox 8.
|
||||
- Banner removal is now optionally supported for Proxmox 9.
|
||||
|
||||
- **xshok-proxmox Detection**
|
||||
Added a check to detect if the `xshok-proxmox` post-install script has already been executed.
|
||||
If detected, a warning is shown to avoid conflicting adjustments:
|
||||
|
||||
```
|
||||
It appears that you have already executed the xshok-proxmox post-install script on this system.
|
||||
|
||||
If you continue, some adjustments may be duplicated or conflict with those already made by xshok.
|
||||
|
||||
Do you want to continue anyway?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Improved
|
||||
|
||||
- **Banner Removal (Proxmox 8.4.9+)**
|
||||
Updated the logic for removing the subscription banner in **Proxmox 8.4.9**, due to changes in `proxmoxlib.js`.
|
||||
|
||||
- **LXC Disk Passthrough (Persistent UUID)**
|
||||
The function to add a physical disk to an LXC container now uses **UUID-based persistent paths**.
|
||||
This ensures that disks remain correctly mounted, even if the `/dev/sdX` order changes due to new hardware.
|
||||
|
||||
```bash
|
||||
PERSISTENT_DISK=$(get_persistent_path "$DISK")
|
||||
if [[ "$PERSISTENT_DISK" != "$DISK" ]] ...
|
||||
```
|
||||
|
||||
- **System Utilities Installer**
|
||||
Now checks whether APT sources are available before installing selected tools.
|
||||
If a new Proxmox installation has no active repos, it will **automatically add the default sources** to avoid installation failure.
|
||||
|
||||
- **IOMMU Activation on ZFS Systems**
|
||||
The function that enables IOMMU for passthrough now verifies existing kernel parameters to avoid duplication if the user has already configured them manually.
|
||||
|
||||
---
|
||||
|
||||
### Fixed
|
||||
|
||||
- Minor code cleanup and improved runtime performance across several modules.
|
||||
|
||||
|
||||
|
||||
## 2025-07-20
|
||||
|
||||
### Changed
|
||||
|
||||
- **Subscription Banner Removal (Proxmox 8.4.5+)**
|
||||
Improved the `remove_subscription_banner` function to ensure compatibility with Proxmox 8.4.5, where the banner removal method was failing after clean installations.
|
||||
|
||||
- **Improved Log2RAM Detection**
|
||||
In both the automatic and customizable post-install scripts, the logic for Log2RAM installation has been improved.
|
||||
Now it correctly detects if Log2RAM is already configured and avoids triggering errors or reconfiguration.
|
||||
|
||||
- **Optimized Figurine Installation**
|
||||
The `install_figurine` function now avoids duplicating `.bashrc` entries if the customization for the root prompt already exists.
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **New Function: Persistent Network Interface Naming**
|
||||
Added a new function `setup_persistent_network` to create stable network interface names using `.link` files based on MAC addresses.
|
||||
This avoids unpredictable renaming (e.g., `enp2s0` becoming `enp3s0`) when hardware changes, PCI topology shifts, or passthrough configurations are applied.
|
||||
|
||||
**Why use `.link` files?**
|
||||
Because predictable interface names in `systemd` can change with hardware reordering or replacement. Using static `.link` files bound to MAC addresses ensures consistency, especially on systems with multiple NICs or passthrough setups.
|
||||
|
||||
Special thanks to [@Andres_Eduardo_Rojas_Moya] for contributing the persistent
|
||||
network naming function and for the original idea.
|
||||
|
||||
```bash
|
||||
[Match]
|
||||
MACAddress=XX:XX:XX:XX:XX:XX
|
||||
|
||||
[Link]
|
||||
Name=eth0
|
||||
```
|
||||
|
||||
|
||||
## 2025-07-01
|
||||
|
||||
### New version v1.1.3
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
MIT License
|
||||
ProxMenux An Interactive Menu for Proxmox VE Management
|
||||
Copyright (c) 2025 MacRimi
|
||||
|
||||
Copyright (c) 2024 MacRimi
|
||||
This project is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License.
|
||||
See the full license terms below.
|
||||
|
||||
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:
|
||||
======================================================================
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
|
||||
|
||||
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.
|
||||
This is a human-readable summary of (and not a substitute for) the license.
|
||||
You may obtain a copy of the full license at:
|
||||
|
||||
https://creativecommons.org/licenses/by-nc/4.0/
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format.
|
||||
- Adapt — remix, transform, and build upon the material.
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to the license,
|
||||
and indicate if changes were made.
|
||||
- NonCommercial — You may not use the material for commercial purposes.
|
||||
|
||||
No additional restrictions — You may not apply legal terms or technological
|
||||
measures that legally restrict others from doing anything the license permits.
|
||||
|
||||
Disclaimer:
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="https://github.com/MacRimi/ProxMenux/blob/main/images/main.png"
|
||||
alt="ProxMenu Logo"
|
||||
alt="ProxMenux Logo"
|
||||
style="max-width: 100%; height: auto;" >
|
||||
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@ Then, follow the on-screen options to manage your Proxmox server efficiently.
|
||||
|
||||
## 📌 System Requirements
|
||||
🖥 **Compatible with:**
|
||||
- Proxmox VE 8.x**
|
||||
- Proxmox VE 8.x and 9.x
|
||||
|
||||
📦 **Dependencies:**
|
||||
- `bash`, `curl`, `wget`, `jq`, `whiptail`, `python3-venv` (These dependencies are installed automatically during setup.)
|
||||
@@ -70,6 +70,12 @@ Then, follow the on-screen options to manage your Proxmox server efficiently.
|
||||
## ⭐ Support the Project!
|
||||
If you find **ProxMenux** useful, consider giving it a ⭐ on GitHub to help others discover it!
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#MacRimi/ProxMenux&Date)
|
||||
|
||||
|
||||
<div style="display: flex; justify-content: center; align-items: center;">
|
||||
<a href="https://ko-fi.com/G2G313ECAN" target="_blank" style="display: flex; align-items: center; text-decoration: none;">
|
||||
<img src="https://raw.githubusercontent.com/MacRimi/HWEncoderX/main/images/kofi.png" alt="Support me on Ko-fi" style="width:140px; margin-right:40px;"/>
|
||||
@@ -78,4 +84,10 @@ If you find **ProxMenux** useful, consider giving it a ⭐ on GitHub to help oth
|
||||
|
||||
Support the project on Ko-fi!
|
||||
|
||||
## Contributors
|
||||
<a href="https://github.com/MacRimi/ProxMenux/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=MacRimi/ProxMenux" />
|
||||
</a>
|
||||
|
||||
[contrib.rocks](https://contrib.rocks).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 323 KiB |
@@ -1,56 +1,271 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenu - 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 : (CC BY-NC 4.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"
|
||||
CACHE_FILE="$BASE_DIR/cache.json"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
#EMERGENCY_FILE="$BASE_DIR/emergency_repair.sh"
|
||||
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||
MENU_SCRIPT="menu"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if ! source <(curl -sSf "$UTILS_URL"); then
|
||||
echo "Error: Could not load utils.sh from $UTILS_URL"
|
||||
exit 1
|
||||
MONITOR_INSTALL_DIR="$BASE_DIR"
|
||||
MONITOR_SERVICE_FILE="/etc/systemd/system/proxmenux-monitor.service"
|
||||
MONITOR_PORT=8008
|
||||
|
||||
# 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..."
|
||||
@@ -62,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
|
||||
@@ -101,17 +327,38 @@ check_existing_installation() {
|
||||
fi
|
||||
}
|
||||
|
||||
uninstall_proxmenu() {
|
||||
uninstall_proxmenux() {
|
||||
local install_type="$1"
|
||||
local force_clean="$2"
|
||||
|
||||
if [ "$force_clean" != "force" ]; then
|
||||
if ! whiptail --title "Uninstall ProxMenu" --yesno "Are you sure you want to uninstall ProxMenu?" 10 60; then
|
||||
if ! whiptail --title "Uninstall ProxMenux" --yesno "Are you sure you want to uninstall ProxMenux?" 10 60; then
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Uninstalling ProxMenu..."
|
||||
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..."
|
||||
@@ -151,7 +398,7 @@ uninstall_proxmenu() {
|
||||
sed -i '/This system is optimised by: ProxMenux/d' /etc/motd
|
||||
fi
|
||||
|
||||
echo "ProxMenu has been uninstalled."
|
||||
echo "ProxMenux has been uninstalled."
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -168,7 +415,7 @@ handle_installation_change() {
|
||||
if whiptail --title "Installation Type Change" \
|
||||
--yesno "Switch from Translation to Normal Version?\n\nThis will remove translation components." 10 60; then
|
||||
echo "Preparing for installation type change..."
|
||||
uninstall_proxmenu "translation" "force" >/dev/null 2>&1
|
||||
uninstall_proxmenux "translation" "force" >/dev/null 2>&1
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
@@ -193,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")
|
||||
local tracked_components=("dialog" "curl" "jq" "python3" "python3-venv" "python3-pip" "virtual_environment" "pip" "googletrans" "proxmenux_monitor")
|
||||
|
||||
if [[ " ${tracked_components[@]} " =~ " ${component} " ]]; then
|
||||
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||
@@ -221,7 +468,7 @@ show_progress() {
|
||||
local total="$2"
|
||||
local message="$3"
|
||||
|
||||
echo -e "\n${BOLD}${BL}${TAB}Installing ProxMenu: Step $step of $total${CL}"
|
||||
echo -e "\n${BOLD}${BL}${TAB}Installing ProxMenux: Step $step of $total${CL}"
|
||||
echo
|
||||
msg_info2 "$message"
|
||||
}
|
||||
@@ -274,7 +521,7 @@ show_installation_confirmation() {
|
||||
case "$install_type" in
|
||||
"1")
|
||||
if whiptail --title "ProxMenux - Normal Version Installation" \
|
||||
--yesno "ProxMenux Normal Version will install:\n\n• dialog (interactive menus) - Official Debian package\n• curl (file downloads) - Official Debian package\n• jq (JSON processing) - Official Debian package\n• ProxMenux core files (/usr/local/share/proxmenux)\n\nThis is a lightweight installation with minimal dependencies.\n\nProceed with installation?" 18 70; then
|
||||
--yesno "ProxMenux Normal Version will install:\n\n• dialog (interactive menus) - Official Debian package\n• curl (file downloads) - Official Debian package\n• jq (JSON processing) - Official Debian package\n• ProxMenux core files (/usr/local/share/proxmenux)\n• ProxMenux Monitor (Web dashboard on port 8008)\n\nThis is a lightweight installation with minimal dependencies.\n\nProceed with installation?" 20 70; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
@@ -282,7 +529,7 @@ show_installation_confirmation() {
|
||||
;;
|
||||
"2")
|
||||
if whiptail --title "ProxMenux - Translation Version Installation" \
|
||||
--yesno "ProxMenux Translation Version will install:\n\n• dialog (interactive menus)\n• curl (file downloads)\n• jq (JSON processing)\n• python3 + python3-venv + python3-pip\n• Google Translate library (googletrans)\n• Virtual environment (/opt/googletrans-env)\n• Translation cache system\n• ProxMenux core files\n\nThis version requires more dependencies for translation support.\n\nProceed with installation?" 18 70; then
|
||||
--yesno "ProxMenux Translation Version will install:\n\n• dialog (interactive menus)\n• curl (file downloads)\n• jq (JSON processing)\n• python3 + python3-venv + python3-pip\n• Google Translate library (googletrans)\n• Virtual environment (/opt/googletrans-env)\n• Translation cache system\n• ProxMenux core files\n• ProxMenux Monitor (Web dashboard on port 8008)\n\nThis version requires more dependencies for translation support.\n\nProceed with installation?" 20 70; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
@@ -291,35 +538,199 @@ show_installation_confirmation() {
|
||||
esac
|
||||
}
|
||||
|
||||
####################################################
|
||||
install_normal_version() {
|
||||
local total_steps=3
|
||||
local current_step=1
|
||||
get_server_ip() {
|
||||
local ip
|
||||
# Try to get the primary IP address
|
||||
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+')
|
||||
|
||||
show_progress $current_step $total_steps "Installing basic dependencies"
|
||||
if [ -z "$ip" ]; then
|
||||
# Fallback: get first non-loopback IP
|
||||
ip=$(hostname -I | awk '{print $1}')
|
||||
fi
|
||||
|
||||
if ! dpkg -l | grep -qw "jq"; then
|
||||
msg_info "Installing jq..."
|
||||
apt-get update > /dev/null 2>&1
|
||||
if apt-get install -y jq > /dev/null 2>&1; then
|
||||
msg_ok "jq installed successfully."
|
||||
update_config "jq" "installed"
|
||||
else
|
||||
msg_error "Failed to install jq. Please install it manually."
|
||||
update_config "jq" "failed"
|
||||
if [ -z "$ip" ]; then
|
||||
# Last resort: use localhost
|
||||
ip="localhost"
|
||||
fi
|
||||
|
||||
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() {
|
||||
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
|
||||
|
||||
local appimage_version=$(get_appimage_version "$appimage_source")
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
systemctl stop proxmenux-monitor.service
|
||||
fi
|
||||
|
||||
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."
|
||||
return 1
|
||||
fi
|
||||
msg_ok "SHA256 verification passed."
|
||||
else
|
||||
msg_warn "SHA256 checksum not available. Skipping verification."
|
||||
fi
|
||||
|
||||
msg_info "Installing ProxMenux Monitor..."
|
||||
mkdir -p "$MONITOR_INSTALL_DIR"
|
||||
|
||||
local target_path="$MONITOR_INSTALL_DIR/ProxMenux-Monitor.AppImage"
|
||||
cp "$appimage_source" "$target_path"
|
||||
chmod +x "$target_path"
|
||||
|
||||
msg_ok "ProxMenux Monitor 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..."
|
||||
|
||||
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
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$MONITOR_INSTALL_DIR
|
||||
ExecStart=$exec_path
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment="PORT=$MONITOR_PORT"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
msg_ok "Created default service file."
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable proxmenux-monitor.service > /dev/null 2>&1
|
||||
systemctl start proxmenux-monitor.service > /dev/null 2>&1
|
||||
|
||||
sleep 3
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
msg_ok "ProxMenux Monitor service started successfully."
|
||||
update_config "proxmenux_monitor" "installed"
|
||||
return 0
|
||||
else
|
||||
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=5
|
||||
local current_step=1
|
||||
|
||||
show_progress $current_step $total_steps "Installing basic dependencies."
|
||||
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
apt-get update > /dev/null 2>&1
|
||||
|
||||
if apt-get install -y jq > /dev/null 2>&1 && command -v jq > /dev/null 2>&1; then
|
||||
update_config "jq" "installed"
|
||||
else
|
||||
local jq_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"
|
||||
if wget -q -O /usr/local/bin/jq "$jq_url" 2>/dev/null && chmod +x /usr/local/bin/jq; 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")
|
||||
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."
|
||||
@@ -327,11 +738,25 @@ install_normal_version() {
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
msg_ok "$pkg is already installed."
|
||||
update_config "$pkg" "already_installed"
|
||||
fi
|
||||
done
|
||||
|
||||
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"
|
||||
@@ -346,34 +771,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"
|
||||
# "$EMERGENCY_FILE $REPO_URL/scripts/emergency_repair.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"
|
||||
# chmod +x "$EMERGENCY_FILE"
|
||||
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Installing ProxMenux Monitor"
|
||||
|
||||
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=4
|
||||
local total_steps=5
|
||||
local current_step=1
|
||||
|
||||
show_progress $current_step $total_steps "Language selection"
|
||||
@@ -382,28 +809,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."
|
||||
@@ -411,36 +845,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."
|
||||
@@ -448,9 +878,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."
|
||||
@@ -460,44 +888,59 @@ 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"
|
||||
# "$EMERGENCY_FILE $REPO_URL/scripts/emergency_repair.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"
|
||||
#chmod +x "$EMERGENCY_FILE"
|
||||
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Installing ProxMenux Monitor"
|
||||
|
||||
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)
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
|
||||
local menu_title="ProxMenux Installation"
|
||||
local menu_text="Choose installation type:"
|
||||
@@ -516,30 +959,49 @@ show_installation_options() {
|
||||
esac
|
||||
fi
|
||||
|
||||
INSTALL_TYPE=$(whiptail --backtitle "ProxMenux" --title "$menu_title" --menu "\n$menu_text" 14 70 2 \
|
||||
"1" "Normal Version (English only)" \
|
||||
"2" "Translation Version (Multi-language support)" 3>&1 1>&2 2>&3)
|
||||
if [[ "$pve_version" -ge 9 ]]; then
|
||||
INSTALL_TYPE=$(whiptail --backtitle "ProxMenux" --title "$menu_title" --menu "\n$menu_text" 14 70 2 \
|
||||
"1" "Normal Version (English only)" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$INSTALL_TYPE" ]; then
|
||||
show_proxmenux_logo
|
||||
msg_warn "Installation cancelled."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
INSTALL_TYPE=$(whiptail --backtitle "ProxMenux" --title "$menu_title" --menu "\n$menu_text" 14 70 2 \
|
||||
"1" "Normal Version (English only)" \
|
||||
"2" "Translation Version (Multi-language support)" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$INSTALL_TYPE" ]; then
|
||||
show_proxmenux_logo
|
||||
msg_warn "Installation cancelled."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$INSTALL_TYPE" ]; then
|
||||
show_proxmenux_logo
|
||||
msg_warn "Installation cancelled."
|
||||
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
|
||||
msg_warn "Installation cancelled."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! handle_installation_change "$current_install_type" "$INSTALL_TYPE"; then
|
||||
show_proxmenux_logo
|
||||
msg_warn "Installation cancelled."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
install_proxmenu() {
|
||||
install_proxmenux() {
|
||||
show_installation_options
|
||||
|
||||
case "$INSTALL_TYPE" in
|
||||
@@ -558,10 +1020,21 @@ install_proxmenu() {
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
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}🌐 ProxMenux Monitor activated${CL}: ${BL}http://${server_ip}:${MONITOR_PORT}${CL}"
|
||||
echo
|
||||
fi
|
||||
|
||||
msg_title "$(translate "ProxMenux has been installed successfully")"
|
||||
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
|
||||
}
|
||||
@@ -572,4 +1045,4 @@ if [ "$(id -u)" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
cleanup_corrupted_files
|
||||
install_proxmenu
|
||||
install_proxmenux
|
||||
|
||||
@@ -202,7 +202,7 @@
|
||||
"it": "Giapponese",
|
||||
"pt": "Japonês"
|
||||
},
|
||||
"Thank you for using ProxMenu. Goodbye!": {
|
||||
"Thank you for using ProxMenux. Goodbye!": {
|
||||
"es": "Gracias por usar ProxMenu. ¡Adiós!",
|
||||
"fr": "Merci d'avoir utilisé ProxMenu. Au revoir!",
|
||||
"de": "Danke für die Nutzung von ProxMenu. Auf Wiedersehen!",
|
||||
@@ -900,7 +900,11 @@
|
||||
"it": "Trova il tuo dispositivo usando https://finds.synology.com"
|
||||
},
|
||||
"Help and Info Commands": {
|
||||
"es": "Comandos de ayuda e información"
|
||||
"es": "Comandos de ayuda e información",
|
||||
"fr": "Aide et Informations (commandes)",
|
||||
"de": "Hilfe & Informationen (Befehle)",
|
||||
"it": "Aiuto e Informazioni (comandi)",
|
||||
"pt": "Ajuda e Informações (comandos)"
|
||||
},
|
||||
"Create VM from template or script": {
|
||||
"es": "Crear VM a partir de plantilla o script",
|
||||
@@ -1908,6 +1912,761 @@
|
||||
"de": "Automatisiertes Post-Installationsskript",
|
||||
"it": "Script automatico post-installazione",
|
||||
"pt": "Script automatizado pós-instalação"
|
||||
},
|
||||
"Network Management - SAFE MODE": {
|
||||
"es": "Gestión de red - Modo seguro",
|
||||
"fr": "Gestion du réseau - Mode sans risque",
|
||||
"de": "Netzwerkverwaltung - SICHERER MODUS",
|
||||
"it": "Gestione rete - Modalità sicura",
|
||||
"pt": "Gerenciamento de rede - Modo seguro"
|
||||
},
|
||||
"Show Interface Details": {
|
||||
"es": "Mostrar detalles de la interfaz",
|
||||
"fr": "Afficher les détails de l'interface",
|
||||
"de": "Schnittstellendetails anzeigen",
|
||||
"it": "Mostra dettagli dell'interfaccia",
|
||||
"pt": "Mostrar detalhes da interface"
|
||||
},
|
||||
"Show Bridge Status": {
|
||||
"es": "Mostrar estado del puente",
|
||||
"fr": "Afficher l'état du pont",
|
||||
"de": "Bridge-Status anzeigen",
|
||||
"it": "Mostra stato del bridge",
|
||||
"pt": "Mostrar status da ponte"
|
||||
},
|
||||
"Show Routing Table": {
|
||||
"es": "Mostrar tabla de enrutamiento",
|
||||
"fr": "Afficher la table de routage",
|
||||
"de": "Routing-Tabelle anzeigen",
|
||||
"it": "Mostra tabella di routing",
|
||||
"pt": "Mostrar tabela de roteamento"
|
||||
},
|
||||
"Test Connectivity": {
|
||||
"es": "Probar conectividad",
|
||||
"fr": "Tester la connectivité",
|
||||
"de": "Konnektivität testen",
|
||||
"it": "Verifica la connettività",
|
||||
"pt": "Testar conectividade"
|
||||
},
|
||||
"Advanced Diagnostics": {
|
||||
"es": "Diagnóstico avanzado",
|
||||
"fr": "Diagnostic avancé",
|
||||
"de": "Erweiterte Diagnose",
|
||||
"it": "Diagnostica avanzata",
|
||||
"pt": "Diagnóstico avançado"
|
||||
},
|
||||
"Analyze Bridge Configuration": {
|
||||
"es": "Analizar configuración del puente",
|
||||
"fr": "Analyser la configuration du pont",
|
||||
"de": "Bridge-Konfiguration analysieren",
|
||||
"it": "Analizza configurazione bridge",
|
||||
"pt": "Analisar configuração da ponte"
|
||||
},
|
||||
"Analyze Network Configuration": {
|
||||
"es": "Analizar configuración de red",
|
||||
"fr": "Analyser la configuration du réseau",
|
||||
"de": "Netzwerkkonfiguration analysieren",
|
||||
"it": "Analizza configurazione di rete",
|
||||
"pt": "Analisar configuração de rede"
|
||||
},
|
||||
"Restart Network Service": {
|
||||
"es": "Reiniciar servicio de red",
|
||||
"fr": "Redémarrer le service réseau",
|
||||
"de": "Netzwerkdienst neu starten",
|
||||
"it": "Riavvia servizio di rete",
|
||||
"pt": "Reiniciar serviço de rede"
|
||||
},
|
||||
"Show Network Config File": {
|
||||
"es": "Mostrar archivo de configuración de red",
|
||||
"fr": "Afficher le fichier de configuration réseau",
|
||||
"de": "Netzwerkkonfigurationsdatei anzeigen",
|
||||
"it": "Mostra file di configurazione di rete",
|
||||
"pt": "Mostrar arquivo de configuração de rede"
|
||||
},
|
||||
"Create Network Backup": {
|
||||
"es": "Crear copia de seguridad de red",
|
||||
"fr": "Créer une sauvegarde du réseau",
|
||||
"de": "Netzwerk-Backup erstellen",
|
||||
"it": "Crea backup della rete",
|
||||
"pt": "Criar backup de rede"
|
||||
},
|
||||
"Restore Network Backup": {
|
||||
"es": "Restaurar copia de seguridad de red",
|
||||
"fr": "Restaurer la sauvegarde du réseau",
|
||||
"de": "Netzwerk-Backup wiederherstellen",
|
||||
"it": "Ripristina backup della rete",
|
||||
"pt": "Restaurar backup de rede"
|
||||
},
|
||||
"Analyzing Bridge Configuration - READ ONLY MODE": {
|
||||
"es": "Analizando configuración del puente - Solo lectura",
|
||||
"fr": "Analyse de la configuration du pont - Mode lecture seule",
|
||||
"de": "Bridge-Konfiguration analysieren - Nur-Lese-Modus",
|
||||
"it": "Analisi configurazione bridge - Solo lettura",
|
||||
"pt": "Analisando configuração da ponte - Modo somente leitura"
|
||||
},
|
||||
"BRIDGE CONFIGURATION ANALYSIS": {
|
||||
"es": "Análisis de configuración del puente",
|
||||
"fr": "Analyse de la configuration du pont",
|
||||
"de": "Bridge-Konfigurationsanalyse",
|
||||
"it": "Analisi configurazione bridge",
|
||||
"pt": "Análise de configuração da ponte"
|
||||
},
|
||||
"Bridge": {
|
||||
"es": "Puente",
|
||||
"fr": "Pont",
|
||||
"de": "Bridge",
|
||||
"it": "Bridge",
|
||||
"pt": "Ponte"
|
||||
},
|
||||
"Status": {
|
||||
"es": "Estado",
|
||||
"fr": "Statut",
|
||||
"de": "Status",
|
||||
"it": "Stato",
|
||||
"pt": "Status"
|
||||
},
|
||||
"IP": {
|
||||
"es": "IP",
|
||||
"fr": "IP",
|
||||
"de": "IP",
|
||||
"it": "IP",
|
||||
"pt": "IP"
|
||||
},
|
||||
"Configured Ports": {
|
||||
"es": "Puertos configurados",
|
||||
"fr": "Ports configurés",
|
||||
"de": "Konfigurierte Ports",
|
||||
"it": "Porte configurate",
|
||||
"pt": "Portas configuradas"
|
||||
},
|
||||
"Port": {
|
||||
"es": "Puerto",
|
||||
"fr": "Port",
|
||||
"de": "Port",
|
||||
"it": "Porta",
|
||||
"pt": "Porta"
|
||||
},
|
||||
"EXISTS": {
|
||||
"es": "Existe",
|
||||
"fr": "Existe",
|
||||
"de": "Existiert",
|
||||
"it": "Esiste",
|
||||
"pt": "Existe"
|
||||
},
|
||||
"ANALYSIS SUMMARY": {
|
||||
"es": "Resumen del análisis",
|
||||
"fr": "Résumé de l'analyse",
|
||||
"de": "Analyse-Zusammenfassung",
|
||||
"it": "Riepilogo analisi",
|
||||
"pt": "Resumo da análise"
|
||||
},
|
||||
"Bridges analyzed": {
|
||||
"es": "Puentes analizados",
|
||||
"fr": "Ponts analysés",
|
||||
"de": "Analysierte Bridges",
|
||||
"it": "Bridge analizzati",
|
||||
"pt": "Pontes analisadas"
|
||||
},
|
||||
"Issues found": {
|
||||
"es": "Problemas encontrados",
|
||||
"fr": "Problèmes trouvés",
|
||||
"de": "Gefundene Probleme",
|
||||
"it": "Problemi trovati",
|
||||
"pt": "Problemas encontrados"
|
||||
},
|
||||
"Physical interfaces available": {
|
||||
"es": "Interfaces físicas disponibles",
|
||||
"fr": "Interfaces physiques disponibles",
|
||||
"de": "Verfügbare physische Schnittstellen",
|
||||
"it": "Interfacce fisiche disponibili",
|
||||
"pt": "Interfaces físicas disponíveis"
|
||||
},
|
||||
"No bridge configuration issues found": {
|
||||
"es": "No se encontraron problemas en la configuración del puente",
|
||||
"fr": "Aucun problème de configuration du pont trouvé",
|
||||
"de": "Keine Bridge-Konfigurationsprobleme gefunden",
|
||||
"it": "Nessun problema di configurazione del bridge trovato",
|
||||
"pt": "Nenhum problema encontrado na configuração da ponte"
|
||||
},
|
||||
"Bridge Configuration Analysis": {
|
||||
"es": "Análisis de configuración del puente",
|
||||
"fr": "Analyse de la configuration du pont",
|
||||
"de": "Bridge-Konfigurationsanalyse",
|
||||
"it": "Analisi configurazione bridge",
|
||||
"pt": "Análise de configuração da ponte"
|
||||
},
|
||||
"Real-time network usage (iftop)": {
|
||||
"es": "Uso de red en tiempo real (iftop)",
|
||||
"fr": "Utilisation du réseau en temps réel (iftop)",
|
||||
"de": "Echtzeit-Netzwerkauslastung (iftop)",
|
||||
"it": "Utilizzo rete in tempo reale (iftop)",
|
||||
"pt": "Uso de rede em tempo real (iftop)"
|
||||
},
|
||||
"Network monitoring tool (iptraf-ng)": {
|
||||
"es": "Herramienta de monitoreo de red (iptraf-ng)",
|
||||
"fr": "Outil de surveillance réseau (iptraf-ng)",
|
||||
"de": "Netzwerküberwachungstool (iptraf-ng)",
|
||||
"it": "Strumento di monitoraggio rete (iptraf-ng)",
|
||||
"pt": "Ferramenta de monitoramento de rede (iptraf-ng)"
|
||||
},
|
||||
"iftop usage": {
|
||||
"es": "Uso de iftop",
|
||||
"fr": "Utilisation de iftop",
|
||||
"de": "Verwendung von iftop",
|
||||
"it": "Utilizzo di iftop",
|
||||
"pt": "Uso do iftop"
|
||||
},
|
||||
"To exit iftop, press q": {
|
||||
"es": "Para salir de iftop, pulse q",
|
||||
"fr": "Pour quitter iftop, appuyez sur q",
|
||||
"de": "Zum Beenden von iftop, q drücken",
|
||||
"it": "Per uscire da iftop, premi q",
|
||||
"pt": "Para sair do iftop, pressione q"
|
||||
},
|
||||
"iptraf-ng usage": {
|
||||
"es": "Uso de iptraf-ng",
|
||||
"fr": "Utilisation de iptraf-ng",
|
||||
"de": "Verwendung von iptraf-ng",
|
||||
"it": "Utilizzo di iptraf-ng",
|
||||
"pt": "Uso do iptraf-ng"
|
||||
},
|
||||
"To exit iptraf-ng, press x": {
|
||||
"es": "Para salir de iptraf-ng, pulse x",
|
||||
"fr": "Pour quitter iptraf-ng, appuyez sur x",
|
||||
"de": "Zum Beenden von iptraf-ng, x drücken",
|
||||
"it": "Per uscire da iptraf-ng, premi x",
|
||||
"pt": "Para sair do iptraf-ng, pressione x"
|
||||
},
|
||||
"Proxmox VE 8 to 9 Manual Upgrade Guide": {
|
||||
"es": "Guía de actualización manual de Proxmox VE 8 a 9",
|
||||
"fr": "Guide de mise à niveau manuelle de Proxmox VE 8 vers 9",
|
||||
"de": "Manuelles Upgrade-Handbuch: Proxmox VE 8 auf 9",
|
||||
"it": "Guida all'aggiornamento manuale da Proxmox VE 8 a 9",
|
||||
"pt": "Guia de atualização manual do Proxmox VE 8 para 9"
|
||||
},
|
||||
"Source:": {
|
||||
"es": "Fuente:",
|
||||
"fr": "Source :",
|
||||
"de": "Quelle:",
|
||||
"it": "Fonte:",
|
||||
"pt": "Fonte:"
|
||||
},
|
||||
"IMPORTANT PREREQUISITES:": {
|
||||
"es": "REQUISITOS PREVIOS IMPORTANTES:",
|
||||
"fr": "PRÉREQUIS IMPORTANTS :",
|
||||
"de": "WICHTIGE VORAUSSETZUNGEN:",
|
||||
"it": "PREREQUISITI IMPORTANTI:",
|
||||
"pt": "PRÉ-REQUISITOS IMPORTANTES:"
|
||||
},
|
||||
"System must be updated to latest PVE 8.4+ before starting": {
|
||||
"es": "El sistema debe estar actualizado a la versión PVE 8.4+ antes de comenzar",
|
||||
"fr": "Le système doit être mis à jour vers PVE 8.4+ avant de commencer",
|
||||
"de": "Das System muss vor dem Start auf PVE 8.4+ aktualisiert sein",
|
||||
"it": "Il sistema deve essere aggiornato a PVE 8.4+ prima di iniziare",
|
||||
"pt": "O sistema deve estar atualizado para PVE 8.4+ antes de começar"
|
||||
},
|
||||
"Use SSH or terminal access (SSH recommended)": {
|
||||
"es": "Use acceso por SSH o terminal (se recomienda SSH)",
|
||||
"fr": "Utilisez un accès SSH ou terminal (SSH recommandé)",
|
||||
"de": "SSH- oder Terminalzugang verwenden (SSH empfohlen)",
|
||||
"it": "Usa accesso SSH o terminale (consigliato SSH)",
|
||||
"pt": "Use acesso por SSH ou terminal (SSH recomendado)"
|
||||
},
|
||||
"Use tmux or screen to avoid interruptions": {
|
||||
"es": "Use tmux o screen para evitar interrupciones",
|
||||
"fr": "Utilisez tmux ou screen pour éviter les interruptions",
|
||||
"de": "Verwenden Sie tmux oder screen, um Unterbrechungen zu vermeiden",
|
||||
"it": "Usa tmux o screen per evitare interruzioni",
|
||||
"pt": "Use tmux ou screen para evitar interrupções"
|
||||
},
|
||||
"Have valid backups of all VMs and containers": {
|
||||
"es": "Tenga copias de seguridad válidas de todas las VMs y contenedores",
|
||||
"fr": "Ayez des sauvegardes valides de toutes les VM et conteneurs",
|
||||
"de": "Halten Sie gültige Backups aller VMs und Container bereit",
|
||||
"it": "Avere backup validi di tutte le VM e i container",
|
||||
"pt": "Tenha backups válidos de todas as VMs e contêineres"
|
||||
},
|
||||
"At least 5GB free space on root filesystem": {
|
||||
"es": "Al menos 5 GB de espacio libre en el sistema de archivos raíz",
|
||||
"fr": "Au moins 5 Go d’espace libre sur le système de fichiers racine",
|
||||
"de": "Mindestens 5 GB freier Speicherplatz auf dem Root-Dateisystem",
|
||||
"it": "Almeno 5 GB di spazio libero sul filesystem root",
|
||||
"pt": "Pelo menos 5 GB de espaço livre no sistema de arquivos raiz"
|
||||
},
|
||||
"Do not run the upgrade from the Web UI virtual console (it will disconnect)": {
|
||||
"es": "No ejecute la actualización desde la consola virtual de la interfaz web (se desconectará)",
|
||||
"fr": "N’exécutez pas la mise à niveau depuis la console virtuelle de l’interface Web (elle se déconnectera)",
|
||||
"de": "Führen Sie das Upgrade nicht über die virtuelle Konsole der Web-UI aus (die Verbindung wird getrennt)",
|
||||
"it": "Non eseguire l’aggiornamento dalla console virtuale dell’interfaccia Web (si disconnetterà)",
|
||||
"pt": "Não execute a atualização pelo console virtual da interface Web (a conexão será interrompida)"
|
||||
},
|
||||
"Update system to latest PVE 8.4+ (if not done already):": {
|
||||
"es": "Actualizar el sistema a PVE 8.4+ (si aún no se ha hecho):",
|
||||
"fr": "Mettre à jour le système vers PVE 8.4+ (si ce n’est pas déjà fait) :",
|
||||
"de": "System auf PVE 8.4+ aktualisieren (falls noch nicht geschehen):",
|
||||
"it": "Aggiornare il sistema a PVE 8.4+ (se non già fatto):",
|
||||
"pt": "Atualizar o sistema para PVE 8.4+ (se ainda não foi feito):"
|
||||
},
|
||||
"Or use ProxMenux update function": {
|
||||
"es": "O use la función de actualización de ProxMenux",
|
||||
"fr": "Ou utilisez la fonction de mise à jour de ProxMenux",
|
||||
"de": "Oder verwenden Sie die Aktualisierungsfunktion von ProxMenux",
|
||||
"it": "Oppure usa la funzione di aggiornamento di ProxMenux",
|
||||
"pt": "Ou use a função de atualização do ProxMenux"
|
||||
},
|
||||
"Verify PVE version (must be 8.4.1 or newer):": {
|
||||
"es": "Verificar la versión de PVE (debe ser 8.4.1 o superior):",
|
||||
"fr": "Vérifier la version de PVE (doit être 8.4.1 ou plus récente) :",
|
||||
"de": "PVE-Version prüfen (muss 8.4.1 oder neuer sein):",
|
||||
"it": "Verificare la versione di PVE (deve essere 8.4.1 o successiva):",
|
||||
"pt": "Verificar a versão do PVE (deve ser 8.4.1 ou superior):"
|
||||
},
|
||||
"If this node runs hyper-converged Ceph: ensure Ceph is 19.x (Squid) BEFORE upgrading PVE.": {
|
||||
"es": "Si este nodo ejecuta Ceph hiperconvergente: asegúrese de que Ceph sea 19.x (Squid) ANTES de actualizar PVE.",
|
||||
"fr": "Si ce nœud exécute un Ceph hyperconvergé : assurez-vous que Ceph est en 19.x (Squid) AVANT de mettre à niveau PVE.",
|
||||
"de": "Wenn dieser Knoten hyperkonvergentes Ceph betreibt: Stellen Sie sicher, dass Ceph 19.x (Squid) ist, BEVOR Sie PVE aktualisieren.",
|
||||
"it": "Se questo nodo esegue Ceph iperconvergente: assicurarsi che Ceph sia 19.x (Squid) PRIMA di aggiornare PVE.",
|
||||
"pt": "Se este nó executa Ceph hiperconvergente: certifique-se de que o Ceph esteja em 19.x (Squid) ANTES de atualizar o PVE."
|
||||
},
|
||||
"If not 19.x, upgrade Ceph (Reef→Squid) first per the official guide:": {
|
||||
"es": "Si no es 19.x, actualice primero Ceph (Reef→Squid) según la guía oficial:",
|
||||
"fr": "Si ce n’est pas 19.x, mettez d’abord à niveau Ceph (Reef→Squid) selon le guide officiel :",
|
||||
"de": "Wenn nicht 19.x, aktualisieren Sie Ceph zuerst (Reef→Squid) gemäß der offiziellen Anleitung:",
|
||||
"it": "Se non è 19.x, aggiornare prima Ceph (Reef→Squid) secondo la guida ufficiale:",
|
||||
"pt": "Se não for 19.x, atualize primeiro o Ceph (Reef→Squid) conforme o guia oficial:"
|
||||
},
|
||||
"Run upgrade checklist script:": {
|
||||
"es": "Ejecutar el script de la lista de verificación de actualización:",
|
||||
"fr": "Exécuter le script de liste de vérification de mise à niveau :",
|
||||
"de": "Upgrade-Checklisten-Skript ausführen:",
|
||||
"it": "Eseguire lo script della checklist di aggiornamento:",
|
||||
"pt": "Executar o script da lista de verificação de atualização:"
|
||||
},
|
||||
"If it warns about 'systemd-boot' meta-package, remove it:": {
|
||||
"es": "Si advierte sobre el metapaquete 'systemd-boot', elimínelo:",
|
||||
"fr": "S’il signale le méta-paquet « systemd-boot », supprimez-le :",
|
||||
"de": "Wenn vor dem Metapaket „systemd-boot“ gewarnt wird, entfernen Sie es:",
|
||||
"it": "Se avvisa del metapacchetto 'systemd-boot', rimuoverlo:",
|
||||
"pt": "Se alertar sobre o metapacote 'systemd-boot', remova-o:"
|
||||
},
|
||||
"Start terminal multiplexer (recommended):": {
|
||||
"es": "Iniciar un multiplexor de terminal (recomendado):",
|
||||
"fr": "Démarrer un multiplexeur de terminal (recommandé) :",
|
||||
"de": "Terminal-Multiplexer starten (empfohlen):",
|
||||
"it": "Avviare un multiplexer di terminale (consigliato):",
|
||||
"pt": "Iniciar um multiplexador de terminal (recomendado):"
|
||||
},
|
||||
"# Recommended: avoids disconnection during upgrade": {
|
||||
"es": "# Recomendado: evita desconexiones durante la actualización",
|
||||
"fr": "# Recommandé : évite les déconnexions pendant la mise à niveau",
|
||||
"de": "# Empfohlen: vermeidet Verbindungsabbrüche während des Upgrades",
|
||||
"it": "# Consigliato: evita disconnessioni durante l’aggiornamento",
|
||||
"pt": "# Recomendado: evita desconexões durante a atualização"
|
||||
},
|
||||
"# Alternative if you prefer screen": {
|
||||
"es": "# Alternativa si prefiere screen",
|
||||
"fr": "# Alternative si vous préférez screen",
|
||||
"de": "# Alternative, wenn Sie screen bevorzugen",
|
||||
"it": "# Alternativa se preferisci screen",
|
||||
"pt": "# Alternativa se preferir screen"
|
||||
},
|
||||
"Update Debian repositories to Trixie:": {
|
||||
"es": "Actualizar los repositorios de Debian a Trixie:",
|
||||
"fr": "Mettre à jour les dépôts Debian vers Trixie :",
|
||||
"de": "Debian-Repositories auf Trixie aktualisieren:",
|
||||
"it": "Aggiornare i repository Debian a Trixie:",
|
||||
"pt": "Atualizar os repositórios Debian para Trixie:"
|
||||
},
|
||||
"Update PVE enterprise repository (Only if using enterprise):": {
|
||||
"es": "Actualizar el repositorio empresarial de PVE (solo si usa enterprise):",
|
||||
"fr": "Mettre à jour le dépôt entreprise de PVE (uniquement si vous utilisez enterprise) :",
|
||||
"de": "PVE-Enterprise-Repository aktualisieren (nur bei Verwendung von Enterprise):",
|
||||
"it": "Aggiornare il repository enterprise di PVE (solo se si usa enterprise):",
|
||||
"pt": "Atualizar o repositório enterprise do PVE (apenas se usar enterprise):"
|
||||
},
|
||||
"Skip this step if using no-subscription repository": {
|
||||
"es": "Omita este paso si usa el repositorio sin suscripción",
|
||||
"fr": "Ignorez cette étape si vous utilisez le dépôt sans abonnement",
|
||||
"de": "Überspringen Sie diesen Schritt, wenn Sie das No-Subscription-Repository verwenden",
|
||||
"it": "Saltare questo passaggio se si usa il repository senza sottoscrizione",
|
||||
"pt": "Pule esta etapa se usar o repositório sem assinatura"
|
||||
},
|
||||
"Add new PVE 9 enterprise repository (deb822 format) (Only if using enterprise):": {
|
||||
"es": "Agregar el nuevo repositorio empresarial de PVE 9 (formato deb822) (solo si usa enterprise):",
|
||||
"fr": "Ajouter le nouveau dépôt entreprise de PVE 9 (format deb822) (uniquement si vous utilisez enterprise) :",
|
||||
"de": "Neues PVE-9-Enterprise-Repository (deb822-Format) hinzufügen (nur bei Enterprise):",
|
||||
"it": "Aggiungere il nuovo repository enterprise di PVE 9 (formato deb822) (solo se si usa enterprise):",
|
||||
"pt": "Adicionar o novo repositório enterprise do PVE 9 (formato deb822) (apenas se usar enterprise):"
|
||||
},
|
||||
"Only if using enterprise subscription": {
|
||||
"es": "Solo si utiliza suscripción empresarial",
|
||||
"fr": "Uniquement si vous avez un abonnement entreprise",
|
||||
"de": "Nur bei vorhandener Enterprise-Abonnement",
|
||||
"it": "Solo se si dispone di una sottoscrizione enterprise",
|
||||
"pt": "Apenas se você tiver assinatura enterprise"
|
||||
},
|
||||
"OR add new PVE 9 no-subscription repository:": {
|
||||
"es": "O agregar el nuevo repositorio de PVE 9 sin suscripción:",
|
||||
"fr": "OU ajouter le nouveau dépôt PVE 9 sans abonnement :",
|
||||
"de": "ODER das neue PVE-9-No-Subscription-Repository hinzufügen:",
|
||||
"it": "OPPURE aggiungere il nuovo repository PVE 9 senza sottoscrizione:",
|
||||
"pt": "OU adicionar o novo repositório PVE 9 sem assinatura:"
|
||||
},
|
||||
"Only if using no-subscription repository": {
|
||||
"es": "Solo si usa el repositorio sin suscripción",
|
||||
"fr": "Uniquement si vous utilisez le dépôt sans abonnement",
|
||||
"de": "Nur bei Verwendung des No-Subscription-Repository",
|
||||
"it": "Solo se si usa il repository senza sottoscrizione",
|
||||
"pt": "Apenas se usar o repositório sem assinatura"
|
||||
},
|
||||
"Refresh APT index and verify repositories:": {
|
||||
"es": "Actualizar el índice de APT y verificar los repositorios:",
|
||||
"fr": "Actualiser l’index APT et vérifier les dépôts :",
|
||||
"de": "APT-Index aktualisieren und Repositories prüfen:",
|
||||
"it": "Aggiornare l’indice APT e verificare i repository:",
|
||||
"pt": "Atualizar o índice do APT e verificar os repositórios:"
|
||||
},
|
||||
"Ensure there are no errors and that proxmox-ve candidate shows 9.x": {
|
||||
"es": "Asegúrese de que no haya errores y de que el candidato de proxmox-ve sea 9.x",
|
||||
"fr": "Assurez-vous qu’il n’y a aucune erreur et que le candidat proxmox-ve indique 9.x",
|
||||
"de": "Stellen Sie sicher, dass keine Fehler auftreten und der proxmox-ve-Kandidat 9.x anzeigt",
|
||||
"it": "Assicurarsi che non vi siano errori e che il candidato di proxmox-ve sia 9.x",
|
||||
"pt": "Certifique-se de que não há erros e que o candidato proxmox-ve mostre 9.x"
|
||||
},
|
||||
"Update Ceph repository (Only if using Ceph):": {
|
||||
"es": "Actualizar el repositorio de Ceph (solo si usa Ceph):",
|
||||
"fr": "Mettre à jour le dépôt Ceph (uniquement si vous utilisez Ceph) :",
|
||||
"de": "Ceph-Repository aktualisieren (nur bei Verwendung von Ceph):",
|
||||
"it": "Aggiornare il repository di Ceph (solo se si usa Ceph):",
|
||||
"pt": "Atualizar o repositório do Ceph (apenas se usar Ceph):"
|
||||
},
|
||||
"Use enterprise URL if you have subscription.": {
|
||||
"es": "Use la URL enterprise si dispone de suscripción.",
|
||||
"fr": "Utilisez l’URL enterprise si vous avez un abonnement.",
|
||||
"de": "Verwenden Sie die Enterprise-URL, wenn Sie ein Abonnement haben.",
|
||||
"it": "Usa l’URL enterprise se hai una sottoscrizione.",
|
||||
"pt": "Use a URL enterprise se tiver assinatura."
|
||||
},
|
||||
"Remove old repository files:": {
|
||||
"es": "Eliminar los archivos de repositorio antiguos:",
|
||||
"fr": "Supprimer les anciens fichiers de dépôt :",
|
||||
"de": "Alte Repository-Dateien entfernen:",
|
||||
"it": "Rimuovere i vecchi file di repository:",
|
||||
"pt": "Remover arquivos antigos de repositório:"
|
||||
},
|
||||
"Also comment any remaining 'bookworm' entries in *.list if present.": {
|
||||
"es": "Comente también cualquier entrada restante de ‘bookworm’ en *.list si existe.",
|
||||
"fr": "Commentez également toute entrée ‘bookworm’ restante dans *.list si présente.",
|
||||
"de": "Kommentieren Sie außerdem verbleibende ‚bookworm‘-Einträge in *.list, falls vorhanden.",
|
||||
"it": "Commentare anche eventuali voci ‘bookworm’ rimanenti in *.list se presenti.",
|
||||
"pt": "Comente também quaisquer entradas ‘bookworm’ restantes em *.list, se houver."
|
||||
},
|
||||
"Update package index:": {
|
||||
"es": "Actualizar el índice de paquetes:",
|
||||
"fr": "Mettre à jour l’index des paquets :",
|
||||
"de": "Paketindex aktualisieren:",
|
||||
"it": "Aggiornare l’indice dei pacchetti:",
|
||||
"pt": "Atualizar o índice de pacotes:"
|
||||
},
|
||||
"Disable kernel audit messages (optional but recommended):": {
|
||||
"es": "Desactivar los mensajes de auditoría del kernel (opcional pero recomendado):",
|
||||
"fr": "Désactiver les messages d’audit du noyau (optionnel mais recommandé) :",
|
||||
"de": "Kernel-Audit-Meldungen deaktivieren (optional, aber empfohlen):",
|
||||
"it": "Disabilitare i messaggi di audit del kernel (opzionale ma consigliato):",
|
||||
"pt": "Desativar as mensagens de auditoria do kernel (opcional, mas recomendado):"
|
||||
},
|
||||
"Start the main system upgrade:": {
|
||||
"es": "Iniciar la actualización principal del sistema:",
|
||||
"fr": "Démarrer la mise à niveau principale du système :",
|
||||
"de": "Hauptsystem-Upgrade starten:",
|
||||
"it": "Avviare l’aggiornamento principale del sistema:",
|
||||
"pt": "Iniciar a atualização principal do sistema:"
|
||||
},
|
||||
"This will take time. Answer prompts carefully - see notes below.": {
|
||||
"es": "Esto llevará tiempo. Responda a las indicaciones con cuidado (vea las notas a continuación).",
|
||||
"fr": "Cela prendra du temps. Répondez aux invites avec attention (voir les notes ci-dessous).",
|
||||
"de": "Dies wird einige Zeit dauern. Beantworten Sie Rückfragen sorgfältig (siehe Hinweise unten).",
|
||||
"it": "Questo richiederà tempo. Rispondi con attenzione alle richieste (vedi note sotto).",
|
||||
"pt": "Isso levará tempo. Responda às solicitações com atenção (veja as notas abaixo)."
|
||||
},
|
||||
"UPGRADE PROMPTS - RECOMMENDED ANSWERS:": {
|
||||
"es": "INDICACIONES DE ACTUALIZACIÓN - RESPUESTAS RECOMENDADAS:",
|
||||
"fr": "INVITES DE MISE À NIVEAU - RÉPONSES RECOMMANDÉES :",
|
||||
"de": "UPGRADE-AUFFORDERUNGEN – EMPFOHLENE ANTWORTEN:",
|
||||
"it": "PROMPT DI AGGIORNAMENTO - RISPOSTE CONSIGLIATE:",
|
||||
"pt": "PROMPTS DE ATUALIZAÇÃO - RESPOSTAS RECOMENDADAS:"
|
||||
},
|
||||
"Keep current version (N)": {
|
||||
"es": "Mantener la versión actual (N)",
|
||||
"fr": "Conserver la version actuelle (N)",
|
||||
"de": "Aktuelle Version beibehalten (N)",
|
||||
"it": "Mantieni la versione corrente (N)",
|
||||
"pt": "Manter a versão atual (N)"
|
||||
},
|
||||
"Install maintainer's version (Y)": {
|
||||
"es": "Instalar la versión del mantenedor del paquete (Y)",
|
||||
"fr": "Installer la version du mainteneur du paquet (Y)",
|
||||
"de": "Version des Paketbetreuers installieren (Y)",
|
||||
"it": "Installare la versione del maintainer del pacchetto (Y)",
|
||||
"pt": "Instalar a versão do mantenedor do pacote (Y)"
|
||||
},
|
||||
"Keep current version (N) if modified": {
|
||||
"es": "Mantener la versión actual (N) si está modificada",
|
||||
"fr": "Conserver la version actuelle (N) si elle a été modifiée",
|
||||
"de": "Aktuelle Version beibehalten (N), falls angepasst",
|
||||
"it": "Mantieni la versione corrente (N) se modificata",
|
||||
"pt": "Manter a versão atual (N) se estiver modificada"
|
||||
},
|
||||
"Service restarts:": {
|
||||
"es": "Reinicios de servicios:",
|
||||
"fr": "Redémarrages de services :",
|
||||
"de": "Neustarts von Diensten:",
|
||||
"it": "Riavvii dei servizi:",
|
||||
"pt": "Reinicializações de serviços:"
|
||||
},
|
||||
"Use default (Yes)": {
|
||||
"es": "Usar la opción predeterminada (Sí)",
|
||||
"fr": "Utiliser l’option par défaut (Oui)",
|
||||
"de": "Standardoption verwenden (Ja)",
|
||||
"it": "Usare l’opzione predefinita (Sì)",
|
||||
"pt": "Usar a opção padrão (Sim)"
|
||||
},
|
||||
"Press 'q' to exit": {
|
||||
"es": "Presione «q» para salir",
|
||||
"fr": "Appuyez sur « q » pour quitter",
|
||||
"de": "Drücken Sie „q“, um zu beenden",
|
||||
"it": "Premi «q» per uscire",
|
||||
"pt": "Pressione «q» para sair"
|
||||
},
|
||||
"If booting in EFI mode with root on LVM: install GRUB for EFI": {
|
||||
"es": "Si inicia en modo EFI con root en LVM: instale GRUB para EFI",
|
||||
"fr": "Si vous démarrez en mode EFI avec root sur LVM : installez GRUB pour EFI",
|
||||
"de": "Wenn Sie im EFI-Modus mit Root auf LVM booten: GRUB für EFI installieren",
|
||||
"it": "Se l’avvio è in modalità EFI con root su LVM: installare GRUB per EFI",
|
||||
"pt": "Se iniciar em modo EFI com root no LVM: instale o GRUB para EFI"
|
||||
},
|
||||
"Per official known issues; ensures proper boot after upgrade": {
|
||||
"es": "Según las incidencias conocidas oficiales, garantiza un arranque correcto tras la actualización",
|
||||
"fr": "Selon les problèmes connus officiels, cela garantit un démarrage correct après la mise à niveau",
|
||||
"de": "Laut den offiziellen Known Issues sorgt dies für einen ordnungsgemäßen Start nach dem Upgrade",
|
||||
"it": "Secondo le note ufficiali dei problemi noti, garantisce un avvio corretto dopo l’aggiornamento",
|
||||
"pt": "De acordo com os problemas conhecidos oficiais, garante uma inicialização correta após a atualização"
|
||||
},
|
||||
"Run checklist again to verify upgrade:": {
|
||||
"es": "Ejecutar de nuevo la lista de verificación para comprobar la actualización:",
|
||||
"fr": "Relancer la liste de vérification pour valider la mise à niveau :",
|
||||
"de": "Checkliste erneut ausführen, um das Upgrade zu verifizieren:",
|
||||
"it": "Eseguire nuovamente la checklist per verificare l’aggiornamento:",
|
||||
"pt": "Executar novamente a lista de verificação para validar a atualização:"
|
||||
},
|
||||
"Should show fewer or no issues": {
|
||||
"es": "Debería mostrar menos problemas o ninguno",
|
||||
"fr": "Devrait afficher moins de problèmes, voire aucun",
|
||||
"de": "Sollte weniger oder keine Probleme anzeigen",
|
||||
"it": "Dovrebbe mostrare meno problemi o nessuno",
|
||||
"pt": "Deve mostrar menos problemas ou nenhum"
|
||||
},
|
||||
"Reboot the system:": {
|
||||
"es": "Reiniciar el sistema:",
|
||||
"fr": "Redémarrer le système :",
|
||||
"de": "System neu starten:",
|
||||
"it": "Riavviare il sistema:",
|
||||
"pt": "Reiniciar o sistema:"
|
||||
},
|
||||
"After reboot, verify PVE version:": {
|
||||
"es": "Después del reinicio, verificar la versión de PVE:",
|
||||
"fr": "Après le redémarrage, vérifier la version de PVE :",
|
||||
"de": "Nach dem Neustart die PVE-Version prüfen:",
|
||||
"it": "Dopo il riavvio, verificare la versione di PVE:",
|
||||
"pt": "Após reiniciar, verificar a versão do PVE:"
|
||||
},
|
||||
"Should show pve-manager/9.x.x": {
|
||||
"es": "Debería mostrar pve-manager/9.x.x",
|
||||
"fr": "Devrait afficher pve-manager/9.x.x",
|
||||
"de": "Sollte pve-manager/9.x.x anzeigen",
|
||||
"it": "Dovrebbe mostrare pve-manager/9.x.x",
|
||||
"pt": "Deve mostrar pve-manager/9.x.x"
|
||||
},
|
||||
"Optional: Modernize repository sources:": {
|
||||
"es": "Opcional: Modernizar las fuentes de repositorio:",
|
||||
"fr": "Optionnel : Moderniser les sources des dépôts :",
|
||||
"de": "Optional: Repository-Quellen modernisieren:",
|
||||
"it": "Opzionale: Modernizzare le fonti del repository:",
|
||||
"pt": "Opcional: Modernizar as fontes de repositório:"
|
||||
},
|
||||
"Converts to deb822; keeps .list backups as .bak": {
|
||||
"es": "Convierte a deb822; mantiene copias .list como .bak",
|
||||
"fr": "Convertit en deb822 ; conserve les sauvegardes .list en .bak",
|
||||
"de": "Konvertiert zu deb822; behält .list-Backups als .bak bei",
|
||||
"it": "Converte in deb822; mantiene i backup .list come .bak",
|
||||
"pt": "Converte para deb822; mantém backups .list como .bak"
|
||||
},
|
||||
"CLUSTER UPGRADE NOTES:": {
|
||||
"es": "NOTAS DE ACTUALIZACIÓN DEL CLÚSTER:",
|
||||
"fr": "NOTES DE MISE À NIVEAU DU CLUSTER :",
|
||||
"de": "HINWEISE ZUM CLUSTER-UPGRADE:",
|
||||
"it": "NOTE DI AGGIORNAMENTO DEL CLUSTER:",
|
||||
"pt": "NOTAS DE ATUALIZAÇÃO DO CLUSTER:"
|
||||
},
|
||||
"Upgrade one node at a time": {
|
||||
"es": "Actualizar un nodo a la vez",
|
||||
"fr": "Mettre à niveau un nœud à la fois",
|
||||
"de": "Jeweils nur einen Knoten aktualisieren",
|
||||
"it": "Aggiornare un nodo alla volta",
|
||||
"pt": "Atualizar um nó por vez"
|
||||
},
|
||||
"Migrate VMs away from node being upgraded": {
|
||||
"es": "Migrar las VMs fuera del nodo que se está actualizando",
|
||||
"fr": "Migrer les VM hors du nœud en cours de mise à niveau",
|
||||
"de": "VMs vom zu aktualisierenden Knoten weg migrieren",
|
||||
"it": "Migrare le VM dal nodo in aggiornamento",
|
||||
"pt": "Migrar as VMs para fora do nó em atualização"
|
||||
},
|
||||
"Wait for each node to complete before starting next": {
|
||||
"es": "Esperar a que cada nodo termine antes de empezar con el siguiente",
|
||||
"fr": "Attendre que chaque nœud soit terminé avant de commencer le suivant",
|
||||
"de": "Warten, bis jeder Knoten abgeschlossen ist, bevor der nächste beginnt",
|
||||
"it": "Attendere che ogni nodo termini prima di iniziare il successivo",
|
||||
"pt": "Aguardar cada nó concluir antes de iniciar o próximo"
|
||||
},
|
||||
"HA groups will be migrated to HA rules automatically": {
|
||||
"es": "Los grupos de HA se migrarán automáticamente a reglas de HA",
|
||||
"fr": "Les groupes HA seront migrés automatiquement vers des règles HA",
|
||||
"de": "HA-Gruppen werden automatisch in HA-Regeln migriert",
|
||||
"it": "I gruppi HA verranno migrati automaticamente a regole HA",
|
||||
"pt": "Grupos de HA serão migrados automaticamente para regras de HA"
|
||||
},
|
||||
"TROUBLESHOOTING:": {
|
||||
"es": "SOLUCIÓN DE PROBLEMAS:",
|
||||
"fr": "DÉPANNAGE :",
|
||||
"de": "FEHLERBEHEBUNG:",
|
||||
"it": "RISOLUZIONE DEI PROBLEMI:",
|
||||
"pt": "SOLUÇÃO DE PROBLEMAS:"
|
||||
},
|
||||
"If upgrade fails:": {
|
||||
"es": "Si la actualización falla:",
|
||||
"fr": "Si la mise à niveau échoue :",
|
||||
"de": "Wenn das Upgrade fehlschlägt:",
|
||||
"it": "Se l’aggiornamento fallisce:",
|
||||
"pt": "Se a atualização falhar:"
|
||||
},
|
||||
"If repositories error:": {
|
||||
"es": "Si hay errores de repositorios:",
|
||||
"fr": "En cas d’erreurs de dépôts :",
|
||||
"de": "Bei Repository-Fehlern:",
|
||||
"it": "In caso di errori dei repository:",
|
||||
"pt": "Se houver erros nos repositórios:"
|
||||
},
|
||||
"If 'proxmox-ve' removal warning:": {
|
||||
"es": "Si aparece advertencia de eliminación de ‘proxmox-ve’:",
|
||||
"fr": "Si un avertissement de suppression de ‘proxmox-ve’ apparaît :",
|
||||
"de": "Bei Warnung zur Entfernung von ‚proxmox-ve‘:",
|
||||
"it": "Se compare un avviso di rimozione di ‘proxmox-ve’:",
|
||||
"pt": "Se aparecer um aviso de remoção de ‘proxmox-ve’:"
|
||||
},
|
||||
"Emergency recovery:": {
|
||||
"es": "Recuperación de emergencia:",
|
||||
"fr": "Récupération d’urgence :",
|
||||
"de": "Notfallwiederherstellung:",
|
||||
"it": "Ripristino di emergenza:",
|
||||
"pt": "Recuperação de emergência:"
|
||||
},
|
||||
"Mount and Share Manager": {
|
||||
"es": "Montajes y Recursos Compartidos",
|
||||
"fr": "Gestionnaire de Montage et Partage",
|
||||
"de": "Mount- und Share-Manager",
|
||||
"it": "Gestore di Mount e Condivisioni",
|
||||
"pt": "Gerenciador de Montagem e Compartilhamento"
|
||||
},
|
||||
"HOST": {
|
||||
"es": "HOST",
|
||||
"fr": "HÔTE",
|
||||
"de": "HOST",
|
||||
"it": "HOST",
|
||||
"pt": "HOST"
|
||||
},
|
||||
"Configure NFS shared on Host": {
|
||||
"es": "Configurar recursos NFS compartidos en el Host",
|
||||
"fr": "Configurer les partages NFS sur l'hôte",
|
||||
"de": "NFS-Freigaben auf Host konfigurieren",
|
||||
"it": "Configurare condivisioni NFS su Host",
|
||||
"pt": "Configurar compartilhamentos NFS no Host"
|
||||
},
|
||||
"Configure Samba shared on Host": {
|
||||
"es": "Configurar recursos Samba compartidos en el Host",
|
||||
"fr": "Configurer les partages Samba sur l'hôte",
|
||||
"de": "Samba-Freigaben auf Host konfigurieren",
|
||||
"it": "Configurare condivisioni Samba su Host",
|
||||
"pt": "Configurar compartilhamentos Samba no Host"
|
||||
},
|
||||
"Configure Local Shared on Host": {
|
||||
"es": "Configurar directorios locales compartidos en el Host",
|
||||
"fr": "Configurer les répertoires locaux partagés sur l'hôte",
|
||||
"de": "Lokale geteilte Verzeichnisse auf Host konfigurieren",
|
||||
"it": "Configurare directory locali condivise su Host",
|
||||
"pt": "Configurar diretórios locais compartilhados no Host"
|
||||
},
|
||||
"LXC": {
|
||||
"es": "LXC",
|
||||
"fr": "LXC",
|
||||
"de": "LXC",
|
||||
"it": "LXC",
|
||||
"pt": "LXC"
|
||||
},
|
||||
"Configure LXC Mount Points (Host ↔ Container)": {
|
||||
"es": "Configurar puntos de montaje LXC (Host ↔ LXC)",
|
||||
"fr": "Configurer les points de montage LXC (Hôte ↔ LXC)",
|
||||
"de": "LXC-Mount-Punkte konfigurieren (Host ↔ LXC)",
|
||||
"it": "Configurare punti di mount LXC (Host ↔ LXC)",
|
||||
"pt": "Configurar pontos de montagem LXC (Host ↔ LXC)"
|
||||
},
|
||||
"Configure NFS Client in LXC (only privileged)": {
|
||||
"es": "Configurar cliente NFS en LXC (solo privilegiados)",
|
||||
"fr": "Configurer le client NFS dans LXC (privilégiés uniquement)",
|
||||
"de": "NFS-Client in LXC konfigurieren (nur privilegiert)",
|
||||
"it": "Configurare client NFS in LXC (solo privilegiati)",
|
||||
"pt": "Configurar cliente NFS em LXC (apenas privilegiados)"
|
||||
},
|
||||
"Configure Samba Client in LXC (only privileged)": {
|
||||
"es": "Configurar cliente Samba en LXC (solo privilegiados)",
|
||||
"fr": "Configurer le client Samba dans LXC (privilégiés uniquement)",
|
||||
"de": "Samba-Client in LXC konfigurieren (nur privilegiert)",
|
||||
"it": "Configurare client Samba in LXC (solo privilegiati)",
|
||||
"pt": "Configurar cliente Samba em LXC (apenas privilegiados)"
|
||||
},
|
||||
"Configure NFS Server in LXC (only privileged)": {
|
||||
"es": "Configurar servidor NFS en LXC (solo privilegiados)",
|
||||
"fr": "Configurer le serveur NFS dans LXC (privilégiés uniquement)",
|
||||
"de": "NFS-Server in LXC konfigurieren (nur privilegiert)",
|
||||
"it": "Configurare server NFS in LXC (solo privilegiati)",
|
||||
"pt": "Configurar servidor NFS em LXC (apenas privilegiados)"
|
||||
},
|
||||
"configure Samba Server in LXC (only privileged)": {
|
||||
"es": "Configurar servidor Samba en LXC (solo privilegiados)",
|
||||
"fr": "Configurer le serveur Samba dans LXC (privilégiés uniquement)",
|
||||
"de": "Samba-Server in LXC konfigurieren (nur privilegiert)",
|
||||
"it": "Configurare server Samba in LXC (solo privilegiati)",
|
||||
"pt": "Configurar servidor Samba em LXC (apenas privilegiados)"
|
||||
},
|
||||
"Help & Info (commands)": {
|
||||
"es": "Comandos de ayuda e información",
|
||||
"fr": "Aide et Informations (commandes)",
|
||||
"de": "Hilfe & Informationen (Befehle)",
|
||||
"it": "Aiuto e Informazioni (comandi)",
|
||||
"pt": "Ajuda e Informações (comandos)"
|
||||
},
|
||||
"English": {
|
||||
"es": "Inglés",
|
||||
"fr": "Anglais",
|
||||
"de": "Englisch",
|
||||
"it": "Inglese",
|
||||
"pt": "Inglês"
|
||||
},
|
||||
"Language Change": {
|
||||
"es": "Cambio de Idioma",
|
||||
"fr": "Changement de Langue",
|
||||
"de": "Sprachänderung",
|
||||
"it": "Cambio Lingua",
|
||||
"pt": "Mudança de Idioma"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenu - A menu-driven script for Proxmox VE management
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 04/07/2025
|
||||
# ==========================================================
|
||||
@@ -13,13 +13,13 @@
|
||||
# This script serves as the main entry point for ProxMenux,
|
||||
# a menu-driven tool designed for Proxmox VE management.
|
||||
#
|
||||
# - Displays the ProxMenu logo on startup.
|
||||
# - Displays the ProxMenux logo on startup.
|
||||
# - Loads necessary configurations and language settings.
|
||||
# - Checks for available updates and installs them if confirmed.
|
||||
# - Downloads and executes the latest main menu script.
|
||||
#
|
||||
# Key Features:
|
||||
# - Ensures ProxMenu is always up-to-date by fetching the latest version.
|
||||
# - Ensures ProxMenux is always up-to-date by fetching the latest version.
|
||||
# - Uses whiptail for interactive menus and language selection.
|
||||
# - Loads utility functions and translation support.
|
||||
# - Maintains a cache system to improve performance.
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
CONFIG_FILE="$BASE_DIR/config.json"
|
||||
CACHE_FILE="$BASE_DIR/cache.json"
|
||||
@@ -44,7 +44,10 @@ if [[ -f "$UTILS_FILE" ]]; then
|
||||
fi
|
||||
|
||||
# =========================================================
|
||||
|
||||
# For now, update is not available in the local version.
|
||||
# Take in mind that in future versions, updates must be
|
||||
# a warning to update the .deb package
|
||||
# =========================================================
|
||||
check_updates() {
|
||||
local INSTALL_SCRIPT="$BASE_DIR/install_proxmenux.sh"
|
||||
|
||||
@@ -67,7 +70,7 @@ check_updates() {
|
||||
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 ProxMenu update...")"
|
||||
msg_warn "$(translate "Starting ProxMenux update...")"
|
||||
|
||||
if wget -qO "$INSTALL_SCRIPT" "$REPO_URL/install_proxmenux.sh"; then
|
||||
chmod +x "$INSTALL_SCRIPT"
|
||||
@@ -80,13 +83,13 @@ check_updates() {
|
||||
}
|
||||
|
||||
|
||||
|
||||
main_menu() {
|
||||
exec bash <(curl -fsSL "$REPO_URL/scripts/menus/main_menu.sh")
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
}
|
||||
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
check_updates
|
||||
# Check updates doesn't make sense in offline mode
|
||||
# check_updates
|
||||
main_menu
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# License : (CC BY-NC 4.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
|
||||
|
||||