Compare commits
1142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,86 @@
|
||||
name: Build ProxMenux Monitor AppImage
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths: [ 'AppImage/**' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths: [ 'AppImage/**' ]
|
||||
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 @@
|
||||
e896eb10de4bf990d31c1d8357289f64cbce481921647f2be53efb850d0b73b2 ProxMenux-1.0.0.AppImage
|
||||
@@ -0,0 +1,41 @@
|
||||
# ProxMenux Monitor
|
||||
|
||||
A modern, responsive dashboard for monitoring Proxmox VE systems built with Next.js and React.
|
||||
|
||||
## Features
|
||||
|
||||
- **System Overview**: Real-time monitoring of CPU, memory, temperature, and active VMs/LXC containers
|
||||
- **Storage Management**: Visual representation of storage distribution and disk performance metrics
|
||||
- **Network Monitoring**: Network interface statistics and performance graphs
|
||||
- **Virtual Machines**: Comprehensive view of VMs and LXC containers with resource usage
|
||||
- **System Logs**: Real-time system log monitoring and filtering
|
||||
- **Dark/Light Theme**: Toggle between themes with Proxmox-inspired design
|
||||
- **Responsive Design**: Works seamlessly on desktop and mobile devices
|
||||
- **Onboarding Experience**: Interactive welcome carousel for first-time users
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: Next.js 15, React 19, TypeScript
|
||||
- **Styling**: Tailwind CSS with custom Proxmox-inspired theme
|
||||
- **Charts**: Recharts for data visualization
|
||||
- **UI Components**: Radix UI primitives with shadcn/ui
|
||||
- **Backend**: Flask server for system data collection
|
||||
- **Packaging**: AppImage for easy distribution
|
||||
|
||||
## Onboarding Images
|
||||
|
||||
To customize the onboarding experience, place your screenshot images in `public/images/onboarding/`:
|
||||
|
||||
- `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
|
||||
|
||||
**Recommended image specifications:**
|
||||
- Format: PNG or JPG
|
||||
- Size: 1200x800px or similar 3:2 aspect ratio
|
||||
- Quality: High-quality screenshots with representative data
|
||||
|
||||
The onboarding carousel will automatically show on first visit and can be dismissed or marked as "Don't show again".
|
||||
@@ -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,7 @@
|
||||
"use client"
|
||||
|
||||
import { ProxmoxDashboard } from "../components/proxmox-dashboard"
|
||||
|
||||
export default function Home() {
|
||||
return <ProxmoxDashboard />
|
||||
}
|
||||
@@ -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,499 @@
|
||||
"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"
|
||||
|
||||
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 baseUrl =
|
||||
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/vms/${vmid}/metrics?timeframe=${timeframe}`
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || "Failed to fetch metrics")
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
const 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,251 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Wifi, Zap } from "lucide-react"
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
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 response = await fetch(`/api/network/${interface_.name}/metrics?timeframe=${timeframe}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch traffic data: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Calculate totals from the data points
|
||||
if (data.data && data.data.length > 0) {
|
||||
const lastPoint = data.data[data.data.length - 1]
|
||||
const firstPoint = data.data[0]
|
||||
|
||||
// Calculate the difference between last and first data points
|
||||
const receivedGB = Math.max(0, (lastPoint.netin || 0) - (firstPoint.netin || 0))
|
||||
const sentGB = Math.max(0, (lastPoint.netout || 0) - (firstPoint.netout || 0))
|
||||
|
||||
setTrafficData({
|
||||
received: receivedGB,
|
||||
sent: sentGB,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch traffic data for card:", error)
|
||||
// Keep showing 0 values on error
|
||||
setTrafficData({ received: 0, sent: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch if interface is up and not a VM
|
||||
if (interface_.status.toLowerCase() === "up" && interface_.vm_type !== "vm") {
|
||||
fetchTrafficData()
|
||||
|
||||
// Refresh every 60 seconds
|
||||
const interval = setInterval(fetchTrafficData, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [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,285 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
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 baseUrl =
|
||||
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
|
||||
const apiUrl = interfaceName
|
||||
? `${baseUrl}/api/network/${interfaceName}/metrics?timeframe=${timeframe}`
|
||||
: `${baseUrl}/api/node/metrics?timeframe=${timeframe}`
|
||||
|
||||
console.log("[v0] Fetching network metrics from:", apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch network metrics: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
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,465 @@
|
||||
"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"
|
||||
|
||||
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 [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 baseUrl =
|
||||
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/node/metrics?timeframe=${timeframe}`
|
||||
|
||||
console.log("[v0] Fetching node metrics from:", apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
|
||||
console.log("[v0] Response status:", response.status)
|
||||
console.log("[v0] Response ok:", response.ok)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.log("[v0] Error response text:", errorText)
|
||||
throw new Error(`Failed to fetch node metrics: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log("[v0] Node metrics result:", result)
|
||||
console.log("[v0] Result keys:", Object.keys(result))
|
||||
console.log("[v0] Data array length:", result.data?.length || 0)
|
||||
|
||||
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>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<TrendingUp className="h-5 w-5 mr-2" />
|
||||
CPU Usage & Load Average
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 30, right: 10 }}>
|
||||
<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={{ 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={{ 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>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<MemoryStick className="h-5 w-5 mr-2" />
|
||||
Memory Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<AreaChart data={data} margin={{ bottom: 60, left: 30, right: 10 }}>
|
||||
<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, "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,274 @@
|
||||
"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"
|
||||
|
||||
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")
|
||||
|
||||
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 {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentSlide > 0) {
|
||||
setDirection("prev")
|
||||
setCurrentSlide(currentSlide - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleDontShowAgain = () => {
|
||||
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={setOpen}>
|
||||
<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">
|
||||
{/* Close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-4 right-4 z-50 h-8 w-8 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
|
||||
onClick={handleSkip}
|
||||
>
|
||||
<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)]" />
|
||||
|
||||
{/* Icon or Image */}
|
||||
<div className="relative z-10 text-white">
|
||||
{slide.image ? (
|
||||
<div className="relative w-full h-36 md:h-48 flex items-center justify-center px-4">
|
||||
<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>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-10 left-10 w-20 h-20 bg-white/10 rounded-full blur-2xl" />
|
||||
<div className="absolute bottom-10 right-10 w-32 h-32 bg-white/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-8 space-y-4 md:space-y-6">
|
||||
<div className="space-y-2 md:space-y-3">
|
||||
<h2 className="text-2xl md:text-3xl font-bold text-foreground text-balance">{slide.title}</h2>
|
||||
<p className="text-base md:text-lg text-muted-foreground leading-relaxed text-pretty">
|
||||
{slide.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress dots */}
|
||||
<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-3 md:gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handlePrev}
|
||||
disabled={currentSlide === 0}
|
||||
className="gap-2 w-full sm:w-auto"
|
||||
>
|
||||
<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">
|
||||
Skip
|
||||
</Button>
|
||||
<Button onClick={handleNext} className="gap-2 bg-blue-500 hover:bg-blue-600 flex-1 sm:flex-none">
|
||||
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"
|
||||
>
|
||||
Get Started!
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Don't show again */}
|
||||
{currentSlide === slides.length - 1 && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleDontShowAgain}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors underline"
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
"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 { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Server,
|
||||
Menu,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
NetworkIcon,
|
||||
Box,
|
||||
Cpu,
|
||||
FileText,
|
||||
} 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[]
|
||||
}
|
||||
|
||||
export function ProxmoxDashboard() {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||
status: "healthy",
|
||||
uptime: "Loading...",
|
||||
lastUpdate: new Date().toLocaleTimeString(),
|
||||
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 fetchSystemData = useCallback(async () => {
|
||||
console.log("[v0] Fetching system data from Flask server...")
|
||||
console.log("[v0] Current window location:", window.location.href)
|
||||
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/system`
|
||||
|
||||
console.log("[v0] API URL:", apiUrl)
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
console.log("[v0] Response status:", response.status)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: FlaskSystemData = await response.json()
|
||||
console.log("[v0] System data received:", data)
|
||||
|
||||
let status: "healthy" | "warning" | "critical" = "healthy"
|
||||
if (data.cpu_usage > 90 || data.memory_usage > 90) {
|
||||
status = "critical"
|
||||
} else if (data.cpu_usage > 75 || data.memory_usage > 75) {
|
||||
status = "warning"
|
||||
}
|
||||
|
||||
setSystemStatus({
|
||||
status,
|
||||
uptime: data.uptime,
|
||||
lastUpdate: new Date().toLocaleTimeString(),
|
||||
serverName: data.hostname,
|
||||
nodeId: data.node_id,
|
||||
})
|
||||
setIsServerConnected(true)
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch system data from Flask server:", error)
|
||||
console.error("[v0] Error details:", {
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
apiUrl,
|
||||
windowLocation: window.location.href,
|
||||
})
|
||||
|
||||
setIsServerConnected(false)
|
||||
setSystemStatus((prev) => ({
|
||||
...prev,
|
||||
status: "critical",
|
||||
serverName: "Server Offline",
|
||||
nodeId: "Server Offline",
|
||||
uptime: "N/A",
|
||||
lastUpdate: new Date().toLocaleTimeString(),
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSystemData()
|
||||
const interval = setInterval(fetchSystemData, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchSystemData])
|
||||
|
||||
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"
|
||||
default:
|
||||
return "Navigation Menu"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<OnboardingCarousel />
|
||||
|
||||
{!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={`http://${typeof window !== "undefined" ? window.location.host : "localhost:8008"}/api/health`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
http://{typeof window !== "undefined" ? window.location.host : "localhost:8008"}/api/health
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="border-b border-border bg-card sticky top-0 z-50 shadow-sm">
|
||||
<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}</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={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>
|
||||
|
||||
<ThemeToggle />
|
||||
</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={refreshData} disabled={isRefreshing} className="h-8 w-8 p-0">
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Server Info */}
|
||||
<div className="lg:hidden mt-2 flex items-center justify-end text-xs text-muted-foreground">
|
||||
<span className="whitespace-nowrap">Uptime: {systemStatus.uptime}</span>
|
||||
</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-6 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>
|
||||
</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>
|
||||
</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>
|
||||
</Tabs>
|
||||
|
||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.0.0</p>
|
||||
<p>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText } from "path-to-icons"
|
||||
|
||||
const menuItems = [
|
||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||
{ name: "Storage", href: "/storage", icon: HardDrive },
|
||||
{ name: "Network", href: "/network", icon: Network },
|
||||
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
|
||||
{ name: "Hardware", href: "/hardware", icon: Cpu }, // New Hardware section
|
||||
{ name: "System Logs", href: "/logs", icon: FileText },
|
||||
]
|
||||
@@ -0,0 +1,237 @@
|
||||
"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"
|
||||
|
||||
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">{storageData.total.toFixed(1)} GB</div>
|
||||
<Progress value={usagePercent} className="mt-2" />
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{storageData.used.toFixed(1)} GB used • {storageData.available.toFixed(1)} GB available
|
||||
</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">{storageData.used.toFixed(1)} GB</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">{storageData.available.toFixed(1)} GB</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">
|
||||
{disk.used.toFixed(1)} GB / {disk.total.toFixed(1)} GB
|
||||
</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,919 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { HardDrive, Database, AlertTriangle, CheckCircle2, XCircle, Square, Thermometer } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
interface DiskInfo {
|
||||
name: string
|
||||
size?: number // Changed from string to number (KB) for formatMemory()
|
||||
size_formatted?: string // Added formatted size string for display
|
||||
temperature: number
|
||||
health: string
|
||||
power_on_hours?: number
|
||||
smart_status?: string
|
||||
model?: string
|
||||
serial?: string
|
||||
mountpoint?: string
|
||||
fstype?: string
|
||||
total?: number
|
||||
used?: number
|
||||
available?: number
|
||||
usage_percent?: number
|
||||
reallocated_sectors?: number
|
||||
pending_sectors?: number
|
||||
crc_errors?: number
|
||||
rotation_rate?: number
|
||||
power_cycles?: number
|
||||
percentage_used?: number // NVMe: Percentage Used (0-100)
|
||||
media_wearout_indicator?: number // SSD: Media Wearout Indicator
|
||||
wear_leveling_count?: number // SSD: Wear Leveling Count
|
||||
total_lbas_written?: number // SSD/NVMe: Total LBAs Written (GB)
|
||||
ssd_life_left?: number // SSD: SSD Life Left percentage
|
||||
}
|
||||
|
||||
interface ZFSPool {
|
||||
name: string
|
||||
size: string
|
||||
allocated: string
|
||||
free: string
|
||||
health: string
|
||||
}
|
||||
|
||||
interface StorageData {
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
disks: DiskInfo[]
|
||||
zfs_pools: ZFSPool[]
|
||||
disk_count: number
|
||||
healthy_disks: number
|
||||
warning_disks: number
|
||||
critical_disks: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface ProxmoxStorage {
|
||||
name: string
|
||||
type: string
|
||||
status: string
|
||||
total: number
|
||||
used: number
|
||||
available: number
|
||||
percent: number
|
||||
}
|
||||
|
||||
interface ProxmoxStorageData {
|
||||
storage: ProxmoxStorage[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
const formatStorage = (sizeInGB: number): string => {
|
||||
if (sizeInGB < 1) {
|
||||
// Less than 1 GB, show in MB
|
||||
return `${(sizeInGB * 1024).toFixed(1)} MB`
|
||||
} else if (sizeInGB < 1024) {
|
||||
// Less than 1024 GB, show in GB
|
||||
return `${sizeInGB.toFixed(1)} GB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
return `${(sizeInGB / 1024).toFixed(1)} TB`
|
||||
}
|
||||
}
|
||||
|
||||
export function StorageOverview() {
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
const [proxmoxStorage, setProxmoxStorage] = useState<ProxmoxStorageData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedDisk, setSelectedDisk] = useState<DiskInfo | null>(null)
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
|
||||
const fetchStorageData = async () => {
|
||||
try {
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
|
||||
const [storageResponse, proxmoxResponse] = await Promise.all([
|
||||
fetch(`${baseUrl}/api/storage`),
|
||||
fetch(`${baseUrl}/api/proxmox-storage`),
|
||||
])
|
||||
|
||||
const data = await storageResponse.json()
|
||||
const proxmoxData = await proxmoxResponse.json()
|
||||
|
||||
console.log("[v0] Storage data received:", data)
|
||||
console.log("[v0] Proxmox storage data received:", proxmoxData)
|
||||
|
||||
setStorageData(data)
|
||||
setProxmoxStorage(proxmoxData)
|
||||
} catch (error) {
|
||||
console.error("Error fetching storage data:", error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStorageData()
|
||||
const interval = setInterval(fetchStorageData, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const getHealthIcon = (health: string) => {
|
||||
switch (health.toLowerCase()) {
|
||||
case "healthy":
|
||||
case "passed":
|
||||
case "online":
|
||||
return <CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
case "warning":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
case "critical":
|
||||
case "failed":
|
||||
case "degraded":
|
||||
return <XCircle className="h-5 w-5 text-red-500" />
|
||||
default:
|
||||
return <AlertTriangle className="h-5 w-5 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getHealthBadge = (health: string) => {
|
||||
switch (health.toLowerCase()) {
|
||||
case "healthy":
|
||||
case "passed":
|
||||
case "online":
|
||||
return <Badge className="bg-green-500/10 text-green-500 border-green-500/20">Healthy</Badge>
|
||||
case "warning":
|
||||
return <Badge className="bg-yellow-500/10 text-yellow-500 border-yellow-500/20">Warning</Badge>
|
||||
case "critical":
|
||||
case "failed":
|
||||
case "degraded":
|
||||
return <Badge className="bg-red-500/10 text-red-500 border-red-500/20">Critical</Badge>
|
||||
default:
|
||||
return <Badge className="bg-gray-500/10 text-gray-500 border-gray-500/20">Unknown</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const getTempColor = (temp: number, diskName?: string, rotationRate?: number) => {
|
||||
if (temp === 0) return "text-gray-500"
|
||||
|
||||
// Determinar el tipo de disco
|
||||
let diskType = "HDD" // Por defecto
|
||||
if (diskName) {
|
||||
if (diskName.startsWith("nvme")) {
|
||||
diskType = "NVMe"
|
||||
} else if (!rotationRate || rotationRate === 0) {
|
||||
diskType = "SSD"
|
||||
}
|
||||
}
|
||||
|
||||
// Aplicar rangos de temperatura según el tipo
|
||||
switch (diskType) {
|
||||
case "NVMe":
|
||||
// NVMe: ≤70°C verde, 71-80°C amarillo, >80°C rojo
|
||||
if (temp <= 70) return "text-green-500"
|
||||
if (temp <= 80) return "text-yellow-500"
|
||||
return "text-red-500"
|
||||
|
||||
case "SSD":
|
||||
// SSD: ≤59°C verde, 60-70°C amarillo, >70°C rojo
|
||||
if (temp <= 59) return "text-green-500"
|
||||
if (temp <= 70) return "text-yellow-500"
|
||||
return "text-red-500"
|
||||
|
||||
case "HDD":
|
||||
default:
|
||||
// HDD: ≤45°C verde, 46-55°C amarillo, >55°C rojo
|
||||
if (temp <= 45) return "text-green-500"
|
||||
if (temp <= 55) return "text-yellow-500"
|
||||
return "text-red-500"
|
||||
}
|
||||
}
|
||||
|
||||
const formatHours = (hours: number) => {
|
||||
if (hours === 0) return "N/A"
|
||||
const years = Math.floor(hours / 8760)
|
||||
const days = Math.floor((hours % 8760) / 24)
|
||||
if (years > 0) {
|
||||
return `${years}y ${days}d`
|
||||
}
|
||||
return `${days}d`
|
||||
}
|
||||
|
||||
const formatRotationRate = (rpm: number | undefined) => {
|
||||
if (!rpm || rpm === 0) return "SSD"
|
||||
return `${rpm.toLocaleString()} RPM`
|
||||
}
|
||||
|
||||
const getDiskType = (diskName: string, rotationRate: number | undefined): string => {
|
||||
if (diskName.startsWith("nvme")) {
|
||||
return "NVMe"
|
||||
}
|
||||
if (!rotationRate || rotationRate === 0) {
|
||||
return "SSD"
|
||||
}
|
||||
return "HDD"
|
||||
}
|
||||
|
||||
const getDiskTypeBadge = (diskName: string, rotationRate: number | undefined) => {
|
||||
const diskType = getDiskType(diskName, rotationRate)
|
||||
const badgeStyles: Record<string, { className: string; label: string }> = {
|
||||
NVMe: {
|
||||
className: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||||
label: "NVMe",
|
||||
},
|
||||
SSD: {
|
||||
className: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
|
||||
label: "SSD",
|
||||
},
|
||||
HDD: {
|
||||
className: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||
label: "HDD",
|
||||
},
|
||||
}
|
||||
return badgeStyles[diskType]
|
||||
}
|
||||
|
||||
const handleDiskClick = (disk: DiskInfo) => {
|
||||
setSelectedDisk(disk)
|
||||
setDetailsOpen(true)
|
||||
}
|
||||
|
||||
const getStorageTypeBadge = (type: string) => {
|
||||
const typeColors: Record<string, string> = {
|
||||
pbs: "bg-purple-500/10 text-purple-500 border-purple-500/20",
|
||||
dir: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||
lvmthin: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20",
|
||||
zfspool: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||
nfs: "bg-orange-500/10 text-orange-500 border-orange-500/20",
|
||||
cifs: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
|
||||
}
|
||||
return typeColors[type.toLowerCase()] || "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case "active":
|
||||
case "online":
|
||||
return <CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
case "inactive":
|
||||
case "offline":
|
||||
return <Square className="h-5 w-5 text-gray-500" />
|
||||
case "error":
|
||||
case "failed":
|
||||
return <AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
default:
|
||||
return <CheckCircle2 className="h-5 w-5 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getWearIndicator = (disk: DiskInfo): { value: number; label: string } | null => {
|
||||
const diskType = getDiskType(disk.name, disk.rotation_rate)
|
||||
|
||||
if (diskType === "NVMe" && disk.percentage_used !== undefined && disk.percentage_used !== null) {
|
||||
return { value: disk.percentage_used, label: "Percentage Used" }
|
||||
}
|
||||
|
||||
if (diskType === "SSD") {
|
||||
// Prioridad: Media Wearout Indicator > Wear Leveling Count > SSD Life Left
|
||||
if (disk.media_wearout_indicator !== undefined && disk.media_wearout_indicator !== null) {
|
||||
return { value: disk.media_wearout_indicator, label: "Media Wearout" }
|
||||
}
|
||||
if (disk.wear_leveling_count !== undefined && disk.wear_leveling_count !== null) {
|
||||
return { value: disk.wear_leveling_count, label: "Wear Level" }
|
||||
}
|
||||
if (disk.ssd_life_left !== undefined && disk.ssd_life_left !== null) {
|
||||
return { value: 100 - disk.ssd_life_left, label: "Life Used" }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getWearColor = (wearPercent: number): string => {
|
||||
if (wearPercent <= 50) return "text-green-500"
|
||||
if (wearPercent <= 80) return "text-yellow-500"
|
||||
return "text-red-500"
|
||||
}
|
||||
|
||||
const getEstimatedLifeRemaining = (disk: DiskInfo): string | null => {
|
||||
const wearIndicator = getWearIndicator(disk)
|
||||
if (!wearIndicator || !disk.power_on_hours || disk.power_on_hours === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const wearPercent = wearIndicator.value
|
||||
const hoursUsed = disk.power_on_hours
|
||||
|
||||
// Si el desgaste es 0, no podemos calcular
|
||||
if (wearPercent === 0) {
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
// Calcular horas totales estimadas: hoursUsed / (wearPercent / 100)
|
||||
const totalEstimatedHours = hoursUsed / (wearPercent / 100)
|
||||
const remainingHours = totalEstimatedHours - hoursUsed
|
||||
|
||||
// Convertir a años
|
||||
const remainingYears = remainingHours / 8760 // 8760 horas en un año
|
||||
|
||||
if (remainingYears < 1) {
|
||||
const remainingMonths = Math.round(remainingYears * 12)
|
||||
return `~${remainingMonths} months`
|
||||
}
|
||||
|
||||
return `~${remainingYears.toFixed(1)} years`
|
||||
}
|
||||
|
||||
const getDiskHealthBreakdown = () => {
|
||||
if (!storageData || !storageData.disks) {
|
||||
return { normal: 0, warning: 0, critical: 0 }
|
||||
}
|
||||
|
||||
let normal = 0
|
||||
let warning = 0
|
||||
let critical = 0
|
||||
|
||||
storageData.disks.forEach((disk) => {
|
||||
if (disk.temperature === 0) {
|
||||
// Si no hay temperatura, considerarlo normal
|
||||
normal++
|
||||
return
|
||||
}
|
||||
|
||||
const diskType = getDiskType(disk.name, disk.rotation_rate)
|
||||
|
||||
switch (diskType) {
|
||||
case "NVMe":
|
||||
if (disk.temperature <= 70) normal++
|
||||
else if (disk.temperature <= 80) warning++
|
||||
else critical++
|
||||
break
|
||||
case "SSD":
|
||||
if (disk.temperature <= 59) normal++
|
||||
else if (disk.temperature <= 70) warning++
|
||||
else critical++
|
||||
break
|
||||
case "HDD":
|
||||
default:
|
||||
if (disk.temperature <= 45) normal++
|
||||
else if (disk.temperature <= 55) warning++
|
||||
else critical++
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
return { normal, warning, critical }
|
||||
}
|
||||
|
||||
const getDiskTypesBreakdown = () => {
|
||||
if (!storageData || !storageData.disks) {
|
||||
return { nvme: 0, ssd: 0, hdd: 0 }
|
||||
}
|
||||
|
||||
let nvme = 0
|
||||
let ssd = 0
|
||||
let hdd = 0
|
||||
|
||||
storageData.disks.forEach((disk) => {
|
||||
const diskType = getDiskType(disk.name, disk.rotation_rate)
|
||||
if (diskType === "NVMe") nvme++
|
||||
else if (diskType === "SSD") ssd++
|
||||
else if (diskType === "HDD") hdd++
|
||||
})
|
||||
|
||||
return { nvme, ssd, hdd }
|
||||
}
|
||||
|
||||
const getWearProgressColor = (wearPercent: number): string => {
|
||||
if (wearPercent < 70) return "[&>div]:bg-blue-500"
|
||||
if (wearPercent < 85) return "[&>div]:bg-yellow-500"
|
||||
return "[&>div]:bg-red-500"
|
||||
}
|
||||
|
||||
const diskHealthBreakdown = getDiskHealthBreakdown()
|
||||
const diskTypesBreakdown = getDiskTypesBreakdown()
|
||||
|
||||
const totalProxmoxUsed =
|
||||
proxmoxStorage && proxmoxStorage.storage
|
||||
? proxmoxStorage.storage
|
||||
.filter(
|
||||
(storage) => storage && storage.total > 0 && storage.status && storage.status.toLowerCase() === "active",
|
||||
)
|
||||
.reduce((sum, storage) => sum + storage.used, 0)
|
||||
: 0
|
||||
|
||||
const usagePercent =
|
||||
storageData && storageData.total > 0 ? ((totalProxmoxUsed / (storageData.total * 1024)) * 100).toFixed(2) : "0.00"
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading storage information...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!storageData || storageData.error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-red-500">Error loading storage data: {storageData?.error || "Unknown error"}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Summary */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Storage</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{storageData.total.toFixed(1)} TB</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{storageData.disk_count} physical disks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Used Storage</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{formatStorage(totalProxmoxUsed)}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{usagePercent}% used</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disk Health */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Disk Health</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{storageData.disk_count} disks</div>
|
||||
<p className="text-xs mt-1">
|
||||
<span className="text-green-500">{diskHealthBreakdown.normal} normal</span>
|
||||
{diskHealthBreakdown.warning > 0 && (
|
||||
<>
|
||||
{", "}
|
||||
<span className="text-yellow-500">{diskHealthBreakdown.warning} warning</span>
|
||||
</>
|
||||
)}
|
||||
{diskHealthBreakdown.critical > 0 && (
|
||||
<>
|
||||
{", "}
|
||||
<span className="text-red-500">{diskHealthBreakdown.critical} critical</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disk Types */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Disk Types</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold">{storageData.disk_count} disks</div>
|
||||
<p className="text-xs mt-1">
|
||||
{diskTypesBreakdown.nvme > 0 && <span className="text-purple-500">{diskTypesBreakdown.nvme} NVMe</span>}
|
||||
{diskTypesBreakdown.ssd > 0 && (
|
||||
<>
|
||||
{diskTypesBreakdown.nvme > 0 && ", "}
|
||||
<span className="text-cyan-500">{diskTypesBreakdown.ssd} SSD</span>
|
||||
</>
|
||||
)}
|
||||
{diskTypesBreakdown.hdd > 0 && (
|
||||
<>
|
||||
{(diskTypesBreakdown.nvme > 0 || diskTypesBreakdown.ssd > 0) && ", "}
|
||||
<span className="text-blue-500">{diskTypesBreakdown.hdd} HDD</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
Proxmox Storage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{proxmoxStorage.storage
|
||||
.filter((storage) => storage && storage.name && storage.total > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((storage) => (
|
||||
<div key={storage.name} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{/* Desktop: Icon + Name + Badge tipo alineados horizontalmente */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<Database className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="font-semibold text-lg">{storage.name}</h3>
|
||||
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex md:hidden items-center gap-2 flex-1">
|
||||
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<Badge className={getStorageTypeBadge(storage.type)}>{storage.type}</Badge>
|
||||
<h3 className="font-semibold text-base flex-1 min-w-0 truncate">{storage.name}</h3>
|
||||
{getStatusIcon(storage.status)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: Badge active + Porcentaje */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Badge
|
||||
className={
|
||||
storage.status === "active"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
}
|
||||
>
|
||||
{storage.status}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">{storage.percent}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Progress
|
||||
value={storage.percent}
|
||||
className={`h-2 ${
|
||||
storage.percent > 90
|
||||
? "[&>div]:bg-red-500"
|
||||
: storage.percent > 75
|
||||
? "[&>div]:bg-yellow-500"
|
||||
: "[&>div]:bg-blue-500"
|
||||
}`}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Total</p>
|
||||
<p className="font-medium">{storage.total.toLocaleString()} GB</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Used</p>
|
||||
<p
|
||||
className={`font-medium ${
|
||||
storage.percent > 90
|
||||
? "text-red-400"
|
||||
: storage.percent > 75
|
||||
? "text-yellow-400"
|
||||
: "text-blue-400"
|
||||
}`}
|
||||
>
|
||||
{storage.used.toLocaleString()} GB
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Available</p>
|
||||
<p className="font-medium text-green-400">{storage.available.toLocaleString()} GB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ZFS Pools */}
|
||||
{storageData.zfs_pools && storageData.zfs_pools.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
ZFS Pools
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.zfs_pools.map((pool) => (
|
||||
<div key={pool.name} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-lg">{pool.name}</h3>
|
||||
{getHealthBadge(pool.health)}
|
||||
</div>
|
||||
{getHealthIcon(pool.health)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Size</p>
|
||||
<p className="font-medium">{pool.size}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Allocated</p>
|
||||
<p className="font-medium">{pool.allocated}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Free</p>
|
||||
<p className="font-medium">{pool.free}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Physical Disks */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
Physical Disks & SMART Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{storageData.disks.map((disk) => (
|
||||
<div key={disk.name}>
|
||||
<div
|
||||
className="sm:hidden border border-white/10 rounded-lg p-4 cursor-pointer bg-white/5 transition-colors"
|
||||
onClick={() => handleDiskClick(disk)}
|
||||
>
|
||||
<div className="space-y-2 mb-3">
|
||||
{/* Row 1: Device name and type badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
||||
<Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}>
|
||||
{getDiskTypeBadge(disk.name, disk.rotation_rate).label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Model, temperature, and health status */}
|
||||
<div className="flex items-center justify-between gap-3 pl-7">
|
||||
{disk.model && disk.model !== "Unknown" && (
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer
|
||||
className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}
|
||||
>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{getHealthBadge(disk.health)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{disk.size_formatted && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Size</p>
|
||||
<p className="font-medium">{disk.size_formatted}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.smart_status && disk.smart_status !== "unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
||||
<p className="font-medium capitalize">{disk.smart_status}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Power On Time</p>
|
||||
<p className="font-medium">{formatHours(disk.power_on_hours)}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.serial && disk.serial !== "Unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Serial</p>
|
||||
<p className="font-medium text-xs">{disk.serial}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="hidden sm:block border border-white/10 rounded-lg p-4 cursor-pointer bg-card hover:bg-white/5 transition-colors"
|
||||
onClick={() => handleDiskClick(disk)}
|
||||
>
|
||||
<div className="space-y-2 mb-3">
|
||||
{/* Row 1: Device name and type badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<h3 className="font-semibold">/dev/{disk.name}</h3>
|
||||
<Badge className={getDiskTypeBadge(disk.name, disk.rotation_rate).className}>
|
||||
{getDiskTypeBadge(disk.name, disk.rotation_rate).label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Model, temperature, and health status */}
|
||||
<div className="flex items-center justify-between gap-3 pl-7">
|
||||
{disk.model && disk.model !== "Unknown" && (
|
||||
<p className="text-sm text-muted-foreground truncate flex-1 min-w-0">{disk.model}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{disk.temperature > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Thermometer
|
||||
className={`h-4 w-4 ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm font-medium ${getTempColor(disk.temperature, disk.name, disk.rotation_rate)}`}
|
||||
>
|
||||
{disk.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{getHealthBadge(disk.health)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{disk.size_formatted && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Size</p>
|
||||
<p className="font-medium">{disk.size_formatted}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.smart_status && disk.smart_status !== "unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
||||
<p className="font-medium capitalize">{disk.smart_status}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.power_on_hours !== undefined && disk.power_on_hours > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Power On Time</p>
|
||||
<p className="font-medium">{formatHours(disk.power_on_hours)}</p>
|
||||
</div>
|
||||
)}
|
||||
{disk.serial && disk.serial !== "Unknown" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Serial</p>
|
||||
<p className="font-medium text-xs">{disk.serial}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disk Details Dialog */}
|
||||
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDrive className="h-5 w-5" />
|
||||
Disk Details: /dev/{selectedDisk?.name}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Complete SMART information and health status</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedDisk && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Model</p>
|
||||
<p className="font-medium">{selectedDisk.model}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Serial Number</p>
|
||||
<p className="font-medium">{selectedDisk.serial}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Capacity</p>
|
||||
<p className="font-medium">{selectedDisk.size_formatted}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Health Status</p>
|
||||
<div className="mt-1">{getHealthBadge(selectedDisk.health)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wear & Lifetime Section */}
|
||||
{getWearIndicator(selectedDisk) && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-semibold mb-3">Wear & Lifetime</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm text-muted-foreground">{getWearIndicator(selectedDisk)!.label}</p>
|
||||
<p className={`font-medium ${getWearColor(getWearIndicator(selectedDisk)!.value)}`}>
|
||||
{getWearIndicator(selectedDisk)!.value}%
|
||||
</p>
|
||||
</div>
|
||||
<Progress
|
||||
value={getWearIndicator(selectedDisk)!.value}
|
||||
className={`h-2 ${getWearProgressColor(getWearIndicator(selectedDisk)!.value)}`}
|
||||
/>
|
||||
</div>
|
||||
{getEstimatedLifeRemaining(selectedDisk) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Estimated Life Remaining</p>
|
||||
<p className="font-medium">{getEstimatedLifeRemaining(selectedDisk)}</p>
|
||||
</div>
|
||||
{selectedDisk.total_lbas_written && selectedDisk.total_lbas_written > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Data Written</p>
|
||||
<p className="font-medium">
|
||||
{selectedDisk.total_lbas_written >= 1024
|
||||
? `${(selectedDisk.total_lbas_written / 1024).toFixed(2)} TB`
|
||||
: `${selectedDisk.total_lbas_written.toFixed(2)} GB`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="font-semibold mb-3">SMART Attributes</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Temperature</p>
|
||||
<p
|
||||
className={`font-medium ${getTempColor(selectedDisk.temperature, selectedDisk.name, selectedDisk.rotation_rate)}`}
|
||||
>
|
||||
{selectedDisk.temperature > 0 ? `${selectedDisk.temperature}°C` : "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Power On Hours</p>
|
||||
<p className="font-medium">
|
||||
{selectedDisk.power_on_hours && selectedDisk.power_on_hours > 0
|
||||
? `${selectedDisk.power_on_hours.toLocaleString()}h (${formatHours(selectedDisk.power_on_hours)})`
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Rotation Rate</p>
|
||||
<p className="font-medium">{formatRotationRate(selectedDisk.rotation_rate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Power Cycles</p>
|
||||
<p className="font-medium">
|
||||
{selectedDisk.power_cycles && selectedDisk.power_cycles > 0
|
||||
? selectedDisk.power_cycles.toLocaleString()
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">SMART Status</p>
|
||||
<p className="font-medium capitalize">{selectedDisk.smart_status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Reallocated Sectors</p>
|
||||
<p
|
||||
className={`font-medium ${selectedDisk.reallocated_sectors && selectedDisk.reallocated_sectors > 0 ? "text-yellow-500" : ""}`}
|
||||
>
|
||||
{selectedDisk.reallocated_sectors ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Pending Sectors</p>
|
||||
<p
|
||||
className={`font-medium ${selectedDisk.pending_sectors && selectedDisk.pending_sectors > 0 ? "text-yellow-500" : ""}`}
|
||||
>
|
||||
{selectedDisk.pending_sectors ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">CRC Errors</p>
|
||||
<p
|
||||
className={`font-medium ${selectedDisk.crc_errors && selectedDisk.crc_errors > 0 ? "text-yellow-500" : ""}`}
|
||||
>
|
||||
{selectedDisk.crc_errors ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
"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"
|
||||
|
||||
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 baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/system`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Flask server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch system data:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVMData = async (): Promise<VMData[]> => {
|
||||
try {
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/vms`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Flask server responded with status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
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 baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/storage/summary`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("[v0] Storage API not available (this is normal if not configured)")
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Storage data unavailable:", error instanceof Error ? error.message : "Unknown error")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchNetworkData = async (): Promise<NetworkData | null> => {
|
||||
try {
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/network/summary`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("[v0] Network API not available (this is normal if not configured)")
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Network data unavailable:", error instanceof Error ? error.message : "Unknown error")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorageData | null> => {
|
||||
try {
|
||||
const baseUrl = typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8008` : ""
|
||||
const apiUrl = `${baseUrl}/api/proxmox-storage`
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("[v0] Proxmox storage API not available")
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Proxmox storage data unavailable:", error instanceof Error ? error.message : "Unknown error")
|
||||
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 [loading, setLoading] = useState(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 fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const systemResult = await fetchSystemData()
|
||||
|
||||
if (!systemResult) {
|
||||
setError("Flask server not available. Please ensure the server is running.")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setSystemData(systemResult)
|
||||
} catch (err) {
|
||||
console.error("[v0] Error fetching system data:", err)
|
||||
setError("Failed to connect to Flask server. Please check your connection.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchData()
|
||||
|
||||
const systemInterval = setInterval(() => {
|
||||
fetchSystemData().then((data) => {
|
||||
if (data) setSystemData(data)
|
||||
})
|
||||
}, 10000)
|
||||
|
||||
return () => {
|
||||
clearInterval(systemInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVMs = async () => {
|
||||
const vmResult = await fetchVMData()
|
||||
setVmData(vmResult)
|
||||
}
|
||||
|
||||
fetchVMs()
|
||||
const vmInterval = setInterval(fetchVMs, 60000)
|
||||
|
||||
return () => {
|
||||
clearInterval(vmInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStorage = async () => {
|
||||
const storageResult = await fetchStorageData()
|
||||
setStorageData(storageResult)
|
||||
|
||||
const proxmoxStorageResult = await fetchProxmoxStorageData()
|
||||
setProxmoxStorageData(proxmoxStorageResult)
|
||||
}
|
||||
|
||||
fetchStorage()
|
||||
const storageInterval = setInterval(fetchStorage, 60000)
|
||||
|
||||
return () => {
|
||||
clearInterval(storageInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchNetwork = async () => {
|
||||
const networkResult = await fetchNetworkData()
|
||||
setNetworkData(networkResult)
|
||||
}
|
||||
|
||||
fetchNetwork()
|
||||
const networkInterval = setInterval(fetchNetwork, 60000)
|
||||
|
||||
return () => {
|
||||
clearInterval(networkInterval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-lg font-medium text-foreground mb-2">Connecting to ProxMenux Monitor...</div>
|
||||
<div className="text-sm text-muted-foreground">Fetching real-time system data</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="bg-card border-border animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
<div className="h-4 bg-muted rounded w-1/2 mb-4"></div>
|
||||
<div className="h-8 bg-muted rounded w-3/4 mb-2"></div>
|
||||
<div className="h-2 bg-muted rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-muted rounded w-2/3"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</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) {
|
||||
// Less than 1 GB, show in MB
|
||||
return `${(sizeInGB * 1024).toFixed(1)} MB`
|
||||
} else if (sizeInGB < 1024) {
|
||||
// Less than 1024 GB, show in GB
|
||||
return `${sizeInGB.toFixed(1)} GB`
|
||||
} else {
|
||||
// 1024 GB or more, show in TB
|
||||
return `${(sizeInGB / 1024).toFixed(2)} TB`
|
||||
}
|
||||
}
|
||||
|
||||
const tempStatus = getTemperatureStatus(systemData.temperature)
|
||||
|
||||
const localStorage = proxmoxStorageData?.storage.find((s) => s.name === "local")
|
||||
|
||||
const vmLxcStorages = proxmoxStorageData?.storage.filter(
|
||||
(s) =>
|
||||
// Include only local storage types that can host VMs/LXCs
|
||||
(s.type === "lvm" || s.type === "lvmthin" || s.type === "zfspool" || s.type === "btrfs" || s.type === "dir") &&
|
||||
// Exclude network storage
|
||||
s.type !== "nfs" &&
|
||||
s.type !== "cifs" &&
|
||||
s.type !== "iscsi" &&
|
||||
// Exclude the "local" storage (used for ISOs/templates)
|
||||
s.name !== "local",
|
||||
)
|
||||
|
||||
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">
|
||||
{/* Key Metrics Cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<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 className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Active VM & LXC</CardTitle>
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">{vmStats.running}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{vmStats.running} Running
|
||||
</Badge>
|
||||
{vmStats.stopped > 0 && (
|
||||
<Badge variant="outline" className="bg-red-500/10 text-red-500 border-red-500/20">
|
||||
{vmStats.stopped} Stopped
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Total: {vmStats.vms} VMs, {vmStats.lxc} LXC
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Node Metrics Charts */}
|
||||
<NodeMetricsCharts />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Storage Summary */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
<HardDrive className="h-5 w-5 mr-2" />
|
||||
Storage Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{storageData ? (
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
|
||||
{/* Network Summary */}
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{/* System Information */}
|
||||
<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>
|
||||
|
||||
{/* System Health & Alerts */}
|
||||
<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,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,97 @@
|
||||
"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,
|
||||
)}
|
||||
{...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-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -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,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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.0",
|
||||
"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,490 @@
|
||||
#!/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/"
|
||||
|
||||
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 \
|
||||
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
|
||||
}
|
||||
|
||||
mkdir -p "$WORK_DIR/debs"
|
||||
cd "$WORK_DIR/debs"
|
||||
|
||||
|
||||
dl_pkg "ipmitool.deb" "ipmitool" || true
|
||||
dl_pkg "libfreeipmi17.deb" "libfreeipmi17" || true
|
||||
dl_pkg "lm-sensors.deb" "lm-sensors" || true
|
||||
dl_pkg "nut-client.deb" "nut-client" || true
|
||||
dl_pkg "libupsclient.deb" "libupsclient6" "libupsclient5" "libupsclient4" || true
|
||||
|
||||
|
||||
# dl_pkg "nvidia-smi.deb" "nvidia-smi" "nvidia-utils" "nvidia-utils-535" "nvidia-utils-550" || true
|
||||
# dl_pkg "intel-gpu-tools.deb" "intel-gpu-tools" || true
|
||||
# dl_pkg "radeontop.deb" "radeontop" || true
|
||||
|
||||
echo "📦 Extracting .deb packages into AppDir..."
|
||||
extracted_count=0
|
||||
shopt -s nullglob
|
||||
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,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,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,204 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 = (url: string) => fetch(url).then((res) => res.json())
|
||||
@@ -1,3 +1,152 @@
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
- **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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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
|
||||
@@ -41,11 +41,16 @@ 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"
|
||||
|
||||
MONITOR_APPIMAGE_URL="https://github.com/MacRimi/ProxMenux/raw/refs/heads/main/AppImage/ProxMenux-1.0.0.AppImage"
|
||||
MONITOR_SHA256_URL="https://github.com/MacRimi/ProxMenux/raw/refs/heads/main/AppImage/ProxMenux-Monitor.AppImage.sha256"
|
||||
MONITOR_INSTALL_PATH="$BASE_DIR/ProxMenux-Monitor.AppImage"
|
||||
MONITOR_SERVICE_FILE="/etc/systemd/system/proxmenux-monitor.service"
|
||||
MONITOR_PORT=8008
|
||||
|
||||
if ! source <(curl -sSf "$UTILS_URL"); then
|
||||
echo "Error: Could not load utils.sh from $UTILS_URL"
|
||||
exit 1
|
||||
@@ -101,17 +106,17 @@ 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 [ -f "$VENV_PATH/bin/activate" ]; then
|
||||
echo "Removing googletrans and virtual environment..."
|
||||
@@ -151,7 +156,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 +173,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 +198,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 +226,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 +279,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 +287,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,9 +296,112 @@ show_installation_confirmation() {
|
||||
esac
|
||||
}
|
||||
|
||||
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+')
|
||||
|
||||
if [ -z "$ip" ]; then
|
||||
# Fallback: get first non-loopback IP
|
||||
ip=$(hostname -I | awk '{print $1}')
|
||||
fi
|
||||
|
||||
if [ -z "$ip" ]; then
|
||||
# Last resort: use localhost
|
||||
ip="localhost"
|
||||
fi
|
||||
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
install_proxmenux_monitor() {
|
||||
# Check if URL is accessible
|
||||
if ! wget --spider -q "$MONITOR_APPIMAGE_URL" 2>/dev/null; then
|
||||
msg_warn "ProxMenux Monitor AppImage not available at: $MONITOR_APPIMAGE_URL"
|
||||
msg_info "The monitor will be available in future releases."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Download AppImage silently
|
||||
if ! wget -q -O "$MONITOR_INSTALL_PATH" "$MONITOR_APPIMAGE_URL" 2>&1; then
|
||||
msg_warn "Failed to download ProxMenux Monitor from GitHub."
|
||||
msg_info "You can install it manually later when available."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Download SHA256 checksum silently
|
||||
local sha256_file="/tmp/proxmenux-monitor.sha256"
|
||||
if ! wget -q -O "$sha256_file" "$MONITOR_SHA256_URL" 2>/dev/null; then
|
||||
msg_warn "SHA256 checksum file not available. Skipping verification."
|
||||
msg_info "AppImage downloaded but integrity cannot be verified."
|
||||
rm -f "$sha256_file"
|
||||
else
|
||||
# Verify SHA256 silently
|
||||
local expected_hash=$(cat "$sha256_file" | awk '{print $1}')
|
||||
local actual_hash=$(sha256sum "$MONITOR_INSTALL_PATH" | awk '{print $1}')
|
||||
|
||||
if [ "$expected_hash" != "$actual_hash" ]; then
|
||||
msg_error "SHA256 verification failed! AppImage may be corrupted."
|
||||
msg_info "Expected: $expected_hash"
|
||||
msg_info "Got: $actual_hash"
|
||||
rm -f "$MONITOR_INSTALL_PATH" "$sha256_file"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$sha256_file"
|
||||
fi
|
||||
|
||||
# Make executable
|
||||
chmod +x "$MONITOR_INSTALL_PATH"
|
||||
|
||||
# Show single success message at the end
|
||||
msg_ok "ProxMenux Monitor installed and activated successfully."
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
create_monitor_service() {
|
||||
msg_info "Creating ProxMenux Monitor service..."
|
||||
|
||||
cat > "$MONITOR_SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=ProxMenux Monitor - Web Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$BASE_DIR
|
||||
ExecStart=$MONITOR_INSTALL_PATH
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment="PORT=$MONITOR_PORT"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# Reload systemd, enable and start service
|
||||
systemctl daemon-reload
|
||||
systemctl enable proxmenux-monitor.service > /dev/null 2>&1
|
||||
systemctl start proxmenux-monitor.service > /dev/null 2>&1
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 2
|
||||
|
||||
# Check if service is running
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
msg_ok "ProxMenux Monitor service started successfully."
|
||||
update_config "proxmenux_monitor" "installed"
|
||||
return 0
|
||||
else
|
||||
msg_warn "ProxMenux Monitor service failed to start. Check logs with: journalctl -u proxmenux-monitor"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
####################################################
|
||||
install_normal_version() {
|
||||
local total_steps=3
|
||||
local total_steps=4 # Increased from 3 to 4 for monitor installation
|
||||
local current_step=1
|
||||
|
||||
show_progress $current_step $total_steps "Installing basic dependencies"
|
||||
@@ -350,7 +458,6 @@ install_normal_version() {
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -368,12 +475,18 @@ install_normal_version() {
|
||||
done
|
||||
|
||||
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
# chmod +x "$EMERGENCY_FILE"
|
||||
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Installing ProxMenux Monitor"
|
||||
|
||||
if install_proxmenux_monitor; then
|
||||
create_monitor_service
|
||||
fi
|
||||
}
|
||||
|
||||
####################################################
|
||||
install_translation_version() {
|
||||
local total_steps=4
|
||||
local total_steps=5 # Increased from 4 to 5 for monitor installation
|
||||
local current_step=1
|
||||
|
||||
show_progress $current_step $total_steps "Language selection"
|
||||
@@ -470,7 +583,6 @@ install_translation_version() {
|
||||
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"
|
||||
)
|
||||
@@ -491,7 +603,13 @@ install_translation_version() {
|
||||
done
|
||||
|
||||
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
#chmod +x "$EMERGENCY_FILE"
|
||||
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Installing ProxMenux Monitor"
|
||||
|
||||
if install_proxmenux_monitor; then
|
||||
create_monitor_service
|
||||
fi
|
||||
}
|
||||
|
||||
####################################################
|
||||
@@ -518,14 +636,12 @@ show_installation_options() {
|
||||
esac
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
@@ -535,14 +651,14 @@ show_installation_options() {
|
||||
"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
|
||||
@@ -550,12 +666,14 @@ show_installation_options() {
|
||||
# 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
|
||||
@@ -582,6 +700,13 @@ install_proxmenu() {
|
||||
esac
|
||||
|
||||
msg_title "$(translate "ProxMenux has been installed successfully")"
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
local server_ip=$(get_server_ip)
|
||||
echo -e "${GN}🌐 $(translate "ProxMenux Monitor activated")${CL}: ${BL}http://${server_ip}:${MONITOR_PORT}${CL}"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo -ne "${GN}"
|
||||
type_text "$(translate "To run ProxMenux, simply execute this command in the console or terminal:")"
|
||||
echo -e "${YWB} menu${CL}"
|
||||
@@ -594,4 +719,4 @@ if [ "$(id -u)" -ne 0 ]; then
|
||||
fi
|
||||
|
||||
cleanup_corrupted_files
|
||||
install_proxmenu
|
||||
install_proxmenu
|
||||
|
||||
@@ -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",
|
||||
@@ -2132,6 +2136,537 @@
|
||||
"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,7 +1,7 @@
|
||||
#!/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
|
||||
@@ -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.
|
||||
@@ -67,7 +67,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"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
#!/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)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# Version : 1.1
|
||||
# Last Updated: 17/08/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script automates the process of enabling and configuring Intel Integrated GPU (iGPU) support in Proxmox VE LXC containers.
|
||||
@@ -32,7 +32,7 @@ initialize_cache
|
||||
|
||||
|
||||
|
||||
# Select LXC container
|
||||
|
||||
select_container() {
|
||||
|
||||
CONTAINERS=$(pct list | awk 'NR>1 {print $1, $3}' | xargs -n2)
|
||||
@@ -59,14 +59,14 @@ select_container() {
|
||||
|
||||
|
||||
|
||||
# Validate that the selected container is valid
|
||||
|
||||
validate_container_id() {
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
msg_error "$(translate 'Container ID not defined. Make sure to select a container first.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if the container is running and stop it before configuration
|
||||
|
||||
if pct status "$CONTAINER_ID" | grep -q "running"; then
|
||||
msg_info "$(translate 'Stopping the container before applying configuration...')"
|
||||
pct stop "$CONTAINER_ID"
|
||||
@@ -76,77 +76,103 @@ validate_container_id() {
|
||||
|
||||
|
||||
|
||||
# Configure LXC for Coral TPU and iGPU
|
||||
configure_lxc_for_igpu() {
|
||||
validate_container_id
|
||||
CONFIG_FILE="/etc/pve/lxc/${CONTAINER_ID}.conf"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
msg_error "$(translate 'Configuration file for container') $CONTAINER_ID $(translate 'not found.')"
|
||||
exit 1
|
||||
validate_container_id
|
||||
|
||||
CONFIG_FILE="/etc/pve/lxc/${CONTAINER_ID}.conf"
|
||||
[[ -f "$CONFIG_FILE" ]] || { msg_error "$(translate 'Configuration file for container') $CONTAINER_ID $(translate 'not found.')"; exit 1; }
|
||||
|
||||
|
||||
if [[ ! -d /dev/dri ]]; then
|
||||
modprobe i915 2>/dev/null || true
|
||||
for _ in {1..5}; do
|
||||
[[ -d /dev/dri ]] && break
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
CT_TYPE=$(pct config "$CONTAINER_ID" | awk '/^unprivileged:/ {print $2}')
|
||||
[[ -z "$CT_TYPE" ]] && CT_TYPE="0"
|
||||
|
||||
msg_info "$(translate 'Configuring Intel iGPU passthrough for container...')"
|
||||
|
||||
for rn in /dev/dri/renderD*; do
|
||||
[[ -e "$rn" ]] || continue
|
||||
chmod 660 "$rn" 2>/dev/null || true
|
||||
chgrp render "$rn" 2>/dev/null || true
|
||||
done
|
||||
|
||||
mapfile -t RENDER_NODES < <(find /dev/dri -maxdepth 1 -type c -name 'renderD*' 2>/dev/null || true)
|
||||
mapfile -t CARD_NODES < <(find /dev/dri -maxdepth 1 -type c -name 'card*' 2>/dev/null || true)
|
||||
FB_NODE=""
|
||||
[[ -e /dev/fb0 ]] && FB_NODE="/dev/fb0"
|
||||
|
||||
if [[ ${#RENDER_NODES[@]} -eq 0 && ${#CARD_NODES[@]} -eq 0 && -z "$FB_NODE" ]]; then
|
||||
msg_warn "$(translate 'No VA-API devices found on host (/dev/dri*, /dev/fb0). Is i915 loaded?')"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if grep -q "^unprivileged: 1" "$CONFIG_FILE"; then
|
||||
msg_info "$(translate 'The container is unprivileged. Changing to privileged...')"
|
||||
sed -i "s/^unprivileged: 1/unprivileged: 0/" "$CONFIG_FILE"
|
||||
STORAGE_TYPE=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk -F, '{print $2}' | cut -d'=' -f2)
|
||||
if [[ "$STORAGE_TYPE" == "dir" ]]; then
|
||||
STORAGE_PATH=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk '{print $2}' | cut -d',' -f1)
|
||||
chown -R root:root "$STORAGE_PATH"
|
||||
fi
|
||||
msg_ok "$(translate 'Container changed to privileged.')"
|
||||
fi
|
||||
|
||||
|
||||
if grep -q "^lxc.apparmor.profile" "$CONFIG_FILE"; then
|
||||
msg_info "$(translate 'Disabling AppArmor profile to avoid conflicts...')"
|
||||
sed -i "/^lxc.apparmor.profile/d" "$CONFIG_FILE"
|
||||
msg_ok "$(translate 'AppArmor profile removed.')"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# Configure iGPU
|
||||
if ! grep -q "features: nesting=1" "$CONFIG_FILE"; then
|
||||
if grep -q '^features:' "$CONFIG_FILE"; then
|
||||
grep -Eq '^features:.*(^|,)\s*nesting=1(\s|,|$)' "$CONFIG_FILE" || sed -i 's/^features:\s*/&nesting=1, /' "$CONFIG_FILE"
|
||||
else
|
||||
echo "features: nesting=1" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
if ! grep -q "c 226:0 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 226:0 rwm # iGPU" >> "$CONFIG_FILE"
|
||||
echo "lxc.cgroup2.devices.allow: c 226:128 rwm # iGPU" >> "$CONFIG_FILE"
|
||||
|
||||
|
||||
if [[ "$CT_TYPE" == "0" ]]; then
|
||||
|
||||
sed -i '/^lxc\.cgroup2\.devices\.allow:\s*c\s*226:/d' "$CONFIG_FILE"
|
||||
sed -i '\|^lxc\.mount\.entry:\s*/dev/dri|d' "$CONFIG_FILE"
|
||||
sed -i '\|^lxc\.mount\.entry:\s*/dev/fb0|d' "$CONFIG_FILE"
|
||||
|
||||
echo "lxc.cgroup2.devices.allow: c 226:* rwm" >> "$CONFIG_FILE"
|
||||
echo "lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir" >> "$CONFIG_FILE"
|
||||
echo "lxc.mount.entry: /dev/dri/renderD128 dev/dri/renderD128 none bind,optional,create=file" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
if ! grep -q "c 29:0 rwm # Framebuffer" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 29:0 rwm # Framebuffer" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
if ! grep -q "lxc.mount.entry: /dev/fb0" "$CONFIG_FILE"; then
|
||||
echo "lxc.mount.entry: /dev/fb0 dev/fb0 none bind,optional,create=file" >> "$CONFIG_FILE"
|
||||
fi
|
||||
[[ -n "$FB_NODE" ]] && echo "lxc.mount.entry: /dev/fb0 dev/fb0 none bind,optional,create=file" >> "$CONFIG_FILE"
|
||||
|
||||
|
||||
msg_ok "$(translate 'Coral TPU and iGPU configuration added to container') $CONTAINER_ID."
|
||||
else
|
||||
sed -i '/^dev[0-9]\+:/d' "$CONFIG_FILE"
|
||||
|
||||
idx=0
|
||||
for c in "${CARD_NODES[@]}"; do
|
||||
echo "dev${idx}: $c,gid=44" >> "$CONFIG_FILE"
|
||||
idx=$((idx+1))
|
||||
done
|
||||
for r in "${RENDER_NODES[@]}"; do
|
||||
echo "dev${idx}: $r,gid=104" >> "$CONFIG_FILE"
|
||||
idx=$((idx+1))
|
||||
done
|
||||
|
||||
fi
|
||||
msg_ok "$(translate 'iGPU configuration added to container') $CONTAINER_ID."
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Install iGPU drivers in the container
|
||||
|
||||
|
||||
install_igpu_in_container() {
|
||||
|
||||
msg_info2 "$(translate 'Installing iGPU drivers inside the container...')"
|
||||
tput sc
|
||||
LOG_FILE=$(mktemp)
|
||||
|
||||
msg_info "$(translate 'Installing iGPU drivers...')"
|
||||
|
||||
pct start "$CONTAINER_ID" >/dev/null 2>&1
|
||||
|
||||
script -q -c "pct exec \"$CONTAINER_ID\" -- bash -c '
|
||||
set -e
|
||||
getent group video >/dev/null || groupadd -g 44 video
|
||||
getent group render >/dev/null || groupadd -g 104 render
|
||||
usermod -aG video,render root || true
|
||||
|
||||
apt-get update >/dev/null 2>&1
|
||||
apt-get install -y va-driver-all ocl-icd-libopencl1 intel-opencl-icd vainfo intel-gpu-tools
|
||||
chgrp video /dev/dri && chmod 755 /dev/dri
|
||||
adduser root video && adduser root render
|
||||
|
||||
chgrp video /dev/dri 2>/dev/null || true
|
||||
chmod 755 /dev/dri 2>/dev/null || true
|
||||
'" "$LOG_FILE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
@@ -167,6 +193,7 @@ install_igpu_in_container() {
|
||||
|
||||
select_container
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Add HW iGPU acceleration to an LXC")"
|
||||
configure_lxc_for_igpu
|
||||
install_igpu_in_container
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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
|
||||
@@ -187,7 +187,12 @@ is_disk_in_use() {
|
||||
|
||||
FREE_DISKS=()
|
||||
|
||||
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -n1 readlink -f | sort -u)
|
||||
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -r -n1 readlink -f | sort -u)
|
||||
|
||||
if [[ -n "$LVM_DEVICES" ]] && echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
|
||||
RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u)
|
||||
|
||||
while read -r DISK; do
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenu - Network Management and Repair Tool
|
||||
# ProxMenux - Network Management and Repair Tool
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
|
||||
@@ -92,6 +92,9 @@ cleanup_duplicate_repos_pve9() {
|
||||
local cleaned_count=0
|
||||
declare -A seen_repos
|
||||
|
||||
if [ ! -s "$sources_file" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "$line" ]]; then
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE (v3 - Minimal Intrusive)
|
||||
# ==========================================================
|
||||
# This version makes a surgical change to the checked_command function
|
||||
# by changing the condition to 'if (false)' and commenting out the banner logic.
|
||||
# Also patches the mobile UI to remove the subscription dialog.
|
||||
# ==========================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source utilities if available
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# File paths
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
MOBILE_UI_FILE="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
BACKUP_DIR="$BASE_DIR/backups"
|
||||
APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
PATCH_BIN="/usr/local/bin/pve-remove-nag-v3.sh"
|
||||
MARK="/* PROXMENUX_NAG_PATCH_V3 */"
|
||||
MOBILE_MARK="<!-- PROXMENUX_MOBILE_NAG_PATCH -->"
|
||||
|
||||
# Ensure tools JSON exists
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
# Register tool in JSON
|
||||
register_tool() {
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
local tool="$1" state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" \
|
||||
> "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
# Verify JS file integrity
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] || return 1
|
||||
[ -s "$file" ] || return 1
|
||||
grep -Eq 'Ext|function|var|const|let' "$file" || return 1
|
||||
if LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Create timestamped backup
|
||||
create_backup() {
|
||||
local file="$1"
|
||||
local timestamp
|
||||
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_file="$BACKUP_DIR/$(basename "$file").backup.$timestamp"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
rm -f "$BACKUP_DIR"/"$(basename "$file")".backup.* 2>/dev/null || true
|
||||
|
||||
cp -a "$file" "$backup_file"
|
||||
echo "$backup_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Create the patch script that will be called by APT hook
|
||||
create_patch_script() {
|
||||
cat > "$PATCH_BIN" <<'EOFPATCH'
|
||||
#!/usr/bin/env bash
|
||||
# ==========================================================
|
||||
# Proxmox Subscription Banner Patch (v3 - Minimal)
|
||||
# ==========================================================
|
||||
set -euo pipefail
|
||||
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
MOBILE_UI_FILE="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
BACKUP_DIR="/usr/local/share/proxmenux/backups"
|
||||
MARK="/* PROXMENUX_NAG_PATCH_V3 */"
|
||||
MOBILE_MARK="<!-- PROXMENUX_MOBILE_NAG_PATCH -->"
|
||||
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] && [ -s "$file" ] && grep -Eq 'Ext|function' "$file" && ! LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null
|
||||
}
|
||||
|
||||
patch_checked_command() {
|
||||
[ -f "$JS_FILE" ] || return 0
|
||||
|
||||
# Check if already patched
|
||||
grep -q "$MARK" "$JS_FILE" && return 0
|
||||
|
||||
# Create backup
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local backup="$BACKUP_DIR/$(basename "$JS_FILE").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp -a "$JS_FILE" "$backup"
|
||||
|
||||
# Set trap to restore on error
|
||||
trap "cp -a '$backup' '$JS_FILE' 2>/dev/null || true" ERR
|
||||
|
||||
# Add patch marker at the beginning
|
||||
sed -i "1s|^|$MARK\n|" "$JS_FILE"
|
||||
|
||||
# Surgical patch: Change the condition in checked_command function
|
||||
# This changes the if condition to 'if (false)' making the banner never show
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
|
||||
# Pattern for newer versions (8.4.5+)
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
elif grep -q "res\.data\.status !== 'Active'" "$JS_FILE"; then
|
||||
# Pattern for older versions
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status !== 'Active'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
# Also handle the NoMoreNagging pattern if present
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'NoMoreNagging'" "$JS_FILE"; then
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'NoMoreNagging'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
# Verify integrity after patch
|
||||
if ! verify_js_integrity "$JS_FILE"; then
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Clean up generated files
|
||||
rm -f "$MIN_JS_FILE" "$GZ_FILE" 2>/dev/null || true
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
|
||||
|
||||
trap - ERR
|
||||
return 0
|
||||
}
|
||||
|
||||
patch_mobile_ui() {
|
||||
[ -f "$MOBILE_UI_FILE" ] || return 0
|
||||
|
||||
# Check if already patched
|
||||
grep -q "$MOBILE_MARK" "$MOBILE_UI_FILE" && return 0
|
||||
|
||||
# Create backup
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local backup="$BACKUP_DIR/$(basename "$MOBILE_UI_FILE").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp -a "$MOBILE_UI_FILE" "$backup"
|
||||
|
||||
# Set trap to restore on error
|
||||
trap "cp -a '$backup' '$MOBILE_UI_FILE' 2>/dev/null || true" ERR
|
||||
|
||||
# Insert the script before </head> tag
|
||||
sed -i "/<\/head>/i\\
|
||||
$MOBILE_MARK\\
|
||||
<!-- Script to remove subscription banner from mobile UI -->\\
|
||||
<script>\\
|
||||
function removeNoSubDialog() {\\
|
||||
const observer = new MutationObserver(() => {\\
|
||||
const diag = document.querySelector('dialog[aria-label=\"No valid subscription\"]');\\
|
||||
if (diag) {\\
|
||||
diag.remove();\\
|
||||
}\\
|
||||
});\\
|
||||
observer.observe(document.body, { childList: true, subtree: true });\\
|
||||
}\\
|
||||
window.addEventListener('load', () => {\\
|
||||
setTimeout(removeNoSubDialog, 200);\\
|
||||
});\\
|
||||
</script>" "$MOBILE_UI_FILE"
|
||||
|
||||
trap - ERR
|
||||
return 0
|
||||
}
|
||||
|
||||
reload_services() {
|
||||
systemctl is-active --quiet pveproxy 2>/dev/null && {
|
||||
systemctl reload pveproxy 2>/dev/null || systemctl restart pveproxy 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet nginx 2>/dev/null && {
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet pvedaemon 2>/dev/null && {
|
||||
systemctl reload pvedaemon 2>/dev/null || true
|
||||
}
|
||||
}
|
||||
|
||||
main() {
|
||||
patch_checked_command || return 1
|
||||
patch_mobile_ui || true
|
||||
reload_services
|
||||
}
|
||||
|
||||
main
|
||||
EOFPATCH
|
||||
|
||||
chmod 755 "$PATCH_BIN"
|
||||
}
|
||||
|
||||
# Create APT hook to reapply patch after updates
|
||||
create_apt_hook() {
|
||||
cat > "$APT_HOOK" <<'EOFAPT'
|
||||
/* ProxMenux: reapply minimal nag patch after upgrades */
|
||||
DPkg::Post-Invoke { "/usr/local/bin/pve-remove-nag-v3.sh || true"; };
|
||||
EOFAPT
|
||||
|
||||
chmod 644 "$APT_HOOK"
|
||||
|
||||
# Verify APT hook syntax
|
||||
apt-config dump >/dev/null 2>&1 || {
|
||||
rm -f "$APT_HOOK"
|
||||
}
|
||||
}
|
||||
|
||||
# Main function to remove subscription banner
|
||||
remove_subscription_banner_v3() {
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown")
|
||||
|
||||
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying banner patch")"
|
||||
|
||||
|
||||
|
||||
# Remove old APT hooks
|
||||
for f in /etc/apt/apt.conf.d/*nag*; do
|
||||
[[ -e "$f" ]] && rm -f "$f"
|
||||
done
|
||||
|
||||
# Create backup for desktop UI
|
||||
local backup_file
|
||||
backup_file=$(create_backup "$JS_FILE")
|
||||
if [ -n "$backup_file" ]; then
|
||||
# msg_ok "$(translate "Desktop UI backup created"): $backup_file"
|
||||
:
|
||||
fi
|
||||
|
||||
if [ -f "$MOBILE_UI_FILE" ]; then
|
||||
local mobile_backup
|
||||
mobile_backup=$(create_backup "$MOBILE_UI_FILE")
|
||||
if [ -n "$mobile_backup" ]; then
|
||||
# msg_ok "$(translate "Mobile UI backup created"): $mobile_backup"
|
||||
:
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create patch script and APT hook
|
||||
create_patch_script
|
||||
create_apt_hook
|
||||
|
||||
# Apply the patch
|
||||
if ! "$PATCH_BIN"; then
|
||||
msg_error "$(translate "Error applying patch. Backups preserved at"): $BACKUP_DIR"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Register tool as applied
|
||||
register_tool "subscription_banner" true
|
||||
|
||||
msg_ok "$(translate "Subscription banner removed successfully")"
|
||||
msg_ok "$(translate "Desktop and Mobile UI patched")"
|
||||
msg_ok "$(translate "Refresh your browser (Ctrl+Shift+R) to see changes")"
|
||||
|
||||
}
|
||||
|
||||
# Run if executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
remove_subscription_banner_v3
|
||||
fi
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE 9.x ONLY
|
||||
# Remove Subscription Banner - Proxmox VE 9.x
|
||||
# ==========================================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
@@ -15,7 +15,7 @@ fi
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# Tool registration system
|
||||
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
@@ -33,7 +33,7 @@ remove_subscription_banner_pve9() {
|
||||
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
|
||||
# Verify PVE 9.x
|
||||
|
||||
local pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1)
|
||||
local pve_major=$(echo "$pve_version" | cut -d. -f1)
|
||||
|
||||
@@ -44,45 +44,49 @@ remove_subscription_banner_pve9() {
|
||||
|
||||
msg_info "Detected Proxmox VE $pve_version - Applying PVE 9.x patches"
|
||||
|
||||
# Verify that the file exists
|
||||
|
||||
if [ ! -f "$JS_FILE" ]; then
|
||||
msg_error "JavaScript file not found: $JS_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
# Create backup of original file
|
||||
|
||||
local backup_file="${JS_FILE}.backup.pve9.$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$JS_FILE" "$backup_file"
|
||||
|
||||
# Clean any existing problematic APT hooks
|
||||
|
||||
for f in /etc/apt/apt.conf.d/*nag*; do
|
||||
[[ -e "$f" ]] && rm -f "$f"
|
||||
done
|
||||
|
||||
[[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE"
|
||||
[[ -f "$MIN_JS_FILE" ]] && rm -f "$MIN_JS_FILE"
|
||||
|
||||
# Main subscription check patches for PVE 9
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
|
||||
|
||||
|
||||
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
|
||||
sed -i "s/title: gettext('No valid subscription')/title: gettext('Community Edition')/g" "$JS_FILE"
|
||||
|
||||
# Additional UX improvements for PVE 9
|
||||
|
||||
sed -i "s/You do not have a valid subscription for this server/Community Edition - No subscription required/g" "$JS_FILE"
|
||||
sed -i "s/Enterprise repository needs valid subscription/Enterprise repository configured/g" "$JS_FILE"
|
||||
sed -i "s/icon: Ext\.Msg\.WARNING/icon: Ext.Msg.INFO/g" "$JS_FILE"
|
||||
|
||||
# Additional subscription patterns that may exist in PVE 9
|
||||
|
||||
sed -i "s/subscription = !(/subscription = false \&\& (/g" "$JS_FILE"
|
||||
|
||||
# Remove compressed/minified files to force regeneration
|
||||
[[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE"
|
||||
[[ -f "$MIN_JS_FILE" ]] && rm -f "$MIN_JS_FILE"
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
|
||||
msg_warn "Some patches may not have applied correctly, retrying..."
|
||||
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
# Clear various caches
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
|
||||
# Create PVE 9.x specific APT hook
|
||||
|
||||
[[ -f "$APT_HOOK" ]] && rm -f "$APT_HOOK"
|
||||
cat > "$APT_HOOK" << 'EOF'
|
||||
DPkg::Post-Invoke {
|
||||
@@ -96,7 +100,7 @@ EOF
|
||||
|
||||
chmod 644 "$APT_HOOK"
|
||||
|
||||
# Verify APT hook syntax
|
||||
|
||||
if ! apt-config dump >/dev/null 2>&1; then
|
||||
msg_warn "APT hook has syntax issues, removing..."
|
||||
rm -f "$APT_HOOK"
|
||||
@@ -104,16 +108,17 @@ EOF
|
||||
msg_ok "APT hook created successfully"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
|
||||
msg_ok "Subscription banner removed successfully for Proxmox VE $pve_version"
|
||||
msg_ok "Banner removal process completed"
|
||||
|
||||
msg_ok "Banner removal process completed - refresh your browser to see changes"
|
||||
|
||||
register_tool "subscription_banner" true
|
||||
}
|
||||
|
||||
|
||||
# Execute function if called directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
remove_subscription_banner_pve9
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE 9.x ONLY
|
||||
# ==========================================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# Tool registration system
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
local tool="$1"
|
||||
local state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
remove_subscription_banner_pve9() {
|
||||
local JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
local MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
|
||||
# Verify PVE 9.x
|
||||
local pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1)
|
||||
local pve_major=$(echo "$pve_version" | cut -d. -f1)
|
||||
|
||||
if [ "$pve_major" -lt 9 ] 2>/dev/null; then
|
||||
msg_error "This script is for PVE 9.x only. Detected PVE $pve_version"
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_info "Detected Proxmox VE $pve_version - Applying PVE 9.x patches"
|
||||
|
||||
# Verify that the file exists
|
||||
if [ ! -f "$JS_FILE" ]; then
|
||||
msg_error "JavaScript file not found: $JS_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
# Create backup of original file
|
||||
local backup_file="${JS_FILE}.backup.pve9.$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$JS_FILE" "$backup_file"
|
||||
|
||||
# Clean any existing problematic APT hooks
|
||||
for f in /etc/apt/apt.conf.d/*nag*; do
|
||||
[[ -e "$f" ]] && rm -f "$f"
|
||||
done
|
||||
|
||||
|
||||
# Main subscription check patches for PVE 9
|
||||
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
|
||||
sed -i "s/title: gettext('No valid subscription')/title: gettext('Community Edition')/g" "$JS_FILE"
|
||||
|
||||
# Additional UX improvements for PVE 9
|
||||
sed -i "s/You do not have a valid subscription for this server/Community Edition - No subscription required/g" "$JS_FILE"
|
||||
sed -i "s/Enterprise repository needs valid subscription/Enterprise repository configured/g" "$JS_FILE"
|
||||
sed -i "s/icon: Ext\.Msg\.WARNING/icon: Ext.Msg.INFO/g" "$JS_FILE"
|
||||
|
||||
# Additional subscription patterns that may exist in PVE 9
|
||||
sed -i "s/subscription = !(/subscription = false \&\& (/g" "$JS_FILE"
|
||||
|
||||
# Remove compressed/minified files to force regeneration
|
||||
[[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE"
|
||||
[[ -f "$MIN_JS_FILE" ]] && rm -f "$MIN_JS_FILE"
|
||||
|
||||
# Clear various caches
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
|
||||
# Create PVE 9.x specific APT hook
|
||||
[[ -f "$APT_HOOK" ]] && rm -f "$APT_HOOK"
|
||||
cat > "$APT_HOOK" << 'EOF'
|
||||
DPkg::Post-Invoke {
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/res\\.data\\.status\\.toLowerCase() !== '\''active'\''/false/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscriptionActive: '\'\'\''/subscriptionActive: true/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/title: gettext('\''No valid subscription'\'')/title: gettext('\''Community Edition'\'')/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscription = !(/subscription = false \\&\\& (/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"rm -f /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz || true";
|
||||
};
|
||||
EOF
|
||||
|
||||
chmod 644 "$APT_HOOK"
|
||||
|
||||
# Verify APT hook syntax
|
||||
if ! apt-config dump >/dev/null 2>&1; then
|
||||
msg_warn "APT hook has syntax issues, removing..."
|
||||
rm -f "$APT_HOOK"
|
||||
else
|
||||
msg_ok "APT hook created successfully"
|
||||
fi
|
||||
|
||||
|
||||
msg_ok "Subscription banner removed successfully for Proxmox VE $pve_version"
|
||||
msg_ok "Banner removal process completed"
|
||||
|
||||
|
||||
register_tool "subscription_banner" true
|
||||
}
|
||||
|
||||
|
||||
# Execute function if called directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
remove_subscription_banner_pve9
|
||||
fi
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE 9.x (Clean Version)
|
||||
# ==========================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
local tool="$1" state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" \
|
||||
> "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MOBILE_TPL="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
PATCH_BIN="/usr/local/bin/pve-remove-nag.sh"
|
||||
|
||||
MARK_JS="PROXMENUX_NAG_REMOVED_v2"
|
||||
MARK_MOBILE="<!-- PROXMENUX: MOBILE NAG PATCH v2 -->"
|
||||
|
||||
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] || return 1
|
||||
[ -s "$file" ] || return 1
|
||||
grep -Eq 'Ext|function|var|const|let' "$file" || return 1
|
||||
if LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
create_backup() {
|
||||
local file="$1"
|
||||
local backup_dir="$BASE_DIR/backups"
|
||||
local timestamp
|
||||
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_file="$backup_dir/$(basename "$file").backup.$timestamp"
|
||||
mkdir -p "$backup_dir"
|
||||
if [ -f "$file" ]; then
|
||||
cp -a "$file" "$backup_file"
|
||||
ls -t "$backup_dir"/"$(basename "$file")".backup.* 2>/dev/null | tail -n +6 | xargs -r rm -f 2>/dev/null || true
|
||||
echo "$backup_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------------------------------
|
||||
|
||||
create_patch_script() {
|
||||
cat > "$PATCH_BIN" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MOBILE_TPL="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
MARK_JS="PROXMENUX_NAG_REMOVED_v2"
|
||||
MARK_MOBILE="<!-- PROXMENUX: MOBILE NAG PATCH v2 -->"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] && [ -s "$file" ] && grep -Eq 'Ext|function' "$file" && ! LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null
|
||||
}
|
||||
|
||||
patch_web() {
|
||||
[ -f "$JS_FILE" ] || return 0
|
||||
grep -q "$MARK_JS" "$JS_FILE" && return 0
|
||||
|
||||
local backup_dir="$BASE_DIR/backups"
|
||||
mkdir -p "$backup_dir"
|
||||
local backup="$backup_dir/$(basename "$JS_FILE").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp -a "$JS_FILE" "$backup"
|
||||
trap "cp -a '$backup' '$JS_FILE' 2>/dev/null || true" ERR
|
||||
|
||||
sed -i '1s|^|/* '"$MARK_JS"' */\n|' "$JS_FILE"
|
||||
|
||||
local patterns_found=0
|
||||
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
|
||||
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
if grep -q "subscriptionActive: ''" "$JS_FILE"; then
|
||||
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
if grep -q "title: gettext('No valid subscription')" "$JS_FILE"; then
|
||||
sed -i "s/title: gettext('No valid subscription')/title: gettext('Community Edition')/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
if grep -q "icon: Ext\.Msg\.WARNING" "$JS_FILE"; then
|
||||
sed -i "s/icon: Ext\.Msg\.WARNING/icon: Ext.Msg.INFO/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
if grep -q "subscription = !(" "$JS_FILE"; then
|
||||
sed -i "s/subscription = !(/subscription = false \&\& (/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
# Si nada coincidió (cambio upstream), restaura y sal limpio
|
||||
if [ "${patterns_found:-0}" -eq 0 ]; then
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Verificación final
|
||||
if ! verify_js_integrity "$JS_FILE"; then
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Limpiar artefactos/cachés
|
||||
rm -f "$MIN_JS_FILE" "$GZ_FILE" 2>/dev/null || true
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
|
||||
|
||||
trap - ERR
|
||||
}
|
||||
|
||||
patch_mobile() {
|
||||
[ -f "$MOBILE_TPL" ] || return 0
|
||||
grep -q "$MARK_MOBILE" "$MOBILE_TPL" && return 0
|
||||
|
||||
local backup_dir="$BASE_DIR/backups"
|
||||
mkdir -p "$backup_dir"
|
||||
cp -a "$MOBILE_TPL" "$backup_dir/$(basename "$MOBILE_TPL").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
cat >> "$MOBILE_TPL" <<EOM
|
||||
$MARK_MOBILE
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
function removeSubscriptionElements() {
|
||||
try {
|
||||
const dialogs = document.querySelectorAll('dialog.pwt-outer-dialog');
|
||||
dialogs.forEach(d => {
|
||||
const text = (d.textContent || '').toLowerCase();
|
||||
if (text.includes('subscription') || text.includes('no valid')) { d.remove(); }
|
||||
});
|
||||
const cards = document.querySelectorAll('.pwt-card.pwt-p-2.pwt-d-flex.pwt-interactive.pwt-justify-content-center');
|
||||
cards.forEach(c => {
|
||||
const text = (c.textContent || '').toLowerCase();
|
||||
const hasButton = c.querySelector('button');
|
||||
if (!hasButton && (text.includes('subscription') || text.includes('no valid'))) { c.remove(); }
|
||||
});
|
||||
const alerts = document.querySelectorAll('[class*="alert"], [class*="warning"], [class*="notice"]');
|
||||
alerts.forEach(a => {
|
||||
const text = (a.textContent || '').toLowerCase();
|
||||
if (text.includes('subscription') || text.includes('no valid')) { a.remove(); }
|
||||
});
|
||||
} catch (e) { console.warn('Error removing subscription elements:', e); }
|
||||
}
|
||||
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', removeSubscriptionElements); }
|
||||
else { removeSubscriptionElements(); }
|
||||
const observer = new MutationObserver(removeSubscriptionElements);
|
||||
if (document.body) {
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
const interval = setInterval(removeSubscriptionElements, 500);
|
||||
setTimeout(() => { try { observer.disconnect(); clearInterval(interval); } catch(e){} }, 30000);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
EOM
|
||||
}
|
||||
|
||||
reload_services() {
|
||||
systemctl is-active --quiet pveproxy 2>/dev/null && {
|
||||
systemctl reload pveproxy 2>/dev/null || systemctl restart pveproxy 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet nginx 2>/dev/null && {
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet pvedaemon 2>/dev/null && {
|
||||
systemctl reload pvedaemon 2>/dev/null || true
|
||||
}
|
||||
find /var/cache/pve-manager/ -type f -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -type f -delete 2>/dev/null || true
|
||||
}
|
||||
|
||||
main() {
|
||||
patch_web || return 1
|
||||
patch_mobile
|
||||
reload_services
|
||||
}
|
||||
|
||||
main
|
||||
EOF
|
||||
chmod 755 "$PATCH_BIN"
|
||||
}
|
||||
# ----------------------------------------------------
|
||||
|
||||
|
||||
create_apt_hook() {
|
||||
cat > "$APT_HOOK" <<'EOF'
|
||||
/* ProxMenux: reapply nag patch after upgrades */
|
||||
DPkg::Post-Invoke { "/usr/local/bin/pve-remove-nag.sh || true"; };
|
||||
EOF
|
||||
chmod 644 "$APT_HOOK"
|
||||
apt-config dump >/dev/null 2>&1 || { msg_warn "APT hook syntax issue"; rm -f "$APT_HOOK"; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
remove_subscription_banner_pve9() {
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || true)
|
||||
local pve_major="${pve_version%%.*}"
|
||||
|
||||
msg_info "$(translate "Detected Proxmox VE ${pve_version:-9.x} – removing subscription banner")"
|
||||
|
||||
create_patch_script
|
||||
create_apt_hook
|
||||
|
||||
if ! "$PATCH_BIN"; then
|
||||
msg_error "$(translate "Error applying patches")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
register_tool "subscription_banner" true
|
||||
msg_ok "$(translate "Subscription banner removed successfully.")"
|
||||
msg_ok "$(translate "Refresh your browser to see changes.")"
|
||||
}
|
||||
|
||||
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
remove_subscription_banner_pve9
|
||||
fi
|
||||
@@ -0,0 +1,901 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Global Share Functions (reusable)
|
||||
# File: scripts/global/share_common.func
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
if [[ -n "${__PROXMENUX_SHARE_COMMON__}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
__PROXMENUX_SHARE_COMMON__=1
|
||||
|
||||
|
||||
: "${PROXMENUX_DEFAULT_SHARE_GROUP:=sharedfiles}"
|
||||
|
||||
|
||||
: "${PROXMENUX_SHARE_MAP_DB:=/usr/local/share/proxmenux/share-map.db}"
|
||||
|
||||
|
||||
mkdir -p "$(dirname "$PROXMENUX_SHARE_MAP_DB")" 2>/dev/null || true
|
||||
touch "$PROXMENUX_SHARE_MAP_DB" 2>/dev/null || true
|
||||
|
||||
|
||||
pmx_share_map_get() {
|
||||
|
||||
local key="$1"
|
||||
awk -F'=' -v k="$key" '$1==k {print $2}' "$PROXMENUX_SHARE_MAP_DB" 2>/dev/null | tail -n1
|
||||
}
|
||||
|
||||
|
||||
pmx_share_map_set() {
|
||||
|
||||
local key="$1" val="$2"
|
||||
|
||||
sed -i "\|^${key}=|d" "$PROXMENUX_SHARE_MAP_DB" 2>/dev/null || true
|
||||
echo "${key}=${val}" >> "$PROXMENUX_SHARE_MAP_DB"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
pmx_choose_or_create_group() {
|
||||
local default_group="${1:-$PROXMENUX_DEFAULT_SHARE_GROUP}"
|
||||
local choice group_name groups menu_args gid_min
|
||||
|
||||
|
||||
gid_min="$(awk '/^\s*GID_MIN\s+[0-9]+/ {print $2}' /etc/login.defs 2>/dev/null | tail -n1)"
|
||||
[[ -z "$gid_min" ]] && gid_min=1000
|
||||
|
||||
choice=$(whiptail --title "$(translate "Shared Group")" \
|
||||
--menu "$(translate "Choose a group policy for this shared directory:")" 18 78 6 \
|
||||
"1" "$(translate "Use default group:") $default_group $(translate "(recommended)")" \
|
||||
"2" "$(translate "Create a new group for isolation")" \
|
||||
"3" "$(translate "Select an existing group")" \
|
||||
3>&1 1>&2 2>&3) || { echo ""; return 1; }
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
|
||||
pmx_ensure_host_group "$default_group" >/dev/null || { echo ""; return 1; }
|
||||
echo "$default_group"
|
||||
;;
|
||||
|
||||
2)
|
||||
group_name=$(whiptail --inputbox "$(translate "Enter new group name:")" 10 70 "sharedfiles-project" \
|
||||
--title "$(translate "New Group")" 3>&1 1>&2 2>&3) || { echo ""; return 1; }
|
||||
|
||||
if [[ -z "$group_name" ]]; then
|
||||
msg_error "$(translate "Group name cannot be empty.")"
|
||||
echo ""; return 1
|
||||
fi
|
||||
|
||||
if ! [[ "$group_name" =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]]; then
|
||||
msg_error "$(translate "Invalid group name. Use letters, digits, underscore or hyphen, and start with a letter or underscore.")"
|
||||
echo ""; return 1
|
||||
fi
|
||||
|
||||
pmx_ensure_host_group "$group_name" >/dev/null || { echo ""; return 1; }
|
||||
echo "$group_name"
|
||||
;;
|
||||
|
||||
3)
|
||||
|
||||
groups=$(getent group | awk -F: -v MIN="$gid_min" '
|
||||
$3 >= MIN && $1 != "nogroup" && $1 !~ /^pve/ {print $0}
|
||||
' | sort -t: -k1,1)
|
||||
|
||||
if [[ -z "$groups" ]]; then
|
||||
whiptail --title "$(translate "Groups")" --msgbox "$(translate "No user groups found.")" 8 60
|
||||
echo ""; return 1
|
||||
fi
|
||||
|
||||
menu_args=()
|
||||
while IFS=: read -r gname _ gid members; do
|
||||
menu_args+=("$gname" "GID=$gid")
|
||||
done <<< "$groups"
|
||||
|
||||
group_name=$(whiptail --title "$(translate "Existing Groups")" \
|
||||
--menu "$(translate "Select an existing group:")" 20 70 12 \
|
||||
"${menu_args[@]}" 3>&1 1>&2 2>&3) || { echo ""; return 1; }
|
||||
|
||||
|
||||
pmx_ensure_host_group "$group_name" >/dev/null || { echo ""; return 1; }
|
||||
echo "$group_name"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo ""; return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
pmx_ensure_host_group() {
|
||||
local group_name="$1"
|
||||
local suggested_gid="${2:-}"
|
||||
local base_gid=101000
|
||||
local new_gid gid
|
||||
|
||||
|
||||
if getent group "$group_name" >/dev/null 2>&1; then
|
||||
gid="$(getent group "$group_name" | cut -d: -f3)"
|
||||
echo "$gid"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$suggested_gid" ]]; then
|
||||
|
||||
if getent group "$suggested_gid" >/dev/null 2>&1; then
|
||||
msg_error "$(translate "GID already in use:") $suggested_gid"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
if ! groupadd -g "$suggested_gid" "$group_name" >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Failed to create group:") $group_name"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
msg_ok "$(translate "Group created:") $group_name"
|
||||
else
|
||||
|
||||
new_gid="$base_gid"
|
||||
while getent group "$new_gid" >/dev/null 2>&1; do
|
||||
new_gid=$((new_gid+1))
|
||||
done
|
||||
if ! groupadd -g "$new_gid" "$group_name" >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Failed to create group:") $group_name"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
msg_ok "$(translate "Group created:") $group_name"
|
||||
fi
|
||||
|
||||
gid="$(getent group "$group_name" | cut -d: -f3)"
|
||||
if [[ -z "$gid" ]]; then
|
||||
msg_error "$(translate "Failed to resolve group GID for") $group_name"
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$gid"
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
pmx_prepare_host_shared_dir() {
|
||||
|
||||
local dir="$1" group_name="$2"
|
||||
[[ -z "$dir" || -z "$group_name" ]] && { msg_error "$(translate "Internal error: missing arguments in pmx_prepare_host_shared_dir")"; return 1; }
|
||||
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
if mkdir -p "$dir" 2>/dev/null; then
|
||||
msg_ok "$(translate "Created directory on host:") $dir"
|
||||
else
|
||||
msg_error "$(translate "Failed to create directory on host:") $dir"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
chown -R root:"$group_name" "$dir" 2>/dev/null || true
|
||||
chmod -R 2775 "$dir" 2>/dev/null || true
|
||||
|
||||
if command -v setfacl >/dev/null 2>&1; then
|
||||
setfacl -R -m d:g:"$group_name":rwx -m d:o::rx -m g:"$group_name":rwx "$dir" 2>/dev/null || true
|
||||
msg_ok "$(translate "Default ACLs applied for group inheritance.")"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
pmx_select_host_mount_point() {
|
||||
local title="${1:-$(translate "Select Mount Point")}"
|
||||
local default_path="${2:-/mnt/shared}"
|
||||
local context="${3:-local}"
|
||||
local choice folder_name result existing_dirs mount_point
|
||||
|
||||
while true; do
|
||||
choice=$(whiptail --title "$title" --menu "$(translate "Where do you want the host folder?")" 16 76 3 \
|
||||
"1" "$(translate "Create new folder in /mnt")" \
|
||||
"2" "$(translate "Enter custom pathr")" 3>&1 1>&2 2>&3) || { echo ""; return 1; }
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
folder_name=$(whiptail --inputbox "$(translate "Enter folder name for /mnt:")" 10 70 "$(basename "$default_path")" --title "$(translate "Folder Name")" 3>&1 1>&2 2>&3) || { echo ""; return 1; }
|
||||
[[ -z "$folder_name" ]] && continue
|
||||
mount_point="/mnt/$folder_name"
|
||||
echo "$mount_point"; return 0
|
||||
;;
|
||||
|
||||
2)
|
||||
result=$(whiptail --inputbox "$(translate "Enter full path:")" 10 80 "$default_path" --title "$(translate "Custom Path")" 3>&1 1>&2 2>&3) || { echo ""; return 1; }
|
||||
[[ -z "$result" ]] && continue
|
||||
echo "$result"; return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
select_host_directory_() {
|
||||
local method choice result
|
||||
|
||||
method=$(whiptail --title "$(translate "Select Host Directory")" --menu "$(translate "How do you want to select the HOST folder to mount?")" 15 70 4 \
|
||||
"mnt" "$(translate "Select from /mnt directories")" \
|
||||
"manual" "$(translate "Enter path manually")" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
case "$method" in
|
||||
mnt|srv|media)
|
||||
local base_path="/$method"
|
||||
local host_dirs=("$base_path"/*)
|
||||
local options=()
|
||||
|
||||
for dir in "${host_dirs[@]}"; do
|
||||
if [[ -d "$dir" ]]; then
|
||||
options+=("$dir" "$(basename "$dir")")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#options[@]} -eq 0 ]]; then
|
||||
msg_error "$(translate "No directories found in") $base_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
result=$(whiptail --title "$(translate "Select Host Folder")" \
|
||||
--menu "$(translate "Select the folder to mount:")" 20 80 10 "${options[@]}" 3>&1 1>&2 2>&3)
|
||||
;;
|
||||
manual)
|
||||
|
||||
result=$(whiptail --title "$(translate "Enter Path")" \
|
||||
--inputbox "$(translate "Enter the full path to the host folder:")" 10 70 "/mnt/" 3>&1 1>&2 2>&3)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -z "$result" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$result" ]]; then
|
||||
msg_error "$(translate "The selected path is not a valid directory:") $result"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
select_host_directory__() {
|
||||
local method result
|
||||
|
||||
method=$(whiptail --title "$(translate "Select Host Directory")" \
|
||||
--menu "$(translate "How do you want to select the HOST folder to mount?")" 15 70 4 \
|
||||
"mnt" "$(translate "Select from /mnt directories")" \
|
||||
"manual" "$(translate "Enter path manually")" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
case "$method" in
|
||||
mnt|srv|media)
|
||||
local base_path="/$method"
|
||||
local host_dirs=("$base_path"/*)
|
||||
local options=()
|
||||
|
||||
for dir in "${host_dirs[@]}"; do
|
||||
[[ -d "$dir" ]] && options+=("$dir" "$(basename "$dir")")
|
||||
done
|
||||
|
||||
if [[ ${#options[@]} -eq 0 ]]; then
|
||||
msg_error "$(translate "No directories found in") $base_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
result=$(whiptail --title "$(translate "Select Host Folder")" \
|
||||
--menu "$(translate "Select the folder to mount:")" 20 80 10 \
|
||||
"${options[@]}" 3>&1 1>&2 2>&3) || return 1
|
||||
;;
|
||||
manual)
|
||||
result=$(whiptail --title "$(translate "Enter Path")" \
|
||||
--inputbox "$(translate "Enter the full path to the host folder:")" \
|
||||
10 70 "/mnt/" 3>&1 1>&2 2>&3) || return 1
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ -z "$result" ]] && return 1
|
||||
[[ ! -d "$result" ]] && {
|
||||
msg_error "$(translate "The selected path is not a valid directory:") $result"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "$result"
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
select_host_directory() {
|
||||
local method result
|
||||
|
||||
method=$(whiptail --title "$(translate "Select Host Directory")" \
|
||||
--menu "$(translate "How do you want to select the HOST folder to mount?")" 15 70 4 \
|
||||
"mnt" "$(translate "Select from /mnt directories")" \
|
||||
"manual" "$(translate "Enter path manually")" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
case "$method" in
|
||||
mnt|srv|media)
|
||||
local base_path="/$method"
|
||||
local host_dirs=("$base_path"/*)
|
||||
local options=()
|
||||
|
||||
for dir in "${host_dirs[@]}"; do
|
||||
[[ -d "$dir" ]] && options+=("$dir" "$(basename "$dir")")
|
||||
done
|
||||
|
||||
if [[ ${#options[@]} -eq 0 ]]; then
|
||||
msg_error "$(translate "No directories found in") $base_path"
|
||||
return 1
|
||||
fi
|
||||
|
||||
result=$(whiptail --title "$(translate "Select Host Folder")" \
|
||||
--menu "$(translate "Select the folder to mount:")" 20 80 10 \
|
||||
"${options[@]}" 3>&1 1>&2 2>&3) || return 1
|
||||
;;
|
||||
manual)
|
||||
result=$(whiptail --title "$(translate "Enter Path")" \
|
||||
--inputbox "$(translate "Enter the full path to the host folder:")" \
|
||||
10 70 "/mnt/" 3>&1 1>&2 2>&3) || return 1
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ -z "$result" ]] && return 1
|
||||
[[ ! -d "$result" ]] && {
|
||||
msg_error "$(translate "The selected path is not a valid directory:") $result"
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "$result"
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
select_lxc_container() {
|
||||
local ct_list ctid ct_status
|
||||
|
||||
ct_list=$(pct list | awk 'NR>1 {print $1, $2, $3}')
|
||||
if [[ -z "$ct_list" ]]; then
|
||||
dialog --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "No LXC containers available")" 8 50
|
||||
return 1
|
||||
fi
|
||||
|
||||
local options=()
|
||||
while read -r id name status; do
|
||||
if [[ -n "$id" ]]; then
|
||||
options+=("$id" "$name ($status)")
|
||||
fi
|
||||
done <<< "$ct_list"
|
||||
|
||||
ctid=$(dialog --title "$(translate "Select LXC Container")" \
|
||||
--menu "\n$(translate "Select container:")" 25 80 15 \
|
||||
"${options[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [[ -z "$ctid" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$ctid"
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
select_container_mount_point_() {
|
||||
local ctid="$1"
|
||||
local host_dir="$2"
|
||||
local choice mount_point existing_dirs options
|
||||
|
||||
while true; do
|
||||
choice=$(whiptail --title "$(translate "Configure Mount Point inside LXC")" \
|
||||
--menu "$(translate "Where to mount inside container?")" 18 70 5 \
|
||||
"1" "$(translate "Create new directory in /mnt")" \
|
||||
"2" "$(translate "Enter path manually")" \
|
||||
"3" "$(translate "Cancel")" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
mount_point=$(whiptail --inputbox "$(translate "Enter folder name for /mnt:")" 10 60 "shared" 3>&1 1>&2 2>&3) || continue
|
||||
[[ -z "$mount_point" ]] && continue
|
||||
mount_point="/mnt/$mount_point"
|
||||
pct exec "$ctid" -- mkdir -p "$mount_point" 2>/dev/null
|
||||
;;
|
||||
|
||||
2)
|
||||
mount_point=$(whiptail --inputbox "$(translate "Enter full path:")" 10 70 "/mnt/shared" 3>&1 1>&2 2>&3) || continue
|
||||
[[ -z "$mount_point" ]] && continue
|
||||
mount_point="/mnt/$mount_point"
|
||||
pct exec "$ctid" -- mkdir -p "$mount_point" 2>/dev/null
|
||||
;;
|
||||
|
||||
3)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if pct exec "$ctid" -- test -d "$mount_point" 2>/dev/null; then
|
||||
echo "$mount_point"
|
||||
return 0
|
||||
else
|
||||
whiptail --msgbox "$(translate "Could not create or access directory:") $mount_point" 8 70
|
||||
continue
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
select_container_mount_point() {
|
||||
local ctid="$1"
|
||||
local host_dir="$2"
|
||||
local choice mount_point base_name
|
||||
|
||||
base_name=$(basename "$host_dir")
|
||||
|
||||
while true; do
|
||||
choice=$(whiptail --title "$(translate "Configure Mount Point inside LXC")" \
|
||||
--menu "$(translate "Where to mount inside container?")" 18 70 5 \
|
||||
"1" "$(translate "Create new directory in /mnt")" \
|
||||
"2" "$(translate "Enter path manually")" \
|
||||
"3" "$(translate "Cancel")" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
case "$choice" in
|
||||
1)
|
||||
mount_point=$(whiptail --inputbox "$(translate "Enter folder name for /mnt:")" \
|
||||
10 60 "$base_name" 3>&1 1>&2 2>&3) || continue
|
||||
[[ -z "$mount_point" ]] && continue
|
||||
mount_point="/mnt/$mount_point"
|
||||
pct exec "$ctid" -- mkdir -p "$mount_point" 2>/dev/null
|
||||
;;
|
||||
|
||||
2)
|
||||
mount_point=$(whiptail --inputbox "$(translate "Enter full path:")" \
|
||||
10 70 "/mnt/$base_name" 3>&1 1>&2 2>&3) || continue
|
||||
[[ -z "$mount_point" ]] && continue
|
||||
pct exec "$ctid" -- mkdir -p "$mount_point" 2>/dev/null
|
||||
;;
|
||||
|
||||
3)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if pct exec "$ctid" -- test -d "$mount_point" 2>/dev/null; then
|
||||
echo "$mount_point"
|
||||
return 0
|
||||
else
|
||||
whiptail --msgbox "$(translate "Could not create or access directory:") $mount_point" 8 70
|
||||
continue
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# CLIENT MOUNT FUNCTIONS (NFS/SAMBA COMMON)
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Check if container is privileged (required for client mounts)
|
||||
select_privileged_lxc() {
|
||||
# === Select CT ===
|
||||
local ct_list ctid ct_status conf unpriv
|
||||
|
||||
ct_list=$(pct list | awk 'NR>1 {print $1, $3}')
|
||||
if [[ -z "$ct_list" ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "No CTs available in the system.")" 8 50
|
||||
return 1
|
||||
fi
|
||||
|
||||
ctid=$(dialog --backtitle "ProxMenux" --title "$(translate "Select CT")" \
|
||||
--menu "$(translate "Select the CT to manage NFS/Samba client:")" 20 70 12 \
|
||||
$ct_list 3>&1 1>&2 2>&3)
|
||||
|
||||
if [[ -z "$ctid" ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "No CT was selected.")" 8 50
|
||||
return 1
|
||||
fi
|
||||
|
||||
# === Start CT if not running ===
|
||||
ct_status=$(pct status "$ctid" | awk '{print $2}')
|
||||
if [[ "$ct_status" != "running" ]]; then
|
||||
show_proxmenux_logo
|
||||
echo -e
|
||||
msg_info "$(translate "Starting CT") $ctid..."
|
||||
pct start "$ctid"
|
||||
sleep 2
|
||||
if [[ "$(pct status "$ctid" | awk '{print $2}')" != "running" ]]; then
|
||||
msg_error "$(translate "Failed to start the CT.")"
|
||||
echo -e ""
|
||||
msg_success "$(translate 'Press Enter to continue...')"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
msg_ok "$(translate "CT started successfully.")"
|
||||
fi
|
||||
|
||||
# === Check privileged/unprivileged ===
|
||||
conf="/etc/pve/lxc/${ctid}.conf"
|
||||
unpriv=$(awk '/^unprivileged:/ {print $2}' "$conf" 2>/dev/null)
|
||||
|
||||
if [[ "$unpriv" == "1" ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Privileged Container Required")" \
|
||||
--msgbox "\n$(translate "Network share mounting (NFS/Samba) requires a PRIVILEGED container.")\n\n$(translate "Selected container") $ctid $(translate "is UNPRIVILEGED.")\n\n$(translate "For unprivileged containers, use instead:")\n • $(translate "Configure LXC mount points")\n • $(translate "Mount shares on HOST first")\n • $(translate "Then bind-mount to container")" 15 75
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Export CTID if all good
|
||||
echo "$ctid"
|
||||
CTID="$ctid"
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Common mount point selection for containers
|
||||
pmx_select_container_mount_point() {
|
||||
local ctid="$1"
|
||||
local share_name="${2:-shared}"
|
||||
|
||||
while true; do
|
||||
local choice=$(whiptail --title "$(translate "Select Mount Point")" --menu "$(translate "Where do you want to mount inside container?")" 15 70 3 \
|
||||
"existing" "$(translate "Select from existing folders in /mnt")" \
|
||||
"new" "$(translate "Create new folder in /mnt")" \
|
||||
"custom" "$(translate "Enter custom path")" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$choice" in
|
||||
existing)
|
||||
local existing_dirs=$(pct exec "$ctid" -- find /mnt -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)
|
||||
if [[ -z "$existing_dirs" ]]; then
|
||||
whiptail --title "$(translate "No Folders")" --msgbox "$(translate "No folders found in /mnt. Please create a new folder.")" 8 60
|
||||
continue
|
||||
fi
|
||||
|
||||
local options=()
|
||||
while IFS= read -r dir; do
|
||||
if [[ -n "$dir" ]]; then
|
||||
local name=$(basename "$dir")
|
||||
if pct exec "$ctid" -- [ "$(ls -A "$dir" 2>/dev/null | wc -l)" -eq 0 ]; then
|
||||
local status="$(translate "Empty")"
|
||||
else
|
||||
local status="$(translate "Contains files")"
|
||||
fi
|
||||
options+=("$dir" "$name ($status)")
|
||||
fi
|
||||
done <<< "$existing_dirs"
|
||||
|
||||
local mount_point=$(whiptail --title "$(translate "Select Existing Folder")" --menu "$(translate "Choose a folder to mount:")" 20 80 10 "${options[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [[ -n "$mount_point" ]]; then
|
||||
if pct exec "$ctid" -- [ "$(ls -A "$mount_point" 2>/dev/null | wc -l)" -gt 0 ]; then
|
||||
local file_count=$(pct exec "$ctid" -- ls -A "$mount_point" 2>/dev/null | wc -l || true)
|
||||
if ! whiptail --yesno "$(translate "WARNING: The selected directory is not empty!")\n\n$(translate "Directory:"): $mount_point\n$(translate "Contains:"): $file_count $(translate "files/folders")\n\n$(translate "Mounting here will hide existing files until unmounted.")\n\n$(translate "Do you want to continue?")" 14 70 --title "$(translate "Directory Not Empty")"; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
echo "$mount_point"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
new)
|
||||
local folder_name=$(whiptail --inputbox "$(translate "Enter new folder name:")" 10 60 "$share_name" --title "$(translate "New Folder in /mnt")" 3>&1 1>&2 2>&3)
|
||||
if [[ -n "$folder_name" ]]; then
|
||||
local mount_point="/mnt/$folder_name"
|
||||
echo "$mount_point"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
custom)
|
||||
local mount_point=$(whiptail --inputbox "$(translate "Enter full path for mount point:")" 10 70 "/mnt/${share_name}" --title "$(translate "Custom Path")" 3>&1 1>&2 2>&3)
|
||||
if [[ -n "$mount_point" ]]; then
|
||||
echo "$mount_point"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Common server discovery function
|
||||
pmx_discover_network_servers() {
|
||||
local service_type="$1" # "NFS" or "Samba"
|
||||
local port="$2" # "2049" for NFS, "139,445" for Samba
|
||||
|
||||
local host_ip=$(hostname -I | awk '{print $1}')
|
||||
local network=$(echo "$host_ip" | cut -d. -f1-3).0/24
|
||||
|
||||
# Install nmap if needed
|
||||
if ! which nmap >/dev/null 2>&1; then
|
||||
apt-get install -y nmap &>/dev/null
|
||||
fi
|
||||
|
||||
local servers
|
||||
if [[ "$service_type" == "Samba" ]]; then
|
||||
servers=$(nmap -p 139,445 --open "$network" 2>/dev/null | grep -B 4 -E "(139|445)/tcp open" | grep "Nmap scan report" | awk '{print $5}' | sort -u || true)
|
||||
else
|
||||
servers=$(nmap -p 2049 --open "$network" 2>/dev/null | grep -B 4 "2049/tcp open" | grep "Nmap scan report" | awk '{print $5}' | sort -u || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$servers" ]]; then
|
||||
whiptail --title "$(translate "No Servers Found")" --msgbox "$(translate "No") $service_type $(translate "servers found on the network.")\n\n$(translate "You can add servers manually.")" 10 60
|
||||
return 1
|
||||
fi
|
||||
|
||||
local options=()
|
||||
while IFS= read -r server; do
|
||||
if [[ -n "$server" ]]; then
|
||||
if [[ "$service_type" == "Samba" ]]; then
|
||||
# Try to get NetBIOS name for Samba
|
||||
local nb_name=$(nmblookup -A "$server" 2>/dev/null | awk '/<00> -.*B <ACTIVE>/ {print $1; exit}')
|
||||
if [[ -z "$nb_name" || "$nb_name" == "$server" || "$nb_name" == "address" || "$nb_name" == "-" ]]; then
|
||||
nb_name="Unknown"
|
||||
fi
|
||||
options+=("$server" "$nb_name ($server)")
|
||||
else
|
||||
# For NFS, show export count
|
||||
local exports_count=$(showmount -e "$server" 2>/dev/null | tail -n +2 | wc -l || echo "0")
|
||||
options+=("$server" "NFS Server ($exports_count exports)")
|
||||
fi
|
||||
fi
|
||||
done <<< "$servers"
|
||||
|
||||
if [[ ${#options[@]} -eq 0 ]]; then
|
||||
whiptail --title "$(translate "No Valid Servers")" --msgbox "$(translate "No accessible") $service_type $(translate "servers found.")" 8 50
|
||||
return 1
|
||||
fi
|
||||
|
||||
local selected_server=$(whiptail --title "$(translate "Select") $service_type $(translate "Server")" --menu "$(translate "Choose a server:")" 20 80 10 "${options[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [[ -n "$selected_server" ]]; then
|
||||
echo "$selected_server"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Common server selection function
|
||||
pmx_select_server() {
|
||||
local service_type="$1" # "NFS" or "Samba"
|
||||
local port="$2" # "2049" for NFS, "139,445" for Samba
|
||||
|
||||
local method=$(whiptail --title "$(translate "$service_type Server Selection")" --menu "$(translate "How do you want to select the") $service_type $(translate "server?")" 15 70 3 \
|
||||
"auto" "$(translate "Auto-discover servers on network")" \
|
||||
"manual" "$(translate "Enter server IP/hostname manually")" \
|
||||
"recent" "$(translate "Select from recent servers")" 3>&1 1>&2 2>&3)
|
||||
|
||||
local result_code=$?
|
||||
if [[ $result_code -ne 0 ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$method" in
|
||||
auto)
|
||||
local discovered_server
|
||||
discovered_server=$(pmx_discover_network_servers "$service_type" "$port")
|
||||
local discover_result=$?
|
||||
if [[ $discover_result -eq 0 && -n "$discovered_server" ]]; then
|
||||
echo "$discovered_server"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
manual)
|
||||
local server=$(whiptail --inputbox "$(translate "Enter") $service_type $(translate "server IP or hostname:")" 10 60 --title "$(translate "$service_type Server")" 3>&1 1>&2 2>&3)
|
||||
local input_result=$?
|
||||
if [[ $input_result -eq 0 && -n "$server" ]]; then
|
||||
echo "$server"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
recent)
|
||||
local fs_type
|
||||
if [[ "$service_type" == "NFS" ]]; then
|
||||
fs_type="nfs"
|
||||
else
|
||||
fs_type="cifs"
|
||||
fi
|
||||
|
||||
# Fix the recent servers detection for NFS
|
||||
local recent
|
||||
if [[ "$service_type" == "NFS" ]]; then
|
||||
recent=$(grep "$fs_type" /etc/fstab 2>/dev/null | awk '{print $1}' | cut -d: -f1 | sort -u || true)
|
||||
else
|
||||
recent=$(grep "$fs_type" /etc/fstab 2>/dev/null | awk '{print $1}' | cut -d/ -f3 | sort -u || true)
|
||||
fi
|
||||
|
||||
if [[ -z "$recent" ]]; then
|
||||
whiptail --title "$(translate "No Recent Servers")" --msgbox "\n$(translate "No recent") $service_type $(translate "servers found.")" 8 50
|
||||
return 1
|
||||
fi
|
||||
|
||||
local options=()
|
||||
while IFS= read -r server; do
|
||||
[[ -n "$server" ]] && options+=("$server" "$(translate "Recent") $service_type $(translate "server")")
|
||||
done <<< "$recent"
|
||||
|
||||
local selected_server=$(whiptail --title "$(translate "Recent") $service_type $(translate "Servers")" --menu "$(translate "Choose a recent server:")" 20 70 10 "${options[@]}" 3>&1 1>&2 2>&3)
|
||||
local select_result=$?
|
||||
if [[ $select_result -eq 0 && -n "$selected_server" ]]; then
|
||||
echo "$selected_server"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Common mount options configuration
|
||||
pmx_configure_mount_options() {
|
||||
local service_type="$1" # "NFS" or "CIFS"
|
||||
|
||||
local mount_type
|
||||
if [[ "$service_type" == "NFS" ]]; then
|
||||
mount_type=$(whiptail --title "$(translate "Mount Options")" --menu "$(translate "Select mount configuration:")" 15 70 4 \
|
||||
"default" "$(translate "Default options")" \
|
||||
"readonly" "$(translate "Read-only mount")" \
|
||||
"performance" "$(translate "Performance optimized")" \
|
||||
"custom" "$(translate "Custom options")" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$mount_type" in
|
||||
default)
|
||||
echo "rw,hard,intr,rsize=8192,wsize=8192,timeo=14"
|
||||
;;
|
||||
readonly)
|
||||
echo "ro,hard,intr,rsize=8192,timeo=14"
|
||||
;;
|
||||
performance)
|
||||
echo "rw,hard,intr,rsize=1048576,wsize=1048576,timeo=14,retrans=2"
|
||||
;;
|
||||
custom)
|
||||
local options=$(whiptail --inputbox "$(translate "Enter custom mount options:")" 10 70 "rw,hard,intr" --title "$(translate "Custom Options")" 3>&1 1>&2 2>&3)
|
||||
echo "${options:-rw,hard,intr}"
|
||||
;;
|
||||
*)
|
||||
echo "rw,hard,intr,rsize=8192,wsize=8192,timeo=14"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
# CIFS options
|
||||
mount_type=$(whiptail --title "$(translate "Mount Options")" --menu "$(translate "Select mount configuration:")" 15 70 4 \
|
||||
"default" "$(translate "Default options")" \
|
||||
"readonly" "$(translate "Read-only mount")" \
|
||||
"performance" "$(translate "Performance optimized")" \
|
||||
"custom" "$(translate "Custom options")" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$mount_type" in
|
||||
default)
|
||||
echo "rw,file_mode=0664,dir_mode=0775,iocharset=utf8"
|
||||
;;
|
||||
readonly)
|
||||
echo "ro,file_mode=0444,dir_mode=0555,iocharset=utf8"
|
||||
;;
|
||||
performance)
|
||||
echo "rw,file_mode=0664,dir_mode=0775,iocharset=utf8,cache=strict,rsize=1048576,wsize=1048576"
|
||||
;;
|
||||
custom)
|
||||
local options=$(whiptail --inputbox "$(translate "Enter custom mount options:")" 10 70 "rw,file_mode=0664,dir_mode=0775" --title "$(translate "Custom Options")" 3>&1 1>&2 2>&3)
|
||||
echo "${options:-rw,file_mode=0664,dir_mode=0775}"
|
||||
;;
|
||||
*)
|
||||
echo "rw,file_mode=0664,dir_mode=0775,iocharset=utf8"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# Common permanent mount question
|
||||
pmx_ask_permanent_mount() {
|
||||
if whiptail --yesno "$(translate "Do you want to make this mount permanent?")\n\n$(translate "This will add the mount to /etc/fstab so it persists after reboot.")" 10 70 --title "$(translate "Permanent Mount")"; then
|
||||
echo "true"
|
||||
else
|
||||
echo "false"
|
||||
fi
|
||||
}
|
||||
@@ -35,6 +35,7 @@ download_common_functions() {
|
||||
}
|
||||
|
||||
update_pve9() {
|
||||
local pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||
local start_time=$(date +%s)
|
||||
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
||||
local changes_made=false
|
||||
@@ -48,8 +49,8 @@ update_pve9() {
|
||||
download_common_functions
|
||||
|
||||
|
||||
msg_info2 "$(translate "Detected: Proxmox VE 9.x (Current: $OS_CODENAME, Target: $TARGET_CODENAME)")"
|
||||
echo
|
||||
msg_info2 "$(translate "Detected: Proxmox VE $pve_version (Current: $OS_CODENAME, Target: $TARGET_CODENAME)")"
|
||||
echo -e
|
||||
|
||||
local available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
@@ -137,13 +138,13 @@ EOF
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian/
|
||||
Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates
|
||||
Components: main contrib non-free-firmware
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: http://security.debian.org/debian-security/
|
||||
Suites: ${TARGET_CODENAME}-security
|
||||
Components: main contrib non-free-firmware
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
|
||||
@@ -158,8 +159,6 @@ EOF
|
||||
msg_ok "$(translate "Non-free firmware warnings disabled")"
|
||||
fi
|
||||
|
||||
cleanup_duplicate_repos
|
||||
|
||||
update_output=$(apt-get update 2>&1)
|
||||
update_exit_code=$?
|
||||
|
||||
@@ -240,14 +239,16 @@ EOF
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Removing conflicting utilities...")"
|
||||
msg_info "$(translate "Cleaning up unused time synchronization services...")"
|
||||
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' purge ntp openntpd systemd-timesyncd > /dev/null 2>&1; then
|
||||
msg_ok "$(translate "Conflicting utilities removed")"
|
||||
msg_ok "$(translate "Old time services removed successfully")"
|
||||
else
|
||||
msg_warn "$(translate "Some conflicting utilities may not have been removed")"
|
||||
msg_warn "$(translate "Some old time services could not be removed (not installed)")"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate "Updating packages...")"
|
||||
apt-get install pv -y > /dev/null 2>&1
|
||||
msg_ok "$(translate "Packages updated successfully")"
|
||||
@@ -313,7 +314,7 @@ EOF
|
||||
lvm_repair_check
|
||||
cleanup_duplicate_repos
|
||||
|
||||
msg_info "$(translate "Performing system cleanup...")"
|
||||
#msg_info "$(translate "Performing system cleanup...")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
@@ -330,6 +331,7 @@ EOF
|
||||
echo -e "${TAB}${GN}🖥️ $(translate "Proxmox VE")${CL}: ${BL}$target_version (Debian $OS_CODENAME)${CL}"
|
||||
|
||||
msg_ok "$(translate "Proxmox VE 9.x configuration completed.")"
|
||||
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
|
||||
@@ -179,38 +179,42 @@ EOF
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
export APT_LISTCHANGES_FRONTEND=none
|
||||
export NEEDRESTART_MODE=a
|
||||
export UCF_FORCE_CONFOLD=1
|
||||
export DPKG_OPTIONS="--force-confdef --force-confold"
|
||||
|
||||
msg_info "$(translate "Performing packages upgrade...")"
|
||||
apt-get install pv -y > /dev/null 2>&1
|
||||
total_packages=$(apt-get -s dist-upgrade | grep "^Inst" | wc -l)
|
||||
msg_ok "$(translate "Packages upgrade successfull")"
|
||||
|
||||
|
||||
if [ "$total_packages" -eq 0 ]; then
|
||||
total_packages=1
|
||||
total_packages=1
|
||||
fi
|
||||
|
||||
tput civis
|
||||
tput sc
|
||||
tput sc
|
||||
|
||||
|
||||
(
|
||||
/usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' dist-upgrade 2>&1 | \
|
||||
/usr/bin/env \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
APT_LISTCHANGES_FRONTEND=none \
|
||||
NEEDRESTART_MODE=a \
|
||||
UCF_FORCE_CONFOLD=1 \
|
||||
apt-get -y \
|
||||
-o Dpkg::Options::="--force-confdef" \
|
||||
-o Dpkg::Options::="--force-confold" \
|
||||
dist-upgrade 2>&1 | \
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^(Setting up|Unpacking|Preparing to unpack|Processing triggers for) ]]; then
|
||||
|
||||
if [[ "$line" =~ ^(Setting\ up|Unpacking|Preparing\ to\ unpack|Processing\ triggers\ for) ]]; then
|
||||
package_name=$(echo "$line" | sed -E 's/.*(Setting up|Unpacking|Preparing to unpack|Processing triggers for) ([^ ]+).*/\2/')
|
||||
|
||||
|
||||
[ -z "$package_name" ] && package_name="$(translate "Unknown")"
|
||||
|
||||
|
||||
tput rc
|
||||
tput ed
|
||||
|
||||
|
||||
row=$(( $(tput lines) - 6 ))
|
||||
tput cup $row 0; echo "$(translate "Installing packages...")"
|
||||
tput cup $((row + 1)) 0; echo "──────────────────────────────────────────────"
|
||||
@@ -218,12 +222,10 @@ EOF
|
||||
tput cup $((row + 3)) 0; echo "Progress: [ ] 0%"
|
||||
tput cup $((row + 4)) 0; echo "──────────────────────────────────────────────"
|
||||
|
||||
|
||||
for i in $(seq 1 10); do
|
||||
progress=$((i * 10))
|
||||
tput cup $((row + 3)) 9
|
||||
tput cup $((row + 3)) 9
|
||||
printf "[%-50s] %3d%%" "$(printf "#%.0s" $(seq 1 $((progress/2))))" "$progress"
|
||||
|
||||
done
|
||||
fi
|
||||
done
|
||||
@@ -232,6 +234,7 @@ EOF
|
||||
if [ $? -eq 0 ]; then
|
||||
tput rc
|
||||
tput ed
|
||||
tput cnorm
|
||||
msg_ok "$(translate "System upgrade completed")"
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Proxmox VE Update Script - Improved Version
|
||||
# ==========================================================
|
||||
|
||||
# Configuration
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
local tool="$1"
|
||||
local state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
download_common_functions() {
|
||||
if ! source <(curl -s "$REPO_URL/scripts/global/common-functions.sh"); then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
update_pve9() {
|
||||
local pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||
local start_time=$(date +%s)
|
||||
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
||||
local changes_made=false
|
||||
local OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
local TARGET_CODENAME="trixie"
|
||||
|
||||
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
|
||||
|
||||
if [ -z "$OS_CODENAME" ]; then
|
||||
OS_CODENAME=$(lsb_release -cs 2>/dev/null || echo "trixie")
|
||||
fi
|
||||
|
||||
download_common_functions
|
||||
|
||||
{
|
||||
msg_info2 "$(translate "Detected: Proxmox VE $pve_version (Current: $OS_CODENAME, Target: $TARGET_CODENAME)")"
|
||||
} | tee -a "$screen_capture"
|
||||
|
||||
|
||||
local available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! ping -c 1 download.proxmox.com >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Cannot reach Proxmox repositories")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
|
||||
disable_sources_repo() {
|
||||
local file="$1"
|
||||
if [[ -f "$file" ]]; then
|
||||
sed -i ':a;/^\n*$/{$d;N;ba}' "$file"
|
||||
|
||||
if grep -q "^Enabled:" "$file"; then
|
||||
sed -i 's/^Enabled:.*$/Enabled: false/' "$file"
|
||||
else
|
||||
echo "Enabled: false" >> "$file"
|
||||
fi
|
||||
|
||||
if ! grep -q "^Types: " "$file"; then
|
||||
msg_warn "$(translate "Malformed .sources file detected, removing: $(basename "$file")")"
|
||||
rm -f "$file"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if disable_sources_repo "/etc/apt/sources.list.d/pve-enterprise.sources"; then
|
||||
msg_ok "$(translate "Enterprise Proxmox repository disabled")" | tee -a "$screen_capture"
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if disable_sources_repo "/etc/apt/sources.list.d/ceph.sources"; then
|
||||
msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")" | tee -a "$screen_capture"
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
for legacy_file in /etc/apt/sources.list.d/pve-public-repo.list \
|
||||
/etc/apt/sources.list.d/pve-install-repo.list \
|
||||
/etc/apt/sources.list.d/debian.list; do
|
||||
if [[ -f "$legacy_file" ]]; then
|
||||
rm -f "$legacy_file"
|
||||
msg_ok "$(translate "Removed legacy repository: $(basename "$legacy_file")")" | tee -a "$screen_capture"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then
|
||||
rm -f /etc/apt/sources.list.d/debian.sources
|
||||
msg_ok "$(translate "Old debian.sources file removed to prevent duplication")" | tee -a "$screen_capture"
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Creating Proxmox VE 9.x no-subscription repository...")"
|
||||
cat > /etc/apt/sources.list.d/proxmox.sources << EOF
|
||||
Enabled: true
|
||||
Types: deb
|
||||
URIs: http://download.proxmox.com/debian/pve
|
||||
Suites: ${TARGET_CODENAME}
|
||||
Components: pve-no-subscription
|
||||
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
EOF
|
||||
msg_ok "$(translate "Proxmox VE 9.x no-subscription repository created")" | tee -a "$screen_capture"
|
||||
changes_made=true
|
||||
|
||||
msg_info "$(translate "Creating Debian ${TARGET_CODENAME} sources file...")"
|
||||
cat > /etc/apt/sources.list.d/debian.sources << EOF
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian/
|
||||
Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: http://security.debian.org/debian-security/
|
||||
Suites: ${TARGET_CODENAME}-security
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
|
||||
msg_ok "$(translate "Debian repositories configured for $TARGET_CODENAME")"
|
||||
|
||||
local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf"
|
||||
if [ ! -f "$firmware_conf" ]; then
|
||||
msg_info "$(translate "Disabling non-free firmware warnings...")"
|
||||
echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > "$firmware_conf"
|
||||
msg_ok "$(translate "Non-free firmware warnings disabled")"
|
||||
fi
|
||||
|
||||
#update_output=$(apt-get update 2>&1)
|
||||
update_output=$(apt-get -o Dpkg::Progress-Fancy=1 update 2>&1)
|
||||
update_exit_code=$?
|
||||
|
||||
if [ $update_exit_code -eq 0 ]; then
|
||||
msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture"
|
||||
else
|
||||
if echo "$update_output" | grep -q "NO_PUBKEY\|GPG error"; then
|
||||
msg_info "$(translate "Fixing GPG key issues...")"
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $(echo "$update_output" | grep "NO_PUBKEY" | sed 's/.*NO_PUBKEY //' | head -1) 2>/dev/null
|
||||
if apt-get update > "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Package lists updated after GPG fix")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
return 1
|
||||
fi
|
||||
elif echo "$update_output" | grep -q "404\|Failed to fetch"; then
|
||||
msg_warn "$(translate "Some repositories are not available, continuing with available ones...")"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
echo "Error details: $update_output"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if apt policy 2>/dev/null | grep -q "${TARGET_CODENAME}.*pve-no-subscription"; then
|
||||
msg_ok "$(translate "Proxmox VE 9.x repositories verified")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_warn "$(translate "Proxmox VE 9.x repositories verification inconclusive, continuing...")"
|
||||
fi
|
||||
|
||||
local current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local upgradable=$(apt list --upgradable 2>/dev/null | grep -c "upgradable")
|
||||
local security_updates=$(apt list --upgradable 2>/dev/null | grep -c "security")
|
||||
|
||||
show_update_menu() {
|
||||
local current_version="$1"
|
||||
local target_version="$2"
|
||||
local upgradable_count="$3"
|
||||
local security_count="$4"
|
||||
|
||||
local menu_text="$(translate "System Update Information")\n\n"
|
||||
menu_text+="$(translate "Current PVE Version"): $current_version\n"
|
||||
if [ -n "$target_version" ] && [ "$target_version" != "$current_version" ]; then
|
||||
menu_text+="$(translate "Available PVE Version"): $target_version\n"
|
||||
fi
|
||||
menu_text+="\n$(translate "Package Updates Available"): $upgradable_count\n"
|
||||
menu_text+="$(translate "Security Updates"): $security_count\n\n"
|
||||
|
||||
if [ "$upgradable_count" -eq 0 ]; then
|
||||
menu_text+="$(translate "System is already up to date")"
|
||||
whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70
|
||||
return 2
|
||||
else
|
||||
menu_text+="$(translate "Do you want to proceed with the system update?")"
|
||||
if whiptail --title "$(translate "Proxmox Update")" --yesno "$menu_text" 18 70; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
show_update_menu "$current_pve_version" "$available_pve_version" "$upgradable" "$security_updates"
|
||||
MENU_RESULT=$?
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [[ $MENU_RESULT -eq 1 ]]; then
|
||||
msg_info2 "$(translate "Update cancelled by user")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
elif [[ $MENU_RESULT -eq 2 ]]; then
|
||||
msg_ok "$(translate "System is already up to date. No update needed.")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Cleaning up unused time synchronization services...")"
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' purge ntp openntpd systemd-timesyncd > /dev/null 2>&1; then
|
||||
msg_ok "$(translate "Old time services removed successfully")"
|
||||
else
|
||||
msg_warn "$(translate "Some old time services could not be removed (not installed)")"
|
||||
fi
|
||||
|
||||
echo -e
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y \
|
||||
-o Dpkg::Options::='--force-confdef' \
|
||||
-o Dpkg::Options::='--force-confold' \
|
||||
dist-upgrade 2>&1 | tee -a "$log_file"
|
||||
|
||||
upgrade_exit_code=${PIPESTATUS[0]}
|
||||
echo -e
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [ $upgrade_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
rm -f "$screen_capture"
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Installing essential Proxmox packages...")"
|
||||
local additional_packages="zfsutils-linux proxmox-backup-restore-image chrony"
|
||||
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install $additional_packages >> "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Essential Proxmox packages installed")"
|
||||
else
|
||||
msg_warn "$(translate "Some essential Proxmox packages may not have been installed")"
|
||||
fi
|
||||
|
||||
lvm_repair_check
|
||||
cleanup_duplicate_repos
|
||||
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
local minutes=$((duration / 60))
|
||||
local seconds=$((duration % 60))
|
||||
|
||||
echo -e "${TAB}${BGN}$(translate "====== PVE UPDATE COMPLETED ======")${CL}"
|
||||
echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}"
|
||||
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}"
|
||||
echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}"
|
||||
echo -e "${TAB}${GN}🖥️ $(translate "Proxmox VE")${CL}: ${BL}$available_pve_version (Debian $OS_CODENAME)${CL}"
|
||||
|
||||
msg_ok "$(translate "Proxmox VE 9.x configuration completed.")"
|
||||
|
||||
rm -f "$screen_capture"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
update_pve9
|
||||
fi
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - Coral TPU Installer (PVE 9.x)
|
||||
# =========================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.3 (PVE9, silent build)
|
||||
# Last Updated: 25/09/2025
|
||||
# =========================================
|
||||
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
LOG_FILE="/tmp/coral_install.log"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
|
||||
|
||||
|
||||
ensure_apex_group_and_udev() {
|
||||
msg_info "Ensuring apex group and udev rules..."
|
||||
|
||||
|
||||
if ! getent group apex >/dev/null; then
|
||||
groupadd --system apex || true
|
||||
msg_ok "System group 'apex' created"
|
||||
else
|
||||
msg_ok "System group 'apex' already exists"
|
||||
fi
|
||||
|
||||
|
||||
cat >/etc/udev/rules.d/99-coral-apex.rules <<'EOF'
|
||||
# Coral / Google APEX TPU (M.2 / PCIe)
|
||||
# Assign group "apex" and safe permissions to device nodes
|
||||
KERNEL=="apex_*", GROUP="apex", MODE="0660"
|
||||
SUBSYSTEM=="apex", GROUP="apex", MODE="0660"
|
||||
EOF
|
||||
|
||||
|
||||
if [[ -f /usr/lib/udev/rules.d/60-gasket-dkms.rules ]]; then
|
||||
sed -i 's/GROUP="[^"]*"/GROUP="apex"/g' /usr/lib/udev/rules.d/60-gasket-dkms.rules || true
|
||||
fi
|
||||
|
||||
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=apex || true
|
||||
|
||||
msg_ok "apex group and udev rules are in place"
|
||||
|
||||
|
||||
if ls -l /dev/apex_* 2>/dev/null | grep -q ' apex '; then
|
||||
msg_ok "Coral TPU device nodes detected with correct group (apex)"
|
||||
else
|
||||
msg_warn "apex device node not found yet; a reboot may be required"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
pre_install_prompt() {
|
||||
if ! dialog --title "$(translate 'Coral TPU Installation')" --yesno \
|
||||
"\n$(translate 'Installing Coral TPU drivers requires rebooting the server after installation. Do you want to proceed?')" 10 70; then
|
||||
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
install_coral_host() {
|
||||
show_proxmenux_logo
|
||||
: >"$LOG_FILE"
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate 'Installing build dependencies...')"
|
||||
apt-get update -qq >>"$LOG_FILE" 2>&1
|
||||
apt-get install -y git devscripts dh-dkms dkms proxmox-headers-$(uname -r) >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Error installing build dependencies. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'Build dependencies installed.')"
|
||||
|
||||
|
||||
|
||||
cd /tmp || exit 1
|
||||
rm -rf gasket-driver >>"$LOG_FILE" 2>&1
|
||||
msg_info "$(translate 'Cloning Google Coral driver repository...')"
|
||||
git clone https://github.com/google/gasket-driver.git >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Could not clone the repository. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'Repository cloned successfully.')"
|
||||
|
||||
|
||||
|
||||
cd /tmp/gasket-driver || exit 1
|
||||
msg_info "$(translate 'Patching source for kernel compatibility...')"
|
||||
|
||||
|
||||
sed -i 's/\.llseek = no_llseek/\.llseek = noop_llseek/' src/gasket_core.c
|
||||
|
||||
sed -i 's/^MODULE_IMPORT_NS(DMA_BUF);/MODULE_IMPORT_NS("DMA_BUF");/' src/gasket_page_table.c
|
||||
|
||||
sed -i "s/\(linux-headers-686-pae | linux-headers-amd64 | linux-headers-generic | linux-headers\)/\1 | proxmox-headers-$(uname -r) | pve-headers-$(uname -r)/" debian/control
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Patching failed. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'Source patched successfully.')"
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate 'Building DKMS package...')"
|
||||
debuild -us -uc -tc -b >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Failed to build DKMS package. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'DKMS package built successfully.')"
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate 'Installing DKMS package...')"
|
||||
dpkg -i ../gasket-dkms_*.deb >>"$LOG_FILE" 2>&1 || true
|
||||
if ! dpkg -s gasket-dkms >/dev/null 2>&1; then
|
||||
msg_error "$(translate 'Failed to install DKMS package. Check /tmp/coral_install.log')"; exit 1
|
||||
fi
|
||||
msg_ok "$(translate 'DKMS package installed.')"
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate 'Compiling Coral TPU drivers for current kernel...')"
|
||||
dkms remove -m gasket -v 1.0 -k "$(uname -r)" >>"$LOG_FILE" 2>&1 || true
|
||||
dkms add -m gasket -v 1.0 >>"$LOG_FILE" 2>&1 || true
|
||||
dkms build -m gasket -v 1.0 -k "$(uname -r)" >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then
|
||||
sed -n '1,200p' /var/lib/dkms/gasket/1.0/build/make.log >>"$LOG_FILE" 2>&1 || true
|
||||
msg_error "$(translate 'DKMS build failed. Check /tmp/coral_install.log')"; exit 1
|
||||
fi
|
||||
dkms install -m gasket -v 1.0 -k "$(uname -r)" >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'DKMS install failed. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'Drivers compiled and installed via DKMS.')"
|
||||
|
||||
|
||||
ensure_apex_group_and_udev
|
||||
|
||||
msg_info "$(translate 'Loading modules...')"
|
||||
modprobe gasket >>"$LOG_FILE" 2>&1 || true
|
||||
modprobe apex >>"$LOG_FILE" 2>&1 || true
|
||||
if lsmod | grep -q '\bapex\b'; then
|
||||
msg_ok "$(translate 'Modules loaded.')"
|
||||
msg_success "$(translate 'Coral TPU drivers installed and loaded successfully.')"
|
||||
else
|
||||
msg_warn "$(translate 'Installation finished but drivers are not loaded. Please check dmesg and /tmp/coral_install.log')"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
echo "---- dmesg | grep -i apex (last lines) ----" >>"$LOG_FILE"
|
||||
dmesg | grep -i apex | tail -n 20 >>"$LOG_FILE" 2>&1
|
||||
}
|
||||
|
||||
restart_prompt() {
|
||||
if whiptail --title "$(translate 'Coral TPU Installation')" --yesno \
|
||||
"$(translate 'The installation requires a server restart to apply changes. Do you want to restart now?')" 10 70; then
|
||||
msg_warn "$(translate 'Restarting the server...')"
|
||||
reboot
|
||||
else
|
||||
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
|
||||
read -r
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
pre_install_prompt
|
||||
install_coral_host
|
||||
restart_prompt
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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
|
||||
@@ -197,13 +197,12 @@ show_vm_ct_commands() {
|
||||
|
||||
echo -e "\n${YELLOW}$(translate 'Listing relevant CT users and their mapped UID/GID on host...')${NC}\n"
|
||||
|
||||
# Obtener el shift de UID del CT (por defecto 100000 si no está configurado)
|
||||
|
||||
UID_SHIFT=$(grep "^lxc.idmap" /etc/pve/lxc/"$id".conf | grep 'u 0' | awk '{print $5}')
|
||||
UID_SHIFT=${UID_SHIFT:-100000}
|
||||
|
||||
# Obtener todos los usuarios y filtrar solo root o UID >= 1000
|
||||
pct exec "$id" -- getent passwd | while IFS=: read -r username _ uid gid _ home _; do
|
||||
if [ "$uid" -eq 0 ] || [ "$uid" -ge 1000 ]; then
|
||||
if [ "$uid" -eq 0 ] || [ "$uid" -eq 65534 ] || [ "$uid" -ge 30 ]; then
|
||||
real_uid=$((UID_SHIFT + uid))
|
||||
real_gid=$((UID_SHIFT + gid))
|
||||
echo -e "${GREEN}$(translate 'User')${NC}: $username"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenu - A menu-driven script for Proxmox VE management
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Revision : @Blaspt (USB passthrough via udev rule with persistent /dev/coral)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script para instalar JDownloader en un contenedor LXC desde el host Proxmox
|
||||
# Autor: MacRimi
|
||||
|
||||
# Mostrar lista de CTs
|
||||
CT_LIST=$(pct list | awk 'NR>1 {print $1, $3}')
|
||||
if [ -z "$CT_LIST" ]; then
|
||||
whiptail --title "Error" --msgbox "No hay contenedores LXC disponibles en el sistema." 8 50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Seleccionar CT
|
||||
CTID=$(whiptail --title "Instalación de JDownloader" --menu "Selecciona el contenedor donde instalar JDownloader:" 20 60 10 $CT_LIST 3>&1 1>&2 2>&3)
|
||||
if [ -z "$CTID" ]; then
|
||||
whiptail --title "Cancelado" --msgbox "No se ha seleccionado ningún contenedor." 8 40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Solicitar email
|
||||
EMAIL=$(whiptail --title "Cuenta My JDownloader" --inputbox "Introduce tu correo electrónico para vincular JDownloader:" 10 60 3>&1 1>&2 2>&3)
|
||||
if [ -z "$EMAIL" ]; then
|
||||
whiptail --title "Error" --msgbox "No se ha introducido ningún correo." 8 40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Solicitar contraseña con confirmación
|
||||
while true; do
|
||||
PASSWORD=$(whiptail --title "Cuenta My JDownloader" --passwordbox "Introduce tu contraseña de My JDownloader:" 10 60 3>&1 1>&2 2>&3)
|
||||
[ -z "$PASSWORD" ] && whiptail --title "Error" --msgbox "No se ha introducido ninguna contraseña." 8 40 && exit 1
|
||||
|
||||
CONFIRM=$(whiptail --title "Confirmación de contraseña" --passwordbox "Repite tu contraseña para confirmar:" 10 60 3>&1 1>&2 2>&3)
|
||||
[ "$PASSWORD" = "$CONFIRM" ] && break
|
||||
whiptail --title "Error" --msgbox "Las contraseñas no coinciden. Intenta de nuevo." 8 50
|
||||
done
|
||||
|
||||
# Confirmación final
|
||||
whiptail --title "Confirmar datos" --yesno "¿Deseas continuar con los siguientes datos?\n\nCorreo: $EMAIL\nContraseña: (oculta)\n\nEsta información se usará para vincular el contenedor con tu cuenta de My.JDownloader." 14 60
|
||||
[ $? -ne 0 ] && whiptail --title "Cancelado" --msgbox "Instalación cancelada por el usuario." 8 40 && exit 1
|
||||
|
||||
clear
|
||||
echo "🔍 Detectando sistema operativo dentro del CT $CTID..."
|
||||
OS_ID=$(pct exec "$CTID" -- awk -F= '/^ID=/{gsub("\"",""); print $2}' /etc/os-release)
|
||||
|
||||
echo "Sistema detectado: $OS_ID"
|
||||
echo "🧰 Preparando entorno..."
|
||||
|
||||
case "$OS_ID" in
|
||||
debian)
|
||||
# Repositorio adicional para Java 8
|
||||
pct exec "$CTID" -- wget -q http://www.mirbsd.org/~tg/Debs/sources.txt/wtf-bookworm.sources
|
||||
pct exec "$CTID" -- mv wtf-bookworm.sources /etc/apt/sources.list.d/
|
||||
pct exec "$CTID" -- apt update -y
|
||||
pct exec "$CTID" -- apt install -y openjdk-8-jdk wget
|
||||
JAVA_PATH="/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java"
|
||||
;;
|
||||
ubuntu)
|
||||
pct exec "$CTID" -- apt update -y
|
||||
pct exec "$CTID" -- apt install -y openjdk-8-jdk wget
|
||||
JAVA_PATH="/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java"
|
||||
;;
|
||||
alpine)
|
||||
pct exec "$CTID" -- apk update
|
||||
pct exec "$CTID" -- apk add openjdk8 wget
|
||||
JAVA_PATH="/usr/lib/jvm/java-1.8-openjdk/bin/java"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Sistema operativo no soportado: $OS_ID"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Crear carpeta de instalación
|
||||
pct exec "$CTID" -- mkdir -p /opt/jdownloader
|
||||
pct exec "$CTID" -- bash -lc '
|
||||
set -e
|
||||
mkdir -p /opt/jdownloader
|
||||
cd /opt/jdownloader
|
||||
if [ ! -f JDownloader.jar ]; then
|
||||
if ls JDownloader.jar.backup.* >/dev/null 2>&1; then
|
||||
cp -a "$(ls -t JDownloader.jar.backup.* | head -1)" JDownloader.jar
|
||||
else
|
||||
curl -fSLo JDownloader.jar https://installer.jdownloader.org/JDownloader.jar
|
||||
fi
|
||||
fi
|
||||
chown root:root JDownloader.jar
|
||||
chmod 0644 JDownloader.jar
|
||||
'
|
||||
|
||||
|
||||
|
||||
# Crear archivo de configuración JSON para My JDownloader
|
||||
pct exec "$CTID" -- bash -c "mkdir -p /opt/jdownloader/cfg && cat > /opt/jdownloader/cfg/org.jdownloader.api.myjdownloader.MyJDownloaderSettings.json" <<EOF
|
||||
{
|
||||
"email" : "$EMAIL",
|
||||
"password" : "$PASSWORD",
|
||||
"enabled" : true
|
||||
}
|
||||
EOF
|
||||
|
||||
|
||||
# Crear servicio según sistema
|
||||
if [[ "$OS_ID" == "alpine" ]]; then
|
||||
# Servicio OpenRC para Alpine
|
||||
pct exec "$CTID" -- bash -c 'cat > /etc/init.d/jdownloader <<EOF
|
||||
#!/sbin/openrc-run
|
||||
|
||||
command="/usr/bin/java"
|
||||
command_args="-jar /opt/jdownloader/JDownloader.jar -norestart"
|
||||
pidfile="/var/run/jdownloader.pid"
|
||||
name="JDownloader"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
}
|
||||
EOF'
|
||||
|
||||
pct exec "$CTID" -- chmod +x /etc/init.d/jdownloader
|
||||
pct exec "$CTID" -- rc-update add jdownloader default
|
||||
pct exec "$CTID" -- rc-service jdownloader start
|
||||
|
||||
else
|
||||
# Servicio systemd para Debian/Ubuntu
|
||||
pct exec "$CTID" -- bash -lc 'cat > /etc/systemd/system/jdownloader.service <<'"'"'EOF'"'"'
|
||||
[Unit]
|
||||
Description=JDownloader
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/jdownloader
|
||||
ExecStartPre=/usr/bin/test -s /opt/jdownloader/JDownloader.jar
|
||||
ExecStart=/usr/bin/java -jar /opt/jdownloader/JDownloader.jar -norestart
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable jdownloader
|
||||
systemctl restart jdownloader
|
||||
systemctl status jdownloader --no-pager || true
|
||||
'
|
||||
|
||||
pct exec "$CTID" -- systemctl daemon-reexec
|
||||
pct exec "$CTID" -- systemctl daemon-reload
|
||||
pct exec "$CTID" -- systemctl enable jdownloader
|
||||
pct exec "$CTID" -- systemctl start jdownloader
|
||||
fi
|
||||
|
||||
pct exec "$CTID" -- reboot
|
||||
|
||||
echo -e "\n\033[1;32m✅ JDownloader se ha instalado correctamente en el CT $CTID y está funcionando como servicio.\033[0m"
|
||||
echo -e "\n➡️ Accede a \033[1;34mhttps://my.jdownloader.org\033[0m con tu cuenta para gestionarlo.\n"
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - Manual LXC Conversion Guide
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 19/08/2025
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
load_language
|
||||
initialize_cache
|
||||
# ==========================================================
|
||||
|
||||
show_command() {
|
||||
local step="$1"
|
||||
local description="$2"
|
||||
local command="$3"
|
||||
local note="$4"
|
||||
local command_extra="$5"
|
||||
|
||||
echo -e "${BGN}${step}.${CL} ${BL}${description}${CL}"
|
||||
echo ""
|
||||
echo -e "${TAB}${command}"
|
||||
echo -e
|
||||
[[ -n "$note" ]] && echo -e "${TAB}${DARK_GRAY}${note}${CL}"
|
||||
[[ -n "$command_extra" ]] && echo -e "${TAB}${YW}${command_extra}${CL}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
show_privileged_to_unprivileged_guide() {
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Manual Guide: Convert LXC Privileged to Unprivileged")"
|
||||
|
||||
echo -e "${TAB}${BL}------------------------------------------------------------------------${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BGN}$(translate "Source:")${CL} ${BL}https://forum.proxmox.com/threads/converting-between-privileged-and-unprivileged-containers.97243/${CL}"
|
||||
echo -e
|
||||
echo -e
|
||||
echo -e "${TAB}${BOLD}$(translate "IMPORTANT PREREQUISITES:")${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BGN}• $(translate "Container must be stopped before conversion")${CL}"
|
||||
echo -e "${TAB}${BGN}• $(translate "Create a backup of your container before proceeding")${CL}"
|
||||
echo -e "${TAB}${BGN}• $(translate "This process changes file ownership inside the container")${CL}"
|
||||
echo -e "${TAB}${BGN}• $(translate "Process may take several minutes depending on container size")${CL}"
|
||||
echo -e "${TAB}${BGN}• $(translate "Works with LVM, ZFS, and BTRFS storage types")${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BL}------------------------------------------------------------------------${CL}"
|
||||
echo -e
|
||||
|
||||
show_command "1" \
|
||||
"$(translate "List all containers to identify the privileged one:")" \
|
||||
"pct list" \
|
||||
"$(translate "Look for containers without 'unprivileged: 1' in their config")"
|
||||
|
||||
show_command "2" \
|
||||
"$(translate "Stop the container if it's running:")" \
|
||||
"pct stop <container-id>" \
|
||||
"$(translate "Replace <container-id> with your actual container ID")" \
|
||||
"$(translate "Example: pct stop 114")"
|
||||
|
||||
show_command "3" \
|
||||
"$(translate "Create a backup of the container configuration:")" \
|
||||
"cp /etc/pve/lxc/<container-id>.conf /etc/pve/lxc/<container-id>.conf.bak" \
|
||||
"$(translate "This creates a backup in case you need to revert changes")" \
|
||||
"$(translate "Example: cp /etc/pve/lxc/114.conf /etc/pve/lxc/114.conf.bak")"
|
||||
|
||||
show_command "4" \
|
||||
"$(translate "Get the container's storage information:")" \
|
||||
"grep '^rootfs:' /etc/pve/lxc/<container-id>.conf" \
|
||||
"$(translate "This shows the storage type and disk identifier")" \
|
||||
"$(translate "Example output: rootfs: local-lvm:vm-114-disk-0,size=8G")"
|
||||
|
||||
show_command "5" \
|
||||
"$(translate "Get the actual disk path:")" \
|
||||
"pvesm path <storage-identifier>" \
|
||||
"$(translate "Replace <storage-identifier> with the value from step 4")" \
|
||||
"$(translate "Example: pvesm path local-lvm:vm-114-disk-0")"
|
||||
|
||||
echo -e "${TAB}${BOLD}$(translate "STEP 6: Choose commands based on your storage type")${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BGN}$(translate "If pvesm path returned a DIRECTORY (ZFS/BTRFS):")${CL}"
|
||||
echo -e "${TAB}${YW}$(translate "Example: /rpool/data/subvol-114-disk-0")${CL}"
|
||||
echo -e
|
||||
|
||||
show_command "6a" \
|
||||
"$(translate "For ZFS/BTRFS - Set the mount path:")" \
|
||||
"MOUNT_PATH=\"/rpool/data/subvol-<container-id>-disk-0\"" \
|
||||
"$(translate "Replace with your actual path from step 5")" \
|
||||
"$(translate "Example: MOUNT_PATH=\"/rpool/data/subvol-114-disk-0\"")"
|
||||
|
||||
echo -e "${TAB}${BGN}$(translate "If pvesm path returned a DEVICE (LVM):")${CL}"
|
||||
echo -e "${TAB}${YW}$(translate "Example: /dev/pve/vm-114-disk-0")${CL}"
|
||||
echo -e
|
||||
|
||||
show_command "6b" \
|
||||
"$(translate "For LVM - Create mount directory and mount:")" \
|
||||
"mkdir -p /tmp/lxc_convert_<container-id>\nmount -o loop /dev/path/to/disk /tmp/lxc_convert_<container-id>\nMOUNT_PATH=\"/tmp/lxc_convert_<container-id>\"" \
|
||||
"$(translate "Replace paths with your actual values from step 5")" \
|
||||
"$(translate "Example: mkdir -p /tmp/lxc_convert_114")"
|
||||
|
||||
show_command "7" \
|
||||
"$(translate "Convert file ownership (this takes time):")" \
|
||||
"find \"\$MOUNT_PATH\" -type f | while read file; do\n if [ -e \"\$file\" ]; then\n CURRENT_UID=\$(stat -c '%u' \"\$file\")\n CURRENT_GID=\$(stat -c '%g' \"\$file\")\n NEW_UID=\$((100000 + CURRENT_UID))\n NEW_GID=\$((100000 + CURRENT_GID))\n chown \"\$NEW_UID:\$NEW_GID\" \"\$file\"\n fi\ndone" \
|
||||
"$(translate "This converts all file UIDs/GIDs by adding 100000")" \
|
||||
"$(translate "Process may take several minutes for large containers")"
|
||||
|
||||
show_command "8" \
|
||||
"$(translate "Convert directory ownership:")" \
|
||||
"find \"\$MOUNT_PATH\" -type d | while read dir; do\n if [ -e \"\$dir\" ]; then\n CURRENT_UID=\$(stat -c '%u' \"\$dir\")\n CURRENT_GID=\$(stat -c '%g' \"\$dir\")\n NEW_UID=\$((100000 + CURRENT_UID))\n NEW_GID=\$((100000 + CURRENT_GID))\n chown \"\$NEW_UID:\$NEW_GID\" \"\$dir\"\n fi\ndone" \
|
||||
"$(translate "This converts all directory UIDs/GIDs by adding 100000")"
|
||||
|
||||
echo -e "${TAB}${BOLD}$(translate "STEP 9: Cleanup (LVM only)")${CL}"
|
||||
echo -e "${TAB}${YW}$(translate "Only run this if you used LVM (step 6b):")${CL}"
|
||||
echo -e
|
||||
|
||||
show_command "9" \
|
||||
"$(translate "Unmount and cleanup (LVM only):")" \
|
||||
"umount /tmp/lxc_convert_<container-id>\nrmdir /tmp/lxc_convert_<container-id>" \
|
||||
"$(translate "Only needed if you mounted the filesystem in step 6b")" \
|
||||
"$(translate "Skip this step for ZFS/BTRFS")"
|
||||
|
||||
show_command "10" \
|
||||
"$(translate "Add unprivileged flag to container configuration:")" \
|
||||
"echo 'unprivileged: 1' >> /etc/pve/lxc/<container-id>.conf" \
|
||||
"$(translate "This marks the container as unprivileged")"
|
||||
|
||||
show_command "11" \
|
||||
"$(translate "Start the converted container:")" \
|
||||
"pct start <container-id>" \
|
||||
"$(translate "The container should now start as unprivileged")"
|
||||
|
||||
show_command "12" \
|
||||
"$(translate "Verify the conversion:")" \
|
||||
"pct config <container-id> | grep unprivileged" \
|
||||
"$(translate "Should show 'unprivileged: 1'")"
|
||||
|
||||
echo -e "${TAB}${BL}------------------------------------------------------------------------${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BOLD}$(translate "STORAGE TYPE IDENTIFICATION:")${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BGN}• $(translate "LVM:")${CL} ${YW}pvesm path returns /dev/xxx (block device)${CL}"
|
||||
echo -e "${TAB}${BGN}• $(translate "ZFS:")${CL} ${YW}pvesm path returns /rpool/xxx (directory)${CL}"
|
||||
echo -e "${TAB}${BGN}• $(translate "BTRFS:")${CL} ${YW}pvesm path returns directory path${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BOLD}$(translate "TROUBLESHOOTING:")${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BGN}$(translate "If mount fails (LVM):")${CL} ${YW}Check that the container is stopped and disk path is correct${CL}"
|
||||
echo -e "${TAB}${BGN}$(translate "If path not accessible (ZFS/BTRFS):")${CL} ${YW}Verify the dataset/subvolume exists and is mounted${CL}"
|
||||
echo -e "${TAB}${BGN}$(translate "If container won't start:")${CL} ${YW}Check /var/log/pve/tasks/ for detailed error messages${CL}"
|
||||
echo -e "${TAB}${BGN}$(translate "To revert changes:")${CL} ${YW}cp /etc/pve/lxc/<container-id>.conf.bak /etc/pve/lxc/<container-id>.conf${CL}"
|
||||
echo -e
|
||||
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
echo -e
|
||||
read -r
|
||||
}
|
||||
|
||||
show_unprivileged_to_privileged_guide() {
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Manual Guide: Convert LXC Unprivileged to Privileged")"
|
||||
|
||||
echo -e "${TAB}${BL}------------------------------------------------------------------------${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${RD}$(translate "SECURITY WARNING:")${CL} ${YW}$(translate "Privileged containers have full root access to the host system!")${CL}"
|
||||
echo -e "${TAB}${YW}$(translate "Only convert to privileged if absolutely necessary for your use case.")${CL}"
|
||||
echo -e
|
||||
echo -e
|
||||
echo -e "${TAB}${BOLD}$(translate "IMPORTANT PREREQUISITES:")${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BGN}• $(translate "Container must be stopped before conversion")${CL}"
|
||||
echo -e "${TAB}${BGN}• $(translate "Create a backup of your container before proceeding")${CL}"
|
||||
echo -e "${TAB}${BGN}• $(translate "Understand the security implications of privileged containers")${CL}"
|
||||
echo -e "${TAB}${BGN}• $(translate "This is a simple configuration change")${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BL}------------------------------------------------------------------------${CL}"
|
||||
echo -e
|
||||
|
||||
|
||||
show_command "1" \
|
||||
"$(translate "List all containers to identify the unprivileged one:")" \
|
||||
"pct list" \
|
||||
"$(translate "Look for containers with 'unprivileged: 1' in their config")"
|
||||
|
||||
show_command "2" \
|
||||
"$(translate "Check if container is unprivileged:")" \
|
||||
"pct config <container-id> | grep unprivileged" \
|
||||
"$(translate "Should show 'unprivileged: 1' if it's unprivileged")" \
|
||||
"$(translate "Example: pct config 110 | grep unprivileged")"
|
||||
|
||||
show_command "3" \
|
||||
"$(translate "Stop the container if it's running:")" \
|
||||
"pct stop <container-id>" \
|
||||
"$(translate "Replace <container-id> with your actual container ID")" \
|
||||
"$(translate "Example: pct stop 110")"
|
||||
|
||||
show_command "4" \
|
||||
"$(translate "Create a backup of the container configuration:")" \
|
||||
"cp /etc/pve/lxc/<container-id>.conf /etc/pve/lxc/<container-id>.conf.bak" \
|
||||
"$(translate "This creates a backup in case you need to revert changes")" \
|
||||
"$(translate "Example: cp /etc/pve/lxc/110.conf /etc/pve/lxc/110.conf.bak")"
|
||||
|
||||
show_command "5" \
|
||||
"$(translate "Remove the unprivileged flag from configuration:")" \
|
||||
"sed -i '/^unprivileged: 1/d' /etc/pve/lxc/<container-id>.conf" \
|
||||
"$(translate "This removes the 'unprivileged: 1' line from the config")" \
|
||||
"$(translate "Example: sed -i '/^unprivileged: 1/d' /etc/pve/lxc/110.conf")"
|
||||
|
||||
show_command "6" \
|
||||
"$(translate "Add explicit privileged flag (optional but recommended):")" \
|
||||
"echo 'unprivileged: 0' >> /etc/pve/lxc/<container-id>.conf" \
|
||||
"$(translate "This explicitly marks the container as privileged")"
|
||||
|
||||
show_command "7" \
|
||||
"$(translate "Start the converted container:")" \
|
||||
"pct start <container-id>" \
|
||||
"$(translate "The container should now start as privileged")"
|
||||
|
||||
show_command "8" \
|
||||
"$(translate "Verify the conversion:")" \
|
||||
"pct config <container-id> | grep unprivileged" \
|
||||
"$(translate "Should show 'unprivileged: 0' or no unprivileged line")"
|
||||
|
||||
echo -e "${TAB}${BL}------------------------------------------------------------------------${CL}"
|
||||
echo -e
|
||||
echo -e
|
||||
echo -e "${TAB}${BOLD}$(translate "SECURITY CONSIDERATIONS:")${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${RD}• $(translate "Privileged containers can access host devices directly")${CL}"
|
||||
echo -e "${TAB}${RD}• $(translate "Root inside container = root on host system")${CL}"
|
||||
echo -e "${TAB}${RD}• $(translate "Use only when unprivileged containers cannot meet your needs")${CL}"
|
||||
echo -e "${TAB}${RD}• $(translate "Consider security implications for production environments")${CL}"
|
||||
echo -e
|
||||
echo -e
|
||||
|
||||
echo -e "${TAB}${BOLD}$(translate "TROUBLESHOOTING:")${CL}"
|
||||
echo -e
|
||||
echo -e "${TAB}${BGN}$(translate "If container won't start:")${CL} ${YW}Check /var/log/pve/tasks/ for detailed error messages${CL}"
|
||||
echo -e "${TAB}${BGN}$(translate "To revert changes:")${CL} ${YW}cp /etc/pve/lxc/<container-id>.conf.bak /etc/pve/lxc/<container-id>.conf${CL}"
|
||||
echo -e "${TAB}${BGN}$(translate "If config issues occur:")${CL} ${YW}Manually edit /etc/pve/lxc/<container-id>.conf${CL}"
|
||||
echo -e
|
||||
echo -e
|
||||
|
||||
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
echo -e
|
||||
read -r
|
||||
}
|
||||
|
||||
show_lxc_conversion_manual_menu() {
|
||||
while true; do
|
||||
CHOICE=$(dialog --title "$(translate "LXC Conversion Manual Guides")" \
|
||||
--menu "$(translate "Select conversion guide:")" 18 70 10 \
|
||||
"1" "$(translate "Convert Privileged to Unprivileged")" \
|
||||
"2" "$(translate "Convert Unprivileged to Privileged")" \
|
||||
"3" "$(translate "Return to Main Menu")" \
|
||||
3>&1 1>&2 2>&3)
|
||||
|
||||
case $CHOICE in
|
||||
1) show_privileged_to_unprivileged_guide ;;
|
||||
2) show_unprivileged_to_privileged_guide ;;
|
||||
3) return ;;
|
||||
*) return ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Main execution
|
||||
show_lxc_conversion_manual_menu
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenu - LXC Privileged to Unprivileged Converter
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 19/08/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script converts a privileged LXC container to an unprivileged one
|
||||
# using the direct conversion method (mount and chown).
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
|
||||
select_privileged_container() {
|
||||
|
||||
CONTAINERS=$(pct list | awk 'NR>1 {print $1, $3}' | while read id name; do
|
||||
if pct config "$id" | grep -q "^unprivileged: 0" || ! pct config "$id" | grep -q "^unprivileged:"; then
|
||||
echo "$id" "$name"
|
||||
fi
|
||||
done | xargs -n2)
|
||||
|
||||
if [ -z "$CONTAINERS" ]; then
|
||||
msg_error "$(translate 'No privileged containers available in Proxmox.')"
|
||||
exit 1
|
||||
fi
|
||||
cleanup
|
||||
CONTAINER_ID=$(whiptail --title "$(translate 'Select Privileged Container')" \
|
||||
--menu "$(translate 'Select the privileged LXC container to convert:')" 20 70 10 $CONTAINERS 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
msg_error "$(translate 'No container selected. Exiting.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Privileged container selected:') $CONTAINER_ID"
|
||||
}
|
||||
|
||||
validate_container_id() {
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
msg_error "$(translate 'Container ID not defined. Make sure to select a container first.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
if pct config "$CONTAINER_ID" | grep -q "^unprivileged: 1"; then
|
||||
msg_error "$(translate 'Container') $CONTAINER_ID $(translate 'is already unprivileged.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if pct status "$CONTAINER_ID" | grep -q "running"; then
|
||||
msg_info "$(translate 'Stopping the container before conversion...')"
|
||||
pct stop "$CONTAINER_ID"
|
||||
msg_ok "$(translate 'Container stopped.')"
|
||||
fi
|
||||
}
|
||||
|
||||
show_backup_warning() {
|
||||
local message="$(translate 'It is strongly recommended to create a backup of your container before proceeding with the conversion.')"
|
||||
message="$message\n\n$(translate 'Do you want to continue with the conversion now, or exit to create a backup first?')"
|
||||
message="$message\n\n$(translate 'Continue: Proceed with conversion')"
|
||||
message="$message\n$(translate 'Exit: Stop to create backup manually')"
|
||||
|
||||
if whiptail --title "$(translate 'Backup Recommendation')" \
|
||||
--yes-button "$(translate 'Continue')" \
|
||||
--no-button "$(translate 'Exit')" \
|
||||
--yesno "$message" 18 80; then
|
||||
return 0
|
||||
else
|
||||
msg_info2 "$(translate 'User chose to exit for manual backup creation.')"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
convert_direct_method() {
|
||||
msg_info2 "$(translate 'Starting direct conversion of container') $CONTAINER_ID..."
|
||||
|
||||
TEMP_DIR="/tmp/lxc_convert_$CONTAINER_ID"
|
||||
mkdir -p "$TEMP_DIR"
|
||||
|
||||
|
||||
ROOTFS_CONFIG=$(pct config "$CONTAINER_ID" | grep "^rootfs:")
|
||||
if [ -z "$ROOTFS_CONFIG" ]; then
|
||||
msg_error "$(translate 'Could not find rootfs configuration for container.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
STORAGE_DISK=$(echo "$ROOTFS_CONFIG" | awk '{print $2}' | cut -d, -f1)
|
||||
|
||||
msg_ok "$(translate 'Storage disk identifier:') $STORAGE_DISK"
|
||||
|
||||
|
||||
DISK_PATH=$(pvesm path "$STORAGE_DISK" 2>/dev/null)
|
||||
|
||||
if [ -n "$DISK_PATH" ] && [ -e "$DISK_PATH" ]; then
|
||||
msg_ok "$(translate 'Disk path resolved via pvesm:') $DISK_PATH"
|
||||
else
|
||||
|
||||
STORAGE_NAME=$(echo "$STORAGE_DISK" | cut -d: -f1)
|
||||
DISK_NAME=$(echo "$STORAGE_DISK" | cut -d: -f2)
|
||||
|
||||
msg_info2 "$(translate 'pvesm path failed, trying manual detection...')"
|
||||
msg_info2 "$(translate 'Storage:') $STORAGE_NAME, $(translate 'Disk:') $DISK_NAME"
|
||||
|
||||
|
||||
for vg in pve $(vgs --noheadings -o vg_name 2>/dev/null | tr -d ' '); do
|
||||
if [ -e "/dev/$vg/$DISK_NAME" ]; then
|
||||
DISK_PATH="/dev/$vg/$DISK_NAME"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
if [ -z "$DISK_PATH" ] || [ ! -e "$DISK_PATH" ]; then
|
||||
ZFS_PATH="/dev/zvol/$STORAGE_NAME/$DISK_NAME"
|
||||
if [ -e "$ZFS_PATH" ]; then
|
||||
DISK_PATH="$ZFS_PATH"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [ -z "$DISK_PATH" ] || [ ! -e "$DISK_PATH" ]; then
|
||||
msg_error "$(translate 'Could not determine disk path for:') $STORAGE_DISK"
|
||||
msg_error "$(translate 'Tried pvesm path and manual detection methods')"
|
||||
msg_info2 "$(translate 'Available storage information:')"
|
||||
pvesm status 2>/dev/null || msg_error "$(translate 'pvesm status failed')"
|
||||
rmdir "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
msg_ok "$(translate 'Mounting container filesystem')"
|
||||
if ! mount "$DISK_PATH" "$TEMP_DIR" 2>/dev/null; then
|
||||
|
||||
if ! mount -o loop "$DISK_PATH" "$TEMP_DIR" 2>/dev/null; then
|
||||
msg_error "$(translate 'Failed to mount container filesystem.')"
|
||||
msg_error "$(translate 'Disk path:') $DISK_PATH"
|
||||
msg_success "$(translate "Press Enter to return")"
|
||||
read -r
|
||||
rmdir "$TEMP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
msg_info "$(translate 'Converting file ownership (this may take several minutes)...')"
|
||||
|
||||
|
||||
find "$TEMP_DIR" -type f -print0 | while IFS= read -r -d '' S; do
|
||||
|
||||
if [ ! -e "$S" ] || [ ! -r "$S" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
if STAT_OUTPUT=$(stat -c "%u %g" "$S" 2>/dev/null); then
|
||||
U=$(echo "$STAT_OUTPUT" | awk '{print $1}')
|
||||
G=$(echo "$STAT_OUTPUT" | awk '{print $2}')
|
||||
F=100000
|
||||
|
||||
|
||||
NEW_UID=$((F + U))
|
||||
NEW_GID=$((F + G))
|
||||
|
||||
|
||||
if ! chown "$NEW_UID:$NEW_GID" "$S" 2>/dev/null; then
|
||||
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
find "$TEMP_DIR" -type d -print0 | while IFS= read -r -d '' S; do
|
||||
|
||||
if [ ! -e "$S" ] || [ ! -r "$S" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
if STAT_OUTPUT=$(stat -c "%u %g" "$S" 2>/dev/null); then
|
||||
U=$(echo "$STAT_OUTPUT" | awk '{print $1}')
|
||||
G=$(echo "$STAT_OUTPUT" | awk '{print $2}')
|
||||
F=100000
|
||||
|
||||
|
||||
NEW_UID=$((F + U))
|
||||
NEW_GID=$((F + G))
|
||||
|
||||
|
||||
if ! chown "$NEW_UID:$NEW_GID" "$S" 2>/dev/null; then
|
||||
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
[ -e "$TEMP_DIR/var/spool/postfix/dev/-random" ] && rm -f "$TEMP_DIR/var/spool/postfix/dev/-random"
|
||||
[ -e "$TEMP_DIR/var/spool/postfix/dev/-urandom" ] && rm -f "$TEMP_DIR/var/spool/postfix/dev/-urandom"
|
||||
|
||||
|
||||
[ -e "$TEMP_DIR/usr/bin/sudo" ] && chmod u+s "$TEMP_DIR/usr/bin/sudo"
|
||||
|
||||
umount "$TEMP_DIR"
|
||||
rmdir "$TEMP_DIR"
|
||||
|
||||
|
||||
CONFIG_FILE="/etc/pve/lxc/$CONTAINER_ID.conf"
|
||||
if ! grep -q "^unprivileged:" "$CONFIG_FILE"; then
|
||||
echo "unprivileged: 1" >> "$CONFIG_FILE"
|
||||
else
|
||||
sed -i 's/^unprivileged:.*/unprivileged: 1/' "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Direct conversion completed for container') $CONTAINER_ID"
|
||||
|
||||
echo -e
|
||||
msg_success "Press Enter to continue..."
|
||||
read -r
|
||||
}
|
||||
|
||||
cleanup_and_finalize() {
|
||||
|
||||
if whiptail --yesno "$(translate 'Do you want to start the converted unprivileged container') $CONTAINER_ID $(translate 'now?')" 10 60; then
|
||||
msg_info2 "$(translate 'Starting unprivileged container...')"
|
||||
pct start "$CONTAINER_ID"
|
||||
msg_ok "$(translate 'Unprivileged container') $CONTAINER_ID $(translate 'started successfully.')"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "LXC Privileged to Unprivileged conversion")"
|
||||
msg_info "$(translate 'Starting LXC Privileged to Unprivileged conversion process...')"
|
||||
|
||||
select_privileged_container
|
||||
validate_container_id
|
||||
show_backup_warning
|
||||
|
||||
convert_direct_method
|
||||
cleanup_and_finalize
|
||||
|
||||
msg_ok "$(translate 'Converted container ID:') $CONTAINER_ID"
|
||||
msg_ok "$(translate 'LXC conversion from privileged to unprivileged completed successfully!')"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
exit 0
|
||||
}
|
||||
|
||||
|
||||
main
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenu - LXC Unprivileged to Privileged Converter
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# Version : 2.0
|
||||
# Last Updated: 19/08/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script converts an unprivileged LXC container to a privileged one
|
||||
# by directly modifying the configuration file.
|
||||
# WARNING: This reduces security. Use only when necessary.
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
select_unprivileged_container() {
|
||||
|
||||
CONTAINERS=$(pct list | awk 'NR>1 {print $1, $3}' | while read id name; do
|
||||
if pct config "$id" | grep -q "^unprivileged: 1"; then
|
||||
echo "$id" "$name"
|
||||
fi
|
||||
done | xargs -n2)
|
||||
|
||||
if [ -z "$CONTAINERS" ]; then
|
||||
msg_error "$(translate 'No unprivileged containers available in Proxmox.')"
|
||||
exit 1
|
||||
fi
|
||||
cleanup
|
||||
CONTAINER_ID=$(whiptail --title "$(translate 'Select Unprivileged Container')" \
|
||||
--menu "$(translate 'Select the unprivileged LXC container to convert:')" 20 70 10 $CONTAINERS 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
msg_error "$(translate 'No container selected. Exiting.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Unprivileged container selected:') $CONTAINER_ID"
|
||||
}
|
||||
|
||||
show_backup_warning() {
|
||||
if ! whiptail --title "$(translate 'Backup Recommendation')" \
|
||||
--yes-button "$(translate 'Continue')" \
|
||||
--no-button "$(translate 'Exit')" \
|
||||
--yesno "$(translate 'It is recommended to create a backup before continuing.')" \
|
||||
12 70; then
|
||||
msg_info "$(translate 'Operation cancelled by user to create backup.')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
convert_to_privileged() {
|
||||
CONF_FILE="/etc/pve/lxc/$CONTAINER_ID.conf"
|
||||
|
||||
CONTAINER_STATUS=$(pct status "$CONTAINER_ID" | awk '{print $2}')
|
||||
|
||||
if [ "$CONTAINER_STATUS" == "running" ]; then
|
||||
msg_info "$(translate 'Stopping container') $CONTAINER_ID..."
|
||||
pct shutdown "$CONTAINER_ID"
|
||||
|
||||
# Wait for container to stop
|
||||
for i in {1..10}; do
|
||||
sleep 1
|
||||
if [ "$(pct status "$CONTAINER_ID" | awk '{print $2}')" != "running" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Verify container stopped
|
||||
if [ "$(pct status "$CONTAINER_ID" | awk '{print $2}')" == "running" ]; then
|
||||
msg_error "$(translate 'Failed to stop the container.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Container stopped.')"
|
||||
else
|
||||
msg_ok "$(translate 'Container is already stopped.')"
|
||||
fi
|
||||
|
||||
msg_info "$(translate 'Creating backup of configuration file...')"
|
||||
cp "$CONF_FILE" "$CONF_FILE.bak"
|
||||
msg_ok "$(translate 'Configuration backup created:') $CONF_FILE.bak"
|
||||
|
||||
msg_info "$(translate 'Converting container to privileged...')"
|
||||
sed -i '/^unprivileged: 1/d' "$CONF_FILE"
|
||||
echo "unprivileged: 0" >> "$CONF_FILE"
|
||||
|
||||
msg_ok "$(translate 'Container successfully converted to privileged.')"
|
||||
|
||||
echo -e
|
||||
msg_success "Press Enter to continue..."
|
||||
read -r
|
||||
}
|
||||
|
||||
finalize_conversion() {
|
||||
|
||||
if whiptail --yesno "$(translate 'Do you want to start the privileged container') $CONTAINER_ID $(translate 'now?')" 10 60; then
|
||||
msg_info "$(translate 'Starting privileged container...')"
|
||||
pct start "$CONTAINER_ID"
|
||||
msg_ok "$(translate 'Privileged container') $CONTAINER_ID $(translate 'started successfully.')"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "LXC Unprivileged to Privileged conversion")"
|
||||
msg_info "$(translate 'Starting LXC Unprivileged to Privileged conversion process...')"
|
||||
|
||||
|
||||
select_unprivileged_container
|
||||
show_backup_warning
|
||||
convert_to_privileged
|
||||
finalize_conversion
|
||||
|
||||
msg_ok "$(translate 'LXC conversion from unprivileged to privileged completed successfully!')"
|
||||
msg_ok "$(translate 'Converted container ID:') $CONTAINER_ID"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Execute main function
|
||||
main
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/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
|
||||
@@ -19,6 +19,7 @@ LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
MENU_SCRIPT="menu"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
MONITOR_SERVICE="proxmenux-monitor.service"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
@@ -33,12 +34,12 @@ detect_installation_type() {
|
||||
local has_venv=false
|
||||
local has_language=false
|
||||
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then
|
||||
has_venv=true
|
||||
fi
|
||||
|
||||
|
||||
# Check if language is configured
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
local current_language=$(jq -r '.language // empty' "$CONFIG_FILE" 2>/dev/null)
|
||||
if [[ -n "$current_language" && "$current_language" != "null" && "$current_language" != "empty" ]]; then
|
||||
@@ -53,6 +54,89 @@ detect_installation_type() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_monitor_status() {
|
||||
if systemctl list-unit-files | grep -q "$MONITOR_SERVICE"; then
|
||||
if systemctl is-active --quiet "$MONITOR_SERVICE"; then
|
||||
echo "active"
|
||||
else
|
||||
echo "inactive"
|
||||
fi
|
||||
else
|
||||
echo "not_installed"
|
||||
fi
|
||||
}
|
||||
|
||||
toggle_monitor_service() {
|
||||
local status=$(check_monitor_status)
|
||||
|
||||
if [ "$status" = "not_installed" ]; then
|
||||
dialog --clear --backtitle "ProxMenux Configuration" \
|
||||
--title "$(translate "ProxMenux Monitor")" \
|
||||
--msgbox "\n\n$(translate "ProxMenux Monitor is not installed.")" 10 50
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$status" = "active" ]; then
|
||||
if dialog --clear --backtitle "ProxMenux Configuration" \
|
||||
--title "$(translate "Deactivate Monitor")" \
|
||||
--yesno "\n$(translate "Do you want to deactivate ProxMenux Monitor?")" 8 60; then
|
||||
systemctl stop "$MONITOR_SERVICE" 2>/dev/null
|
||||
systemctl disable "$MONITOR_SERVICE" 2>/dev/null
|
||||
dialog --clear --backtitle "ProxMenux Configuration" \
|
||||
--title "$(translate "Monitor Deactivated")" \
|
||||
--msgbox "\n\n$(translate "ProxMenux Monitor has been deactivated.")" 10 50
|
||||
fi
|
||||
else
|
||||
if dialog --clear --backtitle "ProxMenux Configuration" \
|
||||
--title "$(translate "Activate Monitor")" \
|
||||
--yesno "\n$(translate "Do you want to activate ProxMenux Monitor?")" 8 60; then
|
||||
systemctl enable "$MONITOR_SERVICE" 2>/dev/null
|
||||
systemctl start "$MONITOR_SERVICE" 2>/dev/null
|
||||
dialog --clear --backtitle "ProxMenux Configuration" \
|
||||
--title "$(translate "Monitor Activated")" \
|
||||
--msgbox "\n\n$(translate "ProxMenux Monitor has been activated.")" 10 50
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
show_monitor_status() {
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "ProxMenux Monitor Service Verification")"
|
||||
echo ""
|
||||
|
||||
local status=$(check_monitor_status)
|
||||
|
||||
if [ "$status" = "not_installed" ]; then
|
||||
msg_warn "$(translate "ProxMenux Monitor is not installed")"
|
||||
echo ""
|
||||
msg_info2 "$(translate "To install the monitor, reinstall ProxMenux with the latest version")"
|
||||
else
|
||||
msg_info2 "$(translate "Service Status"): $MONITOR_SERVICE"
|
||||
echo ""
|
||||
|
||||
if [ "$status" = "active" ]; then
|
||||
msg_ok "$(translate "Service is active and running")"
|
||||
|
||||
local server_ip=$(hostname -I | awk '{print $1}')
|
||||
if [ -n "$server_ip" ]; then
|
||||
echo -e "${TAB}${GN}🌐 $(translate "Monitor URL")${CL}: ${BL}http://${server_ip}:8008${CL}"
|
||||
fi
|
||||
else
|
||||
msg_warn "$(translate "Service is inactive")"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
msg_info2 "$(translate "Detailed service information"):"
|
||||
echo ""
|
||||
systemctl status "$MONITOR_SERVICE" --no-pager -l
|
||||
fi
|
||||
|
||||
echo ""
|
||||
msg_success "$(translate "Press Enter to continue...")"
|
||||
read -r
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
show_config_menu() {
|
||||
local install_type
|
||||
@@ -62,39 +146,68 @@ show_config_menu() {
|
||||
local menu_options=()
|
||||
local option_actions=()
|
||||
|
||||
|
||||
if [ "$install_type" = "translation" ]; then
|
||||
menu_options+=("1" "$(translate "Change Language")")
|
||||
option_actions[1]="change_language"
|
||||
local monitor_status=$(check_monitor_status)
|
||||
local option_num=1
|
||||
|
||||
if [ "$monitor_status" != "not_installed" ]; then
|
||||
if [ "$monitor_status" = "active" ]; then
|
||||
menu_options+=("$option_num" "$(translate "Deactivate ProxMenux Monitor")")
|
||||
option_actions[$option_num]="toggle_monitor"
|
||||
else
|
||||
menu_options+=("$option_num" "$(translate "Activate ProxMenux Monitor")")
|
||||
option_actions[$option_num]="toggle_monitor"
|
||||
fi
|
||||
((option_num++))
|
||||
|
||||
menu_options+=("2" "$(translate "Show Version Information")")
|
||||
option_actions[2]="show_version_info"
|
||||
|
||||
menu_options+=("3" "$(translate "Uninstall ProxMenux")")
|
||||
option_actions[3]="uninstall_proxmenu"
|
||||
|
||||
menu_options+=("4" "$(translate "Return to Main Menu")")
|
||||
option_actions[4]="return_main"
|
||||
else
|
||||
|
||||
menu_options+=("1" "Show Version Information")
|
||||
option_actions[1]="show_version_info"
|
||||
|
||||
menu_options+=("2" "Uninstall ProxMenux")
|
||||
option_actions[2]="uninstall_proxmenu"
|
||||
|
||||
menu_options+=("3" "Return to Main Menu")
|
||||
option_actions[3]="return_main"
|
||||
menu_options+=("$option_num" "$(translate "Show Monitor Service Status")")
|
||||
option_actions[$option_num]="show_monitor_status"
|
||||
((option_num++))
|
||||
fi
|
||||
|
||||
|
||||
# Build menu based on installation type
|
||||
if [ "$install_type" = "translation" ]; then
|
||||
menu_options+=("$option_num" "$(translate "Change Language")")
|
||||
option_actions[$option_num]="change_language"
|
||||
((option_num++))
|
||||
|
||||
menu_options+=("$option_num" "$(translate "Show Version Information")")
|
||||
option_actions[$option_num]="show_version_info"
|
||||
((option_num++))
|
||||
|
||||
menu_options+=("$option_num" "$(translate "Uninstall ProxMenux")")
|
||||
option_actions[$option_num]="uninstall_proxmenu"
|
||||
((option_num++))
|
||||
|
||||
menu_options+=("$option_num" "$(translate "Return to Main Menu")")
|
||||
option_actions[$option_num]="return_main"
|
||||
else
|
||||
# Normal version (English only)
|
||||
menu_options+=("$option_num" "Show Version Information")
|
||||
option_actions[$option_num]="show_version_info"
|
||||
((option_num++))
|
||||
|
||||
menu_options+=("$option_num" "Uninstall ProxMenux")
|
||||
option_actions[$option_num]="uninstall_proxmenu"
|
||||
((option_num++))
|
||||
|
||||
menu_options+=("$option_num" "Return to Main Menu")
|
||||
option_actions[$option_num]="return_main"
|
||||
fi
|
||||
|
||||
# Show menu
|
||||
OPTION=$(dialog --clear --backtitle "ProxMenux Configuration" \
|
||||
--title "$(translate "Configuration Menu")" \
|
||||
--menu "$(translate "Select an option:")" 20 70 10 \
|
||||
"${menu_options[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
|
||||
# Execute selected action
|
||||
case "${option_actions[$OPTION]}" in
|
||||
"toggle_monitor")
|
||||
toggle_monitor_service
|
||||
;;
|
||||
"show_monitor_status")
|
||||
show_monitor_status
|
||||
;;
|
||||
"change_language")
|
||||
change_language
|
||||
;;
|
||||
@@ -131,7 +244,7 @@ change_language() {
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
# Update language in config file
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
tmp=$(mktemp)
|
||||
jq --arg lang "$new_language" '.language = $lang' "$CONFIG_FILE" > "$tmp" && mv "$tmp" "$CONFIG_FILE"
|
||||
@@ -143,7 +256,7 @@ change_language() {
|
||||
--title "$(translate "Language Change")" \
|
||||
--msgbox "\n\n$(translate "Language changed to") $new_language" 10 50
|
||||
|
||||
|
||||
# Reload menu with new language
|
||||
TMP_FILE=$(mktemp)
|
||||
curl -s "$REPO_URL/scripts/menus/config_menu.sh" > "$TMP_FILE"
|
||||
chmod +x "$TMP_FILE"
|
||||
@@ -164,7 +277,7 @@ show_version_info() {
|
||||
|
||||
info_message+="$(translate "Current ProxMenux version:") $version\n\n"
|
||||
|
||||
|
||||
# Show installation type
|
||||
info_message+="$(translate "Installation type:")\n"
|
||||
if [ "$install_type" = "translation" ]; then
|
||||
info_message+="✓ $(translate "Translation Version (Multi-language support)")\n"
|
||||
@@ -197,13 +310,13 @@ show_version_info() {
|
||||
info_message+="$(translate "No installation information available.")\n"
|
||||
fi
|
||||
|
||||
info_message+="\n$(translate "ProxMenu files:")\n"
|
||||
info_message+="\n$(translate "ProxMenux files:")\n"
|
||||
[ -f "$INSTALL_DIR/$MENU_SCRIPT" ] && info_message+="✓ $MENU_SCRIPT → $INSTALL_DIR/$MENU_SCRIPT\n" || info_message+="✗ $MENU_SCRIPT\n"
|
||||
[ -f "$UTILS_FILE" ] && info_message+="✓ utils.sh → $UTILS_FILE\n" || info_message+="✗ utils.sh\n"
|
||||
[ -f "$CONFIG_FILE" ] && info_message+="✓ config.json → $CONFIG_FILE\n" || info_message+="✗ config.json\n"
|
||||
[ -f "$LOCAL_VERSION_FILE" ] && info_message+="✓ version.txt → $LOCAL_VERSION_FILE\n" || info_message+="✗ version.txt\n"
|
||||
|
||||
|
||||
# Show translation-specific files
|
||||
if [ "$install_type" = "translation" ]; then
|
||||
[ -f "$CACHE_FILE" ] && info_message+="✓ cache.json → $CACHE_FILE\n" || info_message+="✗ cache.json\n"
|
||||
|
||||
@@ -222,7 +335,7 @@ show_version_info() {
|
||||
info_message+="\n$(translate "Language:")\nEnglish (Fixed)\n"
|
||||
fi
|
||||
|
||||
|
||||
# Display information in a scrollable text box
|
||||
tmpfile=$(mktemp)
|
||||
echo -e "$info_message" > "$tmpfile"
|
||||
dialog --clear --backtitle "ProxMenux Configuration" \
|
||||
@@ -237,14 +350,14 @@ uninstall_proxmenu() {
|
||||
install_type=$(detect_installation_type)
|
||||
|
||||
if ! dialog --clear --backtitle "ProxMenux Configuration" \
|
||||
--title "Uninstall ProxMenu" \
|
||||
--yesno "\n$(translate "Are you sure you want to uninstall ProxMenu?")" 8 60; then
|
||||
--title "Uninstall ProxMenux" \
|
||||
--yesno "\n$(translate "Are you sure you want to uninstall ProxMenux?")" 8 60; then
|
||||
return
|
||||
fi
|
||||
|
||||
local deps_to_remove=""
|
||||
|
||||
|
||||
# Show different dependency options based on installation type
|
||||
if [ "$install_type" = "translation" ]; then
|
||||
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
|
||||
--title "Remove Dependencies" \
|
||||
@@ -263,12 +376,12 @@ uninstall_proxmenu() {
|
||||
3>&1 1>&2 2>&3)
|
||||
fi
|
||||
|
||||
|
||||
# Perform uninstallation with progress bar
|
||||
(
|
||||
echo "10" ; echo "Removing ProxMenu files..."
|
||||
sleep 1
|
||||
|
||||
|
||||
# Remove googletrans and virtual environment if exists
|
||||
if [ -f "$VENV_PATH/bin/activate" ]; then
|
||||
echo "30" ; echo "Removing googletrans and virtual environment..."
|
||||
source "$VENV_PATH/bin/activate"
|
||||
@@ -281,7 +394,7 @@ uninstall_proxmenu() {
|
||||
rm -f "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
rm -rf "$BASE_DIR"
|
||||
|
||||
|
||||
# Remove selected dependencies
|
||||
if [ -n "$deps_to_remove" ]; then
|
||||
echo "70" ; echo "Removing selected dependencies..."
|
||||
read -r -a DEPS_ARRAY <<< "$(echo "$deps_to_remove" | tr -d '"')"
|
||||
@@ -293,7 +406,7 @@ uninstall_proxmenu() {
|
||||
fi
|
||||
|
||||
echo "90" ; echo "Restoring system files..."
|
||||
|
||||
# Restore .bashrc and motd
|
||||
[ -f /root/.bashrc.bak ] && mv /root/.bashrc.bak /root/.bashrc
|
||||
if [ -f /etc/motd.bak ]; then
|
||||
mv /etc/motd.bak /etc/motd
|
||||
@@ -308,7 +421,7 @@ uninstall_proxmenu() {
|
||||
--title "Uninstalling ProxMenux" \
|
||||
--gauge "Starting uninstallation..." 10 60 0
|
||||
|
||||
|
||||
# Show completion message
|
||||
local final_message="ProxMenux has been uninstalled successfully.\n\n"
|
||||
if [ -n "$deps_to_remove" ]; then
|
||||
final_message+="The following dependencies were removed:\n$deps_to_remove\n\n"
|
||||
@@ -324,4 +437,4 @@ uninstall_proxmenu() {
|
||||
|
||||
# ==========================================================
|
||||
# Main execution
|
||||
show_config_menu
|
||||
show_config_menu
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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
|
||||
@@ -47,7 +47,7 @@ initialize_cache
|
||||
fi
|
||||
;;
|
||||
3)
|
||||
bash <(curl -s "$REPO_URL/scripts/install_coral_pve.sh")
|
||||
bash <(curl -s "$REPO_URL/scripts/gpu_tpu/install_coral_pve9.sh")
|
||||
if [ $? -ne 0 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - LXC Conversion Management Menu
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT (https://raw.githubusercontent.com/MacRimi/ProxMenux/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 19/08/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script provides a menu interface for LXC container privilege conversions.
|
||||
# Allows converting between privileged and unprivileged containers safely.
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
|
||||
show_main_menu() {
|
||||
CHOICE=$(dialog --backtitle "ProxMenux" --title "$(translate 'LXC Management')" \
|
||||
--menu "$(translate 'Select conversion option:')" 20 70 10 \
|
||||
"1" "$(translate 'Convert Privileged to Unprivileged')" \
|
||||
"2" "$(translate 'Convert Unprivileged to Privileged')" \
|
||||
"3" "$(translate 'Show Container Privilege Status')" \
|
||||
"4" "$(translate "Help & Info (commands)")" \
|
||||
"5" "$(translate 'Exit')" 3>&1 1>&2 2>&3)
|
||||
|
||||
case $CHOICE in
|
||||
1)
|
||||
bash <(curl -s "$REPO_URL/scripts/lxc/lxc-privileged-to-unprivileged.sh")
|
||||
;;
|
||||
2)
|
||||
bash <(curl -s "$REPO_URL/scripts/lxc/lxc-unprivileged-to-privileged.sh")
|
||||
;;
|
||||
3)
|
||||
show_container_status
|
||||
;;
|
||||
4)
|
||||
bash <(curl -s "$REPO_URL/scripts/lxc/lxc-conversion-manual-guide.sh")
|
||||
;;
|
||||
5)
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh")
|
||||
;;
|
||||
*)
|
||||
exec bash <(curl -s "$REPO_URL/scripts/menus/main_menu.sh")
|
||||
esac
|
||||
}
|
||||
|
||||
|
||||
|
||||
show_container_status() {
|
||||
msg_info "$(translate 'Gathering container privilege information...')"
|
||||
|
||||
|
||||
TEMP_FILE=$(mktemp)
|
||||
|
||||
echo "$(translate 'LXC Container Privilege Status')" > "$TEMP_FILE"
|
||||
echo "=================================" >> "$TEMP_FILE"
|
||||
echo "" >> "$TEMP_FILE"
|
||||
|
||||
|
||||
pct list | awk 'NR>1 {print $1, $3}' | while read id name; do
|
||||
if pct config "$id" | grep -q "^unprivileged: 1"; then
|
||||
status="$(translate 'Unprivileged')"
|
||||
else
|
||||
status="$(translate 'Privileged')"
|
||||
fi
|
||||
|
||||
running_status=$(pct status "$id" | grep -q "running" && echo "$(translate 'Running')" || echo "$(translate 'Stopped')")
|
||||
|
||||
printf "ID: %-4s | %-20s | %-12s | %s\n" "$id" "$name" "$status" "$running_status" >> "$TEMP_FILE"
|
||||
done
|
||||
|
||||
echo "" >> "$TEMP_FILE"
|
||||
echo "$(translate 'Legend:')" >> "$TEMP_FILE"
|
||||
echo "$(translate 'Privileged: Full host access (less secure)')" >> "$TEMP_FILE"
|
||||
echo "$(translate 'Unprivileged: Limited access (more secure)')" >> "$TEMP_FILE"
|
||||
|
||||
cleanup
|
||||
dialog --title "$(translate 'Container Status')" --textbox "$TEMP_FILE" 25 80
|
||||
|
||||
|
||||
rm -f "$TEMP_FILE"
|
||||
|
||||
|
||||
show_main_menu
|
||||
}
|
||||
|
||||
|
||||
|
||||
show_main_menu
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/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
|
||||
@@ -16,43 +16,103 @@ BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
# ==========================================================
|
||||
|
||||
if ! command -v dialog &>/dev/null; then
|
||||
apt update -qq >/dev/null 2>&1
|
||||
apt install -y dialog >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
|
||||
check_pve9_translation_compatibility() {
|
||||
local pve_version
|
||||
|
||||
if command -v pveversion &>/dev/null; then
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ -n "$pve_version" ]] && [[ "$pve_version" -ge 9 ]] && [[ -d "$VENV_PATH" ]]; then
|
||||
|
||||
local has_googletrans=false
|
||||
local has_cache=false
|
||||
|
||||
if [[ -f "$VENV_PATH/bin/pip" ]]; then
|
||||
if "$VENV_PATH/bin/pip" list 2>/dev/null | grep -q "googletrans"; then
|
||||
has_googletrans=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$BASE_DIR/cache.json" ]]; then
|
||||
has_cache=true
|
||||
fi
|
||||
|
||||
if [[ "$has_googletrans" = true ]] || [[ "$has_cache" = true ]]; then
|
||||
|
||||
dialog --clear \
|
||||
--backtitle "ProxMenux - Compatibility Required" \
|
||||
--title "Translation Environment Incompatible with PVE $pve_version" \
|
||||
--msgbox "NOTICE: You are running Proxmox VE $pve_version with translation components installed.\n\nTranslations are NOT supported in PVE 9+. This causes:\n• Menu loading errors\n• Translation failures\n• System instability\n\nREQUIRED ACTION:\nProxMenux will now automatically reinstall the Normal Version.\n\nThis process will:\n• Remove incompatible translation components\n• Install PVE 9+ compatible version\n• Preserve all your settings and preferences\n\nPress OK to continue with automatic reinstallation..." 20 75
|
||||
|
||||
bash <(curl -sSL "$REPO_URL/install_proxmenux.sh")
|
||||
|
||||
fi
|
||||
exit
|
||||
fi
|
||||
}
|
||||
|
||||
check_pve9_translation_compatibility
|
||||
|
||||
# ==========================================================
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$PROXMENUX_PVE9_WARNING_SHOWN" = "1" ]]; then
|
||||
|
||||
if ! load_language 2>/dev/null; then
|
||||
LANGUAGE="en"
|
||||
fi
|
||||
|
||||
else
|
||||
load_language
|
||||
initialize_cache
|
||||
fi
|
||||
|
||||
# ==========================================================
|
||||
|
||||
show_menu() {
|
||||
local TEMP_FILE
|
||||
TEMP_FILE=$(mktemp)
|
||||
|
||||
while true; do
|
||||
|
||||
local menu_title="Main ProxMenux"
|
||||
if [[ -n "$PROXMENUX_PVE9_WARNING_SHOWN" ]]; then
|
||||
menu_title="Main ProxMenux"
|
||||
fi
|
||||
|
||||
dialog --clear \
|
||||
--backtitle "ProxMenux" \
|
||||
--title "$(translate "Main ProxMenux")" \
|
||||
--title "$(translate "$menu_title")" \
|
||||
--menu "$(translate "Select an option:")" 20 70 10 \
|
||||
1 "$(translate "Settings post-install Proxmox")" \
|
||||
2 "$(translate "Help and Info Commands")" \
|
||||
3 "$(translate "Hardware: GPUs and Coral-TPU")" \
|
||||
4 "$(translate "Create VM from template or script")" \
|
||||
5 "$(translate "Disk and Storage Manager")" \
|
||||
2 "$(translate "Hardware: GPUs and Coral-TPU")" \
|
||||
3 "$(translate "Create VM from template or script")" \
|
||||
4 "$(translate "Disk and Storage Manager")" \
|
||||
5 "$(translate "Mount and Share Manager")" \
|
||||
6 "$(translate "Proxmox VE Helper Scripts")" \
|
||||
7 "$(translate "Network Management")" \
|
||||
8 "$(translate "Utilities and Tools")" \
|
||||
9 "$(translate "Settings")" \
|
||||
h "$(translate "Help and Info Commands")" \
|
||||
s "$(translate "Settings")" \
|
||||
0 "$(translate "Exit")" 2>"$TEMP_FILE"
|
||||
|
||||
local EXIT_STATUS=$?
|
||||
|
||||
if [[ $EXIT_STATUS -ne 0 ]]; then
|
||||
# ESC pressed or Cancel
|
||||
clear
|
||||
msg_ok "$(translate "Thank you for using ProxMenux. Goodbye!")"
|
||||
rm -f "$TEMP_FILE"
|
||||
@@ -63,20 +123,19 @@ show_menu() {
|
||||
|
||||
case $OPTION in
|
||||
1) exec bash <(curl -s "$REPO_URL/scripts/menus/menu_post_install.sh") ;;
|
||||
2) bash <(curl -s "$REPO_URL/scripts/help_info_menu.sh") ;;
|
||||
3) exec bash <(curl -s "$REPO_URL/scripts/menus/hw_grafics_menu.sh") ;;
|
||||
4) exec bash <(curl -s "$REPO_URL/scripts/menus/create_vm_menu.sh") ;;
|
||||
5) exec bash <(curl -s "$REPO_URL/scripts/menus/storage_menu.sh") ;;
|
||||
2) exec bash <(curl -s "$REPO_URL/scripts/menus/hw_grafics_menu.sh") ;;
|
||||
3) exec bash <(curl -s "$REPO_URL/scripts/menus/create_vm_menu.sh") ;;
|
||||
4) exec bash <(curl -s "$REPO_URL/scripts/menus/storage_menu.sh") ;;
|
||||
5) exec bash <(curl -s "$REPO_URL/scripts/menus/share_menu.sh") ;;
|
||||
6) exec bash <(curl -s "$REPO_URL/scripts/menus/menu_Helper_Scripts.sh") ;;
|
||||
7) exec bash <(curl -s "$REPO_URL/scripts/menus/network_menu.sh") ;;
|
||||
8) exec bash <(curl -s "$REPO_URL/scripts/menus/utilities_menu.sh") ;;
|
||||
9) exec bash <(curl -s "$REPO_URL/scripts/menus/config_menu.sh") ;;
|
||||
0) clear; msg_ok "$(translate "Thank you for using ProxMenu. Goodbye!")"; rm -f "$TEMP_FILE"; exit 0 ;;
|
||||
h) bash <(curl -s "$REPO_URL/scripts/help_info_menu.sh") ;;
|
||||
s) exec bash <(curl -s "$REPO_URL/scripts/menus/config_menu.sh") ;;
|
||||
0) clear; msg_ok "$(translate "Thank you for using ProxMenux. Goodbye!")"; rm -f "$TEMP_FILE"; exit 0 ;;
|
||||
*) msg_warn "$(translate "Invalid option")"; sleep 2 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
|
||||
show_menu
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# 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)
|
||||
# Version : 2.0
|
||||
# Last Updated: 04/04/2025
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
# ==========================================================
|
||||
|
||||
if ! command -v dialog &>/dev/null; then
|
||||
apt update -qq >/dev/null 2>&1
|
||||
apt install -y dialog >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
show_menu() {
|
||||
local TEMP_FILE
|
||||
TEMP_FILE=$(mktemp)
|
||||
|
||||
while true; do
|
||||
dialog --clear \
|
||||
--backtitle "ProxMenux" \
|
||||
--title "$(translate "Main ProxMenux")" \
|
||||
--menu "$(translate "Select an option:")" 20 70 10 \
|
||||
1 "$(translate "Settings post-install Proxmox")" \
|
||||
2 "$(translate "Help and Info Commands")" \
|
||||
3 "$(translate "Hardware: GPUs and Coral-TPU")" \
|
||||
4 "$(translate "Create VM from template or script")" \
|
||||
5 "$(translate "Disk and Storage Manager")" \
|
||||
6 "$(translate "Proxmox VE Helper Scripts")" \
|
||||
7 "$(translate "Network Management")" \
|
||||
8 "$(translate "Utilities and Tools")" \
|
||||
9 "$(translate "Settings")" \
|
||||
0 "$(translate "Exit")" 2>"$TEMP_FILE"
|
||||
|
||||
local EXIT_STATUS=$?
|
||||
|
||||
if [[ $EXIT_STATUS -ne 0 ]]; then
|
||||
# ESC pressed or Cancel
|
||||
clear
|
||||
msg_ok "$(translate "Thank you for using ProxMenux. Goodbye!")"
|
||||
rm -f "$TEMP_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OPTION=$(<"$TEMP_FILE")
|
||||
|
||||
case $OPTION in
|
||||
1) exec bash <(curl -s "$REPO_URL/scripts/menus/menu_post_install.sh") ;;
|
||||
2) bash <(curl -s "$REPO_URL/scripts/help_info_menu.sh") ;;
|
||||
3) exec bash <(curl -s "$REPO_URL/scripts/menus/hw_grafics_menu.sh") ;;
|
||||
4) exec bash <(curl -s "$REPO_URL/scripts/menus/create_vm_menu.sh") ;;
|
||||
5) exec bash <(curl -s "$REPO_URL/scripts/menus/storage_menu.sh") ;;
|
||||
6) exec bash <(curl -s "$REPO_URL/scripts/menus/menu_Helper_Scripts.sh") ;;
|
||||
7) exec bash <(curl -s "$REPO_URL/scripts/menus/network_menu.sh") ;;
|
||||
8) exec bash <(curl -s "$REPO_URL/scripts/menus/utilities_menu.sh") ;;
|
||||
9) exec bash <(curl -s "$REPO_URL/scripts/menus/config_menu.sh") ;;
|
||||
0) clear; msg_ok "$(translate "Thank you for using ProxMenux. Goodbye!")"; rm -f "$TEMP_FILE"; exit 0 ;;
|
||||
*) msg_warn "$(translate "Invalid option")"; sleep 2 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
|
||||
show_menu
|
||||