Compare commits
562 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10f37b88c3 | |||
| 9bfacd9da9 | |||
| a286770fd2 | |||
| 6f99e1e8c1 | |||
| 4545aeb9c6 | |||
| 84cf3e6a15 | |||
| 484f117b8e | |||
| 83889d7e3c | |||
| 2eb970a6a2 | |||
| e3a611f33d | |||
| 8fb2a9094e | |||
| d1e7154040 | |||
| e695b4e764 | |||
| b1eae7b768 | |||
| 55bb5b5a1c | |||
| e8232a9ea0 | |||
| aeabb99be6 | |||
| 38ee6d836d | |||
| 7bb4bd3da5 | |||
| 7524615671 | |||
| f5fe883d49 | |||
| bec6406216 | |||
| ef041f2702 | |||
| df0f15419e | |||
| dc531eaa37 | |||
| 1eaabd14bd | |||
| c9c8987cca | |||
| 06dc6ea23f | |||
| 8b3a76dfc5 | |||
| 60398210c7 | |||
| 486c7ef530 | |||
| 94131097a5 | |||
| 6d69e009dc | |||
| 6d9b132ab8 | |||
| efec1aff18 | |||
| 258d6d9a49 | |||
| 1c4b7c7b97 | |||
| 8bc6306813 | |||
| 2923c00738 | |||
| b30b6a062a | |||
| 8f5df889ab | |||
| 4ec8b19251 | |||
| 1035a94775 | |||
| 3ca2ae7175 | |||
| 4ba1ca890c | |||
| cba012bd15 | |||
| 9515ccd816 | |||
| 46622f5028 | |||
| 9190c8e5bf | |||
| 109498e2df | |||
| 60d7c395bc | |||
| 782d847e54 | |||
| d96e4019aa | |||
| 6b438bc4aa | |||
| 50d07f81fd | |||
| 7d69e64adc | |||
| c2fa6095cc | |||
| 0b8b72be5c | |||
| fd6f0967b0 | |||
| ca9698f75d | |||
| 968a5bd789 | |||
| 1fe4ee5b81 | |||
| 137aeac91a | |||
| ccb0b58a2d | |||
| 680123eb64 | |||
| aec04f0b8c | |||
| e75bbc0a22 | |||
| 81fc625c5d | |||
| f85683239f | |||
| c0f54c334e | |||
| 5c2d4e4718 | |||
| 64a0aa6157 | |||
| ff2e40d49a | |||
| 1226e7bee1 | |||
| 342203bb81 | |||
| e4bc526a09 | |||
| 5941bd4b68 | |||
| 1c95319608 | |||
| eeea948844 | |||
| 59bb0070e9 | |||
| ec2206ade0 | |||
| 7796f7d3bc | |||
| b806bf80b1 | |||
| 173ea58701 | |||
| 775b6ff4fd | |||
| b0f18461b3 | |||
| b8ccbfd222 | |||
| c2fa497137 | |||
| bdcfa6929c | |||
| 8470b58b60 | |||
| 002413c067 | |||
| ecce59e734 | |||
| 1935c76f30 | |||
| 81b7a3e665 | |||
| a68bf6fc8f | |||
| 459dd2d9c7 | |||
| fed4cc2a97 | |||
| 7eaa692712 | |||
| 691bae9a96 | |||
| d5a8c9b7d1 | |||
| 8c20e7c661 | |||
| 47a2d28c6a | |||
| 31f8961e27 | |||
| 424bd0bc28 | |||
| 9c078583dd | |||
| ca27048679 | |||
| 4e65663748 | |||
| c7c5cbde83 | |||
| a4905ad207 | |||
| bebf0e692a | |||
| 8ff9a87dfe | |||
| 62f2d8ac16 | |||
| 8fef2a6232 | |||
| 94064fe78c | |||
| 2ffcc43adc | |||
| 3846fce73a | |||
| ea950e9dbc | |||
| f2639c4ff1 | |||
| 32c1798eb8 | |||
| 75e3167b65 | |||
| ad07a61aa7 | |||
| c91b6329f3 | |||
| 9cc60efd5a | |||
| 08eeea6b9c | |||
| 8dea7335de | |||
| 2ad6d43422 | |||
| 12c2e7aefb | |||
| 6b62e46950 | |||
| 853c58e0a0 | |||
| eb0abc425a | |||
| c808e40bf6 | |||
| f0bbb14f3f | |||
| 95dd0ea6fb | |||
| 7f34102ae6 | |||
| 7623962da5 | |||
| cfb34b59df | |||
| e5004bb55e | |||
| c0193fdf73 | |||
| 6cbafd557c | |||
| ee8ab75907 | |||
| f2e93ad69e | |||
| 5faf3fd61c | |||
| 956a8f4864 | |||
| d26bc56b5c | |||
| 7457770ef8 | |||
| 54af9073cb | |||
| a8dcf5e8f5 | |||
| 9e3334d75f | |||
| cca6e71911 | |||
| 7fbd377ab2 | |||
| 24417feba3 | |||
| f8c24964e3 | |||
| 1ae2ebfaf0 | |||
| 4feea6d153 | |||
| ec6b658685 | |||
| fb0f05a08d | |||
| 11bc477f1f | |||
| 9760375855 | |||
| a6e20bd9f0 | |||
| 90fedbf9a2 | |||
| eb03262abc | |||
| 59eb6e5f1b | |||
| edf513aca9 | |||
| efed63519a | |||
| d78f781506 | |||
| 93fe269b09 | |||
| 8cad6c4e56 | |||
| f92049dc71 | |||
| a3497a9d39 | |||
| bfc0a2ed57 | |||
| c49b45d262 | |||
| 15678cf96a | |||
| feeaaa7f2b | |||
| 50df1a2212 | |||
| ac9254d049 | |||
| e15eeb36a5 | |||
| e275e03d4e | |||
| 41c8826ca8 | |||
| d8af31ba5b | |||
| 2eb7cb1687 | |||
| 207e75f5b9 | |||
| 7b9e1a71a3 | |||
| 345838c6ce | |||
| b02a60f4b3 | |||
| ecd3a4e490 | |||
| 8c0c9bd60a | |||
| 943a8bf02d | |||
| d3beb72652 | |||
| c62dd2014e | |||
| 62fee7827b | |||
| 80b9d16494 | |||
| cb5581c49f | |||
| 0098000ae0 | |||
| ddc8429499 | |||
| 0424961d46 | |||
| cbf510cfd1 | |||
| cbb44ae253 | |||
| 4dd4f045aa | |||
| ab0d7f8dc6 | |||
| 69f93fcb59 | |||
| de68e0d7c2 | |||
| cdbcb451e1 | |||
| 105c543a98 | |||
| ab421e3184 | |||
| d76b7a99b8 | |||
| e8dae63e05 | |||
| ea58b70435 | |||
| f90f6f364a | |||
| 7fc967c64c | |||
| 8969a229d1 | |||
| 9601e0428e | |||
| 94fd91ce4a | |||
| 310f972c7f | |||
| 4378a5843c | |||
| 9bd403ec51 | |||
| 2f53786ca9 | |||
| 07ed213c94 | |||
| 05a2eca9a7 | |||
| d30c836d04 | |||
| 8c623adad8 | |||
| 5191edfc0c | |||
| ff99663d5c | |||
| 360335a608 | |||
| 1c83e5eeab | |||
| 122ebb12f4 | |||
| fed242315d | |||
| 84e8e18ef8 | |||
| 36a1916b5f | |||
| a1089460d7 | |||
| c62f0dea6f | |||
| a6c121dc33 | |||
| c627c65a7d | |||
| 72006aff21 | |||
| 68338ebeff | |||
| 49b8503b64 | |||
| 0fc41df7e7 | |||
| bb82c52747 | |||
| a79367fb1c | |||
| 4dbc6db6f0 | |||
| a50cee62be | |||
| 382aa5cb16 | |||
| d1c2ff277b | |||
| 92b08b5550 | |||
| da85470fef | |||
| 9d1e7d94cc | |||
| 65438286ec | |||
| ffa7d27148 | |||
| 4a7d951d0d | |||
| 89f1911a6e | |||
| b990bd1792 | |||
| 88667416d8 | |||
| 216491012e | |||
| c88f3dcf75 | |||
| 6c3e21339d | |||
| e7f9f9f13d | |||
| 6b8d6da5be | |||
| 8c73c5d662 | |||
| f7dc2c9a9e | |||
| eadf825b67 | |||
| 150999d71b | |||
| 7cd89a594e | |||
| b67f1cb4b8 | |||
| 4678f8c7da | |||
| 0577f48437 | |||
| 0c079482f0 | |||
| 684fe3945d | |||
| d91d325744 | |||
| 040d7564ed | |||
| d1db34445e | |||
| 9639dd422a | |||
| f60bfe8c54 | |||
| fe53c11447 | |||
| 9bd17bdf6f | |||
| 4b64308951 | |||
| bb7dacea91 | |||
| 0a369621a3 | |||
| e0ee1a50ae | |||
| 6b49fc4294 | |||
| ed20ea6af4 | |||
| 73fe4dc7a0 | |||
| c4967de530 | |||
| bcf3d36ba1 | |||
| d52bd7f012 | |||
| e6232be244 | |||
| b33f313e2e | |||
| 0b4372fe88 | |||
| 4e07c7f2dc | |||
| 941e194df3 | |||
| 2b8f94f457 | |||
| 7ec8c0cea5 | |||
| c69384dabd | |||
| 8c92216a1d | |||
| 41537c0bad | |||
| c112f56b37 | |||
| f22de50527 | |||
| a22e08f39d | |||
| 210d470473 | |||
| 0eebb77438 | |||
| f819cb9c5f | |||
| 240963f1f3 | |||
| 16819d98fa | |||
| 8be7e0f0cb | |||
| 3a51daf51b | |||
| 7622e72b70 | |||
| b59173cac4 | |||
| 18411ee5bd | |||
| 6e1c6fab2d | |||
| 98eb2d8836 | |||
| 504e32f922 | |||
| c096054b1f | |||
| ac2f198851 | |||
| 9aed659f17 | |||
| 0b8f5d3b22 | |||
| 55c74e8891 | |||
| 3a49aa6a67 | |||
| 10770b6fe1 | |||
| c81ea08f42 | |||
| 73b6ab4a18 | |||
| 7497235d7b | |||
| 27191e4234 | |||
| 7b0110ce42 | |||
| 117a635a1e | |||
| 98c922fb3e | |||
| bf84d04f1f | |||
| f4e358b509 | |||
| 060ad7966e | |||
| f0301fd1a4 | |||
| ae8212a51d | |||
| 393a0d5cdc | |||
| 4cf43a8d74 | |||
| 74b2f47e3a | |||
| 1e727db09a | |||
| 1daa120d06 | |||
| a1d2445ae6 | |||
| 4d4e35e24b | |||
| 400cc599e3 | |||
| e55352346b | |||
| cca226dec0 | |||
| fec95c91f8 | |||
| 9955418a8e | |||
| 90c7539956 | |||
| a751e45602 | |||
| b50d388f9e | |||
| fd60292b5d | |||
| 4ebb0c432e | |||
| 897b2478e8 | |||
| b8ebb7f6c4 | |||
| f32dba72b4 | |||
| 498ad280e0 | |||
| 32358de718 | |||
| 2474a6ce01 | |||
| 1ba45200ee | |||
| da793856ce | |||
| d950588c36 | |||
| 2b4a5d2ce7 | |||
| 86daedc802 | |||
| 3788487196 | |||
| 25559b7e3e | |||
| 246db33ee6 | |||
| d435e9b58b | |||
| 09ecc79050 | |||
| 1914435707 | |||
| f6c237afc5 | |||
| a1f2579047 | |||
| 1ea6617a5d | |||
| 489175aa45 | |||
| cb72f43b03 | |||
| 4bbbcc7c39 | |||
| af1e4884b7 | |||
| 5213d6255a | |||
| a9af689aa5 | |||
| 407a9f7780 | |||
| a0ca667ca7 | |||
| c2f6f97c34 | |||
| 2daefbe2f4 | |||
| 84b0c9d4b7 | |||
| 0d848569f0 | |||
| 611f8397ca | |||
| 11ed0a1367 | |||
| ff51966fbb | |||
| 5491d51eba | |||
| 61a5a7e929 | |||
| 3de000bc94 | |||
| ef456e6ea0 | |||
| 2a8b67e22a | |||
| c35b66f6e1 | |||
| c8348dcaaa | |||
| e38174110e | |||
| a95130c01f | |||
| 0e93417090 | |||
| 07054bf55a | |||
| 368eab476a | |||
| 996679a2d2 | |||
| 85a6943cd5 | |||
| 0b96893f3b | |||
| 846e2e27ba | |||
| 43ea9b7696 | |||
| 9dd4df2ca9 | |||
| 2b4fb55526 | |||
| 72cf16301f | |||
| c512dde028 | |||
| 1e13c7ab31 | |||
| cdbab86dee | |||
| fec03d1fd4 | |||
| 6aa24e23c0 | |||
| 78770d1da5 | |||
| 6f72447e2e | |||
| cb75a15a6f | |||
| c3555237b3 | |||
| e4a2cc7ac8 | |||
| 3900d305b9 | |||
| cb3d501649 | |||
| 28323a486a | |||
| dfcad4b9fd | |||
| 6fb2869cd8 | |||
| e764e39ba9 | |||
| 128077dcbc | |||
| 1c51107f1e | |||
| d154cab054 | |||
| 7ed4368d5b | |||
| ee64df2376 | |||
| b13f03eb97 | |||
| 8d20829428 | |||
| 97401f609e | |||
| fe074729ea | |||
| db5141e010 | |||
| 4564fdc6aa | |||
| a477b36a57 | |||
| 3b8ae2c879 | |||
| ebe3a51398 | |||
| 76d22f0cb5 | |||
| c61d676dfb | |||
| b1913e7204 | |||
| b6609e0a14 | |||
| 55fa759344 | |||
| 8992a713cc | |||
| c55dcec252 | |||
| e3dd6cbef5 | |||
| dd3e5ea368 | |||
| ac2e77e0d6 | |||
| 9f57622f54 | |||
| cfed460eba | |||
| 06f97b671f | |||
| aebf83d735 | |||
| 31894dd117 | |||
| e041d802ec | |||
| 82ea15388c | |||
| bf9ed8ff00 | |||
| c02606df6a | |||
| 7372e2e385 | |||
| ba86fa6d3e | |||
| 0e434cbd1c | |||
| c89300022a | |||
| 1300756d6f | |||
| c4ad02ff92 | |||
| b3f47f140a | |||
| 2206b3d5b5 | |||
| b08f8a450d | |||
| 37c8be8a6e | |||
| ae58c265a0 | |||
| 54e6d1aa16 | |||
| 4ddb5f14d9 | |||
| 623aec495b | |||
| f6d2b9bad0 | |||
| 08b5a278f3 | |||
| f62b30b50d | |||
| 50e3b8e7d4 | |||
| e26956dbe8 | |||
| cff2c12d70 | |||
| 5781d532a4 | |||
| f161a593f8 | |||
| 5725d5a2fe | |||
| 23280fd97b | |||
| fe6679f16a | |||
| 19a95a3670 | |||
| 90cffb3791 | |||
| 31168fbeca | |||
| c4cce5d184 | |||
| 08b59dd082 | |||
| 4aaf1a5868 | |||
| 6e78fa0b1f | |||
| e1a42189a6 | |||
| 386e0c9b6b | |||
| 3b1b423936 | |||
| 8e8e8161bb | |||
| b368fde82d | |||
| 7267111083 | |||
| d05dab6633 | |||
| e1409a8045 | |||
| ae69fec7ce | |||
| a2862f22f6 | |||
| 7db8e18bcc | |||
| 0ffe1272fe | |||
| 92b54075c4 | |||
| ce5c679d6b | |||
| 4f61386b21 | |||
| 2738ae1abc | |||
| f5e43ff7b4 | |||
| 63c499bf2c | |||
| 9e72720bda | |||
| bbe10b2dab | |||
| f3b0784651 | |||
| 9c0ea9b1c7 | |||
| 620a088c6c | |||
| 867a74cffb | |||
| f2316fdd3a | |||
| 7d49d4f948 | |||
| f85b2b889c | |||
| 9471ac4a52 | |||
| db520c39e3 | |||
| cc59fbe2ba | |||
| e260af58f2 | |||
| 166fc6dad9 | |||
| 959433d737 | |||
| f9fa9ce6d8 | |||
| 6b3a41dfe0 | |||
| 37428ecca4 | |||
| 6934df253f | |||
| 00782598a4 | |||
| 565c500810 | |||
| e3c16166e6 | |||
| cfa8d1b689 | |||
| a19397f9b5 | |||
| ddfc80b45f | |||
| 8591f9b2a1 | |||
| cb26a55e65 | |||
| ef92394685 | |||
| d588ef438e | |||
| 09cd363b11 | |||
| 2d5c7fdbb5 | |||
| 2f0e28368d | |||
| f7f1a2a3b3 | |||
| 30afb85260 | |||
| 78d883a1b4 | |||
| 7913b673a3 | |||
| 5edc27297f | |||
| ebc24c2476 | |||
| ed7dd037e5 | |||
| 277924c04d | |||
| 26ea0feddb | |||
| 63c1eab930 | |||
| 813e7711df | |||
| 6c1f50a230 | |||
| 470b6359ba | |||
| 2f45233748 | |||
| 82fd52f572 | |||
| ed6331e6a4 | |||
| 2ae9188535 | |||
| 1a55a5394a | |||
| 99d2f37cfc | |||
| 09b531e0c1 | |||
| 232e872c0d | |||
| acdb0d2838 | |||
| bd0ea1379f | |||
| 5461ea1a3a | |||
| 200ee075b5 | |||
| 79e9e5fcf1 | |||
| a2df23d562 | |||
| 55af3d7f65 | |||
| ef54f3fe59 | |||
| 9d84ff6aa7 | |||
| ee26006f3c |
@@ -0,0 +1,143 @@
|
||||
title: "[Prompt] "
|
||||
labels:
|
||||
- custom-prompt
|
||||
- community
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Share Your Custom Prompt
|
||||
|
||||
Thank you for sharing your custom prompt with the community!
|
||||
Please fill in all the required fields so others can use your prompt effectively.
|
||||
|
||||
- type: input
|
||||
id: prompt-name
|
||||
attributes:
|
||||
label: Prompt Name
|
||||
description: A short descriptive name for your prompt
|
||||
placeholder: "e.g., Concise Technical Alerts"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: AI Provider
|
||||
description: Which AI provider did you test this prompt with?
|
||||
options:
|
||||
- OpenAI
|
||||
- Gemini (Google)
|
||||
- Anthropic (Claude)
|
||||
- Groq
|
||||
- OpenRouter
|
||||
- Ollama (Local)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: The specific model you tested with
|
||||
placeholder: "e.g., gpt-4o-mini, gemini-2.0-flash, llama3.2:3b"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: language
|
||||
attributes:
|
||||
label: Output Language
|
||||
description: What language does your prompt output?
|
||||
options:
|
||||
- English
|
||||
- Spanish
|
||||
- German
|
||||
- French
|
||||
- Italian
|
||||
- Portuguese
|
||||
- Dutch
|
||||
- Polish
|
||||
- Russian
|
||||
- Chinese
|
||||
- Japanese
|
||||
- Korean
|
||||
- Other (specify in description)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe what your prompt does and its main features
|
||||
placeholder: |
|
||||
This prompt generates concise, technical notifications focused on...
|
||||
|
||||
Features:
|
||||
- Brief format (2-3 lines)
|
||||
- Includes severity indicators
|
||||
- etc.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: prompt-content
|
||||
attributes:
|
||||
label: Prompt Content
|
||||
description: Paste your complete custom prompt here
|
||||
render: text
|
||||
placeholder: |
|
||||
You are a notification formatter for ProxMenux Monitor.
|
||||
|
||||
Your task is to...
|
||||
|
||||
RULES:
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
OUTPUT FORMAT:
|
||||
[TITLE]
|
||||
...
|
||||
[BODY]
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: example-output
|
||||
attributes:
|
||||
label: Example Output
|
||||
description: Show an example of how a notification looks with your prompt
|
||||
placeholder: |
|
||||
**Input notification:**
|
||||
CPU usage high on node pve01
|
||||
|
||||
**Output with this prompt:**
|
||||
pve01: High CPU Usage
|
||||
CPU at 95% for 5 minutes. Check running processes.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-notes
|
||||
attributes:
|
||||
label: Additional Notes
|
||||
description: Any tips, variations, or known limitations
|
||||
placeholder: |
|
||||
- Works best with models that support system prompts
|
||||
- May need adjustment for very long notifications
|
||||
- Tested with Proxmox VE 8.x
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: Confirmation
|
||||
options:
|
||||
- label: I have tested this prompt and it works correctly
|
||||
required: true
|
||||
- label: I am sharing this prompt for the community to use freely
|
||||
required: true
|
||||
@@ -3,26 +3,28 @@ import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
# ---------- Config ----------
|
||||
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
|
||||
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
|
||||
POCKETBASE_BASE = "https://db.community-scripts.org/api/collections"
|
||||
SCRIPT_COLLECTION_URL = f"{POCKETBASE_BASE}/script_scripts/records"
|
||||
CATEGORY_COLLECTION_URL = f"{POCKETBASE_BASE}/script_categories/records"
|
||||
|
||||
# Escribimos siempre en <raiz_repo>/json/helpers_cache.json, independientemente del cwd
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ----------------------------
|
||||
|
||||
TYPE_TO_PATH_PREFIX = {
|
||||
"lxc": "ct",
|
||||
"vm": "vm",
|
||||
"addon": "tools/addon",
|
||||
"pve": "tools/pve",
|
||||
}
|
||||
|
||||
|
||||
def to_mirror_url(raw_url: str) -> str:
|
||||
"""
|
||||
Convierte una URL raw de GitHub al raw del mirror.
|
||||
GH : https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/docker.sh
|
||||
MIR: https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/main/ct/docker.sh
|
||||
"""
|
||||
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", raw_url or "")
|
||||
if not m:
|
||||
return ""
|
||||
@@ -32,143 +34,202 @@ def to_mirror_url(raw_url: str) -> str:
|
||||
return f"https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/{branch}/{path}"
|
||||
|
||||
|
||||
def guess_os_from_script_path(script_path: str) -> str | None:
|
||||
"""
|
||||
Heurística suave cuando el JSON no publica resources.os:
|
||||
- tools/pve/* -> proxmox
|
||||
- ct/alpine-* -> alpine
|
||||
- tools/addon/* -> generic (suele ejecutarse sobre LXC existente)
|
||||
- ct/* -> debian (por defecto para CTs)
|
||||
"""
|
||||
if not script_path:
|
||||
return None
|
||||
if script_path.startswith("tools/pve/") or script_path == "tools/pve/host-backup.sh" or script_path.startswith("vm/"):
|
||||
return "proxmox"
|
||||
if "/alpine-" in script_path or script_path.startswith("ct/alpine-"):
|
||||
return "alpine"
|
||||
if script_path.startswith("tools/addon/"):
|
||||
return "generic"
|
||||
if script_path.startswith("ct/"):
|
||||
return "debian"
|
||||
return None
|
||||
|
||||
|
||||
def fetch_directory_json(api_url: str) -> list[dict]:
|
||||
r = requests.get(api_url, timeout=30)
|
||||
def fetch_json(url: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
r = requests.get(url, params=params, timeout=60)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError("GitHub API no devolvió una lista.")
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"Unexpected response from {url}: expected object")
|
||||
return data
|
||||
|
||||
|
||||
def fetch_all_records(url: str, *, expand: str | None = None, per_page: int = 500) -> list[dict[str, Any]]:
|
||||
page = 1
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
while True:
|
||||
params: dict[str, Any] = {"page": page, "perPage": per_page}
|
||||
if expand:
|
||||
params["expand"] = expand
|
||||
|
||||
data = fetch_json(url, params=params)
|
||||
page_items = data.get("items", [])
|
||||
if not isinstance(page_items, list):
|
||||
raise RuntimeError(f"Unexpected items list from {url}")
|
||||
|
||||
items.extend(page_items)
|
||||
|
||||
total_pages = data.get("totalPages", page)
|
||||
if not isinstance(total_pages, int) or page >= total_pages:
|
||||
break
|
||||
page += 1
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def normalize_os_variants(install_methods_json: list[dict[str, Any]]) -> list[str]:
|
||||
os_values: list[str] = []
|
||||
for item in install_methods_json:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
resources = item.get("resources", {})
|
||||
if not isinstance(resources, dict):
|
||||
continue
|
||||
os_name = resources.get("os")
|
||||
if isinstance(os_name, str) and os_name.strip():
|
||||
normalized = os_name.strip().lower()
|
||||
if normalized not in os_values:
|
||||
os_values.append(normalized)
|
||||
return os_values
|
||||
|
||||
|
||||
def build_script_path(type_name: str, slug: str) -> str:
|
||||
type_name = (type_name or "").strip().lower()
|
||||
slug = (slug or "").strip()
|
||||
|
||||
if type_name == "turnkey":
|
||||
return "turnkey/turnkey.sh"
|
||||
|
||||
prefix = TYPE_TO_PATH_PREFIX.get(type_name)
|
||||
if not prefix or not slug:
|
||||
return ""
|
||||
|
||||
return f"{prefix}/{slug}.sh"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
directory = fetch_directory_json(API_URL)
|
||||
scripts = fetch_all_records(SCRIPT_COLLECTION_URL, expand="type,categories")
|
||||
categories = fetch_all_records(CATEGORY_COLLECTION_URL)
|
||||
except Exception as e:
|
||||
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
|
||||
print(f"ERROR: Unable to fetch PocketBase data: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cache: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set() # (slug, script) para evitar duplicados
|
||||
category_map: dict[str, dict[str, Any]] = {}
|
||||
for category in categories:
|
||||
category_id = category.get("id")
|
||||
if isinstance(category_id, str) and category_id:
|
||||
category_map[category_id] = category
|
||||
|
||||
total_items = len(directory)
|
||||
processed = 0
|
||||
kept = 0
|
||||
cache: list[dict[str, Any]] = []
|
||||
|
||||
for item in directory:
|
||||
url = item.get("download_url")
|
||||
name_in_dir = item.get("name", "")
|
||||
if not url or not url.endswith(".json"):
|
||||
print(f"Fetched {len(scripts)} scripts and {len(category_map)} categories")
|
||||
|
||||
for idx, raw in enumerate(scripts, start=1):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
|
||||
try:
|
||||
raw = requests.get(url, timeout=30).json()
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
except Exception:
|
||||
print(f"❌ Error al obtener/parsing {name_in_dir}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
|
||||
name = raw.get("name", "")
|
||||
slug = raw.get("slug")
|
||||
type_ = raw.get("type", "")
|
||||
name = raw.get("name", "")
|
||||
desc = raw.get("description", "")
|
||||
categories = raw.get("categories", [])
|
||||
notes = [n.get("text", "") for n in raw.get("notes", []) if isinstance(n, dict)]
|
||||
|
||||
# Credenciales (si existen, se copian tal cual)
|
||||
credentials = raw.get("default_credentials", {})
|
||||
cred_username = credentials.get("username") if isinstance(credentials, dict) else None
|
||||
cred_password = credentials.get("password") if isinstance(credentials, dict) else None
|
||||
add_credentials = any([
|
||||
cred_username not in (None, ""),
|
||||
cred_password not in (None, "")
|
||||
])
|
||||
|
||||
install_methods = raw.get("install_methods", [])
|
||||
if not isinstance(install_methods, list) or not install_methods:
|
||||
# Sin install_methods válidos -> continuamos
|
||||
if not isinstance(slug, str) or not slug.strip():
|
||||
continue
|
||||
|
||||
for im in install_methods:
|
||||
if not isinstance(im, dict):
|
||||
continue
|
||||
script = im.get("script", "")
|
||||
if not script:
|
||||
continue
|
||||
expand = raw.get("expand", {}) if isinstance(raw.get("expand"), dict) else {}
|
||||
type_expanded = expand.get("type", {}) if isinstance(expand.get("type"), dict) else {}
|
||||
type_name = type_expanded.get("type", "") if isinstance(type_expanded.get("type"), str) else ""
|
||||
|
||||
# OS desde resources u heurística
|
||||
resources = im.get("resources", {}) if isinstance(im, dict) else {}
|
||||
os_name = resources.get("os") if isinstance(resources, dict) else None
|
||||
if not os_name:
|
||||
os_name = guess_os_from_script_path(script)
|
||||
if isinstance(os_name, str):
|
||||
os_name = os_name.strip().lower()
|
||||
script_path = build_script_path(type_name, slug)
|
||||
if not script_path:
|
||||
print(f"[{idx:03d}] WARNING: Unable to build script path for slug={slug} type={type_name!r}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
full_script_url = f"{SCRIPT_BASE}/{script}"
|
||||
script_url_mirror = to_mirror_url(full_script_url)
|
||||
full_script_url = f"{SCRIPT_BASE}/{script_path}"
|
||||
script_url_mirror = to_mirror_url(full_script_url)
|
||||
|
||||
key = (slug or "", script)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
install_methods_json = raw.get("install_methods_json", [])
|
||||
if not isinstance(install_methods_json, list):
|
||||
install_methods_json = []
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script,
|
||||
"script_url": full_script_url,
|
||||
"script_url_mirror": script_url_mirror, # nuevo
|
||||
"os": os_name, # nuevo
|
||||
"categories": categories,
|
||||
"notes": notes,
|
||||
"type": type_,
|
||||
notes_json = raw.get("notes_json", [])
|
||||
if not isinstance(notes_json, list):
|
||||
notes_json = []
|
||||
|
||||
notes = [
|
||||
note.get("text", "")
|
||||
for note in notes_json
|
||||
if isinstance(note, dict) and isinstance(note.get("text"), str) and note.get("text", "").strip()
|
||||
]
|
||||
|
||||
category_ids = raw.get("categories", [])
|
||||
if not isinstance(category_ids, list):
|
||||
category_ids = []
|
||||
|
||||
expanded_categories = expand.get("categories", []) if isinstance(expand.get("categories"), list) else []
|
||||
category_names: list[str] = []
|
||||
for cat in expanded_categories:
|
||||
if isinstance(cat, dict):
|
||||
cat_name = cat.get("name")
|
||||
if isinstance(cat_name, str) and cat_name.strip():
|
||||
category_names.append(cat_name.strip())
|
||||
|
||||
if not category_names:
|
||||
for cat_id in category_ids:
|
||||
cat = category_map.get(cat_id, {})
|
||||
cat_name = cat.get("name")
|
||||
if isinstance(cat_name, str) and cat_name.strip():
|
||||
category_names.append(cat_name.strip())
|
||||
|
||||
# Shared fields across all install method entries
|
||||
default_user = raw.get("default_user")
|
||||
default_passwd = raw.get("default_passwd")
|
||||
default_credentials: dict[str, str] | None = None
|
||||
if (isinstance(default_user, str) and default_user.strip()) or (isinstance(default_passwd, str) and default_passwd.strip()):
|
||||
default_credentials = {
|
||||
"username": default_user if isinstance(default_user, str) else "",
|
||||
"password": default_passwd if isinstance(default_passwd, str) else "",
|
||||
}
|
||||
if add_credentials:
|
||||
entry["default_credentials"] = {
|
||||
"username": cred_username,
|
||||
"password": cred_password,
|
||||
}
|
||||
|
||||
base_entry: dict[str, Any] = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script_path,
|
||||
"script_url": full_script_url,
|
||||
"script_url_mirror": script_url_mirror,
|
||||
"type": type_name,
|
||||
"type_id": raw.get("type", ""),
|
||||
"categories": category_ids,
|
||||
"category_names": category_names,
|
||||
"notes": notes,
|
||||
"port": raw.get("port", 0),
|
||||
"website": raw.get("website", ""),
|
||||
"documentation": raw.get("documentation", ""),
|
||||
"logo": raw.get("logo", ""),
|
||||
"updateable": bool(raw.get("updateable", False)),
|
||||
"privileged": bool(raw.get("privileged", False)),
|
||||
"has_arm": bool(raw.get("has_arm", False)),
|
||||
"is_dev": bool(raw.get("is_dev", False)),
|
||||
"execute_in": raw.get("execute_in", []),
|
||||
"config_path": raw.get("config_path", ""),
|
||||
}
|
||||
if default_credentials:
|
||||
base_entry["default_credentials"] = default_credentials
|
||||
|
||||
# Emit one entry per install method so the menu shell can offer an
|
||||
# explicit OS choice. When there is only one method (or none), a
|
||||
# single entry is emitted with os="" (script decides at runtime).
|
||||
os_variants = normalize_os_variants(install_methods_json)
|
||||
|
||||
if len(os_variants) > 1:
|
||||
for os_name in os_variants:
|
||||
entry = {**base_entry, "os": os_name}
|
||||
cache.append(entry)
|
||||
print(f"[{len(cache):03d}] {slug:<24} → {script_path:<28} type={type_name:<7} os={os_name}")
|
||||
else:
|
||||
os_name = os_variants[0] if os_variants else ""
|
||||
entry = {**base_entry, "os": os_name}
|
||||
cache.append(entry)
|
||||
kept += 1
|
||||
print(f"[{len(cache):03d}] {slug:<24} → {script_path:<28} type={type_name:<7} os={os_name or 'n/a'}")
|
||||
|
||||
# Progreso ligero
|
||||
print(f"[{kept:03d}] {slug or name:<24} → {script:<28} os={os_name or 'n/a'} src={'GH+MR' if script_url_mirror else 'GH'}")
|
||||
|
||||
# Orden estable para commits reproducibles
|
||||
cache.sort(key=lambda x: (x.get("slug") or "", x.get("script") or ""))
|
||||
|
||||
with OUTPUT_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✅ helpers_cache.json → {OUTPUT_FILE}")
|
||||
print(f" Total JSON en índice: {total_items}")
|
||||
print(f" Procesados: {processed} | Guardados: {kept} | Únicos (slug,script): {len(seen)}")
|
||||
print(f" Guardados: {len(cache)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# ---------- Config ----------
|
||||
# API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE/contents/frontend/public/json"
|
||||
API_URL = "https://api.github.com/repos/community-scripts/ProxmoxVE-Frontend-Archive/contents/public/json"
|
||||
SCRIPT_BASE = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main"
|
||||
|
||||
# Escribimos siempre en <raiz_repo>/json/helpers_cache.json, independientemente del cwd
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
OUTPUT_FILE = REPO_ROOT / "json" / "helpers_cache.json"
|
||||
OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ----------------------------
|
||||
|
||||
|
||||
def to_mirror_url(raw_url: str) -> str:
|
||||
"""
|
||||
Convierte una URL raw de GitHub al raw del mirror.
|
||||
GH : https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/docker.sh
|
||||
MIR: https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/main/ct/docker.sh
|
||||
"""
|
||||
m = re.match(r"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$", raw_url or "")
|
||||
if not m:
|
||||
return ""
|
||||
org, repo, branch, path = m.groups()
|
||||
if org.lower() != "community-scripts" or repo != "ProxmoxVE":
|
||||
return ""
|
||||
return f"https://git.community-scripts.org/community-scripts/ProxmoxVE/raw/branch/{branch}/{path}"
|
||||
|
||||
|
||||
def guess_os_from_script_path(script_path: str) -> str | None:
|
||||
"""
|
||||
Heurística suave cuando el JSON no publica resources.os:
|
||||
- tools/pve/* -> proxmox
|
||||
- ct/alpine-* -> alpine
|
||||
- tools/addon/* -> generic (suele ejecutarse sobre LXC existente)
|
||||
- ct/* -> debian (por defecto para CTs)
|
||||
"""
|
||||
if not script_path:
|
||||
return None
|
||||
if script_path.startswith("tools/pve/") or script_path == "tools/pve/host-backup.sh" or script_path.startswith("vm/"):
|
||||
return "proxmox"
|
||||
if "/alpine-" in script_path or script_path.startswith("ct/alpine-"):
|
||||
return "alpine"
|
||||
if script_path.startswith("tools/addon/"):
|
||||
return "generic"
|
||||
if script_path.startswith("ct/"):
|
||||
return "debian"
|
||||
return None
|
||||
|
||||
|
||||
def fetch_directory_json(api_url: str) -> list[dict]:
|
||||
r = requests.get(api_url, timeout=30)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if not isinstance(data, list):
|
||||
raise RuntimeError("GitHub API no devolvió una lista.")
|
||||
return data
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
directory = fetch_directory_json(API_URL)
|
||||
except Exception as e:
|
||||
print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cache: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set() # (slug, script) para evitar duplicados
|
||||
|
||||
total_items = len(directory)
|
||||
processed = 0
|
||||
kept = 0
|
||||
|
||||
for item in directory:
|
||||
url = item.get("download_url")
|
||||
name_in_dir = item.get("name", "")
|
||||
if not url or not url.endswith(".json"):
|
||||
continue
|
||||
|
||||
try:
|
||||
raw = requests.get(url, timeout=30).json()
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
except Exception:
|
||||
print(f"❌ Error al obtener/parsing {name_in_dir}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
|
||||
name = raw.get("name", "")
|
||||
slug = raw.get("slug")
|
||||
type_ = raw.get("type", "")
|
||||
desc = raw.get("description", "")
|
||||
categories = raw.get("categories", [])
|
||||
notes = [n.get("text", "") for n in raw.get("notes", []) if isinstance(n, dict)]
|
||||
|
||||
# Credenciales (si existen, se copian tal cual)
|
||||
credentials = raw.get("default_credentials", {})
|
||||
cred_username = credentials.get("username") if isinstance(credentials, dict) else None
|
||||
cred_password = credentials.get("password") if isinstance(credentials, dict) else None
|
||||
add_credentials = any([
|
||||
cred_username not in (None, ""),
|
||||
cred_password not in (None, "")
|
||||
])
|
||||
|
||||
install_methods = raw.get("install_methods", [])
|
||||
if not isinstance(install_methods, list) or not install_methods:
|
||||
# Sin install_methods válidos -> continuamos
|
||||
continue
|
||||
|
||||
for im in install_methods:
|
||||
if not isinstance(im, dict):
|
||||
continue
|
||||
script = im.get("script", "")
|
||||
if not script:
|
||||
continue
|
||||
|
||||
# OS desde resources u heurística
|
||||
resources = im.get("resources", {}) if isinstance(im, dict) else {}
|
||||
os_name = resources.get("os") if isinstance(resources, dict) else None
|
||||
if not os_name:
|
||||
os_name = guess_os_from_script_path(script)
|
||||
if isinstance(os_name, str):
|
||||
os_name = os_name.strip().lower()
|
||||
|
||||
full_script_url = f"{SCRIPT_BASE}/{script}"
|
||||
script_url_mirror = to_mirror_url(full_script_url)
|
||||
|
||||
key = (slug or "", script)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
entry = {
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"desc": desc,
|
||||
"script": script,
|
||||
"script_url": full_script_url,
|
||||
"script_url_mirror": script_url_mirror, # nuevo
|
||||
"os": os_name, # nuevo
|
||||
"categories": categories,
|
||||
"notes": notes,
|
||||
"type": type_,
|
||||
}
|
||||
if add_credentials:
|
||||
entry["default_credentials"] = {
|
||||
"username": cred_username,
|
||||
"password": cred_password,
|
||||
}
|
||||
|
||||
cache.append(entry)
|
||||
kept += 1
|
||||
|
||||
# Progreso ligero
|
||||
print(f"[{kept:03d}] {slug or name:<24} → {script:<28} os={os_name or 'n/a'} src={'GH+MR' if script_url_mirror else 'GH'}")
|
||||
|
||||
# Orden estable para commits reproducibles
|
||||
cache.sort(key=lambda x: (x.get("slug") or "", x.get("script") or ""))
|
||||
|
||||
with OUTPUT_FILE.open("w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✅ helpers_cache.json → {OUTPUT_FILE}")
|
||||
print(f" Total JSON en índice: {total_items}")
|
||||
print(f" Procesados: {processed} | Guardados: {kept} | Únicos (slug,script): {len(seen)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+24
-22
@@ -1,24 +1,29 @@
|
||||
name: Build ProxMenux Monitor AppImage
|
||||
name: Build AppImage Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
@@ -45,13 +50,6 @@ jobs:
|
||||
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: |
|
||||
@@ -60,22 +58,26 @@ jobs:
|
||||
echo "Generated SHA256:"
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage and checksum to /AppImage folder in main
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: |
|
||||
AppImage/dist/*.AppImage
|
||||
AppImage/dist/*.sha256
|
||||
retention-days: 30
|
||||
|
||||
- name: Commit AppImage to main
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
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 commit -m "Update AppImage release build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin main
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Build AppImage Beta
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: develop
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Build Next.js app
|
||||
working-directory: AppImage
|
||||
run: npm run build
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3 python3-pip python3-venv
|
||||
|
||||
- name: Make build script executable
|
||||
working-directory: AppImage
|
||||
run: chmod +x scripts/build_appimage.sh
|
||||
|
||||
- name: Build AppImage
|
||||
working-directory: AppImage
|
||||
run: ./scripts/build_appimage.sh
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
working-directory: AppImage
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate SHA256 checksum
|
||||
run: |
|
||||
cd AppImage/dist
|
||||
sha256sum *.AppImage > ProxMenux-Monitor.AppImage.sha256
|
||||
echo "Generated SHA256:"
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-beta-AppImage
|
||||
path: |
|
||||
AppImage/dist/*.AppImage
|
||||
AppImage/dist/*.sha256
|
||||
retention-days: 30
|
||||
|
||||
- name: Commit AppImage to develop
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
rm -f AppImage/*.AppImage AppImage/*.sha256 || true
|
||||
cp AppImage/dist/*.AppImage AppImage/
|
||||
cp AppImage/dist/ProxMenux-Monitor.AppImage.sha256 AppImage/
|
||||
|
||||
git add AppImage/*.AppImage AppImage/*.sha256
|
||||
git commit -m "Update AppImage beta build ($(date +'%Y-%m-%d %H:%M:%S'))" || echo "No changes to commit"
|
||||
git push origin develop
|
||||
@@ -9,18 +9,21 @@ on:
|
||||
paths: [ 'AppImage/**' ]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: AppImage
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
|
||||
@@ -21,7 +21,6 @@ A modern, responsive dashboard for monitoring Proxmox VE systems built with Next
|
||||
- [Integration Examples](#integration-examples)
|
||||
- [Homepage Integration](#homepage-integration)
|
||||
- [Home Assistant Integration](#home-assistant-integration)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
@@ -43,35 +42,6 @@ Get a quick overview of ProxMenux Monitor's main features:
|
||||
<em>System Overview - Monitor CPU, memory, temperature, and uptime in real-time</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen2.png" alt="Storage Management" width="800"/>
|
||||
<br/>
|
||||
<em>Storage Management - Visual representation of disk usage and health</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen3.png" alt="Network Monitoring" width="800"/>
|
||||
<br/>
|
||||
<em>Network Monitoring - Real-time traffic graphs and interface statistics</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen4.png" alt="Virtual Machines & LXC" width="800"/>
|
||||
<br/>
|
||||
<em>VMs & LXC Containers - Comprehensive view with resource usage and controls</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen5.png" alt="Hardware Information" width="800"/>
|
||||
<br/>
|
||||
<em>Hardware Information - Detailed specs for CPU, GPU, and PCIe devices</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="public/images/onboarding/imagen6.png" alt="System Logs" width="800"/>
|
||||
<br/>
|
||||
<em>System Logs - Real-time monitoring with filtering and search</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
@@ -122,17 +92,6 @@ ProxMenux Monitor includes built-in support for reverse proxy configurations. If
|
||||
- Adjust API endpoints to work correctly through the proxy
|
||||
- Maintain full functionality for all features including authentication and API access
|
||||
|
||||
**Example Nginx configuration:**
|
||||
```nginx
|
||||
location /proxmenux-monitor/ {
|
||||
proxy_pass http://localhost:8008/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Authentication & Security
|
||||
@@ -770,42 +729,7 @@ entities:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit issues, feature requests, or pull requests.
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. Clone the repository
|
||||
2. Install dependencies: `npm install`
|
||||
3. Run development server: `npm run dev`
|
||||
4. Build AppImage: `./build_appimage.sh`
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the **Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)**.
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format
|
||||
- Adapt — remix, transform, and build upon the material
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made
|
||||
- NonCommercial — You may not use the material for commercial purposes
|
||||
|
||||
For more details, see the [full license](https://creativecommons.org/licenses/by-nc/4.0/).
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For support, feature requests, or bug reports, please visit:
|
||||
- GitHub Issues: [github.com/your-repo/issues](https://github.com/your-repo/issues)
|
||||
- Documentation: [github.com/your-repo/wiki](https://github.com/your-repo/wiki)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -144,3 +144,22 @@
|
||||
stroke: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Ajustes para xterm.js */
|
||||
/* ===================== */
|
||||
|
||||
/* Quitar padding para que la terminal ocupe el 100% del ancho */
|
||||
.xterm {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Por si acaso el viewport añade padding extra */
|
||||
.xterm .xterm-viewport {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Opcional: asegurar que no haya margen raro */
|
||||
.xterm-rows {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent } from "./ui/dialog"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Shield, Lock, User, AlertCircle } from "lucide-react"
|
||||
import { Shield, Lock, User, AlertCircle, Eye, EyeOff } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
|
||||
interface AuthSetupProps {
|
||||
@@ -20,6 +20,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkOnboardingStatus = async () => {
|
||||
@@ -135,6 +137,9 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogTitle className="sr-only">
|
||||
{step === "choice" ? "Setup Dashboard Protection" : "Create Password"}
|
||||
</DialogTitle>
|
||||
{step === "choice" ? (
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -210,7 +215,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -218,6 +223,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
disabled={loading}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,7 +242,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
@@ -237,6 +250,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
disabled={loading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
disabled={loading}
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+157
-111
@@ -5,19 +5,21 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Thermometer,
|
||||
CpuIcon,
|
||||
Zap,
|
||||
HardDrive,
|
||||
Network,
|
||||
FanIcon,
|
||||
PowerIcon,
|
||||
Battery,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
Cpu as Gpu,
|
||||
HardDrive,
|
||||
Thermometer,
|
||||
Zap,
|
||||
Loader2,
|
||||
CpuIcon,
|
||||
Cpu as Gpu,
|
||||
Network,
|
||||
MemoryStick,
|
||||
PowerIcon,
|
||||
FanIcon,
|
||||
Battery,
|
||||
} from "lucide-react"
|
||||
import { Download } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import useSWR from "swr"
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
fetcher as swrFetcher,
|
||||
} from "../types/hardware"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
|
||||
const parseLsblkSize = (sizeStr: string | undefined): number => {
|
||||
if (!sizeStr) return 0
|
||||
@@ -236,6 +239,8 @@ export default function Hardware() {
|
||||
const [selectedDisk, setSelectedDisk] = useState<StorageDevice | null>(null)
|
||||
const [selectedNetwork, setSelectedNetwork] = useState<PCIDevice | null>(null)
|
||||
const [selectedUPS, setSelectedUPS] = useState<any>(null)
|
||||
const [showNvidiaInstaller, setShowNvidiaInstaller] = useState(false)
|
||||
const [installingNvidiaDriver, setInstallingNvidiaDriver] = useState(false)
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
const data = await fetchApi(url)
|
||||
@@ -246,12 +251,17 @@ export default function Hardware() {
|
||||
data: hardwareDataSWR,
|
||||
error: swrError,
|
||||
isLoading: swrLoading,
|
||||
mutate,
|
||||
mutate: mutateHardware,
|
||||
} = useSWR<HardwareData>("/api/hardware", fetcher, {
|
||||
refreshInterval: 30000,
|
||||
revalidateOnFocus: false,
|
||||
})
|
||||
|
||||
const handleInstallNvidiaDriver = () => {
|
||||
console.log("[v0] Opening NVIDIA installer terminal")
|
||||
setShowNvidiaInstaller(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGPU) return
|
||||
|
||||
@@ -778,13 +788,7 @@ export default function Hardware() {
|
||||
)}
|
||||
|
||||
{/* GPU Detail Modal - Shows immediately with basic info, then loads real-time data */}
|
||||
<Dialog
|
||||
open={selectedGPU !== null}
|
||||
onOpenChange={() => {
|
||||
setSelectedGPU(null)
|
||||
setRealtimeGPUData(null)
|
||||
}}
|
||||
>
|
||||
<Dialog open={!!selectedGPU} onOpenChange={(open) => !open && setSelectedGPU(null)}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
{selectedGPU && (
|
||||
<>
|
||||
@@ -1090,11 +1094,22 @@ export default function Hardware() {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-blue-500 mb-1">Extended Monitoring Not Available</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{getMonitoringToolRecommendation(selectedGPU.vendor)}
|
||||
</p>
|
||||
{selectedGPU.vendor.toLowerCase().includes("nvidia") && (
|
||||
<Button
|
||||
onClick={handleInstallNvidiaDriver}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Install NVIDIA Drivers
|
||||
</>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1105,92 +1120,6 @@ export default function Hardware() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* PCI Devices - Changed to modal */}
|
||||
{hardwareDataSWR?.pci_devices && hardwareDataSWR.pci_devices.length > 0 && (
|
||||
<Card className="border-border/50 bg-card/50 p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<CpuIcon className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">PCI Devices</h2>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{hardwareDataSWR.pci_devices.length} devices
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{hardwareDataSWR.pci_devices.map((device, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => setSelectedPCIDevice(device)}
|
||||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{device.type}</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
|
||||
</div>
|
||||
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{device.vendor}</p>
|
||||
{device.driver && (
|
||||
<p className="mt-1 font-mono text-xs text-green-500 truncate">Driver: {device.driver}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* PCI Device Detail Modal */}
|
||||
<Dialog open={selectedPCIDevice !== null} onOpenChange={() => setSelectedPCIDevice(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedPCIDevice?.device}</DialogTitle>
|
||||
<DialogDescription>PCI Device Information</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedPCIDevice && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Device Type</span>
|
||||
<Badge className={getDeviceTypeColor(selectedPCIDevice.type)}>{selectedPCIDevice.type}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">PCI Slot</span>
|
||||
<span className="font-mono text-sm">{selectedPCIDevice.slot}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Device Name</span>
|
||||
<span className="text-sm text-right">{selectedPCIDevice.device}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Vendor</span>
|
||||
<span className="text-sm">{selectedPCIDevice.vendor}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Class</span>
|
||||
<span className="font-mono text-sm">{selectedPCIDevice.class}</span>
|
||||
</div>
|
||||
|
||||
{selectedPCIDevice.driver && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Driver</span>
|
||||
<span className="font-mono text-sm text-green-500">{selectedPCIDevice.driver}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPCIDevice.kernel_module && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Kernel Module</span>
|
||||
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Power Consumption */}
|
||||
{hardwareDataSWR?.power_meter && (
|
||||
<Card className="border-border/50 bg-card/50 p-6">
|
||||
@@ -1525,31 +1454,31 @@ export default function Hardware() {
|
||||
<div className="grid gap-2">
|
||||
{selectedUPS.manufacturer && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm text-muted-foreground">Manufacturer</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">Manufacturer</span>
|
||||
<span className="text-sm font-medium">{selectedUPS.manufacturer}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedUPS.model && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm text-muted-foreground">Model</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">Model</span>
|
||||
<span className="text-sm font-medium">{selectedUPS.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedUPS.serial && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm text-muted-foreground">Serial Number</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">Serial Number</span>
|
||||
<span className="font-mono text-sm">{selectedUPS.serial}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedUPS.firmware && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm text-muted-foreground">Firmware</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">Firmware</span>
|
||||
<span className="text-sm font-medium">{selectedUPS.firmware}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedUPS.driver && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm text-muted-foreground">Driver</span>
|
||||
<span className="text-sm font-medium text-muted-foreground">Driver</span>
|
||||
<span className="font-mono text-sm text-green-500">{selectedUPS.driver}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1561,6 +1490,92 @@ export default function Hardware() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* PCI Devices - Changed to modal */}
|
||||
{hardwareDataSWR?.pci_devices && hardwareDataSWR.pci_devices.length > 0 && (
|
||||
<Card className="border-border/50 bg-card/50 p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<CpuIcon className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">PCI Devices</h2>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{hardwareDataSWR.pci_devices.length} devices
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{hardwareDataSWR.pci_devices.map((device, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => setSelectedPCIDevice(device)}
|
||||
className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<Badge className={`${getDeviceTypeColor(device.type)} text-xs shrink-0`}>{device.type}</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground shrink-0">{device.slot}</span>
|
||||
</div>
|
||||
<p className="font-medium text-sm line-clamp-2 break-words">{device.device}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{device.vendor}</p>
|
||||
{device.driver && (
|
||||
<p className="mt-1 font-mono text-xs text-green-500 truncate">Driver: {device.driver}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* PCI Device Detail Modal */}
|
||||
<Dialog open={selectedPCIDevice !== null} onOpenChange={() => setSelectedPCIDevice(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedPCIDevice?.device}</DialogTitle>
|
||||
<DialogDescription>PCI Device Information</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedPCIDevice && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Device Type</span>
|
||||
<Badge className={getDeviceTypeColor(selectedPCIDevice.type)}>{selectedPCIDevice.type}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">PCI Slot</span>
|
||||
<span className="font-mono text-sm">{selectedPCIDevice.slot}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Device Name</span>
|
||||
<span className="text-sm text-right">{selectedPCIDevice.device}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Vendor</span>
|
||||
<span className="text-sm">{selectedPCIDevice.vendor}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Class</span>
|
||||
<span className="font-mono text-sm">{selectedPCIDevice.class}</span>
|
||||
</div>
|
||||
|
||||
{selectedPCIDevice.driver && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Driver</span>
|
||||
<span className="font-mono text-sm text-green-500">{selectedPCIDevice.driver}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPCIDevice.kernel_module && (
|
||||
<div className="flex justify-between border-b border-border/50 pb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Kernel Module</span>
|
||||
<span className="font-mono text-sm">{selectedPCIDevice.kernel_module}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Network Summary - Clickable */}
|
||||
{hardwareDataSWR?.pci_devices &&
|
||||
hardwareDataSWR.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length > 0 && (
|
||||
@@ -2006,6 +2021,37 @@ export default function Hardware() {
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* NVIDIA Installation Monitor */}
|
||||
{/* <HybridScriptMonitor
|
||||
sessionId={nvidiaSessionId}
|
||||
title="NVIDIA Driver Installation"
|
||||
description="Installing NVIDIA proprietary drivers for GPU monitoring..."
|
||||
onClose={() => {
|
||||
setNvidiaSessionId(null)
|
||||
mutateHardware()
|
||||
}}
|
||||
onComplete={(success) => {
|
||||
console.log("[v0] NVIDIA installation completed:", success ? "success" : "failed")
|
||||
if (success) {
|
||||
mutateHardware()
|
||||
}
|
||||
}}
|
||||
/> */}
|
||||
<ScriptTerminalModal
|
||||
open={showNvidiaInstaller}
|
||||
onClose={() => {
|
||||
setShowNvidiaInstaller(false)
|
||||
mutateHardware()
|
||||
}}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/gpu_tpu/nvidia_installer.sh"
|
||||
scriptName="nvidia_installer"
|
||||
params={{
|
||||
EXECUTION_MODE: "web",
|
||||
}}
|
||||
title="NVIDIA Driver Installation"
|
||||
description="Installing NVIDIA proprietary drivers for GPU monitoring..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface CategoryCheck {
|
||||
status: string
|
||||
reason?: string
|
||||
details?: any
|
||||
dismissable?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -315,6 +316,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<div className="mt-2 space-y-1">
|
||||
{Object.entries(details).map(([detailKey, detailValue]: [string, any]) => {
|
||||
if (typeof detailValue === "object" && detailValue !== null) {
|
||||
const isDismissable = detailValue.dismissable !== false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={detailKey}
|
||||
@@ -326,7 +329,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<span className="ml-1 text-muted-foreground">{detailValue.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
{(status === "WARNING" || status === "CRITICAL") && (
|
||||
{(status === "WARNING" || status === "CRITICAL") && isDismissable && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -237,7 +237,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.0.1</p>
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.0.2</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { Card, CardContent } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Wifi, Zap } from "lucide-react"
|
||||
import { Wifi, Zap } from 'lucide-react'
|
||||
import { useState, useEffect } from "react"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
|
||||
interface NetworkCardProps {
|
||||
interface_: {
|
||||
@@ -59,39 +60,37 @@ const getVMTypeBadge = (vmType: string | undefined) => {
|
||||
return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20", label: "Unknown" }
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number | undefined): string => {
|
||||
if (!bytes || bytes === 0) return "0 B"
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const formatSpeed = (speed: number): string => {
|
||||
if (speed === 0) return "N/A"
|
||||
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbps`
|
||||
return `${speed} Mbps`
|
||||
}
|
||||
|
||||
const formatStorage = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B"
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const value = bytes / Math.pow(k, i)
|
||||
const decimals = value >= 10 ? 1 : 2
|
||||
return `${value.toFixed(decimals)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps) {
|
||||
const typeBadge = getInterfaceTypeBadge(interface_.type)
|
||||
const vmTypeBadge = interface_.vm_type ? getVMTypeBadge(interface_.vm_type) : null
|
||||
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(getNetworkUnit())
|
||||
|
||||
const [trafficData, setTrafficData] = useState<{ received: number; sent: number }>({
|
||||
received: 0,
|
||||
sent: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnitChange = () => {
|
||||
setNetworkUnit(getNetworkUnit())
|
||||
}
|
||||
|
||||
window.addEventListener("networkUnitChanged", handleUnitChange)
|
||||
window.addEventListener("storage", handleUnitChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("networkUnitChanged", handleUnitChange)
|
||||
window.removeEventListener("storage", handleUnitChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTrafficData = async () => {
|
||||
try {
|
||||
@@ -207,15 +206,15 @@ export function NetworkCard({ interface_, timeframe, onClick }: NetworkCardProps
|
||||
<div className="font-medium text-foreground text-xs">
|
||||
{interface_.status.toLowerCase() === "up" && interface_.vm_type !== "vm" ? (
|
||||
<>
|
||||
<span className="text-green-500">↓ {formatStorage(trafficData.received * 1024 * 1024 * 1024)}</span>
|
||||
<span className="text-green-500">↓ {formatNetworkTraffic(trafficData.received * 1024 * 1024 * 1024, networkUnit)}</span>
|
||||
{" / "}
|
||||
<span className="text-blue-500">↑ {formatStorage(trafficData.sent * 1024 * 1024 * 1024)}</span>
|
||||
<span className="text-blue-500">↑ {formatNetworkTraffic(trafficData.sent * 1024 * 1024 * 1024, networkUnit)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-green-500">↓ {formatBytes(interface_.bytes_recv)}</span>
|
||||
<span className="text-green-500">↓ {formatNetworkTraffic(interface_.bytes_recv || 0, networkUnit)}</span>
|
||||
{" / "}
|
||||
<span className="text-blue-500">↑ {formatBytes(interface_.bytes_sent)}</span>
|
||||
<span className="text-blue-500">↑ {formatNetworkTraffic(interface_.bytes_sent || 0, networkUnit)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { Wifi, Activity, Network, Router, AlertCircle, Zap } from "lucide-react"
|
||||
import { Wifi, Activity, Network, Router, AlertCircle, Zap } from 'lucide-react'
|
||||
import useSWR from "swr"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
|
||||
interface NetworkData {
|
||||
interfaces: NetworkInterface[]
|
||||
@@ -132,6 +133,7 @@ const fetcher = async (url: string): Promise<NetworkData> => {
|
||||
return fetchApi<NetworkData>(url)
|
||||
}
|
||||
|
||||
|
||||
export function NetworkMetrics() {
|
||||
const {
|
||||
data: networkData,
|
||||
@@ -149,6 +151,19 @@ export function NetworkMetrics() {
|
||||
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
||||
const [interfaceTotals, setInterfaceTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
||||
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(() => getNetworkUnit())
|
||||
|
||||
useEffect(() => {
|
||||
setNetworkUnit(getNetworkUnit())
|
||||
|
||||
const handleUnitChange = (e: CustomEvent) => {
|
||||
setNetworkUnit(e.detail === "Bits" ? "Bits" : "Bytes")
|
||||
}
|
||||
|
||||
window.addEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
return () => window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
}, [])
|
||||
|
||||
const { data: modalNetworkData } = useSWR<NetworkData>(selectedInterface ? "/api/network" : null, fetcher, {
|
||||
refreshInterval: 17000,
|
||||
revalidateOnFocus: false,
|
||||
@@ -191,8 +206,16 @@ export function NetworkMetrics() {
|
||||
)
|
||||
}
|
||||
|
||||
const trafficInFormatted = formatStorage(networkTotals.received * 1024 * 1024 * 1024) // Convert GB to bytes
|
||||
const trafficOutFormatted = formatStorage(networkTotals.sent * 1024 * 1024 * 1024)
|
||||
const trafficInFormatted = formatNetworkTraffic(
|
||||
networkTotals.received * 1024 ** 3,
|
||||
networkUnit,
|
||||
2
|
||||
)
|
||||
const trafficOutFormatted = formatNetworkTraffic(
|
||||
networkTotals.sent * 1024 ** 3,
|
||||
networkUnit,
|
||||
2
|
||||
)
|
||||
const packetsRecvK = networkData.traffic.packets_recv ? (networkData.traffic.packets_recv / 1000).toFixed(0) : "0"
|
||||
|
||||
const totalErrors = (networkData.traffic.errin || 0) + (networkData.traffic.errout || 0)
|
||||
@@ -375,7 +398,7 @@ export function NetworkMetrics() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<NetworkTrafficChart timeframe={timeframe} onTotalsCalculated={setNetworkTotals} />
|
||||
<NetworkTrafficChart timeframe={timeframe} onTotalsCalculated={setNetworkTotals} networkUnit={networkUnit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -712,13 +735,6 @@ export function NetworkMetrics() {
|
||||
|
||||
const displayInterface = currentInterfaceData || selectedInterface
|
||||
|
||||
console.log("[v0] Selected Interface:", selectedInterface.name)
|
||||
console.log("[v0] Selected Interface bytes_recv:", selectedInterface.bytes_recv)
|
||||
console.log("[v0] Selected Interface bytes_sent:", selectedInterface.bytes_sent)
|
||||
console.log("[v0] Display Interface bytes_recv:", displayInterface.bytes_recv)
|
||||
console.log("[v0] Display Interface bytes_sent:", displayInterface.bytes_sent)
|
||||
console.log("[v0] Modal Network Data available:", !!modalNetworkData)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Basic Information */}
|
||||
@@ -869,29 +885,40 @@ export function NetworkMetrics() {
|
||||
)
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Traffic Data - Top Row */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Bytes Received</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"}
|
||||
</div>
|
||||
<div className="font-medium text-green-500 text-lg">
|
||||
{formatStorage(interfaceTotals.received * 1024 * 1024 * 1024)}
|
||||
{formatNetworkTraffic(
|
||||
interfaceTotals.received * 1024 ** 3,
|
||||
networkUnit,
|
||||
2
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Bytes Sent</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"}
|
||||
</div>
|
||||
<div className="font-medium text-blue-500 text-lg">
|
||||
{formatStorage(interfaceTotals.sent * 1024 * 1024 * 1024)}
|
||||
{formatNetworkTraffic(
|
||||
interfaceTotals.sent * 1024 ** 3,
|
||||
networkUnit,
|
||||
2
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Network Traffic Chart - Full Width Below */}
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<NetworkTrafficChart
|
||||
timeframe={modalTimeframe}
|
||||
interfaceName={displayInterface.name}
|
||||
onTotalsCalculated={setInterfaceTotals}
|
||||
refreshInterval={60000}
|
||||
networkUnit={networkUnit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -932,15 +959,19 @@ export function NetworkMetrics() {
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-4">Traffic since last boot</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Bytes Received</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{networkUnit === "Bits" ? "Bits Received" : "Bytes Received"}
|
||||
</div>
|
||||
<div className="font-medium text-green-500 text-lg">
|
||||
{formatBytes(displayInterface.bytes_recv)}
|
||||
{formatNetworkTraffic(displayInterface.bytes_recv || 0, networkUnit)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Bytes Sent</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{networkUnit === "Bits" ? "Bits Sent" : "Bytes Sent"}
|
||||
</div>
|
||||
<div className="font-medium text-blue-500 text-lg">
|
||||
{formatBytes(displayInterface.bytes_sent)}
|
||||
{formatNetworkTraffic(displayInterface.bytes_sent || 0, networkUnit)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from "recharts"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { fetchApi } from "@/lib/api-config"
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
|
||||
interface NetworkMetricsData {
|
||||
time: string
|
||||
@@ -17,9 +18,10 @@ interface NetworkTrafficChartProps {
|
||||
interfaceName?: string
|
||||
onTotalsCalculated?: (totals: { received: number; sent: number }) => void
|
||||
refreshInterval?: number // En milisegundos, por defecto 60000 (60 segundos)
|
||||
networkUnit?: "Bytes" | "Bits" // Added networkUnit prop
|
||||
}
|
||||
|
||||
const CustomNetworkTooltip = ({ active, payload, label }: any) => {
|
||||
const CustomNetworkTooltip = ({ active, payload, label, networkUnit }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-lg p-3 shadow-xl">
|
||||
@@ -29,7 +31,9 @@ const CustomNetworkTooltip = ({ active, payload, label }: any) => {
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs text-gray-300 min-w-[60px]">{entry.name}:</span>
|
||||
<span className="text-sm font-semibold text-white">{entry.value.toFixed(3)} GB</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{entry.value.toFixed(3)} {networkUnit === "Bits" ? "Gb" : "GB"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -44,6 +48,7 @@ export function NetworkTrafficChart({
|
||||
interfaceName,
|
||||
onTotalsCalculated,
|
||||
refreshInterval = 60000,
|
||||
networkUnit: networkUnitProp, // Rename prop to avoid conflict
|
||||
}: NetworkTrafficChartProps) {
|
||||
const [data, setData] = useState<NetworkMetricsData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -53,11 +58,36 @@ export function NetworkTrafficChart({
|
||||
netIn: true,
|
||||
netOut: true,
|
||||
})
|
||||
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">(
|
||||
networkUnitProp || getNetworkUnit()
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleUnitChange = () => {
|
||||
const newUnit = getNetworkUnit()
|
||||
setNetworkUnit(newUnit)
|
||||
}
|
||||
|
||||
window.addEventListener("networkUnitChanged", handleUnitChange)
|
||||
window.addEventListener("storage", handleUnitChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("networkUnitChanged", handleUnitChange)
|
||||
window.removeEventListener("storage", handleUnitChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (networkUnitProp) {
|
||||
setNetworkUnit(networkUnitProp)
|
||||
}
|
||||
}, [networkUnitProp])
|
||||
|
||||
useEffect(() => {
|
||||
setIsInitialLoad(true)
|
||||
fetchMetrics()
|
||||
}, [timeframe, interfaceName])
|
||||
}, [timeframe, interfaceName, networkUnit])
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshInterval > 0) {
|
||||
@@ -67,7 +97,7 @@ export function NetworkTrafficChart({
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [timeframe, interfaceName, refreshInterval])
|
||||
}, [timeframe, interfaceName, refreshInterval, networkUnit]) // Added networkUnit to dependencies
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (isInitialLoad) {
|
||||
@@ -138,6 +168,15 @@ export function NetworkTrafficChart({
|
||||
const netInBytes = (item.netin || 0) * intervalSeconds
|
||||
const netOutBytes = (item.netout || 0) * intervalSeconds
|
||||
|
||||
if (networkUnit === "Bits") {
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: item.time,
|
||||
netIn: Number(((netInBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
|
||||
netOut: Number(((netOutBytes * 8) / 1024 / 1024 / 1024).toFixed(4)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
time: timeLabel,
|
||||
timestamp: item.time,
|
||||
@@ -148,11 +187,20 @@ export function NetworkTrafficChart({
|
||||
|
||||
setData(transformedData)
|
||||
|
||||
const totalReceived = transformedData.reduce((sum: number, item: NetworkMetricsData) => sum + item.netIn, 0)
|
||||
const totalSent = transformedData.reduce((sum: number, item: NetworkMetricsData) => sum + item.netOut, 0)
|
||||
const totalReceivedGB = result.data.reduce((sum: number, item: any, index: number) => {
|
||||
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
|
||||
const netInBytes = (item.netin || 0) * intervalSeconds
|
||||
return sum + (netInBytes / 1024 / 1024 / 1024)
|
||||
}, 0)
|
||||
|
||||
const totalSentGB = result.data.reduce((sum: number, item: any, index: number) => {
|
||||
const intervalSeconds = index > 0 ? item.time - result.data[index - 1].time : 60
|
||||
const netOutBytes = (item.netout || 0) * intervalSeconds
|
||||
return sum + (netOutBytes / 1024 / 1024 / 1024)
|
||||
}, 0)
|
||||
|
||||
if (onTotalsCalculated) {
|
||||
onTotalsCalculated({ received: totalReceived, sent: totalSent })
|
||||
onTotalsCalculated({ received: totalReceivedGB, sent: totalSentGB })
|
||||
}
|
||||
|
||||
if (isInitialLoad) {
|
||||
@@ -240,10 +288,15 @@ export function NetworkTrafficChart({
|
||||
stroke="currentColor"
|
||||
className="text-foreground"
|
||||
tick={{ fill: "currentColor", fontSize: 12 }}
|
||||
label={{ value: "GB", angle: -90, position: "insideLeft", fill: "currentColor" }}
|
||||
label={{
|
||||
value: networkUnit === "Bits" ? "Gb" : "GB", // Dynamic label based on unit
|
||||
angle: -90,
|
||||
position: "insideLeft",
|
||||
fill: "currentColor",
|
||||
}}
|
||||
domain={[0, "auto"]}
|
||||
/>
|
||||
<Tooltip content={<CustomNetworkTooltip />} />
|
||||
<Tooltip content={<CustomNetworkTooltip networkUnit={networkUnit} />} /> // Pass networkUnit to tooltip
|
||||
<Legend verticalAlign="top" height={36} content={renderLegend} />
|
||||
<Area
|
||||
type="monotone"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent } from "./ui/dialog"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -159,6 +159,7 @@ export function OnboardingCarousel() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl p-0 gap-0 overflow-hidden border-0 bg-transparent">
|
||||
<DialogTitle className="sr-only">ProxMenux Onboarding</DialogTitle>
|
||||
<div className="relative bg-card rounded-lg overflow-hidden shadow-2xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
import { getApiUrl, fetchApi } from "../lib/api-config"
|
||||
import { TerminalPanel } from "./terminal-panel"
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
Cpu,
|
||||
FileText,
|
||||
SettingsIcon,
|
||||
Terminal,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
@@ -259,6 +261,8 @@ export function ProxmoxDashboard() {
|
||||
return "VMs & LXCs"
|
||||
case "hardware":
|
||||
return "Hardware"
|
||||
case "terminal":
|
||||
return "Terminal"
|
||||
case "logs":
|
||||
return "System Logs"
|
||||
case "settings":
|
||||
@@ -412,7 +416,7 @@ export function ProxmoxDashboard() {
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6 pt-4 md:pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||
<TabsList className="hidden md:grid w-full grid-cols-7 bg-card border border-border">
|
||||
<TabsList className="hidden md:grid w-full grid-cols-8 bg-card border border-border">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
@@ -449,6 +453,12 @@ export function ProxmoxDashboard() {
|
||||
>
|
||||
System Logs
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="terminal"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
>
|
||||
Terminal
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="settings"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
@@ -563,6 +573,21 @@ export function ProxmoxDashboard() {
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setActiveTab("terminal")
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className={`w-full justify-start gap-3 ${
|
||||
activeTab === "terminal"
|
||||
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
@@ -611,13 +636,17 @@ export function ProxmoxDashboard() {
|
||||
<SystemLogs key={`logs-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Settings />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.0.1</p>
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.0.2</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent } from "./ui/dialog"
|
||||
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Link2, Shield, Zap, HardDrive, Gauge, Wrench, Settings } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.0.1" // Sync with AppImage/package.json
|
||||
const APP_VERSION = "1.0.2" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
@@ -110,6 +110,7 @@ export function ReleaseNotesModal({ open, onClose }: ReleaseNotesModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] p-0 gap-0 border-0 bg-transparent">
|
||||
<DialogTitle className="sr-only">Release Notes - Version {APP_VERSION}</DialogTitle>
|
||||
<div className="relative bg-card rounded-lg shadow-2xl h-full flex flex-col max-h-[85vh]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Loader2,
|
||||
Activity,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CornerDownLeft,
|
||||
GripHorizontal,
|
||||
} from "lucide-react"
|
||||
import "xterm/css/xterm.css"
|
||||
import { API_PORT } from "@/lib/api-config"
|
||||
|
||||
interface WebInteraction {
|
||||
type: "yesno" | "menu" | "msgbox" | "input" | "inputbox"
|
||||
id: string
|
||||
title: string
|
||||
message: string
|
||||
options?: Array<{ label: string; value: string }>
|
||||
default?: string
|
||||
}
|
||||
|
||||
interface ScriptTerminalModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
scriptPath: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function ScriptTerminalModal({
|
||||
open: isOpen,
|
||||
onClose,
|
||||
scriptPath,
|
||||
title,
|
||||
description,
|
||||
}: ScriptTerminalModalProps) {
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const fitAddonRef = useRef<any>(null)
|
||||
const sessionIdRef = useRef<string>(Math.random().toString(36).substring(2, 8))
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<"connecting" | "online" | "offline">("connecting")
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [currentInteraction, setCurrentInteraction] = useState<WebInteraction | null>(null)
|
||||
const [interactionInput, setInteractionInput] = useState("")
|
||||
const checkConnectionInterval = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [isTablet, setIsTablet] = useState(false)
|
||||
|
||||
const [isWaitingNextInteraction, setIsWaitingNextInteraction] = useState(false)
|
||||
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const [modalHeight, setModalHeight] = useState(600)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const resizeBarRef = useRef<HTMLDivElement>(null)
|
||||
const modalHeightRef = useRef(600)
|
||||
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttemptsRef.current++
|
||||
setConnectionStatus("connecting")
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus("online")
|
||||
reconnectAttemptsRef.current = 0
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
}
|
||||
keepAliveIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }))
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: {
|
||||
EXECUTION_MODE: "web",
|
||||
},
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
const cols = termRef.current.cols
|
||||
const rows = termRef.current.rows
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }))
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
setCurrentInteraction({
|
||||
type: msg.interaction.type,
|
||||
id: msg.interaction.id,
|
||||
title: msg.interaction.title || "",
|
||||
message: msg.interaction.message || "",
|
||||
options: msg.interaction.options,
|
||||
default: msg.interaction.default,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (msg.type === "error") {
|
||||
termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
termRef.current?.write(event.data)
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setConnectionStatus("offline")
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnectionStatus("offline")
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
if (!isComplete && reconnectAttemptsRef.current < 3) {
|
||||
reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000)
|
||||
} else {
|
||||
setIsComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [isOpen, isComplete, scriptPath])
|
||||
|
||||
const sendKey = useCallback((key: string) => {
|
||||
if (!termRef.current) return
|
||||
|
||||
const keyMap: Record<string, string> = {
|
||||
escape: "\x1b",
|
||||
tab: "\t",
|
||||
up: "\x1bOA",
|
||||
down: "\x1bOB",
|
||||
left: "\x1bOD",
|
||||
right: "\x1bOC",
|
||||
enter: "\r",
|
||||
ctrlc: "\x03",
|
||||
}
|
||||
|
||||
const sequence = keyMap[key]
|
||||
if (sequence && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(sequence)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const initializeTerminal = async () => {
|
||||
const [TerminalClass, FitAddonClass] = await Promise.all([
|
||||
import("xterm").then((mod) => mod.Terminal),
|
||||
import("xterm-addon-fit").then((mod) => mod.FitAddon),
|
||||
import("xterm/css/xterm.css"),
|
||||
])
|
||||
|
||||
const fontSize = window.innerWidth < 768 ? 12 : 16
|
||||
|
||||
const term = new TerminalClass({
|
||||
rendererType: "dom",
|
||||
fontFamily: '"Courier", "Courier New", "Liberation Mono", "DejaVu Sans Mono", monospace',
|
||||
fontSize: fontSize,
|
||||
lineHeight: 1,
|
||||
cursorBlink: true,
|
||||
scrollback: 2000,
|
||||
disableStdin: false,
|
||||
customGlyphs: true,
|
||||
fontWeight: "500",
|
||||
fontWeightBold: "700",
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#ffffff",
|
||||
cursorAccent: "#000000",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddonClass()
|
||||
term.loadAddon(fitAddon)
|
||||
if (terminalContainerRef.current) {
|
||||
term.open(terminalContainerRef.current)
|
||||
}
|
||||
|
||||
termRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current) {
|
||||
fitAddonRef.current.fit()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
const wsUrl = getScriptWebSocketUrl(sessionIdRef.current)
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnectionStatus("online")
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
}
|
||||
keepAliveIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }))
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: {
|
||||
EXECUTION_MODE: "web",
|
||||
},
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
const cols = termRef.current.cols
|
||||
const rows = termRef.current.rows
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols: cols,
|
||||
rows: rows,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
setCurrentInteraction({
|
||||
type: msg.interaction.type,
|
||||
id: msg.interaction.id,
|
||||
title: msg.interaction.title || "",
|
||||
message: msg.interaction.message || "",
|
||||
options: msg.interaction.options,
|
||||
default: msg.interaction.default,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.type === "error") {
|
||||
term.writeln(`\x1b[31m${msg.message}\x1b[0m`)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, es output normal de terminal
|
||||
}
|
||||
|
||||
term.write(event.data)
|
||||
|
||||
setIsWaitingNextInteraction(false)
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\x1b[31mWebSocket error occurred\x1b[0m")
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setConnectionStatus("offline")
|
||||
term.writeln("\x1b[33mConnection closed\x1b[0m")
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
setIsComplete(true)
|
||||
}
|
||||
}
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
})
|
||||
|
||||
checkConnectionInterval.current = setInterval(() => {
|
||||
if (wsRef.current) {
|
||||
setConnectionStatus(
|
||||
wsRef.current.readyState === WebSocket.OPEN
|
||||
? "online"
|
||||
: wsRef.current.readyState === WebSocket.CONNECTING
|
||||
? "connecting"
|
||||
: "offline",
|
||||
)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout) clearTimeout(resizeTimeout)
|
||||
resizeTimeout = setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
fitAddonRef.current.fit()
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols: termRef.current.cols,
|
||||
rows: termRef.current.rows,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
|
||||
if (terminalContainerRef.current) {
|
||||
resizeObserver.observe(terminalContainerRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const savedHeight = localStorage.getItem("scriptModalHeight")
|
||||
if (savedHeight) {
|
||||
const height = Number.parseInt(savedHeight, 10)
|
||||
setModalHeight(height)
|
||||
modalHeightRef.current = height
|
||||
}
|
||||
|
||||
if (isOpen) {
|
||||
initializeTerminal()
|
||||
} else {
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
if (waitingTimeoutRef.current) {
|
||||
clearTimeout(waitingTimeoutRef.current)
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
termRef.current = null
|
||||
}
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current)
|
||||
keepAliveIntervalRef.current = null
|
||||
}
|
||||
|
||||
sessionIdRef.current = Math.random().toString(36).substring(2, 8)
|
||||
reconnectAttemptsRef.current = 0
|
||||
setIsComplete(false)
|
||||
setInteractionInput("")
|
||||
setCurrentInteraction(null)
|
||||
setIsWaitingNextInteraction(false)
|
||||
setConnectionStatus("connecting")
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
const updateDeviceType = () => {
|
||||
const width = window.innerWidth
|
||||
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0
|
||||
const isTabletSize = width >= 768 && width <= 1366
|
||||
|
||||
setIsMobile(width < 768)
|
||||
setIsTablet(isTouchDevice && isTabletSize)
|
||||
}
|
||||
|
||||
updateDeviceType()
|
||||
const handleResize = () => updateDeviceType()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden && isOpen) {
|
||||
if (wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
|
||||
attemptReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (isOpen && wsRef.current?.readyState !== WebSocket.OPEN && !isComplete) {
|
||||
attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
let wakeLock: any = null
|
||||
const requestWakeLock = async () => {
|
||||
if ("wakeLock" in navigator && isOpen) {
|
||||
try {
|
||||
wakeLock = await (navigator as any).wakeLock.request("screen")
|
||||
} catch (err) {
|
||||
// Wake Lock no soportado o denegado, continuar sin él
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestWakeLock()
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.addEventListener("focus", handleFocus)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize)
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
window.removeEventListener("focus", handleFocus)
|
||||
if (wakeLock) {
|
||||
wakeLock.release().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [isOpen, isComplete, attemptReconnect])
|
||||
|
||||
const getScriptWebSocketUrl = (sid: string): string => {
|
||||
if (typeof window === "undefined") {
|
||||
return `ws://localhost:${API_PORT}/ws/script/${sid}`
|
||||
}
|
||||
|
||||
const { protocol, hostname, port } = window.location
|
||||
const isStandardPort = port === "" || port === "80" || port === "443"
|
||||
const wsProtocol = protocol === "https:" ? "wss:" : "ws:"
|
||||
|
||||
if (isStandardPort) {
|
||||
return `${wsProtocol}//${hostname}/ws/script/${sid}`
|
||||
} else {
|
||||
return `${wsProtocol}//${hostname}:${API_PORT}/ws/script/${sid}`
|
||||
}
|
||||
}
|
||||
|
||||
const handleInteractionResponse = (value: string) => {
|
||||
if (!wsRef.current || !currentInteraction) {
|
||||
return
|
||||
}
|
||||
|
||||
if (value === "cancel" || value === "") {
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
handleCloseModal()
|
||||
return
|
||||
}
|
||||
|
||||
const response = JSON.stringify({
|
||||
type: "interaction_response",
|
||||
id: currentInteraction.id,
|
||||
value: value,
|
||||
})
|
||||
|
||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(response)
|
||||
}
|
||||
|
||||
setCurrentInteraction(null)
|
||||
setInteractionInput("")
|
||||
|
||||
waitingTimeoutRef.current = setTimeout(() => {
|
||||
setIsWaitingNextInteraction(true)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
if (checkConnectionInterval.current) {
|
||||
clearInterval(checkConnectionInterval.current)
|
||||
}
|
||||
if (termRef.current) {
|
||||
termRef.current.dispose()
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
setIsResizing(true)
|
||||
|
||||
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY
|
||||
const startY = clientY
|
||||
const startHeight = modalHeight
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
|
||||
const currentY = "touches" in moveEvent ? moveEvent.touches[0].clientY : moveEvent.clientY
|
||||
const deltaY = currentY - startY
|
||||
const newHeight = Math.max(300, Math.min(window.innerHeight - 50, startHeight + deltaY))
|
||||
|
||||
modalHeightRef.current = newHeight
|
||||
setModalHeight(newHeight)
|
||||
}
|
||||
|
||||
const handleEnd = () => {
|
||||
const finalHeight = modalHeightRef.current
|
||||
setIsResizing(false)
|
||||
|
||||
document.removeEventListener("mousemove", handleMove as any)
|
||||
document.removeEventListener("mouseup", handleEnd)
|
||||
document.removeEventListener("touchmove", handleMove as any)
|
||||
document.removeEventListener("touchend", handleEnd)
|
||||
document.removeEventListener("touchcancel", handleEnd)
|
||||
|
||||
localStorage.setItem("scriptModalHeight", finalHeight.toString())
|
||||
|
||||
if (fitAddonRef.current && termRef.current && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit()
|
||||
wsRef.current?.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols: termRef.current.cols,
|
||||
rows: termRef.current.rows,
|
||||
}),
|
||||
)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove as any)
|
||||
document.addEventListener("mouseup", handleEnd)
|
||||
document.addEventListener("touchmove", handleMove as any, { passive: false })
|
||||
document.addEventListener("touchend", handleEnd)
|
||||
document.addEventListener("touchcancel", handleEnd)
|
||||
}
|
||||
|
||||
const sendCommand = (command: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(command)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="max-w-7xl p-0 flex flex-col gap-0 overflow-hidden"
|
||||
style={{
|
||||
height: isMobile ? "80vh" : `${modalHeight}px`,
|
||||
maxHeight: "none",
|
||||
}}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
hideClose
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
|
||||
<div className="flex items-center gap-2 p-4 border-b">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden relative flex-1">
|
||||
<div ref={terminalContainerRef} className="w-full h-full" />
|
||||
|
||||
{isWaitingNextInteraction && !currentInteraction && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-muted-foreground">Processing...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<div
|
||||
ref={resizeBarRef}
|
||||
onMouseDown={handleResizeStart}
|
||||
onTouchStart={handleResizeStart}
|
||||
className={`h-2 w-full cursor-row-resize transition-colors flex items-center justify-center group relative ${
|
||||
isResizing ? "bg-blue-500" : "bg-zinc-800 hover:bg-blue-600"
|
||||
}`}
|
||||
style={{ touchAction: "none" }}
|
||||
>
|
||||
<GripHorizontal
|
||||
className={`h-4 w-4 transition-colors pointer-events-none ${
|
||||
isResizing ? "text-white" : "text-zinc-600 group-hover:text-white"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isMobile || isTablet) && (
|
||||
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1b")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
|
||||
>
|
||||
ESC
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\t")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[50px]"
|
||||
>
|
||||
TAB
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOA")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOB")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOD")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x1bOC")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\r")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2.5 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white"
|
||||
>
|
||||
<CornerDownLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
sendCommand("\x03")
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs bg-zinc-800 hover:bg-zinc-700 border-zinc-700 text-white min-w-[65px]"
|
||||
>
|
||||
CTRL+C
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-blue-500" />
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
connectionStatus === "online"
|
||||
? "bg-green-500"
|
||||
: connectionStatus === "connecting"
|
||||
? "bg-blue-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
title={
|
||||
connectionStatus === "online"
|
||||
? "Connected"
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting"
|
||||
: "Disconnected"
|
||||
}
|
||||
></div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{connectionStatus === "online"
|
||||
? "Online"
|
||||
: connectionStatus === "connecting"
|
||||
? "Connecting..."
|
||||
: "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCloseModal}
|
||||
variant="outline"
|
||||
className="bg-red-600 hover:bg-red-700 border-red-500 text-white"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{currentInteraction && (
|
||||
<Dialog open={true}>
|
||||
<DialogContent
|
||||
className="max-w-4xl max-h-[80vh] overflow-y-auto animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
hideClose
|
||||
>
|
||||
<DialogTitle>{currentInteraction.title}</DialogTitle>
|
||||
<div className="space-y-4">
|
||||
<p
|
||||
className="whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: currentInteraction.message.replace(/\\n/g, "<br/>").replace(/\n/g, "<br/>"),
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentInteraction.type === "yesno" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("yes")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white transition-all duration-150"
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentInteraction.type === "menu" && currentInteraction.options && (
|
||||
<div className="space-y-2">
|
||||
{currentInteraction.options.map((option, index) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
onClick={() => handleInteractionResponse(option.value)}
|
||||
variant="outline"
|
||||
className="w-full justify-start hover:bg-blue-600 hover:text-white transition-all duration-100 animate-in fade-in-0 slide-in-from-left-2"
|
||||
style={{ animationDelay: `${index * 30}ms` }}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="w-full hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(currentInteraction.type === "input" || currentInteraction.type === "inputbox") && (
|
||||
<div className="space-y-2">
|
||||
<Label>Your input:</Label>
|
||||
<Input
|
||||
value={interactionInput}
|
||||
onChange={(e) => setInteractionInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleInteractionResponse(interactionInput)
|
||||
}
|
||||
}}
|
||||
placeholder={currentInteraction.default || ""}
|
||||
className="transition-all duration-150"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse(interactionInput)}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentInteraction.type === "msgbox" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("ok")}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 transition-all duration-150"
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleInteractionResponse("cancel")}
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-red-600 hover:text-white hover:border-red-600 transition-all duration-150"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,24 +5,12 @@ import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import {
|
||||
Shield,
|
||||
Lock,
|
||||
User,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
LogOut,
|
||||
Wrench,
|
||||
Package,
|
||||
Key,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react"
|
||||
import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Wrench, Package, Key, Copy, Eye, EyeOff, Ruler } from 'lucide-react'
|
||||
import { APP_VERSION } from "./release-notes-modal"
|
||||
import { getApiUrl, fetchApi } from "../lib/api-config"
|
||||
import { TwoFactorSetup } from "./two-factor-setup"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
|
||||
interface ProxMenuxTool {
|
||||
key: string
|
||||
@@ -68,9 +56,13 @@ export function Settings() {
|
||||
const [generatingToken, setGeneratingToken] = useState(false)
|
||||
const [tokenCopied, setTokenCopied] = useState(false)
|
||||
|
||||
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
|
||||
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuthStatus()
|
||||
loadProxmenuxTools()
|
||||
getUnitsSettings() // Load units settings on mount
|
||||
}, [])
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
@@ -361,6 +353,28 @@ export function Settings() {
|
||||
}))
|
||||
}
|
||||
|
||||
const changeNetworkUnit = (unit: string) => {
|
||||
const networkUnit = unit as "Bytes" | "Bits"
|
||||
localStorage.setItem("proxmenux-network-unit", networkUnit)
|
||||
setNetworkUnitSettings(networkUnit)
|
||||
|
||||
// Dispatch custom event to notify other components
|
||||
window.dispatchEvent(new CustomEvent("networkUnitChanged", { detail: networkUnit }))
|
||||
|
||||
// Also dispatch storage event for backward compatibility
|
||||
window.dispatchEvent(new StorageEvent("storage", {
|
||||
key: "proxmenux-network-unit",
|
||||
newValue: networkUnit,
|
||||
url: window.location.href
|
||||
}))
|
||||
}
|
||||
|
||||
const getUnitsSettings = () => {
|
||||
const networkUnit = getNetworkUnit()
|
||||
setNetworkUnitSettings(networkUnit)
|
||||
setLoadingUnitSettings(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -662,6 +676,37 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5 text-green-500" />
|
||||
<CardTitle>Network Units</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Change how network traffic is displayed</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingUnitSettings ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-green-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-foreground flex items-center justify-between">
|
||||
<div className="flex items-center">Network Unit Display</div>
|
||||
<Select value={networkUnitSettings} onValueChange={changeNetworkUnit}>
|
||||
<SelectTrigger className="w-28 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Bytes">Bytes</SelectItem>
|
||||
<SelectItem value="Bits">Bits</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* API Access Tokens */}
|
||||
{authEnabled && (
|
||||
<Card>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon } from "lucide-react"
|
||||
"use client"
|
||||
|
||||
import { LayoutDashboard, HardDrive, Network, Server, Cpu, FileText, SettingsIcon, Terminal } from "lucide-react"
|
||||
|
||||
const menuItems = [
|
||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||
@@ -7,7 +9,117 @@ const menuItems = [
|
||||
{ name: "Virtual Machines", href: "/virtual-machines", icon: Server },
|
||||
{ name: "Hardware", href: "/hardware", icon: Cpu },
|
||||
{ name: "System Logs", href: "/logs", icon: FileText },
|
||||
{ name: "Terminal", href: "/terminal", icon: Terminal },
|
||||
{ name: "Settings", href: "/settings", icon: SettingsIcon },
|
||||
]
|
||||
|
||||
// ... existing code ...
|
||||
const Sidebar = ({ currentPath, setOpen }) => {
|
||||
const handleNavigation = (tabName: string) => {
|
||||
// Dispatch custom event to change tab in dashboard
|
||||
const event = new CustomEvent("changeTab", { detail: { tab: tabName } })
|
||||
window.dispatchEvent(event)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => handleNavigation("overview")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/" || currentPath === "/overview"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Overview</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("storage")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/storage"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<HardDrive className="h-5 w-5" />
|
||||
<span>Storage</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("network")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/network"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<Network className="h-5 w-5" />
|
||||
<span>Network</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("vms")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/virtual-machines"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<Server className="h-5 w-5" />
|
||||
<span>VMs & LXCs</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("hardware")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/hardware"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<Cpu className="h-5 w-5" />
|
||||
<span>Hardware</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("logs")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/logs"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-5 w-5" />
|
||||
<span>System Logs</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("terminal")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/terminal"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<Terminal className="h-5 w-5" />
|
||||
<span>Terminal</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleNavigation("settings")}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPath === "/settings"
|
||||
? "bg-blue-500/10 text-blue-500"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
<SettingsIcon className="h-5 w-5" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
|
||||
@@ -597,13 +597,15 @@ export function StorageOverview() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{proxmoxStorage.storage
|
||||
.filter(
|
||||
(storage) =>
|
||||
storage && storage.name && storage.total > 0 && storage.used >= 0 && storage.available >= 0,
|
||||
)
|
||||
.filter((storage) => storage && storage.name && storage.used >= 0 && storage.available >= 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((storage) => (
|
||||
<div key={storage.name} className="border rounded-lg p-4">
|
||||
<div
|
||||
key={storage.name}
|
||||
className={`border rounded-lg p-4 ${
|
||||
storage.status === "error" ? "border-red-500/50 bg-red-500/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
{/* Desktop: Icon + Name + Badge tipo alineados horizontalmente */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
@@ -625,7 +627,9 @@ export function StorageOverview() {
|
||||
className={
|
||||
storage.status === "active"
|
||||
? "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
: storage.status === "error"
|
||||
? "bg-red-500/10 text-red-500 border-red-500/20"
|
||||
: "bg-gray-500/10 text-gray-500 border-gray-500/20"
|
||||
}
|
||||
>
|
||||
{storage.status}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
|
||||
interface SystemData {
|
||||
cpu_usage: number
|
||||
@@ -96,14 +97,21 @@ interface ProxmoxStorageData {
|
||||
}>
|
||||
}
|
||||
|
||||
const fetchSystemData = async (): Promise<SystemData | null> => {
|
||||
try {
|
||||
const data = await fetchApi<SystemData>("/api/system")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch system data:", error)
|
||||
return null
|
||||
const fetchSystemData = async (retries = 3, delayMs = 500): Promise<SystemData | null> => {
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const data = await fetchApi<SystemData>("/api/system")
|
||||
return data
|
||||
} catch (error) {
|
||||
if (attempt === retries - 1) {
|
||||
console.error("[v0] Failed to fetch system data after retries:", error)
|
||||
return null
|
||||
}
|
||||
// Wait before retry
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fetchVMData = async (): Promise<VMData[]> => {
|
||||
@@ -146,6 +154,12 @@ const fetchProxmoxStorageData = async (): Promise<ProxmoxStorageData | null> =>
|
||||
}
|
||||
}
|
||||
|
||||
const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
if (typeof window === "undefined") return "Bytes"
|
||||
const raw = window.localStorage.getItem("proxmenux-network-unit")
|
||||
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
|
||||
}
|
||||
|
||||
export function SystemOverview() {
|
||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||
const [vmData, setVmData] = useState<VMData[]>([])
|
||||
@@ -159,8 +173,10 @@ export function SystemOverview() {
|
||||
network: true,
|
||||
})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasAttemptedLoad, setHasAttemptedLoad] = useState(false) // Added hasAttemptedLoad state
|
||||
const [networkTimeframe, setNetworkTimeframe] = useState("day")
|
||||
const [networkTotals, setNetworkTotals] = useState<{ received: number; sent: number }>({ received: 0, sent: 0 })
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Added networkUnit state
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAllData = async () => {
|
||||
@@ -173,6 +189,8 @@ export function SystemOverview() {
|
||||
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
|
||||
])
|
||||
|
||||
setHasAttemptedLoad(true)
|
||||
|
||||
if (!systemResult) {
|
||||
setError("Flask server not available. Please ensure the server is running.")
|
||||
return
|
||||
@@ -215,24 +233,27 @@ export function SystemOverview() {
|
||||
if (data) setNetworkData(data)
|
||||
}, 59000)
|
||||
|
||||
setNetworkUnit(getNetworkUnit()) // Load initial setting
|
||||
|
||||
const handleUnitChange = (e: CustomEvent) => {
|
||||
setNetworkUnit(e.detail === "Bits" ? "Bits" : "Bytes")
|
||||
}
|
||||
|
||||
window.addEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
|
||||
return () => {
|
||||
clearInterval(systemInterval)
|
||||
clearInterval(vmInterval)
|
||||
clearInterval(storageInterval)
|
||||
clearInterval(networkInterval)
|
||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isInitialLoading = loadingStates.system && !systemData
|
||||
|
||||
if (isInitialLoading) {
|
||||
if (!hasAttemptedLoad || loadingStates.system) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-8">
|
||||
<div className="text-lg font-medium text-foreground mb-2">Connecting to ProxMenux Monitor...</div>
|
||||
<div className="text-sm text-muted-foreground">Fetching real-time system data</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i} className="bg-card border-border animate-pulse">
|
||||
<CardContent className="p-6">
|
||||
@@ -298,16 +319,6 @@ export function SystemOverview() {
|
||||
return (bytes / 1024 ** 3).toFixed(2)
|
||||
}
|
||||
|
||||
const formatStorage = (sizeInGB: number): string => {
|
||||
if (sizeInGB < 1) {
|
||||
return `${(sizeInGB * 1024).toFixed(1)} MB`
|
||||
} else if (sizeInGB > 999) {
|
||||
return `${(sizeInGB / 1024).toFixed(2)} TB`
|
||||
} else {
|
||||
return `${sizeInGB.toFixed(2)} GB`
|
||||
}
|
||||
}
|
||||
|
||||
const tempStatus = getTemperatureStatus(systemData.temperature)
|
||||
|
||||
const localStorage = proxmoxStorageData?.storage.find((s) => s.name === "local")
|
||||
@@ -411,26 +422,6 @@ export function SystemOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
|
||||
<Thermometer className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{systemData.temperature === 0 ? "N/A" : `${systemData.temperature}°C`}
|
||||
</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<Badge variant="outline" className={tempStatus.color}>
|
||||
{tempStatus.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{systemData.temperature === 0 ? "No sensor available" : "Live temperature reading"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
@@ -465,6 +456,26 @@ export function SystemOverview() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Temperature</CardTitle>
|
||||
<Thermometer className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-xl lg:text-2xl font-bold text-foreground">
|
||||
{systemData.temperature === 0 ? "N/A" : `${systemData.temperature}°C`}
|
||||
</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<Badge variant="outline" className={tempStatus.color}>
|
||||
{tempStatus.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{systemData.temperature === 0 ? "No sensor available" : "Live temperature reading"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<NodeMetricsCharts />
|
||||
@@ -496,7 +507,9 @@ export function SystemOverview() {
|
||||
<div className="space-y-2 pb-4 border-b-2 border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">Total Node Capacity:</span>
|
||||
<span className="text-lg font-bold text-foreground">{formatStorage(totalCapacity)}</span>
|
||||
<span className="text-lg font-bold text-foreground">
|
||||
{formatNetworkTraffic(totalCapacity, "Bytes")}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={totalPercent}
|
||||
@@ -505,10 +518,16 @@ export function SystemOverview() {
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Used: <span className="font-semibold text-foreground">{formatStorage(totalUsed)}</span>
|
||||
Used:{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatNetworkTraffic(totalUsed, "Bytes")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Free: <span className="font-semibold text-green-500">{formatStorage(totalAvailable)}</span>
|
||||
Free:{" "}
|
||||
<span className="font-semibold text-green-500">
|
||||
{formatNetworkTraffic(totalAvailable, "Bytes")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-muted-foreground">{totalPercent.toFixed(1)}%</span>
|
||||
@@ -535,18 +554,21 @@ export function SystemOverview() {
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">VM/LXC Storage</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Used:</span>
|
||||
<span className="text-sm font-semibold text-foreground">{formatStorage(vmLxcStorageUsed)}</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatNetworkTraffic(vmLxcStorageUsed, "Bytes")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Available:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatStorage(vmLxcStorageAvailable)}
|
||||
{formatNetworkTraffic(vmLxcStorageAvailable, "Bytes")}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={vmLxcStoragePercent} className="mt-2 [&>div]:bg-blue-500" />
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatStorage(vmLxcStorageUsed)} / {formatStorage(vmLxcStorageTotal)}
|
||||
{formatNetworkTraffic(vmLxcStorageUsed, "Bytes")} /{" "}
|
||||
{formatNetworkTraffic(vmLxcStorageTotal, "Bytes")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{vmLxcStoragePercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
@@ -568,18 +590,21 @@ export function SystemOverview() {
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Local Storage (System)</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Used:</span>
|
||||
<span className="text-sm font-semibold text-foreground">{formatStorage(localStorage.used)}</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatNetworkTraffic(localStorage.used, "Bytes")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Available:</span>
|
||||
<span className="text-sm font-semibold text-green-500">
|
||||
{formatStorage(localStorage.available)}
|
||||
{formatNetworkTraffic(localStorage.available, "Bytes")}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={localStorage.percent} className="mt-2 [&>div]:bg-purple-500" />
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatStorage(localStorage.used)} / {formatStorage(localStorage.total)}
|
||||
{formatNetworkTraffic(localStorage.used, "Bytes")} /{" "}
|
||||
{formatNetworkTraffic(localStorage.total, "Bytes")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{localStorage.percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
@@ -667,21 +692,31 @@ export function SystemOverview() {
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Received:</span>
|
||||
<span className="text-lg font-semibold text-green-500 flex items-center gap-1">
|
||||
↓ {formatStorage(networkTotals.received)}
|
||||
↓{" "}
|
||||
{networkUnit === "Bytes"
|
||||
? `${networkTotals.received.toFixed(2)} GB`
|
||||
: formatNetworkTraffic(networkTotals.received * 1024 * 1024 * 1024, "Bits")}
|
||||
<span className="text-xs text-muted-foreground">({getTimeframeLabel(networkTimeframe)})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Sent:</span>
|
||||
<span className="text-lg font-semibold text-blue-500 flex items-center gap-1">
|
||||
↑ {formatStorage(networkTotals.sent)}
|
||||
↑{" "}
|
||||
{networkUnit === "Bytes"
|
||||
? `${networkTotals.sent.toFixed(2)} GB`
|
||||
: formatNetworkTraffic(networkTotals.sent * 1024 * 1024 * 1024, "Bits")}
|
||||
<span className="text-xs text-muted-foreground">({getTimeframeLabel(networkTimeframe)})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-border">
|
||||
<NetworkTrafficChart timeframe={networkTimeframe} onTotalsCalculated={setNetworkTotals} />
|
||||
<NetworkTrafficChart
|
||||
timeframe={networkTimeframe}
|
||||
onTotalsCalculated={setNetworkTotals}
|
||||
networkUnit={networkUnit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,8 +31,10 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
hideClose?: boolean
|
||||
}
|
||||
>(({ className, children, hideClose, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -45,10 +47,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideClose && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
|
||||
@@ -8,24 +8,11 @@ import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import {
|
||||
Server,
|
||||
Play,
|
||||
Square,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Network,
|
||||
Power,
|
||||
RotateCcw,
|
||||
StopCircle,
|
||||
Container,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from "lucide-react"
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import useSWR from "swr"
|
||||
import { MetricsView } from "./metrics-dialog"
|
||||
import { formatStorage } from "@/lib/utils" // Import formatStorage utility
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
|
||||
interface VMData {
|
||||
@@ -137,8 +124,15 @@ const fetcher = async (url: string) => {
|
||||
return fetchApi(url)
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number | undefined): string => {
|
||||
if (!bytes || bytes === 0) return "0 B"
|
||||
const formatBytes = (bytes: number | undefined, isNetwork: boolean = false): string => {
|
||||
if (!bytes || bytes === 0) return isNetwork ? "0 B/s" : "0 B"
|
||||
|
||||
if (isNetwork) {
|
||||
const networkUnit = getNetworkUnit()
|
||||
return formatNetworkTraffic(bytes, networkUnit, 2)
|
||||
}
|
||||
|
||||
// For non-network (disk), use standard bytes
|
||||
const k = 1024
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
@@ -272,6 +266,7 @@ export function VirtualMachines() {
|
||||
const [selectedMetric, setSelectedMetric] = useState<string | null>(null)
|
||||
const [ipsLoaded, setIpsLoaded] = useState(false)
|
||||
const [loadingIPs, setLoadingIPs] = useState(false)
|
||||
const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes")
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLXCIPs = async () => {
|
||||
@@ -324,6 +319,23 @@ export function VirtualMachines() {
|
||||
fetchLXCIPs()
|
||||
}, [vmData, ipsLoaded, loadingIPs])
|
||||
|
||||
// Load initial network unit and listen for changes
|
||||
useEffect(() => {
|
||||
setNetworkUnit(getNetworkUnit())
|
||||
|
||||
const handleNetworkUnitChange = () => {
|
||||
setNetworkUnit(getNetworkUnit())
|
||||
}
|
||||
|
||||
window.addEventListener("networkUnitChanged", handleNetworkUnitChange)
|
||||
window.addEventListener("storage", handleNetworkUnitChange)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("networkUnitChanged", handleNetworkUnitChange)
|
||||
window.removeEventListener("storage", handleNetworkUnitChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleVMClick = async (vm: VMData) => {
|
||||
setSelectedVM(vm)
|
||||
setCurrentView("main")
|
||||
@@ -924,11 +936,11 @@ export function VirtualMachines() {
|
||||
<div className="text-sm font-semibold space-y-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-green-500" />
|
||||
<span className="text-green-500">↓ {formatBytes(vm.diskread)}</span>
|
||||
<span className="text-green-500">↓ {formatBytes(vm.diskread, false)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-blue-500">↑ {formatBytes(vm.diskwrite)}</span>
|
||||
<span className="text-blue-500">↑ {formatBytes(vm.diskwrite, false)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -938,11 +950,11 @@ export function VirtualMachines() {
|
||||
<div className="text-sm font-semibold space-y-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<Network className="h-3 w-3 text-green-500" />
|
||||
<span className="text-green-500">↓ {formatBytes(vm.netin)}</span>
|
||||
<span className="text-green-500">↓ {formatBytes(vm.netin, true)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Network className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-blue-500">↑ {formatBytes(vm.netout)}</span>
|
||||
<span className="text-blue-500">↑ {formatBytes(vm.netout, true)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1167,11 +1179,11 @@ export function VirtualMachines() {
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-green-500 flex items-center gap-1">
|
||||
<span>↓</span>
|
||||
<span>{((selectedVM.netin || 0) / 1024 ** 2).toFixed(2)} MB</span>
|
||||
<span>{formatNetworkTraffic(selectedVM.netin || 0, networkUnit)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-blue-500 flex items-center gap-1">
|
||||
<span>↑</span>
|
||||
<span>{((selectedVM.netout || 0) / 1024 ** 2).toFixed(2)} MB</span>
|
||||
<span>{formatNetworkTraffic(selectedVM.netout || 0, networkUnit)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1226,7 +1238,8 @@ export function VirtualMachines() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />+ Info
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
+ Info
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Utility functions for formatting network traffic data
|
||||
* Supports conversion between Bytes and Bits based on user preferences
|
||||
*/
|
||||
|
||||
export type NetworkUnit = 'Bytes' | 'Bits';
|
||||
|
||||
/**
|
||||
* Format network traffic value with appropriate unit
|
||||
* @param bytes - Value in bytes
|
||||
* @param unit - Target unit ('Bytes' or 'Bits')
|
||||
* @param decimals - Number of decimal places (default: 2)
|
||||
* @returns Formatted string with value and unit
|
||||
*/
|
||||
export function formatNetworkTraffic(
|
||||
bytes: number,
|
||||
unit: NetworkUnit = 'Bytes',
|
||||
decimals: number = 2
|
||||
): string {
|
||||
if (bytes === 0) return unit === 'Bits' ? '0 b' : '0 B';
|
||||
|
||||
const k = unit === 'Bits' ? 1000 : 1024;
|
||||
const dm = decimals < 0 ? 0 : Math.min(decimals, 2);
|
||||
|
||||
// For Bits: convert bytes to bits first (multiply by 8)
|
||||
const value = unit === 'Bits' ? bytes * 8 : bytes;
|
||||
|
||||
const sizes = unit === 'Bits'
|
||||
? ['b', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb']
|
||||
: ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
const i = Math.floor(Math.log(value) / Math.log(k));
|
||||
const finalDecimals = 2; // Always use 2 decimals for consistency
|
||||
const formattedValue = parseFloat((value / Math.pow(k, i)).toFixed(finalDecimals));
|
||||
|
||||
return `${formattedValue} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current network unit preference from localStorage
|
||||
* @returns 'Bytes' or 'Bits'
|
||||
*/
|
||||
export function getNetworkUnit(): NetworkUnit {
|
||||
if (typeof window === 'undefined') return 'Bytes';
|
||||
|
||||
const stored = localStorage.getItem('proxmenux-network-unit');
|
||||
return stored === 'Bits' ? 'Bits' : 'Bytes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the label for network traffic based on current unit
|
||||
* @param direction - 'received' or 'sent'
|
||||
* @returns Label string
|
||||
*/
|
||||
export function getNetworkLabel(direction: 'received' | 'sent'): string {
|
||||
const unit = getNetworkUnit();
|
||||
const prefix = direction === 'received' ? 'Received' : 'Sent';
|
||||
return unit === 'Bits' ? `${prefix}` : `${prefix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unit suffix for displaying in charts
|
||||
* @returns Unit suffix string (e.g., 'GB' or 'Gb')
|
||||
*/
|
||||
export function getNetworkUnitSuffix(): string {
|
||||
const unit = getNetworkUnit();
|
||||
return unit === 'Bits' ? 'b' : 'B';
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { exec } from "child_process"
|
||||
import { promisify } from "util"
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface ScriptExecutorOptions {
|
||||
env?: Record<string, string>
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
interface ScriptResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
export async function executeScript(scriptPath: string, options: ScriptExecutorOptions = {}): Promise<ScriptResult> {
|
||||
const { env = {}, timeout = 300000 } = options // 5 minutes default timeout
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(`bash ${scriptPath}`, {
|
||||
env: { ...process.env, ...env },
|
||||
timeout,
|
||||
maxBuffer: 1024 * 1024 * 10, // 10MB buffer
|
||||
})
|
||||
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: 0,
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
stdout: error.stdout || "",
|
||||
stderr: error.stderr || error.message || "Unknown error",
|
||||
exitCode: error.code || 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "Proxmox System Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -55,11 +55,14 @@
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"recharts": "2.15.4",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^1.7.4",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.9",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"zod": "3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -85,6 +85,10 @@ cp "$SCRIPT_DIR/health_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
|
||||
cp "$SCRIPT_DIR/health_persistence.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_persistence.py not found"
|
||||
cp "$SCRIPT_DIR/flask_health_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_health_routes.py not found"
|
||||
cp "$SCRIPT_DIR/flask_proxmenux_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_proxmenux_routes.py not found"
|
||||
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
|
||||
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
||||
|
||||
echo "📋 Adding translation support..."
|
||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
||||
@@ -281,7 +285,15 @@ if [ -f "$APP_DIR/proxmenux-monitor.png" ]; then
|
||||
fi
|
||||
|
||||
echo "📦 Installing Python dependencies..."
|
||||
# Phase 1: Install googletrans with its old dependencies
|
||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
|
||||
googletrans==4.0.0-rc1 \
|
||||
httpx==0.13.3 \
|
||||
httpcore==0.9.1 \
|
||||
h11==0.9.0 || true
|
||||
|
||||
# Phase 2: Install modern Flask/WebSocket dependencies (will upgrade h11 and related packages)
|
||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \
|
||||
flask \
|
||||
flask-cors \
|
||||
psutil \
|
||||
@@ -289,11 +301,15 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
|
||||
PyJWT \
|
||||
pyotp \
|
||||
segno \
|
||||
googletrans==4.0.0-rc1 \
|
||||
httpx==0.13.3 \
|
||||
httpcore==0.9.1 \
|
||||
beautifulsoup4
|
||||
|
||||
# Phase 3: Install WebSocket with newer h11
|
||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
|
||||
h11>=0.14.0 \
|
||||
wsproto>=1.2.0 \
|
||||
simple-websocket>=0.10.0 \
|
||||
flask-sock>=0.6.0
|
||||
|
||||
cat > "$APP_DIR/usr/lib/python3/dist-packages/cgi.py" << 'PYEOF'
|
||||
from typing import Tuple, Dict
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script Runner System for ProxMenux
|
||||
Executes bash scripts and provides real-time log streaming with interactive menu support
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
class ScriptRunner:
|
||||
"""Manages script execution with real-time log streaming and menu interactions"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_sessions = {}
|
||||
self.log_dir = Path("/var/log/proxmenux/scripts")
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.interaction_handlers = {}
|
||||
|
||||
def create_session(self, script_name):
|
||||
"""Create a new script execution session"""
|
||||
session_id = str(uuid.uuid4())[:8]
|
||||
log_file = self.log_dir / f"{script_name}_{session_id}_{int(time.time())}.log"
|
||||
|
||||
self.active_sessions[session_id] = {
|
||||
'script_name': script_name,
|
||||
'log_file': str(log_file),
|
||||
'start_time': datetime.now().isoformat(),
|
||||
'status': 'initializing',
|
||||
'process': None,
|
||||
'exit_code': None,
|
||||
'pending_interaction': None
|
||||
}
|
||||
|
||||
return session_id
|
||||
|
||||
def execute_script(self, script_path, session_id, env_vars=None):
|
||||
"""Execute a script in web mode with logging"""
|
||||
if session_id not in self.active_sessions:
|
||||
return {'success': False, 'error': 'Invalid session ID'}
|
||||
|
||||
session = self.active_sessions[session_id]
|
||||
log_file = session['log_file']
|
||||
|
||||
print(f"[DEBUG] execute_script called for session {session_id}", file=sys.stderr, flush=True)
|
||||
print(f"[DEBUG] Script path: {script_path}", file=sys.stderr, flush=True)
|
||||
print(f"[DEBUG] Log file: {log_file}", file=sys.stderr, flush=True)
|
||||
|
||||
# Prepare environment
|
||||
env = os.environ.copy()
|
||||
env['EXECUTION_MODE'] = 'web'
|
||||
env['LOG_FILE'] = log_file
|
||||
|
||||
if env_vars:
|
||||
env.update(env_vars)
|
||||
|
||||
print(f"[DEBUG] Environment variables set: EXECUTION_MODE=web, LOG_FILE={log_file}", file=sys.stderr, flush=True)
|
||||
|
||||
# Initialize log file
|
||||
with open(log_file, 'w') as f:
|
||||
init_line = json.dumps({
|
||||
'type': 'init',
|
||||
'session_id': session_id,
|
||||
'script': script_path,
|
||||
'timestamp': int(time.time())
|
||||
}) + '\n'
|
||||
f.write(init_line)
|
||||
print(f"[DEBUG] Wrote init line to log: {init_line.strip()}", file=sys.stderr, flush=True)
|
||||
|
||||
try:
|
||||
# Execute script
|
||||
session['status'] = 'running'
|
||||
print(f"[DEBUG] Starting subprocess with /bin/bash {script_path}", file=sys.stderr, flush=True)
|
||||
|
||||
process = subprocess.Popen(
|
||||
['/bin/bash', script_path],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
bufsize=0 # Unbuffered
|
||||
)
|
||||
|
||||
print(f"[DEBUG] Process started with PID: {process.pid}", file=sys.stderr, flush=True)
|
||||
session['process'] = process
|
||||
|
||||
lines_read = [0] # Lista para compartir entre threads
|
||||
|
||||
def monitor_output():
|
||||
print(f"[DEBUG] monitor_output thread started for session {session_id}", file=sys.stderr, flush=True)
|
||||
print(f"[DEBUG] Will monitor log file: {log_file}", file=sys.stderr, flush=True)
|
||||
|
||||
try:
|
||||
# Read log file in real-time (similar to tail -f)
|
||||
last_position = 0
|
||||
|
||||
# Wait a moment for script to start writing
|
||||
time.sleep(0.5)
|
||||
|
||||
while process.poll() is None or last_position < os.path.getsize(log_file):
|
||||
try:
|
||||
if os.path.exists(log_file):
|
||||
with open(log_file, 'r') as log_f:
|
||||
log_f.seek(last_position)
|
||||
new_lines = log_f.readlines()
|
||||
|
||||
for line in new_lines:
|
||||
decoded_line = line.rstrip()
|
||||
if decoded_line: # Skip empty lines
|
||||
lines_read[0] += 1
|
||||
print(f"[DEBUG] Read line {lines_read[0]} from log: {decoded_line[:100]}...", file=sys.stderr, flush=True)
|
||||
|
||||
# Check for interaction requests in the line
|
||||
if 'WEB_INTERACTION:' in decoded_line:
|
||||
print(f"[DEBUG] Detected WEB_INTERACTION line: {decoded_line}", file=sys.stderr, flush=True)
|
||||
session['pending_interaction'] = decoded_line
|
||||
|
||||
last_position = log_f.tell()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DEBUG ERROR] Error reading log file: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
time.sleep(0.1) # Poll every 100ms
|
||||
|
||||
print(f"[DEBUG] monitor_output thread finished. Total lines read: {lines_read[0]}", file=sys.stderr, flush=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DEBUG ERROR] Exception in monitor_output: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
monitor_thread = threading.Thread(target=monitor_output, daemon=False)
|
||||
monitor_thread.start()
|
||||
|
||||
print(f"[DEBUG] Waiting for process to complete...", file=sys.stderr, flush=True)
|
||||
|
||||
# Wait for completion
|
||||
process.wait()
|
||||
print(f"[DEBUG] Process exited with code: {process.returncode}", file=sys.stderr, flush=True)
|
||||
|
||||
monitor_thread.join(timeout=30)
|
||||
if monitor_thread.is_alive():
|
||||
print(f"[DEBUG WARNING] monitor_thread still alive after 30s timeout", file=sys.stderr, flush=True)
|
||||
else:
|
||||
print(f"[DEBUG] monitor_thread joined successfully", file=sys.stderr, flush=True)
|
||||
|
||||
session['exit_code'] = process.returncode
|
||||
session['status'] = 'completed' if process.returncode == 0 else 'failed'
|
||||
session['end_time'] = datetime.now().isoformat()
|
||||
|
||||
print(f"[DEBUG] Script execution completed. Lines captured: {lines_read[0]}", file=sys.stderr, flush=True)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'session_id': session_id,
|
||||
'exit_code': process.returncode,
|
||||
'log_file': log_file
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DEBUG ERROR] Exception in execute_script: {e}", file=sys.stderr, flush=True)
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(e)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def get_session_status(self, session_id):
|
||||
"""Get current status of a script execution session"""
|
||||
if session_id not in self.active_sessions:
|
||||
return {'success': False, 'error': 'Session not found'}
|
||||
|
||||
session = self.active_sessions[session_id]
|
||||
return {
|
||||
'success': True,
|
||||
'session_id': session_id,
|
||||
'status': session['status'],
|
||||
'start_time': session['start_time'],
|
||||
'script_name': session['script_name'],
|
||||
'exit_code': session['exit_code'],
|
||||
'pending_interaction': session.get('pending_interaction')
|
||||
}
|
||||
|
||||
def respond_to_interaction(self, session_id, interaction_id, value):
|
||||
"""Respond to a script interaction request"""
|
||||
if session_id not in self.active_sessions:
|
||||
return {'success': False, 'error': 'Session not found'}
|
||||
|
||||
session = self.active_sessions[session_id]
|
||||
|
||||
# Write response to file that script is waiting for
|
||||
response_file = f"/tmp/nvidia_response_{interaction_id}.json"
|
||||
with open(response_file, 'w') as f:
|
||||
json.dump({
|
||||
'interaction_id': interaction_id,
|
||||
'value': value,
|
||||
'timestamp': int(time.time())
|
||||
}, f)
|
||||
|
||||
# Clear pending interaction
|
||||
session['pending_interaction'] = None
|
||||
|
||||
return {'success': True}
|
||||
|
||||
def stream_logs(self, session_id):
|
||||
"""Generator that yields log entries as they are written"""
|
||||
if session_id not in self.active_sessions:
|
||||
yield json.dumps({'type': 'error', 'message': 'Invalid session ID'})
|
||||
return
|
||||
|
||||
session = self.active_sessions[session_id]
|
||||
log_file = session['log_file']
|
||||
|
||||
# Wait for log file to be created
|
||||
timeout = 10
|
||||
start = time.time()
|
||||
while not os.path.exists(log_file) and (time.time() - start) < timeout:
|
||||
time.sleep(0.1)
|
||||
|
||||
if not os.path.exists(log_file):
|
||||
yield json.dumps({'type': 'error', 'message': 'Log file not created'})
|
||||
return
|
||||
|
||||
# Stream log file
|
||||
with open(log_file, 'r') as f:
|
||||
# Start from beginning
|
||||
f.seek(0)
|
||||
|
||||
while session['status'] in ['initializing', 'running']:
|
||||
line = f.readline()
|
||||
if line:
|
||||
# Try to parse as JSON, yield as-is if not JSON
|
||||
try:
|
||||
log_entry = json.loads(line.strip())
|
||||
yield json.dumps(log_entry)
|
||||
except json.JSONDecodeError:
|
||||
yield json.dumps({'type': 'raw', 'message': line.strip()})
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
|
||||
# Read any remaining lines after completion
|
||||
for line in f:
|
||||
try:
|
||||
log_entry = json.loads(line.strip())
|
||||
yield json.dumps(log_entry)
|
||||
except json.JSONDecodeError:
|
||||
yield json.dumps({'type': 'raw', 'message': line.strip()})
|
||||
|
||||
def cleanup_session(self, session_id):
|
||||
"""Clean up a completed session"""
|
||||
if session_id in self.active_sessions:
|
||||
del self.active_sessions[session_id]
|
||||
return {'success': True}
|
||||
return {'success': False, 'error': 'Session not found'}
|
||||
|
||||
# Global instance
|
||||
script_runner = ScriptRunner()
|
||||
@@ -1,49 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ProxMenux Flask Server
|
||||
Provides REST API endpoints for Proxmox monitoring data
|
||||
Runs on port 8008 and serves system metrics, storage info, network stats, etc.
|
||||
Also serves the Next.js dashboard as static files
|
||||
|
||||
- Provides REST API endpoints for Proxmox monitoring (system, storage, network, VMs, etc.)
|
||||
- Serves the Next.js dashboard as static files
|
||||
- Integrates a web terminal powered by xterm.js
|
||||
"""
|
||||
|
||||
from flask import Flask, jsonify, request, send_from_directory, send_file
|
||||
from flask_cors import CORS
|
||||
import psutil
|
||||
import subprocess
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
import urllib.parse
|
||||
import hardware_monitor
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
import re # Added for regex matching
|
||||
import select # Added for non-blocking read
|
||||
import shutil # Added for shutil.which
|
||||
import xml.etree.ElementTree as ET # Added for XML parsing
|
||||
import math # Imported math for format_bytes function
|
||||
import urllib.parse # Added for URL encoding
|
||||
import platform # Added for platform.release()
|
||||
import hashlib
|
||||
import secrets
|
||||
import jwt
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from flask_health_routes import health_bp
|
||||
import jwt
|
||||
import psutil
|
||||
from flask import Flask, jsonify, request, send_file, send_from_directory, Response
|
||||
from flask_cors import CORS
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
# Ensure local imports work even if working directory changes
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if BASE_DIR not in sys.path:
|
||||
sys.path.insert(0, BASE_DIR)
|
||||
|
||||
from flask_auth_routes import auth_bp
|
||||
from flask_proxmenux_routes import proxmenux_bp
|
||||
from jwt_middleware import require_auth
|
||||
from flask_script_runner import script_runner
|
||||
import threading
|
||||
from proxmox_storage_monitor import proxmox_storage_monitor
|
||||
from flask_terminal_routes import terminal_bp, init_terminal_routes # noqa: E402
|
||||
from flask_health_routes import health_bp # noqa: E402
|
||||
from flask_auth_routes import auth_bp # noqa: E402
|
||||
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
||||
from jwt_middleware import require_auth # noqa: E402
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Logging
|
||||
# -------------------------------------------------------------------
|
||||
logger = logging.getLogger("proxmenux.flask")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Proxmox node name cache
|
||||
# -------------------------------------------------------------------
|
||||
_PROXMOX_NODE_CACHE = {"name": None, "timestamp": 0.0}
|
||||
_PROXMOX_NODE_CACHE_TTL = 300 # seconds (5 minutes)
|
||||
|
||||
|
||||
def get_proxmox_node_name() -> str:
|
||||
"""
|
||||
Retrieve the real Proxmox node name.
|
||||
|
||||
- First tries reading from: `pvesh get /nodes`
|
||||
- Uses an in-memory cache to avoid repeated API calls
|
||||
- Falls back to the short hostname if the API call fails
|
||||
"""
|
||||
now = time.time()
|
||||
cached_name = _PROXMOX_NODE_CACHE.get("name")
|
||||
cached_ts = _PROXMOX_NODE_CACHE.get("timestamp", 0.0)
|
||||
|
||||
# Cache hit
|
||||
if cached_name and (now - float(cached_ts)) < _PROXMOX_NODE_CACHE_TTL:
|
||||
return str(cached_name)
|
||||
|
||||
# Try Proxmox API
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pvesh", "get", "/nodes", "--output-format", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout:
|
||||
nodes = json.loads(result.stdout)
|
||||
if isinstance(nodes, list) and nodes:
|
||||
node_name = nodes[0].get("node")
|
||||
if node_name:
|
||||
_PROXMOX_NODE_CACHE["name"] = node_name
|
||||
_PROXMOX_NODE_CACHE["timestamp"] = now
|
||||
return node_name
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to get Proxmox node name from API: %s", exc)
|
||||
|
||||
# Fallback: short hostname (without domain)
|
||||
hostname = socket.gethostname()
|
||||
short_hostname = hostname.split(".", 1)[0]
|
||||
return short_hostname
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Flask application and Blueprints
|
||||
# -------------------------------------------------------------------
|
||||
app = Flask(__name__)
|
||||
CORS(app) # Enable CORS for Next.js frontend
|
||||
|
||||
# Register Blueprints
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(proxmenux_bp)
|
||||
|
||||
# Initialize terminal / WebSocket routes
|
||||
init_terminal_routes(app)
|
||||
|
||||
|
||||
def identify_gpu_type(name, vendor=None, bus=None, driver=None):
|
||||
@@ -451,7 +525,8 @@ def get_vm_lxc_names():
|
||||
vm_lxc_map = {}
|
||||
|
||||
try:
|
||||
local_node = socket.gethostname()
|
||||
# local_node = socket.gethostname()
|
||||
local_node = get_proxmox_node_name()
|
||||
|
||||
result = subprocess.run(['pvesh', 'get', '/cluster/resources', '--type', 'vm', '--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
@@ -1645,7 +1720,8 @@ def get_smart_data(disk_name):
|
||||
def get_proxmox_storage():
|
||||
"""Get Proxmox storage information using pvesh (filtered by local node)"""
|
||||
try:
|
||||
local_node = socket.gethostname()
|
||||
# local_node = socket.gethostname()
|
||||
local_node = get_proxmox_node_name()
|
||||
|
||||
result = subprocess.run(['pvesh', 'get', '/cluster/resources', '--type', 'storage', '--output-format', 'json'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
@@ -1685,18 +1761,7 @@ def get_proxmox_storage():
|
||||
pass
|
||||
continue
|
||||
|
||||
# Si total es 0, significa que hay un error de conexión o el datastore no está disponible
|
||||
if total == 0:
|
||||
# print(f"[v0] Skipping storage {name} - invalid data (total=0, likely connection error)")
|
||||
pass
|
||||
continue
|
||||
|
||||
# Si el status es "inactive", también lo omitimos
|
||||
if status.lower() != "available":
|
||||
# print(f"[v0] Skipping storage {name} - status is not available: {status}")
|
||||
pass
|
||||
continue
|
||||
|
||||
# No filtrar storages no disponibles - mantenerlos para mostrar errores
|
||||
# Calcular porcentaje
|
||||
percent = (used / total * 100) if total > 0 else 0.0
|
||||
|
||||
@@ -1705,10 +1770,18 @@ def get_proxmox_storage():
|
||||
used_gb = round(used / (1024**3), 2)
|
||||
available_gb = round(available / (1024**3), 2)
|
||||
|
||||
# Determine storage status
|
||||
if total == 0:
|
||||
storage_status = 'error'
|
||||
elif status.lower() != "available":
|
||||
storage_status = 'error'
|
||||
else:
|
||||
storage_status = 'active'
|
||||
|
||||
storage_info = {
|
||||
'name': name,
|
||||
'type': storage_type,
|
||||
'status': 'active', # Normalizar status para compatibilidad con frontend
|
||||
'status': storage_status, # Usar el status determinado (active o error)
|
||||
'total': total_gb,
|
||||
'used': used_gb,
|
||||
'available': available_gb,
|
||||
@@ -1719,6 +1792,17 @@ def get_proxmox_storage():
|
||||
|
||||
storage_list.append(storage_info)
|
||||
|
||||
# Get unavailable storages from monitor
|
||||
storage_status_data = proxmox_storage_monitor.get_storage_status()
|
||||
unavailable_storages = storage_status_data.get('unavailable', [])
|
||||
|
||||
# Get list of storage names already added
|
||||
existing_storage_names = {s['name'] for s in storage_list}
|
||||
|
||||
# Add unavailable storages to the list (only if not already present)
|
||||
for unavailable_storage in unavailable_storages:
|
||||
if unavailable_storage['name'] not in existing_storage_names:
|
||||
storage_list.append(unavailable_storage)
|
||||
|
||||
return {'storage': storage_list}
|
||||
|
||||
@@ -1970,7 +2054,8 @@ def get_network_info():
|
||||
'bridge_interfaces': [], # Added separate list for bridge interfaces
|
||||
'vm_lxc_interfaces': [],
|
||||
'traffic': {'bytes_sent': 0, 'bytes_recv': 0, 'packets_sent': 0, 'packets_recv': 0},
|
||||
'hostname': socket.gethostname(),
|
||||
# 'hostname': socket.gethostname(),
|
||||
'hostname': get_proxmox_node_name(),
|
||||
'domain': None,
|
||||
'dns_servers': []
|
||||
}
|
||||
@@ -2197,7 +2282,9 @@ def get_proxmox_vms():
|
||||
all_vms = []
|
||||
|
||||
try:
|
||||
local_node = socket.gethostname()
|
||||
# local_node = socket.gethostname()
|
||||
local_node = get_proxmox_node_name()
|
||||
|
||||
# print(f"[v0] Local node detected: {local_node}")
|
||||
pass
|
||||
|
||||
@@ -2508,10 +2595,11 @@ def get_ups_info():
|
||||
# END OF CHANGES FOR get_ups_info
|
||||
|
||||
|
||||
def identify_temperature_sensor(sensor_name, adapter):
|
||||
def identify_temperature_sensor(sensor_name, adapter, chip_name=None):
|
||||
"""Identify what a temperature sensor corresponds to"""
|
||||
sensor_lower = sensor_name.lower()
|
||||
adapter_lower = adapter.lower() if adapter else ""
|
||||
chip_lower = chip_name.lower() if chip_name else ""
|
||||
|
||||
# CPU/Package temperatures
|
||||
if "package" in sensor_lower or "tctl" in sensor_lower or "tccd" in sensor_lower:
|
||||
@@ -2519,6 +2607,18 @@ def identify_temperature_sensor(sensor_name, adapter):
|
||||
if "core" in sensor_lower:
|
||||
core_num = re.search(r'(\d+)', sensor_name)
|
||||
return f"CPU Core {core_num.group(1)}" if core_num else "CPU Core"
|
||||
|
||||
# <CHANGE> DDR5 Memory temperature sensors (SPD5118)
|
||||
if "spd5118" in chip_lower or ("smbus" in adapter_lower and "temp1" in sensor_lower):
|
||||
# Try to identify which DIMM slot
|
||||
# Example: spd5118-i2c-0-50 -> i2c bus 0, address 0x50 (DIMM A1)
|
||||
# Addresses: 0x50=DIMM1, 0x51=DIMM2, 0x52=DIMM3, 0x53=DIMM4, etc.
|
||||
dimm_match = re.search(r'i2c-\d+-([0-9a-f]+)', chip_lower)
|
||||
if dimm_match:
|
||||
i2c_addr = int(dimm_match.group(1), 16)
|
||||
dimm_num = (i2c_addr - 0x50) + 1
|
||||
return f"DDR5 DIMM {dimm_num}"
|
||||
return "DDR5 Memory"
|
||||
|
||||
# Motherboard/Chipset
|
||||
if "temp1" in sensor_lower and ("isa" in adapter_lower or "acpi" in adapter_lower):
|
||||
@@ -2532,16 +2632,103 @@ def identify_temperature_sensor(sensor_name, adapter):
|
||||
if "sata" in sensor_lower or "ata" in sensor_lower:
|
||||
return "SATA Drive"
|
||||
|
||||
# GPU
|
||||
if any(gpu in adapter_lower for gpu in ["nouveau", "amdgpu", "radeon", "i915"]):
|
||||
# GPU - Enhanced detection using both adapter and chip name
|
||||
if any(gpu_driver in (adapter_lower + " " + chip_lower) for gpu_driver in ["nouveau", "amdgpu", "radeon", "i915"]):
|
||||
gpu_vendor = None
|
||||
|
||||
# Determine GPU vendor from driver
|
||||
if "nouveau" in adapter_lower or "nouveau" in chip_lower:
|
||||
gpu_vendor = "NVIDIA"
|
||||
elif "amdgpu" in adapter_lower or "amdgpu" in chip_lower or "radeon" in adapter_lower or "radeon" in chip_lower:
|
||||
gpu_vendor = "AMD"
|
||||
elif "i915" in adapter_lower or "i915" in chip_lower:
|
||||
gpu_vendor = "Intel"
|
||||
|
||||
# Try to get detailed GPU name from lspci if possible
|
||||
if gpu_vendor:
|
||||
# Extract PCI address from chip name or adapter
|
||||
pci_match = re.search(r'pci-([0-9a-f]{4})', adapter_lower + " " + chip_lower)
|
||||
|
||||
if pci_match:
|
||||
pci_code = pci_match.group(1)
|
||||
pci_address = f"{pci_code[0:2]}:{pci_code[2:4]}.0"
|
||||
|
||||
# Try to get detailed GPU name from hardware_monitor
|
||||
try:
|
||||
gpu_map = hardware_monitor.get_pci_gpu_map()
|
||||
if pci_address in gpu_map:
|
||||
gpu_info = gpu_map[pci_address]
|
||||
return f"GPU {gpu_info['vendor']} {gpu_info['name']}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: return vendor name only
|
||||
return f"GPU {gpu_vendor}"
|
||||
|
||||
return "GPU"
|
||||
|
||||
# Network adapters
|
||||
# Network adapters and other PCI devices
|
||||
if "pci" in adapter_lower and "temp" in sensor_lower:
|
||||
return "PCI Device"
|
||||
|
||||
return sensor_name
|
||||
|
||||
|
||||
def identify_fan(sensor_name, adapter, chip_name=None):
|
||||
"""Identify what a fan sensor corresponds to, using hardware_monitor for GPU detection"""
|
||||
sensor_lower = sensor_name.lower()
|
||||
adapter_lower = adapter.lower() if adapter else ""
|
||||
chip_lower = chip_name.lower() if chip_name else "" # <CHANGE> Add chip name
|
||||
|
||||
# GPU fans - Check both adapter and chip name for GPU drivers
|
||||
if "pci adapter" in adapter_lower or "pci adapter" in chip_lower or any(gpu_driver in adapter_lower + chip_lower for gpu_driver in ["nouveau", "amdgpu", "radeon", "i915"]):
|
||||
gpu_vendor = None
|
||||
|
||||
# Determine GPU vendor from driver
|
||||
if "nouveau" in adapter_lower or "nouveau" in chip_lower:
|
||||
gpu_vendor = "NVIDIA"
|
||||
elif "amdgpu" in adapter_lower or "amdgpu" in chip_lower or "radeon" in adapter_lower or "radeon" in chip_lower:
|
||||
gpu_vendor = "AMD"
|
||||
elif "i915" in adapter_lower or "i915" in chip_lower:
|
||||
gpu_vendor = "Intel"
|
||||
|
||||
# Try to get detailed GPU name from lspci if possible
|
||||
if gpu_vendor:
|
||||
# Extract PCI address from adapter string
|
||||
# Example: "nouveau-pci-0200" -> "02:00.0"
|
||||
pci_match = re.search(r'pci-([0-9a-f]{4})', adapter_lower + " " + chip_lower)
|
||||
|
||||
if pci_match:
|
||||
pci_code = pci_match.group(1)
|
||||
pci_address = f"{pci_code[0:2]}:{pci_code[2:4]}.0"
|
||||
|
||||
# Try to get detailed GPU name from hardware_monitor
|
||||
try:
|
||||
gpu_map = hardware_monitor.get_pci_gpu_map()
|
||||
if pci_address in gpu_map:
|
||||
gpu_info = gpu_map[pci_address]
|
||||
return f"GPU {gpu_info['vendor']} {gpu_info['name']}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: return vendor name only
|
||||
return f"GPU {gpu_vendor}"
|
||||
|
||||
# Ultimate fallback if vendor detection fails
|
||||
return "GPU"
|
||||
|
||||
# CPU/System fans - keep original name
|
||||
if any(cpu_fan in sensor_lower for cpu_fan in ["cpu_fan", "cpufan", "sys_fan", "sysfan"]):
|
||||
return sensor_name
|
||||
|
||||
# Chassis fans - keep original name
|
||||
if "chassis" in sensor_lower or "case" in sensor_lower:
|
||||
return sensor_name
|
||||
|
||||
# Default: return original name
|
||||
return sensor_name
|
||||
|
||||
|
||||
def get_temperature_info():
|
||||
"""Get detailed temperature information from sensors command"""
|
||||
temperatures = []
|
||||
@@ -2551,6 +2738,7 @@ def get_temperature_info():
|
||||
result = subprocess.run(['sensors'], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
current_adapter = None
|
||||
current_chip = None
|
||||
current_sensor = None
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
@@ -2558,11 +2746,16 @@ def get_temperature_info():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Detect chip name (e.g., "nouveau-pci-0200")
|
||||
if not ':' in line and not line.startswith(' ') and not line.startswith('Adapter'):
|
||||
current_chip = line
|
||||
continue
|
||||
|
||||
# Detect adapter line
|
||||
if line.startswith('Adapter:'):
|
||||
current_adapter = line.replace('Adapter:', '').strip()
|
||||
continue
|
||||
|
||||
|
||||
# Detect sensor name (lines without ':' at the start are sensor names)
|
||||
if ':' in line and not line.startswith(' '):
|
||||
parts = line.split(':', 1)
|
||||
@@ -2599,8 +2792,14 @@ def get_temperature_info():
|
||||
|
||||
high_value = float(high_match.group(1)) if high_match else 0
|
||||
crit_value = float(crit_match.group(1)) if crit_match else 0
|
||||
# Skip internal NVMe sensors (only keep Composite)
|
||||
if current_chip and 'nvme' in current_chip.lower():
|
||||
sensor_lower_check = sensor_name.lower()
|
||||
# Skip "Sensor 1", "Sensor 2", "Sensor 8", etc. (keep only "Composite")
|
||||
if sensor_lower_check.startswith('sensor') and sensor_lower_check.replace('sensor', '').strip().split()[0].isdigit():
|
||||
continue
|
||||
|
||||
identified_name = identify_temperature_sensor(sensor_name, current_adapter)
|
||||
identified_name = identify_temperature_sensor(sensor_name, current_adapter, current_chip)
|
||||
|
||||
temperatures.append({
|
||||
'name': identified_name,
|
||||
@@ -2625,7 +2824,30 @@ def get_temperature_info():
|
||||
except Exception as e:
|
||||
# print(f"[v0] Error getting temperature info: {e}")
|
||||
pass
|
||||
|
||||
if power_meter is None:
|
||||
try:
|
||||
rapl_power = hardware_monitor.get_power_info()
|
||||
if rapl_power:
|
||||
power_meter = rapl_power
|
||||
# print(f"[v0] Power meter from RAPL: {power_meter.get('watts', 0)}W")
|
||||
pass
|
||||
except Exception as e:
|
||||
# print(f"[v0] Error getting RAPL power info: {e}")
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
hba_temps = hardware_monitor.get_hba_temperatures()
|
||||
for hba_temp in hba_temps:
|
||||
temperatures.append({
|
||||
'name': hba_temp['name'],
|
||||
'value': hba_temp['temperature'],
|
||||
'adapter': hba_temp['adapter']
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'temperatures': temperatures,
|
||||
'power_meter': power_meter
|
||||
@@ -4401,6 +4623,7 @@ def get_hardware_info():
|
||||
result = subprocess.run(['sensors'], capture_output=True, text=True, timeout=5)
|
||||
if result.returncode == 0:
|
||||
current_adapter = None
|
||||
current_chip = None # <CHANGE> Add chip name tracking
|
||||
fans = []
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
@@ -4408,6 +4631,12 @@ def get_hardware_info():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# <CHANGE> Detect chip name (e.g., "nouveau-pci-0200")
|
||||
# Chip names don't have ":" and are not indented
|
||||
if not ':' in line and not line.startswith(' ') and not line.startswith('Adapter'):
|
||||
current_chip = line
|
||||
continue
|
||||
|
||||
# Detect adapter line
|
||||
if line.startswith('Adapter:'):
|
||||
current_adapter = line.replace('Adapter:', '').strip()
|
||||
@@ -4425,9 +4654,7 @@ def get_hardware_info():
|
||||
if rpm_match:
|
||||
fan_speed = int(float(rpm_match.group(1)))
|
||||
|
||||
# Placeholder for identify_fan - needs implementation
|
||||
# identified_name = identify_fan(sensor_name, current_adapter)
|
||||
identified_name = sensor_name # Use original name for now
|
||||
identified_name = identify_fan(sensor_name, current_adapter, current_chip)
|
||||
|
||||
fans.append({
|
||||
'name': identified_name,
|
||||
@@ -4564,6 +4791,7 @@ def api_system():
|
||||
'uptime': uptime,
|
||||
'load_average': list(load_avg),
|
||||
'hostname': socket.gethostname(),
|
||||
'proxmox_node': get_proxmox_node_name(),
|
||||
'node_id': socket.gethostname(),
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'cpu_cores': cpu_cores,
|
||||
@@ -4691,7 +4919,8 @@ def api_network_interface_metrics(interface_name):
|
||||
return jsonify({'error': f'Invalid timeframe. Must be one of: {", ".join(valid_timeframes)}'}), 400
|
||||
|
||||
# Get local node name
|
||||
local_node = socket.gethostname()
|
||||
# local_node = socket.gethostname()
|
||||
local_node = get_proxmox_node_name()
|
||||
|
||||
|
||||
# Determine interface type and get appropriate RRD data
|
||||
@@ -4780,7 +5009,8 @@ def api_vm_metrics(vmid):
|
||||
return jsonify({'error': f'Invalid timeframe. Must be one of: {", ".join(valid_timeframes)}'}), 400
|
||||
|
||||
# Get local node name
|
||||
local_node = socket.gethostname()
|
||||
# local_node = socket.gethostname()
|
||||
local_node = get_proxmox_node_name()
|
||||
|
||||
|
||||
# First, determine if it's a qemu VM or lxc container
|
||||
@@ -4847,10 +5077,26 @@ def api_node_metrics():
|
||||
return jsonify({'error': f'Invalid timeframe. Must be one of: {", ".join(valid_timeframes)}'}), 400
|
||||
|
||||
# Get local node name
|
||||
local_node = socket.gethostname()
|
||||
# local_node = socket.gethostname()
|
||||
local_node = get_proxmox_node_name()
|
||||
|
||||
# print(f"[v0] Local node: {local_node}")
|
||||
pass
|
||||
|
||||
|
||||
zfs_arc_size = 0
|
||||
try:
|
||||
with open('/proc/spl/kstat/zfs/arcstats', 'r') as f:
|
||||
for line in f:
|
||||
if line.startswith('size'):
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
zfs_arc_size = int(parts[2])
|
||||
break
|
||||
except (FileNotFoundError, PermissionError, ValueError):
|
||||
# ZFS not available or no access
|
||||
pass
|
||||
|
||||
# Get RRD data for the node
|
||||
|
||||
rrd_result = subprocess.run(['pvesh', 'get', f'/nodes/{local_node}/rrddata',
|
||||
@@ -4858,16 +5104,20 @@ def api_node_metrics():
|
||||
capture_output=True, text=True, timeout=10)
|
||||
|
||||
if rrd_result.returncode == 0:
|
||||
|
||||
rrd_data = json.loads(rrd_result.stdout)
|
||||
|
||||
|
||||
if zfs_arc_size > 0:
|
||||
for item in rrd_data:
|
||||
# If zfsarc field is missing or 0, add current value
|
||||
if 'zfsarc' not in item or item.get('zfsarc', 0) == 0:
|
||||
item['zfsarc'] = zfs_arc_size
|
||||
|
||||
return jsonify({
|
||||
'node': local_node,
|
||||
'timeframe': timeframe,
|
||||
'data': rrd_data
|
||||
})
|
||||
else:
|
||||
|
||||
return jsonify({'error': f'Failed to get RRD data: {rrd_result.stderr}'}), 500
|
||||
|
||||
except Exception as e:
|
||||
@@ -5455,7 +5705,7 @@ def api_health():
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'version': '1.0.1'
|
||||
'version': '1.0.2'
|
||||
})
|
||||
|
||||
@app.route('/api/prometheus', methods=['GET'])
|
||||
@@ -5721,7 +5971,7 @@ def api_info():
|
||||
"""Root endpoint with API information"""
|
||||
return jsonify({
|
||||
'name': 'ProxMenux Monitor API',
|
||||
'version': '1.0.1',
|
||||
'version': '1.0.2',
|
||||
'endpoints': [
|
||||
'/api/system',
|
||||
'/api/system-info',
|
||||
@@ -5850,7 +6100,8 @@ def get_vm_config(vmid):
|
||||
"""Get detailed configuration for a specific VM/LXC"""
|
||||
try:
|
||||
# Get VM/LXC configuration
|
||||
node = socket.gethostname() # Get node name
|
||||
# node = socket.gethostname() # Get node name
|
||||
node = get_proxmox_node_name()
|
||||
|
||||
result = subprocess.run(
|
||||
['pvesh', 'get', f'/nodes/{node}/qemu/{vmid}/config', '--output-format', 'json'],
|
||||
@@ -6093,6 +6344,87 @@ def api_vm_config_update(vmid):
|
||||
pass
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/scripts/execute', methods=['POST'])
|
||||
def execute_script():
|
||||
"""Execute a script with real-time logging"""
|
||||
try:
|
||||
data = request.json
|
||||
script_name = data.get('script_name')
|
||||
script_params = data.get('params', {})
|
||||
|
||||
|
||||
script_relative_path = data.get('script_relative_path')
|
||||
|
||||
if not script_relative_path:
|
||||
return jsonify({'error': 'script_relative_path is required'}), 400
|
||||
|
||||
|
||||
BASE_SCRIPTS_DIR = '/usr/local/share/proxmenux/scripts'
|
||||
script_path = os.path.join(BASE_SCRIPTS_DIR, script_relative_path)
|
||||
|
||||
|
||||
script_path = os.path.abspath(script_path)
|
||||
if not script_path.startswith(BASE_SCRIPTS_DIR):
|
||||
return jsonify({'error': 'Invalid script path'}), 403
|
||||
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
return jsonify({'success': False, 'error': 'Script file not found'}), 404
|
||||
|
||||
# Create session and start execution in background thread
|
||||
session_id = script_runner.create_session(script_name)
|
||||
|
||||
def run_script():
|
||||
script_runner.execute_script(script_path, session_id, script_params)
|
||||
|
||||
thread = threading.Thread(target=run_script, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'session_id': session_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/scripts/status/<session_id>', methods=['GET'])
|
||||
def get_script_status(session_id):
|
||||
"""Get status of a running script"""
|
||||
try:
|
||||
status = script_runner.get_session_status(session_id)
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/scripts/respond', methods=['POST'])
|
||||
def respond_to_script():
|
||||
"""Respond to script interaction"""
|
||||
try:
|
||||
data = request.json
|
||||
session_id = data.get('session_id')
|
||||
interaction_id = data.get('interaction_id')
|
||||
value = data.get('value')
|
||||
|
||||
result = script_runner.respond_to_interaction(session_id, interaction_id, value)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/scripts/logs/<session_id>', methods=['GET'])
|
||||
def stream_script_logs(session_id):
|
||||
"""Stream logs from a running script"""
|
||||
try:
|
||||
def generate():
|
||||
for log_entry in script_runner.stream_logs(session_id):
|
||||
yield f"data: {log_entry}\n\n"
|
||||
|
||||
return Response(generate(), mimetype='text/event-stream')
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# API endpoints available at: /api/system, /api/system-info, /api/storage, /api/proxmox-storage, /api/network, /api/vms, /api/logs, /api/health, /api/hardware, /api/prometheus, /api/node/metrics
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ProxMenux Terminal WebSocket Routes
|
||||
Provides a WebSocket endpoint for interactive terminal sessions
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask_sock import Sock
|
||||
import subprocess
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import struct
|
||||
import fcntl
|
||||
import termios
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
import json
|
||||
import tempfile
|
||||
import base64
|
||||
|
||||
terminal_bp = Blueprint('terminal', __name__)
|
||||
sock = Sock()
|
||||
|
||||
# Active terminal sessions
|
||||
active_sessions = {}
|
||||
|
||||
@terminal_bp.route('/api/terminal/health', methods=['GET'])
|
||||
def terminal_health():
|
||||
"""Health check for terminal service"""
|
||||
return {'success': True, 'active_sessions': len(active_sessions)}
|
||||
|
||||
@terminal_bp.route('/api/terminal/search-command', methods=['GET'])
|
||||
def search_command():
|
||||
"""Proxy endpoint for cheat.sh API to avoid CORS issues"""
|
||||
query = request.args.get('q', '')
|
||||
|
||||
if not query or len(query) < 2:
|
||||
return jsonify({'error': 'Query too short'}), 400
|
||||
|
||||
try:
|
||||
url = f'https://cht.sh/{query.replace(" ", "+")}?QT'
|
||||
headers = {
|
||||
'User-Agent': 'curl/7.68.0'
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
content = response.text
|
||||
examples = []
|
||||
current_description = []
|
||||
|
||||
for line in content.split('\n'):
|
||||
stripped = line.strip()
|
||||
|
||||
# Ignorar líneas vacías
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
# Si es un comentario
|
||||
if stripped.startswith('#'):
|
||||
# Acumular descripciones
|
||||
current_description.append(stripped[1:].strip())
|
||||
# Si no es comentario, es un comando
|
||||
elif stripped and not stripped.startswith('http'):
|
||||
# Unir las descripciones acumuladas
|
||||
description = ' '.join(current_description) if current_description else ''
|
||||
|
||||
examples.append({
|
||||
'description': description,
|
||||
'command': stripped
|
||||
})
|
||||
|
||||
# Resetear descripciones para el siguiente comando
|
||||
current_description = []
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'examples': examples
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'API returned status {response.status_code}'
|
||||
}), response.status_code
|
||||
|
||||
except requests.Timeout:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Request timeout'
|
||||
}), 504
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
def set_winsize(fd, rows, cols):
|
||||
"""Set terminal window size"""
|
||||
try:
|
||||
winsize = struct.pack('HHHH', rows, cols, 0, 0)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
||||
except Exception as e:
|
||||
print(f"Error setting window size: {e}")
|
||||
|
||||
def read_and_forward_output(master_fd, ws):
|
||||
"""Read from PTY and send to WebSocket"""
|
||||
while True:
|
||||
try:
|
||||
# Use select with timeout to check if data is available
|
||||
r, _, _ = select.select([master_fd], [], [], 0.01)
|
||||
if master_fd in r:
|
||||
try:
|
||||
data = os.read(master_fd, 4096)
|
||||
if data:
|
||||
ws.send(data.decode('utf-8', errors='ignore'))
|
||||
else:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error reading from PTY: {e}")
|
||||
break
|
||||
|
||||
@sock.route('/ws/terminal')
|
||||
def terminal_websocket(ws):
|
||||
"""WebSocket endpoint for terminal sessions"""
|
||||
|
||||
# Create pseudo-terminal
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
# Start bash process
|
||||
shell_process = subprocess.Popen(
|
||||
['/bin/bash', '-i'],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
preexec_fn=os.setsid,
|
||||
cwd='/',
|
||||
env=dict(os.environ, TERM='xterm-256color', PS1='\\u@\\h:\\w\\$ ')
|
||||
)
|
||||
|
||||
session_id = id(ws)
|
||||
active_sessions[session_id] = {
|
||||
'process': shell_process,
|
||||
'master_fd': master_fd
|
||||
}
|
||||
|
||||
# Set non-blocking mode for master_fd
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Set initial terminal size
|
||||
set_winsize(master_fd, 30, 120)
|
||||
|
||||
# Start thread to read PTY output and forward to WebSocket
|
||||
output_thread = threading.Thread(
|
||||
target=read_and_forward_output,
|
||||
args=(master_fd, ws),
|
||||
daemon=True
|
||||
)
|
||||
output_thread.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Receive data from WebSocket (blocking)
|
||||
data = ws.receive(timeout=None)
|
||||
|
||||
if data is None:
|
||||
# Client closed connection
|
||||
break
|
||||
|
||||
handled = False
|
||||
|
||||
# Try to handle JSON control messages (e.g. resize)
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
except Exception:
|
||||
msg = None
|
||||
|
||||
if isinstance(msg, dict) and msg.get('type') == 'resize':
|
||||
cols = int(msg.get('cols', 120))
|
||||
rows = int(msg.get('rows', 30))
|
||||
set_winsize(master_fd, rows, cols)
|
||||
handled = True
|
||||
|
||||
if handled:
|
||||
# Control message processed, do not send to bash
|
||||
continue
|
||||
|
||||
# Optional: legacy resize escape sequence support
|
||||
if isinstance(data, str) and data.startswith('\x1b[8;'):
|
||||
try:
|
||||
parts = data[4:-1].split(';')
|
||||
rows, cols = int(parts[0]), int(parts[1])
|
||||
set_winsize(master_fd, rows, cols)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Send input to bash
|
||||
try:
|
||||
os.write(master_fd, data.encode('utf-8'))
|
||||
except OSError as e:
|
||||
print(f"Error writing to PTY: {e}")
|
||||
break
|
||||
|
||||
# Check if process is still alive
|
||||
if shell_process.poll() is not None:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Terminal session error: {e}")
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
shell_process.terminate()
|
||||
shell_process.wait(timeout=1)
|
||||
except:
|
||||
try:
|
||||
shell_process.kill()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.close(slave_fd)
|
||||
except:
|
||||
pass
|
||||
|
||||
if session_id in active_sessions:
|
||||
del active_sessions[session_id]
|
||||
|
||||
@sock.route('/ws/script/<session_id>')
|
||||
def script_websocket(ws, session_id):
|
||||
"""WebSocket endpoint for executing scripts with hybrid web mode"""
|
||||
|
||||
try:
|
||||
init_data = ws.receive(timeout=10)
|
||||
|
||||
if not init_data:
|
||||
error_msg = '{"type": "error", "message": "No script data received"}\r\n'
|
||||
ws.send(error_msg)
|
||||
return
|
||||
|
||||
script_data = json.loads(init_data)
|
||||
|
||||
script_path = script_data.get('script_path')
|
||||
params = script_data.get('params', {})
|
||||
|
||||
if not script_path:
|
||||
error_msg = '{"type": "error", "message": "No script_path provided"}\r\n'
|
||||
ws.send(error_msg)
|
||||
return
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
error_msg = f'{{"type": "error", "message": "Script not found: {script_path}"}}\r\n'
|
||||
ws.send(error_msg)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'{{"type": "error", "message": "Invalid init data: {str(e)}"}}\r\n'
|
||||
ws.send(error_msg)
|
||||
return
|
||||
|
||||
web_log_fd, web_log_path = tempfile.mkstemp(suffix='.log', prefix='proxmenux_web_')
|
||||
|
||||
# Create pseudo-terminal for script execution
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
|
||||
env = os.environ.copy()
|
||||
env['EXECUTION_MODE'] = 'web'
|
||||
env['WEB_LOG'] = web_log_path
|
||||
for key, value in params.items():
|
||||
env[key] = str(value)
|
||||
env['PYTHONUNBUFFERED'] = '1'
|
||||
env['TERM'] = 'xterm-256color'
|
||||
|
||||
script_process = subprocess.Popen(
|
||||
['/bin/bash', script_path],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
preexec_fn=os.setsid,
|
||||
env=env
|
||||
)
|
||||
|
||||
# Set non-blocking mode for master_fd
|
||||
flags = fcntl.fcntl(master_fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
|
||||
# Set terminal size
|
||||
set_winsize(master_fd, 30, 120)
|
||||
|
||||
def monitor_web_log():
|
||||
last_position = 0
|
||||
|
||||
while script_process.poll() is None:
|
||||
try:
|
||||
if os.path.exists(web_log_path):
|
||||
with open(web_log_path, 'r') as f:
|
||||
f.seek(last_position)
|
||||
new_lines = f.readlines()
|
||||
last_position = f.tell()
|
||||
|
||||
for line in new_lines:
|
||||
line = line.strip()
|
||||
if line.startswith('WEB_INTERACTION:'):
|
||||
try:
|
||||
# Parse: WEB_INTERACTION:type:id:title_b64:message_b64[:options_json]
|
||||
parts = line[16:].split(':', 4)
|
||||
interaction_type = parts[0]
|
||||
interaction_id = parts[1]
|
||||
title_b64 = parts[2]
|
||||
message_b64 = parts[3]
|
||||
|
||||
title = base64.b64decode(title_b64).decode('utf-8')
|
||||
message = base64.b64decode(message_b64).decode('utf-8')
|
||||
|
||||
interaction_data = {
|
||||
'type': 'web_interaction',
|
||||
'interaction': {
|
||||
'type': interaction_type,
|
||||
'id': interaction_id,
|
||||
'title': title,
|
||||
'message': message
|
||||
}
|
||||
}
|
||||
|
||||
# Parse options for menu
|
||||
if interaction_type == 'menu' and len(parts) > 4:
|
||||
options_json = parts[4]
|
||||
interaction_data['interaction']['options'] = json.loads(options_json)
|
||||
|
||||
# Parse default for inputbox
|
||||
if interaction_type == 'inputbox' and len(parts) > 4:
|
||||
default_b64 = parts[4]
|
||||
interaction_data['interaction']['default'] = base64.b64decode(default_b64).decode('utf-8')
|
||||
|
||||
# Send interaction to WebSocket
|
||||
ws.send(json.dumps(interaction_data))
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
time.sleep(0.01)
|
||||
except Exception as e:
|
||||
break
|
||||
|
||||
web_log_thread = threading.Thread(target=monitor_web_log, daemon=True)
|
||||
web_log_thread.start()
|
||||
|
||||
# Thread to read script output and forward to WebSocket
|
||||
def read_script_output():
|
||||
while True:
|
||||
try:
|
||||
r, _, _ = select.select([master_fd], [], [], 0.01)
|
||||
if master_fd in r:
|
||||
try:
|
||||
data = os.read(master_fd, 4096)
|
||||
if not data:
|
||||
break
|
||||
|
||||
text = data.decode('utf-8', errors='ignore')
|
||||
|
||||
# Send raw text to terminal
|
||||
try:
|
||||
ws.send(text)
|
||||
except Exception as e:
|
||||
break
|
||||
|
||||
except OSError as e:
|
||||
break
|
||||
except Exception as e:
|
||||
break
|
||||
|
||||
script_process.wait()
|
||||
exit_code = script_process.returncode if script_process.returncode is not None else 0
|
||||
|
||||
try:
|
||||
ws.send(f'\r\n[Script exited with code {exit_code}]\r\n')
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
output_thread = threading.Thread(target=read_script_output, daemon=True)
|
||||
output_thread.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = ws.receive(timeout=None)
|
||||
|
||||
if data is None:
|
||||
break
|
||||
|
||||
try:
|
||||
msg = json.loads(data)
|
||||
|
||||
if msg.get('type') == 'interaction_response':
|
||||
interaction_id = msg.get('id')
|
||||
value = msg.get('value')
|
||||
|
||||
# Write response to the file the script is waiting for
|
||||
response_file = f"/tmp/proxmenux_response_{interaction_id}"
|
||||
|
||||
with open(response_file, 'w') as f:
|
||||
f.write(value)
|
||||
|
||||
continue
|
||||
|
||||
# Handle resize
|
||||
if msg.get('type') == 'resize':
|
||||
cols = int(msg.get('cols', 120))
|
||||
rows = int(msg.get('rows', 30))
|
||||
set_winsize(master_fd, rows, cols)
|
||||
continue
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Raw text input, send to script
|
||||
try:
|
||||
os.write(master_fd, data.encode('utf-8'))
|
||||
except OSError as e:
|
||||
break
|
||||
|
||||
if script_process.poll() is not None:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
script_process.terminate()
|
||||
script_process.wait(timeout=1)
|
||||
except:
|
||||
try:
|
||||
script_process.kill()
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.close(slave_fd)
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
os.close(web_log_fd)
|
||||
os.unlink(web_log_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def init_terminal_routes(app):
|
||||
"""Initialize terminal routes with Flask app"""
|
||||
sock.init_app(app)
|
||||
app.register_blueprint(terminal_bp)
|
||||
@@ -1,369 +1,413 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
"""
|
||||
Hardware Monitor - RAPL Power Monitoring and GPU Identification
|
||||
|
||||
This module provides:
|
||||
1. CPU power consumption monitoring using Intel RAPL (Running Average Power Limit)
|
||||
2. PCI GPU identification for better fan labeling
|
||||
3. HBA controller detection and temperature monitoring
|
||||
|
||||
Only contains these specialized functions - all other hardware monitoring
|
||||
is handled by flask_server.py to avoid code duplication.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import subprocess
|
||||
import re
|
||||
import os
|
||||
from typing import Dict, List, Any, Optional
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
def run_command(cmd: List[str]) -> str:
|
||||
"""Run a command and return its output."""
|
||||
# Global variable to store previous energy reading for power calculation
|
||||
_last_energy_reading = {'energy_uj': None, 'timestamp': None}
|
||||
|
||||
|
||||
def get_pci_gpu_map() -> Dict[str, Dict[str, str]]:
|
||||
"""
|
||||
Get a mapping of PCI addresses to GPU names from lspci.
|
||||
|
||||
This function parses lspci output to identify GPU models by their PCI addresses,
|
||||
which allows us to provide meaningful names for GPU fans in sensors output.
|
||||
|
||||
Returns:
|
||||
dict: Mapping of PCI addresses (e.g., '02:00.0') to GPU info
|
||||
Example: {
|
||||
'02:00.0': {
|
||||
'vendor': 'NVIDIA',
|
||||
'name': 'GeForce GTX 1080',
|
||||
'full_name': 'NVIDIA Corporation GP104 [GeForce GTX 1080]'
|
||||
}
|
||||
}
|
||||
"""
|
||||
gpu_map = {}
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
|
||||
return result.stdout
|
||||
# Run lspci to get VGA/3D/Display controllers
|
||||
result = subprocess.run(
|
||||
['lspci', '-nn'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'VGA compatible controller' in line or '3D controller' in line or 'Display controller' in line:
|
||||
# Example line: "02:00.0 VGA compatible controller [0300]: NVIDIA Corporation GP104 [GeForce GTX 1080] [10de:1b80]"
|
||||
match = re.match(r'^([0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])\s+.*:\s+(.+?)\s+\[([0-9a-f]{4}):([0-9a-f]{4})\]', line)
|
||||
|
||||
if match:
|
||||
pci_address = match.group(1)
|
||||
device_name = match.group(2).strip()
|
||||
|
||||
# Extract vendor
|
||||
vendor = None
|
||||
if 'NVIDIA' in device_name.upper() or 'GEFORCE' in device_name.upper() or 'QUADRO' in device_name.upper():
|
||||
vendor = 'NVIDIA'
|
||||
elif 'AMD' in device_name.upper() or 'RADEON' in device_name.upper():
|
||||
vendor = 'AMD'
|
||||
elif 'INTEL' in device_name.upper() or 'ARC' in device_name.upper():
|
||||
vendor = 'Intel'
|
||||
|
||||
# Extract model name (text between brackets is usually the commercial name)
|
||||
bracket_match = re.search(r'\[([^\]]+)\]', device_name)
|
||||
if bracket_match:
|
||||
model_name = bracket_match.group(1)
|
||||
else:
|
||||
# Fallback: use everything after the vendor name
|
||||
if vendor:
|
||||
model_name = device_name.split(vendor)[-1].strip()
|
||||
else:
|
||||
model_name = device_name
|
||||
|
||||
gpu_map[pci_address] = {
|
||||
'vendor': vendor if vendor else 'Unknown',
|
||||
'name': model_name,
|
||||
'full_name': device_name
|
||||
}
|
||||
|
||||
except Exception:
|
||||
return ""
|
||||
pass
|
||||
|
||||
return gpu_map
|
||||
|
||||
def get_nvidia_gpu_info() -> List[Dict[str, Any]]:
|
||||
"""Get detailed NVIDIA GPU information using nvidia-smi."""
|
||||
gpus = []
|
||||
|
||||
# Check if nvidia-smi is available
|
||||
if not os.path.exists('/usr/bin/nvidia-smi'):
|
||||
return gpus
|
||||
|
||||
try:
|
||||
# Query all GPU metrics at once
|
||||
query_fields = [
|
||||
'index',
|
||||
'name',
|
||||
'driver_version',
|
||||
'memory.total',
|
||||
'memory.used',
|
||||
'memory.free',
|
||||
'temperature.gpu',
|
||||
'utilization.gpu',
|
||||
'utilization.memory',
|
||||
'power.draw',
|
||||
'power.limit',
|
||||
'clocks.current.graphics',
|
||||
'clocks.current.memory',
|
||||
'pcie.link.gen.current',
|
||||
'pcie.link.width.current'
|
||||
]
|
||||
|
||||
cmd = ['nvidia-smi', '--query-gpu=' + ','.join(query_fields), '--format=csv,noheader,nounits']
|
||||
output = run_command(cmd)
|
||||
|
||||
if not output:
|
||||
return gpus
|
||||
|
||||
for line in output.strip().split('\n'):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
values = [v.strip() for v in line.split(',')]
|
||||
if len(values) < len(query_fields):
|
||||
continue
|
||||
|
||||
gpu_info = {
|
||||
'index': values[0],
|
||||
'name': values[1],
|
||||
'driver_version': values[2],
|
||||
'memory_total': f"{values[3]} MiB",
|
||||
'memory_used': f"{values[4]} MiB",
|
||||
'memory_free': f"{values[5]} MiB",
|
||||
'temperature': values[6],
|
||||
'utilization_gpu': values[7],
|
||||
'utilization_memory': values[8],
|
||||
'power_draw': f"{values[9]} W",
|
||||
'power_limit': f"{values[10]} W",
|
||||
'clock_graphics': f"{values[11]} MHz",
|
||||
'clock_memory': f"{values[12]} MHz",
|
||||
'pcie_gen': values[13],
|
||||
'pcie_width': f"x{values[14]}"
|
||||
}
|
||||
|
||||
# Get CUDA version if available
|
||||
cuda_output = run_command(['nvidia-smi', '--query-gpu=compute_cap', '--format=csv,noheader', '-i', values[0]])
|
||||
if cuda_output:
|
||||
gpu_info['compute_capability'] = cuda_output.strip()
|
||||
|
||||
gpus.append(gpu_info)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting NVIDIA GPU info: {e}", file=sys.stderr)
|
||||
|
||||
return gpus
|
||||
|
||||
def get_amd_gpu_info() -> List[Dict[str, Any]]:
|
||||
"""Get AMD GPU information using rocm-smi."""
|
||||
gpus = []
|
||||
|
||||
# Check if rocm-smi is available
|
||||
if not os.path.exists('/opt/rocm/bin/rocm-smi'):
|
||||
return gpus
|
||||
|
||||
try:
|
||||
# Get basic GPU info
|
||||
output = run_command(['/opt/rocm/bin/rocm-smi', '--showid', '--showtemp', '--showuse', '--showmeminfo', 'vram'])
|
||||
|
||||
if not output:
|
||||
return gpus
|
||||
|
||||
# Parse rocm-smi output (format varies, this is a basic parser)
|
||||
current_gpu = None
|
||||
for line in output.split('\n'):
|
||||
if 'GPU[' in line:
|
||||
if current_gpu:
|
||||
gpus.append(current_gpu)
|
||||
current_gpu = {'index': line.split('[')[1].split(']')[0]}
|
||||
elif current_gpu:
|
||||
if 'Temperature' in line:
|
||||
temp_match = re.search(r'(\d+\.?\d*)', line)
|
||||
if temp_match:
|
||||
current_gpu['temperature'] = temp_match.group(1)
|
||||
elif 'GPU use' in line:
|
||||
use_match = re.search(r'(\d+)%', line)
|
||||
if use_match:
|
||||
current_gpu['utilization_gpu'] = use_match.group(1)
|
||||
elif 'VRAM' in line:
|
||||
mem_match = re.search(r'(\d+)MB / (\d+)MB', line)
|
||||
if mem_match:
|
||||
current_gpu['memory_used'] = f"{mem_match.group(1)} MiB"
|
||||
current_gpu['memory_total'] = f"{mem_match.group(2)} MiB"
|
||||
|
||||
if current_gpu:
|
||||
gpus.append(current_gpu)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting AMD GPU info: {e}", file=sys.stderr)
|
||||
|
||||
return gpus
|
||||
|
||||
def get_temperatures() -> List[Dict[str, Any]]:
|
||||
"""Get temperature readings from sensors."""
|
||||
temps = []
|
||||
output = run_command(['sensors', '-A', '-u'])
|
||||
|
||||
current_adapter = None
|
||||
current_sensor = None
|
||||
|
||||
for line in output.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.endswith(':') and not line.startswith(' '):
|
||||
current_adapter = line[:-1]
|
||||
elif '_input:' in line and current_adapter:
|
||||
parts = line.split(':')
|
||||
if len(parts) == 2:
|
||||
sensor_name = parts[0].replace('_input', '').replace('_', ' ').title()
|
||||
try:
|
||||
temp_value = float(parts[1].strip())
|
||||
temps.append({
|
||||
'name': sensor_name,
|
||||
'current': round(temp_value, 1),
|
||||
'adapter': current_adapter
|
||||
})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return temps
|
||||
|
||||
def get_fans() -> List[Dict[str, Any]]:
|
||||
"""Get fan speed readings."""
|
||||
fans = []
|
||||
output = run_command(['sensors', '-A', '-u'])
|
||||
|
||||
current_adapter = None
|
||||
|
||||
for line in output.split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.endswith(':') and not line.startswith(' '):
|
||||
current_adapter = line[:-1]
|
||||
elif 'fan' in line.lower() and '_input:' in line and current_adapter:
|
||||
parts = line.split(':')
|
||||
if len(parts) == 2:
|
||||
fan_name = parts[0].replace('_input', '').replace('_', ' ').title()
|
||||
try:
|
||||
speed = float(parts[1].strip())
|
||||
fans.append({
|
||||
'name': fan_name,
|
||||
'speed': int(speed),
|
||||
'unit': 'RPM'
|
||||
})
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return fans
|
||||
|
||||
def get_network_cards() -> List[Dict[str, Any]]:
|
||||
"""Get network interface information."""
|
||||
cards = []
|
||||
output = run_command(['ip', '-o', 'link', 'show'])
|
||||
|
||||
for line in output.split('\n'):
|
||||
if not line or 'lo:' in line:
|
||||
continue
|
||||
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
name = parts[1].rstrip(':')
|
||||
state = 'UP' if 'UP' in line else 'DOWN'
|
||||
|
||||
# Get interface type
|
||||
iface_type = 'Unknown'
|
||||
if 'ether' in line:
|
||||
iface_type = 'Ethernet'
|
||||
elif 'wlan' in name or 'wifi' in name:
|
||||
iface_type = 'WiFi'
|
||||
|
||||
# Try to get speed
|
||||
speed = None
|
||||
speed_output = run_command(['ethtool', name])
|
||||
speed_match = re.search(r'Speed: (\d+\w+)', speed_output)
|
||||
if speed_match:
|
||||
speed = speed_match.group(1)
|
||||
|
||||
cards.append({
|
||||
'name': name,
|
||||
'type': iface_type,
|
||||
'status': state,
|
||||
'speed': speed
|
||||
})
|
||||
|
||||
return cards
|
||||
|
||||
def get_storage_devices() -> List[Dict[str, Any]]:
|
||||
"""Get storage device information."""
|
||||
devices = []
|
||||
output = run_command(['lsblk', '-d', '-o', 'NAME,TYPE,SIZE,MODEL', '-n'])
|
||||
|
||||
for line in output.split('\n'):
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split(None, 3)
|
||||
if len(parts) >= 3:
|
||||
name = parts[0]
|
||||
dev_type = parts[1]
|
||||
size = parts[2]
|
||||
model = parts[3] if len(parts) > 3 else 'Unknown'
|
||||
|
||||
if dev_type in ['disk', 'nvme']:
|
||||
devices.append({
|
||||
'name': name,
|
||||
'type': dev_type,
|
||||
'size': size,
|
||||
'model': model.strip()
|
||||
})
|
||||
|
||||
return devices
|
||||
|
||||
def get_pci_devices() -> List[Dict[str, Any]]:
|
||||
"""Get PCI device information including GPUs."""
|
||||
devices = []
|
||||
output = run_command(['lspci', '-vmm'])
|
||||
|
||||
current_device = {}
|
||||
|
||||
for line in output.split('\n'):
|
||||
line = line.strip()
|
||||
|
||||
if not line:
|
||||
if current_device:
|
||||
devices.append(current_device)
|
||||
current_device = {}
|
||||
continue
|
||||
|
||||
if ':' in line:
|
||||
key, value = line.split(':', 1)
|
||||
key = key.strip().lower().replace(' ', '_')
|
||||
value = value.strip()
|
||||
current_device[key] = value
|
||||
|
||||
if current_device:
|
||||
devices.append(current_device)
|
||||
|
||||
# Enhance GPU devices with monitoring data
|
||||
nvidia_gpus = get_nvidia_gpu_info()
|
||||
amd_gpus = get_amd_gpu_info()
|
||||
|
||||
nvidia_idx = 0
|
||||
amd_idx = 0
|
||||
|
||||
for device in devices:
|
||||
# Check if it's a GPU
|
||||
device_class = device.get('class', '').lower()
|
||||
vendor = device.get('vendor', '').lower()
|
||||
|
||||
if 'vga' in device_class or 'display' in device_class or '3d' in device_class:
|
||||
device['type'] = 'GPU'
|
||||
|
||||
# Add NVIDIA GPU monitoring data
|
||||
if 'nvidia' in vendor and nvidia_idx < len(nvidia_gpus):
|
||||
gpu_data = nvidia_gpus[nvidia_idx]
|
||||
device['gpu_memory'] = gpu_data.get('memory_total')
|
||||
device['gpu_driver_version'] = gpu_data.get('driver_version')
|
||||
device['gpu_compute_capability'] = gpu_data.get('compute_capability')
|
||||
device['gpu_power_draw'] = gpu_data.get('power_draw')
|
||||
device['gpu_temperature'] = float(gpu_data.get('temperature', 0))
|
||||
device['gpu_utilization'] = float(gpu_data.get('utilization_gpu', 0))
|
||||
device['gpu_memory_used'] = gpu_data.get('memory_used')
|
||||
device['gpu_memory_total'] = gpu_data.get('memory_total')
|
||||
device['gpu_clock_speed'] = gpu_data.get('clock_graphics')
|
||||
device['gpu_memory_clock'] = gpu_data.get('clock_memory')
|
||||
nvidia_idx += 1
|
||||
|
||||
# Add AMD GPU monitoring data
|
||||
elif 'amd' in vendor and amd_idx < len(amd_gpus):
|
||||
gpu_data = amd_gpus[amd_idx]
|
||||
device['gpu_temperature'] = float(gpu_data.get('temperature', 0))
|
||||
device['gpu_utilization'] = float(gpu_data.get('utilization_gpu', 0))
|
||||
device['gpu_memory_used'] = gpu_data.get('memory_used')
|
||||
device['gpu_memory_total'] = gpu_data.get('memory_total')
|
||||
amd_idx += 1
|
||||
elif 'network' in device_class or 'ethernet' in device_class:
|
||||
device['type'] = 'Network'
|
||||
elif 'storage' in device_class or 'sata' in device_class or 'nvme' in device_class:
|
||||
device['type'] = 'Storage'
|
||||
else:
|
||||
device['type'] = 'Other'
|
||||
|
||||
return devices
|
||||
|
||||
def get_power_info() -> Optional[Dict[str, Any]]:
|
||||
"""Get power consumption information if available."""
|
||||
# Try to get system power from RAPL (Running Average Power Limit)
|
||||
"""
|
||||
Get CPU power consumption using Intel RAPL interface.
|
||||
|
||||
This function measures power consumption by reading energy counters
|
||||
from /sys/class/powercap/intel-rapl interfaces and calculating
|
||||
the power draw based on the change in energy over time.
|
||||
|
||||
Used as fallback when IPMI power monitoring is not available.
|
||||
|
||||
Returns:
|
||||
dict: Power meter information with 'name', 'watts', and 'adapter' keys
|
||||
or None if RAPL interface is unavailable
|
||||
|
||||
Example:
|
||||
{
|
||||
'name': 'CPU Power',
|
||||
'watts': 45.32,
|
||||
'adapter': 'Intel RAPL (CPU only)'
|
||||
}
|
||||
"""
|
||||
global _last_energy_reading
|
||||
|
||||
rapl_path = '/sys/class/powercap/intel-rapl/intel-rapl:0/energy_uj'
|
||||
|
||||
if os.path.exists(rapl_path):
|
||||
try:
|
||||
# Read current energy value in microjoules
|
||||
with open(rapl_path, 'r') as f:
|
||||
energy_uj = int(f.read().strip())
|
||||
current_energy_uj = int(f.read().strip())
|
||||
current_time = time.time()
|
||||
|
||||
watts = 0.0
|
||||
|
||||
# Calculate power if we have a previous reading
|
||||
if _last_energy_reading['energy_uj'] is not None and _last_energy_reading['timestamp'] is not None:
|
||||
time_diff = current_time - _last_energy_reading['timestamp']
|
||||
if time_diff > 0:
|
||||
energy_diff = current_energy_uj - _last_energy_reading['energy_uj']
|
||||
# Handle counter overflow (wraps around at max value)
|
||||
if energy_diff < 0:
|
||||
energy_diff = current_energy_uj
|
||||
# Power (W) = Energy (µJ) / time (s) / 1,000,000
|
||||
watts = round((energy_diff / time_diff) / 1000000, 2)
|
||||
|
||||
# Store current reading for next calculation
|
||||
_last_energy_reading['energy_uj'] = current_energy_uj
|
||||
_last_energy_reading['timestamp'] = current_time
|
||||
|
||||
# Detect CPU vendor for display purposes
|
||||
cpu_vendor = 'CPU'
|
||||
try:
|
||||
with open('/proc/cpuinfo', 'r') as f:
|
||||
cpuinfo = f.read()
|
||||
if 'GenuineIntel' in cpuinfo:
|
||||
cpu_vendor = 'Intel'
|
||||
elif 'AuthenticAMD' in cpuinfo:
|
||||
cpu_vendor = 'AMD'
|
||||
except:
|
||||
pass
|
||||
|
||||
# This is cumulative energy, would need to track over time for watts
|
||||
# For now, just indicate power monitoring is available
|
||||
return {
|
||||
'name': 'System Power',
|
||||
'watts': 0, # Would need time-based calculation
|
||||
'adapter': 'RAPL'
|
||||
'name': 'CPU Power',
|
||||
'watts': watts,
|
||||
'adapter': f'{cpu_vendor} RAPL (CPU only)'
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def main():
|
||||
"""Main function to gather all hardware information."""
|
||||
data = {
|
||||
'temperatures': get_temperatures(),
|
||||
'fans': get_fans(),
|
||||
'network_cards': get_network_cards(),
|
||||
'storage_devices': get_storage_devices(),
|
||||
'pci_devices': get_pci_devices(),
|
||||
}
|
||||
|
||||
power_info = get_power_info()
|
||||
if power_info:
|
||||
data['power_meter'] = power_info
|
||||
|
||||
print(json.dumps(data, indent=2))
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
main()
|
||||
def get_hba_info() -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Detect HBA/RAID controllers from lspci.
|
||||
|
||||
This function identifies LSI/Broadcom, Adaptec, and other RAID/HBA controllers
|
||||
present in the system via lspci output.
|
||||
|
||||
Returns:
|
||||
list: List of HBA controller dictionaries
|
||||
Example: [
|
||||
{
|
||||
'pci_address': '01:00.0',
|
||||
'vendor': 'LSI/Broadcom',
|
||||
'model': 'SAS3008 PCI-Express Fusion-MPT SAS-3',
|
||||
'controller_id': 0
|
||||
}
|
||||
]
|
||||
"""
|
||||
hba_list = []
|
||||
|
||||
try:
|
||||
# Run lspci to find RAID/SAS controllers
|
||||
result = subprocess.run(
|
||||
['lspci', '-nn'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
controller_id = 0
|
||||
for line in result.stdout.split('\n'):
|
||||
# Look for RAID bus controller, SCSI storage controller, Serial Attached SCSI controller
|
||||
if any(keyword in line for keyword in ['RAID bus controller', 'SCSI storage controller', 'Serial Attached SCSI']):
|
||||
# Example: "01:00.0 RAID bus controller [0104]: Broadcom / LSI SAS3008 PCI-Express Fusion-MPT SAS-3 [1000:0097]"
|
||||
match = re.match(r'^([0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])\s+.*:\s+(.+?)\s+\[([0-9a-f]{4}):([0-9a-f]{4})\]', line)
|
||||
|
||||
if match:
|
||||
pci_address = match.group(1)
|
||||
device_name = match.group(2).strip()
|
||||
|
||||
# Extract vendor
|
||||
vendor = 'Unknown'
|
||||
if 'LSI' in device_name.upper() or 'BROADCOM' in device_name.upper() or 'AVAGO' in device_name.upper():
|
||||
vendor = 'LSI/Broadcom'
|
||||
elif 'ADAPTEC' in device_name.upper():
|
||||
vendor = 'Adaptec'
|
||||
elif 'ARECA' in device_name.upper():
|
||||
vendor = 'Areca'
|
||||
elif 'HIGHPOINT' in device_name.upper():
|
||||
vendor = 'HighPoint'
|
||||
elif 'DELL' in device_name.upper():
|
||||
vendor = 'Dell'
|
||||
elif 'HP' in device_name.upper() or 'HEWLETT' in device_name.upper():
|
||||
vendor = 'HP'
|
||||
|
||||
# Extract model name
|
||||
model_name = device_name
|
||||
# Remove vendor prefix if present
|
||||
for v in ['Broadcom / LSI', 'Broadcom', 'LSI Logic', 'LSI', 'Adaptec', 'Areca', 'HighPoint', 'Dell', 'HP', 'Hewlett-Packard']:
|
||||
if model_name.startswith(v):
|
||||
model_name = model_name[len(v):].strip()
|
||||
|
||||
hba_list.append({
|
||||
'pci_address': pci_address,
|
||||
'vendor': vendor,
|
||||
'model': model_name,
|
||||
'controller_id': controller_id,
|
||||
'full_name': device_name
|
||||
})
|
||||
controller_id += 1
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return hba_list
|
||||
|
||||
|
||||
def get_hba_temperatures() -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Get HBA controller temperatures using storcli64 or megacli.
|
||||
|
||||
This function attempts to read temperature data from LSI/Broadcom RAID controllers
|
||||
using the storcli64 tool (preferred) or megacli as fallback.
|
||||
|
||||
Returns:
|
||||
list: List of temperature dictionaries
|
||||
Example: [
|
||||
{
|
||||
'name': 'HBA Controller 0',
|
||||
'temperature': 65,
|
||||
'adapter': 'LSI/Broadcom SAS3008'
|
||||
}
|
||||
]
|
||||
"""
|
||||
temperatures = []
|
||||
|
||||
# Check which tool is available
|
||||
storcli_paths = [
|
||||
'/opt/MegaRAID/storcli/storcli64',
|
||||
'/usr/sbin/storcli64',
|
||||
'/usr/local/sbin/storcli64',
|
||||
'storcli64'
|
||||
]
|
||||
|
||||
megacli_paths = [
|
||||
'/opt/MegaRAID/MegaCli/MegaCli64',
|
||||
'/usr/sbin/megacli',
|
||||
'/usr/local/sbin/megacli',
|
||||
'megacli'
|
||||
]
|
||||
|
||||
storcli_path = None
|
||||
megacli_path = None
|
||||
|
||||
# Find storcli64
|
||||
for path in storcli_paths:
|
||||
try:
|
||||
result = subprocess.run([path, '-v'], capture_output=True, timeout=2)
|
||||
if result.returncode == 0:
|
||||
storcli_path = path
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
# Try storcli64 first (preferred)
|
||||
if storcli_path:
|
||||
try:
|
||||
# Get list of controllers
|
||||
result = subprocess.run(
|
||||
[storcli_path, 'show'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Parse controller IDs
|
||||
controller_ids = []
|
||||
for line in result.stdout.split('\n'):
|
||||
match = re.search(r'^\s*(\d+)\s+', line)
|
||||
if match and 'Ctl' in line:
|
||||
controller_ids.append(match.group(1))
|
||||
|
||||
# Get temperature for each controller
|
||||
for ctrl_id in controller_ids:
|
||||
try:
|
||||
temp_result = subprocess.run(
|
||||
[storcli_path, f'/c{ctrl_id}', 'show', 'temperature'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if temp_result.returncode == 0:
|
||||
# Parse temperature from output
|
||||
for line in temp_result.stdout.split('\n'):
|
||||
if 'ROC temperature' in line or 'Controller Temp' in line:
|
||||
temp_match = re.search(r'(\d+)\s*C', line)
|
||||
if temp_match:
|
||||
temp_c = int(temp_match.group(1))
|
||||
|
||||
# Get HBA info for better naming
|
||||
hba_list = get_hba_info()
|
||||
adapter_name = 'LSI/Broadcom Controller'
|
||||
if int(ctrl_id) < len(hba_list):
|
||||
hba = hba_list[int(ctrl_id)]
|
||||
adapter_name = f"{hba['vendor']} {hba['model']}"
|
||||
|
||||
temperatures.append({
|
||||
'name': f'HBA Controller {ctrl_id}',
|
||||
'temperature': temp_c,
|
||||
'adapter': adapter_name
|
||||
})
|
||||
break
|
||||
except:
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
|
||||
# Fallback to megacli if storcli not available
|
||||
elif not temperatures:
|
||||
for path in megacli_paths:
|
||||
try:
|
||||
result = subprocess.run([path, '-v'], capture_output=True, timeout=2)
|
||||
if result.returncode == 0:
|
||||
megacli_path = path
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if megacli_path:
|
||||
try:
|
||||
# Get adapter count
|
||||
result = subprocess.run(
|
||||
[megacli_path, '-adpCount'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Parse adapter count
|
||||
adapter_count = 0
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'Controller Count' in line:
|
||||
count_match = re.search(r'(\d+)', line)
|
||||
if count_match:
|
||||
adapter_count = int(count_match.group(1))
|
||||
break
|
||||
|
||||
# Get temperature for each adapter
|
||||
for adapter_id in range(adapter_count):
|
||||
try:
|
||||
temp_result = subprocess.run(
|
||||
[megacli_path, '-AdpAllInfo', f'-a{adapter_id}'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if temp_result.returncode == 0:
|
||||
# Parse temperature
|
||||
for line in temp_result.stdout.split('\n'):
|
||||
if 'ROC temperature' in line or 'Controller Temp' in line:
|
||||
temp_match = re.search(r'(\d+)\s*C', line)
|
||||
if temp_match:
|
||||
temp_c = int(temp_match.group(1))
|
||||
|
||||
# Get HBA info for better naming
|
||||
hba_list = get_hba_info()
|
||||
adapter_name = 'LSI/Broadcom Controller'
|
||||
if adapter_id < len(hba_list):
|
||||
hba = hba_list[adapter_id]
|
||||
adapter_name = f"{hba['vendor']} {hba['model']}"
|
||||
|
||||
temperatures.append({
|
||||
'name': f'HBA Controller {adapter_id}',
|
||||
'temperature': temp_c,
|
||||
'adapter': adapter_name
|
||||
})
|
||||
break
|
||||
except:
|
||||
continue
|
||||
except:
|
||||
pass
|
||||
|
||||
return temperatures
|
||||
|
||||
+906
-284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ProxMenux - Proxmox Storage Monitor
|
||||
Monitors configured Proxmox storages and tracks unavailable storages
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import socket
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
class ProxmoxStorageMonitor:
|
||||
"""Monitor Proxmox storage configuration and status"""
|
||||
|
||||
def __init__(self):
|
||||
self.configured_storages: Dict[str, Dict[str, Any]] = {}
|
||||
self._load_configured_storages()
|
||||
|
||||
def _get_node_name(self) -> str:
|
||||
"""Get current Proxmox node name"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pvesh', 'get', '/nodes', '--output-format', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
nodes = json.loads(result.stdout)
|
||||
hostname = socket.gethostname()
|
||||
for node in nodes:
|
||||
if node.get('node') == hostname:
|
||||
return hostname
|
||||
if nodes:
|
||||
return nodes[0].get('node', hostname)
|
||||
return socket.gethostname()
|
||||
except Exception:
|
||||
return socket.gethostname()
|
||||
|
||||
def _load_configured_storages(self) -> None:
|
||||
"""Load configured storages from Proxmox configuration"""
|
||||
try:
|
||||
local_node = self._get_node_name()
|
||||
|
||||
# Read storage configuration from pvesh
|
||||
result = subprocess.run(
|
||||
['pvesh', 'get', '/storage', '--output-format', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return
|
||||
|
||||
storages = json.loads(result.stdout)
|
||||
|
||||
for storage in storages:
|
||||
storage_id = storage.get('storage')
|
||||
if not storage_id:
|
||||
continue
|
||||
|
||||
# Check if storage is enabled for this node
|
||||
nodes = storage.get('nodes')
|
||||
if nodes and local_node not in nodes.split(','):
|
||||
continue
|
||||
|
||||
disabled = storage.get('disable', 0)
|
||||
if disabled == 1:
|
||||
continue
|
||||
|
||||
self.configured_storages[storage_id] = {
|
||||
'name': storage_id,
|
||||
'type': storage.get('type', 'unknown'),
|
||||
'content': storage.get('content', ''),
|
||||
'path': storage.get('path', ''),
|
||||
'enabled': True
|
||||
}
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_storage_status(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get storage status, including unavailable storages
|
||||
|
||||
Returns:
|
||||
{
|
||||
'available': [...],
|
||||
'unavailable': [...]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
local_node = self._get_node_name()
|
||||
|
||||
# Get current storage status from pvesh
|
||||
result = subprocess.run(
|
||||
['pvesh', 'get', '/cluster/resources', '--type', 'storage', '--output-format', 'json'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {'available': [], 'unavailable': list(self.configured_storages.values())}
|
||||
|
||||
resources = json.loads(result.stdout)
|
||||
|
||||
# Track which configured storages are available
|
||||
available_storages = []
|
||||
unavailable_storages = []
|
||||
seen_storage_names = set()
|
||||
|
||||
for resource in resources:
|
||||
node = resource.get('node', '')
|
||||
|
||||
# Filter only local node storages
|
||||
if node != local_node:
|
||||
continue
|
||||
|
||||
name = resource.get('storage', 'unknown')
|
||||
seen_storage_names.add(name)
|
||||
storage_type = resource.get('plugintype', 'unknown')
|
||||
status = resource.get('status', 'unknown')
|
||||
|
||||
try:
|
||||
total = int(resource.get('maxdisk', 0))
|
||||
used = int(resource.get('disk', 0))
|
||||
available = total - used if total > 0 else 0
|
||||
except (ValueError, TypeError):
|
||||
total = 0
|
||||
used = 0
|
||||
available = 0
|
||||
|
||||
# Calculate percentage
|
||||
percent = (used / total * 100) if total > 0 else 0.0
|
||||
|
||||
# Convert bytes to GB
|
||||
total_gb = round(total / (1024**3), 2)
|
||||
used_gb = round(used / (1024**3), 2)
|
||||
available_gb = round(available / (1024**3), 2)
|
||||
|
||||
storage_info = {
|
||||
'name': name,
|
||||
'type': storage_type,
|
||||
'total': total_gb,
|
||||
'used': used_gb,
|
||||
'available': available_gb,
|
||||
'percent': round(percent, 2),
|
||||
'node': node
|
||||
}
|
||||
|
||||
# Check if storage is available
|
||||
if total == 0 or status.lower() != "available":
|
||||
storage_info['status'] = 'error'
|
||||
storage_info['status_detail'] = 'unavailable' if total == 0 else status
|
||||
unavailable_storages.append(storage_info)
|
||||
else:
|
||||
storage_info['status'] = 'active'
|
||||
available_storages.append(storage_info)
|
||||
|
||||
# Check for configured storages that are completely missing
|
||||
for storage_name, storage_config in self.configured_storages.items():
|
||||
if storage_name not in seen_storage_names:
|
||||
unavailable_storages.append({
|
||||
'name': storage_name,
|
||||
'type': storage_config['type'],
|
||||
'status': 'error',
|
||||
'status_detail': 'not_found',
|
||||
'total': 0,
|
||||
'used': 0,
|
||||
'available': 0,
|
||||
'percent': 0,
|
||||
'node': local_node
|
||||
})
|
||||
|
||||
return {
|
||||
'available': available_storages,
|
||||
'unavailable': unavailable_storages
|
||||
}
|
||||
|
||||
except Exception:
|
||||
return {
|
||||
'available': [],
|
||||
'unavailable': list(self.configured_storages.values())
|
||||
}
|
||||
|
||||
def get_unavailable_count(self) -> int:
|
||||
"""Get count of unavailable storages"""
|
||||
status = self.get_storage_status()
|
||||
return len(status['unavailable'])
|
||||
|
||||
def reload_configuration(self) -> None:
|
||||
"""Reload storage configuration from Proxmox"""
|
||||
self.configured_storages.clear()
|
||||
self._load_configured_storages()
|
||||
|
||||
|
||||
# Global instance
|
||||
proxmox_storage_monitor = ProxmoxStorageMonitor()
|
||||
@@ -1,3 +1,88 @@
|
||||
## 2026-03-14
|
||||
|
||||
### New version v1.1.9 — *Helper Scripts Catalog Rebuilt*
|
||||
|
||||
### Changed
|
||||
|
||||
- **Helper Scripts Menu — Full Catalog Rebuild**
|
||||
The Helper Scripts catalog has been completely rebuilt to adapt to the new data architecture of the [Community Scripts](https://community-scripts.github.io/ProxmoxVE/) project.
|
||||
|
||||
The previous implementation relied on a `metadata.json` file that no longer exists in the upstream repository. The catalog now connects directly to the **PocketBase API** (`db.community-scripts.org`), which is the new official data source for the project.
|
||||
|
||||
A new GitHub Actions workflow generates a local `helpers_cache.json` index that replaces the old metadata dependency. This new cache is richer, more structured, and includes:
|
||||
- Script type, slug, description, notes, and default credentials
|
||||
- OS variants per script (e.g. Debian, Alpine) — each shown as a separate selectable option in the menu
|
||||
- Direct GitHub URL and **Mirror URL** (`git.community-scripts.org`) for every script
|
||||
- Category names embedded directly in the cache — no external requests needed to build the menu
|
||||
- Additional metadata: default port, website, logo, update support, ARM availability
|
||||
|
||||
Scripts that support multiple OS variants (e.g. Docker with Alpine and Debian) now correctly show **one entry per OS**, each with its own GitHub and Mirror download option — restoring the behavior that existed before the upstream migration.
|
||||
|
||||
---
|
||||
|
||||
### 🎖 Special Acknowledgment
|
||||
|
||||
This update would not have been possible without the openness and collaboration of the **Community Scripts** maintainers.
|
||||
|
||||
When the upstream metadata structure changed and broke the ProxMenux catalog, the maintainers responded quickly, explained the new architecture in detail, and provided all the information needed to rebuild the integration cleanly.
|
||||
|
||||
Special thanks to:
|
||||
|
||||
- **MickLeskCanbiZ ([@MickLesk](https://github.com/MickLesk))** — for documenting the new script path structure by type and slug, and for the clear and direct technical guidance.
|
||||
- **Michel Roegl-Brunner ([@michelroegl-brunner](https://github.com/michelroegl-brunner))** — for explaining the new PocketBase collections structure (`script_scripts`, `script_categories`).
|
||||
|
||||
The Helper Scripts project is an extraordinary resource for the Proxmox community. The scripts belong entirely to their authors and maintainers — ProxMenux simply offers a guided way to discover and launch them. All credit goes to the community behind [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE).
|
||||
|
||||
## 2025-09-18
|
||||
|
||||
### New version v1.1.8 — *ProxMenux Offline Mode*
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Added
|
||||
|
||||
- **Offline Execution Mode (no GitHub dependency)**
|
||||
All ProxMenux core scripts now run **entirely locally**, without requiring live requests to GitHub (`raw.githubusercontent.com`).
|
||||
This change provides:
|
||||
- Greater stability during execution
|
||||
- No interruptions due to network timeouts or regional GitHub blocks
|
||||
- Support for **offline or isolated environments**
|
||||
|
||||
⚠️ This update resolves recent issues where users in certain regions were unable to run scripts due to CDN or TLS filtering errors while downloading `.sh` files from GitHub raw URLs.
|
||||
|
||||
**🎖 Special Acknowledgment: @cod378**
|
||||
This offline conversion has been made possible thanks to the extraordinary work of **@cod378**,
|
||||
who redesigned the entire internal logic of the installer and updater, refactored the file management system,
|
||||
and implemented the new fully local execution workflow.
|
||||
Without his collaboration, dedication, and technical contribution, this transformation would not have been possible.
|
||||
|
||||
- **ProxMenux Monitor v1.0.1**
|
||||
This update brings a major leap in the **ProxMenux Monitor** interface.
|
||||
New features and improvements:
|
||||
- `Proxy Support`: Access ProxMenux through reverse proxies with full functionality
|
||||
- `Authentication System`: Secure your dashboard with password protection
|
||||
- `Two-Factor Authentication (2FA)`: Optional TOTP support for enhanced security
|
||||
- `PCIe Link Speed Detection`: View NVMe connection speeds and detect performance bottlenecks
|
||||
- `Enhanced Storage Display`: Auto-formats disk sizes (GB → TB when appropriate)
|
||||
- `SATA/SAS Interface Info`: Detect and show storage type (SATA, SAS, NVMe, etc.)
|
||||
- `Health Monitoring System`: Built-in system health check with dismissible alerts
|
||||
- Improved rendering across browsers and better performance
|
||||
|
||||
- **Helper Scripts Menu (Mirror Support)**
|
||||
The `Helper Scripts` menu now:
|
||||
- Detects **mirror URLs** and shows alternative download options when available
|
||||
- Lists available OS versions when a helper script is version-dependent (e.g. template installers)
|
||||
|
||||
---
|
||||
|
||||
### Fixed
|
||||
|
||||
- Minor fixes and refinements throughout the codebase to ensure full offline compatibility and a smoother user experience.
|
||||
|
||||
|
||||
|
||||
## 2025-09-04
|
||||
|
||||
### New version v1.1.7
|
||||
|
||||
@@ -1,34 +1,37 @@
|
||||
ProxMenux An Interactive Menu for Proxmox VE Management
|
||||
ProxMenux - An Interactive Menu for Proxmox VE Management
|
||||
Copyright (c) 2025 MacRimi
|
||||
|
||||
This project is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License.
|
||||
See the full license terms below.
|
||||
======================================================================
|
||||
LICENSE: GNU General Public License v3.0 (GPL-3.0)
|
||||
======================================================================
|
||||
|
||||
ProxMenux is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
Under this license:
|
||||
1. Attribution: You must give appropriate credit to the original author (MacRimi).
|
||||
2. Copyleft: If you remix, transform, or build upon ProxMenux, you must
|
||||
distribute your contributions under the same GPL-3.0 license.
|
||||
3. Source Code: Anyone distributing a modified version must make the
|
||||
source code available.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
======================================================================
|
||||
|
||||
Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
|
||||
|
||||
This is a human-readable summary of (and not a substitute for) the license.
|
||||
You may obtain a copy of the full license at:
|
||||
|
||||
https://creativecommons.org/licenses/by-nc/4.0/
|
||||
|
||||
You are free to:
|
||||
- Share — copy and redistribute the material in any medium or format.
|
||||
- Adapt — remix, transform, and build upon the material.
|
||||
|
||||
Under the following terms:
|
||||
- Attribution — You must give appropriate credit, provide a link to the license,
|
||||
and indicate if changes were made.
|
||||
- NonCommercial — You may not use the material for commercial purposes.
|
||||
|
||||
No additional restrictions — You may not apply legal terms or technological
|
||||
measures that legally restrict others from doing anything the license permits.
|
||||
|
||||
Disclaimer:
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
DISCLAIMER:
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
|
||||
FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
@@ -57,25 +57,120 @@ Then, follow the on-screen options to manage your Proxmox server efficiently.
|
||||
|
||||
---
|
||||
|
||||
## 📌 System Requirements
|
||||
🖥 **Compatible with:**
|
||||
- Proxmox VE 8.x and 9.x
|
||||
|
||||
📦 **Dependencies:**
|
||||
- `bash`, `curl`, `wget`, `jq`, `whiptail`, `python3-venv` (These dependencies are installed automatically during setup.)
|
||||
- **Translations are handled in a Python virtual environment using `googletrans-env`.**
|
||||
## 🧪 Beta Program
|
||||
|
||||
Want to try the latest features before the official release and help shape the final version?
|
||||
|
||||
The **ProxMenux Beta Program** gives early access to new functionality — including the newest builds of ProxMenux Monitor — directly from the `develop` branch. Beta builds may contain bugs or incomplete features. Your feedback is what helps fix them before the stable release.
|
||||
|
||||
**Install the beta version:**
|
||||
|
||||
```bash
|
||||
bash -c "$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/install_proxmenux_beta.sh)"
|
||||
```
|
||||
|
||||
**What to expect:**
|
||||
|
||||
- You'll get new features and Monitor builds before anyone else
|
||||
- Some things may not work perfectly — that's expected and normal
|
||||
- When a stable release is published, ProxMenux will notify you on the next `menu` launch and offer to switch automatically
|
||||
|
||||
**How to report issues:**
|
||||
|
||||
Open a [GitHub Issue](https://github.com/MacRimi/ProxMenux/issues) and include:
|
||||
- What you did and what you expected to happen
|
||||
- Any error messages shown on screen
|
||||
- Logs from the Monitor if relevant:
|
||||
|
||||
```bash
|
||||
journalctl -u proxmenux-monitor -n 50
|
||||
```
|
||||
|
||||
> 💙 Thank you for being part of the beta program. Your help makes ProxMenux better for everyone.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🖥️ ProxMenux Monitor
|
||||
|
||||
ProxMenux Monitor is an integrated web dashboard that provides real-time visibility into your Proxmox infrastructure — accessible from any browser on your network, without needing a terminal.
|
||||
|
||||
**What it offers:**
|
||||
|
||||
- Real-time monitoring of CPU, RAM, disk usage and network traffic
|
||||
- Overview of running VMs and LXC containers with status indicators
|
||||
- Login authentication to protect access
|
||||
- Two-Factor Authentication (2FA) with TOTP support
|
||||
- Reverse proxy support (Nginx / Traefik)
|
||||
- Designed to work across desktop and mobile devices
|
||||
|
||||
**Access:**
|
||||
|
||||
Once installed, the dashboard is available at:
|
||||
|
||||
```
|
||||
http://<your-proxmox-ip>:8008
|
||||
```
|
||||
|
||||
The Monitor is installed automatically as part of the standard ProxMenux installation and runs as a systemd service (`proxmenux-monitor.service`) that starts automatically on boot.
|
||||
|
||||
**Useful commands:**
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
systemctl status proxmenux-monitor
|
||||
|
||||
# View logs
|
||||
journalctl -u proxmenux-monitor -n 50
|
||||
|
||||
# Restart the service
|
||||
systemctl restart proxmenux-monitor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🔧 Dependencies
|
||||
|
||||
The following dependencies are installed automatically during setup:
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `dialog` | Interactive terminal menus |
|
||||
| `curl` | Downloads and connectivity checks |
|
||||
| `jq` | JSON processing |
|
||||
| `git` | Repository cloning and updates |
|
||||
| `python3` + `python3-venv` | Translation support *(Translation version only)* |
|
||||
| `googletrans` | Google Translate library *(Translation version only)* |
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
## ⭐ Support the Project!
|
||||
If you find **ProxMenux** useful, consider giving it a ⭐ on GitHub to help others discover it!
|
||||
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions, bug reports and feature suggestions are welcome!
|
||||
|
||||
- 🐛 [Report a bug](https://github.com/MacRimi/ProxMenux/issues/new)
|
||||
- 💡 [Suggest a feature](https://github.com/MacRimi/ProxMenux/discussions)
|
||||
- 🔀 [Submit a pull request](https://github.com/MacRimi/ProxMenux/pulls)
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#MacRimi/ProxMenux&Date)
|
||||
|
||||
|
||||
|
||||
<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;"/>
|
||||
|
||||
+48
-3
@@ -7,7 +7,7 @@
|
||||
# Contributors : cod378
|
||||
# Subproject : ProxMenux Monitor (System Health & Web Dashboard)
|
||||
# Copyright : (c) 2024-2025 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.4
|
||||
# Last Updated : 12/11/2025
|
||||
# ==========================================================
|
||||
@@ -440,7 +440,7 @@ update_config() {
|
||||
local status="$2"
|
||||
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
local tracked_components=("dialog" "curl" "jq" "python3" "python3-venv" "python3-pip" "virtual_environment" "pip" "googletrans" "proxmenux_monitor")
|
||||
local tracked_components=("dialog" "curl" "jq" "git" "python3" "python3-venv" "python3-pip" "virtual_environment" "pip" "googletrans" "proxmenux_monitor")
|
||||
|
||||
if [[ " ${tracked_components[@]} " =~ " ${component} " ]]; then
|
||||
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||
@@ -727,7 +727,17 @@ install_normal_version() {
|
||||
update_config "jq" "already_installed"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
BASIC_DEPS=("dialog" "curl" "git")
|
||||
|
||||
if [ -z "${APT_UPDATED:-}" ]; then
|
||||
apt-get update -y > /dev/null 2>&1 || true
|
||||
APT_UPDATED=1
|
||||
fi
|
||||
|
||||
for pkg in "${BASIC_DEPS[@]}"; do
|
||||
if ! dpkg -l | grep -qw "$pkg"; then
|
||||
if apt-get install -y "$pkg" > /dev/null 2>&1; then
|
||||
@@ -741,9 +751,42 @@ install_normal_version() {
|
||||
update_config "$pkg" "already_installed"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
|
||||
if ! command -v git > /dev/null 2>&1; then
|
||||
msg_info "Installing git (required to clone the ProxMenux repository)."
|
||||
|
||||
|
||||
if [ -z "${APT_UPDATED:-}" ]; then
|
||||
apt-get update -y > /dev/null 2>&1 || true
|
||||
APT_UPDATED=1
|
||||
fi
|
||||
|
||||
if ! apt-get install -y git > /dev/null 2>&1; then
|
||||
msg_error "Failed to install git. Please run 'apt-get install git' manually and rerun the installer."
|
||||
update_config "git" "failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if ! command -v git > /dev/null 2>&1; then
|
||||
msg_error "Git is still not available after installation. Aborting to avoid a broken setup."
|
||||
update_config "git" "failed"
|
||||
return 1
|
||||
fi
|
||||
|
||||
update_config "git" "installed"
|
||||
else
|
||||
update_config "git" "already_installed"
|
||||
fi
|
||||
|
||||
msg_ok "jq, dialog, curl and git installed successfully."
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
((current_step++))
|
||||
|
||||
show_progress $current_step $total_steps "Install ProxMenux repository"
|
||||
@@ -1037,6 +1080,8 @@ install_proxmenux() {
|
||||
type_text "To run ProxMenux, simply execute this command in the console or terminal:"
|
||||
echo -e "${YWB} menu${CL}"
|
||||
echo
|
||||
# -------
|
||||
exit 0
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
|
||||
+12928
-1923
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,14 +5,13 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 04/07/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script serves as the main entry point for ProxMenux,
|
||||
# a menu-driven tool designed for Proxmox VE management.
|
||||
#
|
||||
# - Displays the ProxMenux logo on startup.
|
||||
# - Loads necessary configurations and language settings.
|
||||
# - Checks for available updates and installs them if confirmed.
|
||||
@@ -29,10 +28,10 @@
|
||||
# for managing Proxmox VE using ProxMenux.
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
LOCAL_SCRIPTS="$BASE_DIR/scripts"
|
||||
CONFIG_FILE="$BASE_DIR/config.json"
|
||||
CACHE_FILE="$BASE_DIR/cache.json"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
@@ -40,56 +39,67 @@ LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
: "${LOCAL_SCRIPTS:=/usr/local/share/proxmenux/scripts}"
|
||||
|
||||
# =========================================================
|
||||
# For now, update is not available in the local version.
|
||||
# Take in mind that in future versions, updates must be
|
||||
# a warning to update the .deb package
|
||||
# =========================================================
|
||||
|
||||
check_updates() {
|
||||
local INSTALL_SCRIPT="$BASE_DIR/install_proxmenux.sh"
|
||||
local VERSION_URL INSTALL_URL INSTALL_SCRIPT
|
||||
local REMOTE_VERSION LOCAL_VERSION
|
||||
|
||||
local REMOTE_VERSION
|
||||
REMOTE_VERSION=$(curl -fsSL "$REPO_URL/version.txt" | head -n 1)
|
||||
|
||||
VERSION_URL="$REPO_URL/version.txt"
|
||||
INSTALL_URL="$REPO_URL/install_proxmenux.sh"
|
||||
INSTALL_SCRIPT="$BASE_DIR/install_proxmenux.sh"
|
||||
|
||||
if [ -z "$REMOTE_VERSION" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
||||
local LOCAL_VERSION
|
||||
LOCAL_VERSION=$(head -n 1 "$LOCAL_VERSION_FILE")
|
||||
|
||||
[[ ! -f "$LOCAL_VERSION_FILE" ]] && return 0
|
||||
|
||||
[ "$LOCAL_VERSION" = "$REMOTE_VERSION" ] && return 0
|
||||
|
||||
REMOTE_VERSION="$(curl -fsSL "$VERSION_URL" 2>/dev/null | head -n 1)"
|
||||
[[ -z "$REMOTE_VERSION" ]] && return 0
|
||||
|
||||
if whiptail --title "$(translate "Update Available")" \
|
||||
--yesno "$(translate "New version available") ($REMOTE_VERSION)\n\n$(translate "Do you want to update now?")" \
|
||||
|
||||
LOCAL_VERSION="$(head -n 1 "$LOCAL_VERSION_FILE" 2>/dev/null)"
|
||||
[[ -z "$LOCAL_VERSION" ]] && return 0
|
||||
|
||||
|
||||
[[ "$LOCAL_VERSION" = "$REMOTE_VERSION" ]] && return 0
|
||||
|
||||
|
||||
if whiptail --title "$(translate 'Update Available')" \
|
||||
--yesno "$(translate 'New version available') ($REMOTE_VERSION)\n\n$(translate 'Do you want to update now?')" \
|
||||
10 60 --defaultno; then
|
||||
msg_warn "$(translate "Starting ProxMenux update...")"
|
||||
|
||||
if wget -qO "$INSTALL_SCRIPT" "$REPO_URL/install_proxmenux.sh"; then
|
||||
msg_warn "$(translate 'Starting ProxMenux update...')"
|
||||
|
||||
|
||||
if curl -fsSL "$INSTALL_URL" -o "$INSTALL_SCRIPT"; then
|
||||
chmod +x "$INSTALL_SCRIPT"
|
||||
|
||||
source "$INSTALL_SCRIPT"
|
||||
|
||||
bash "$INSTALL_SCRIPT" --update
|
||||
|
||||
return 0
|
||||
|
||||
fi
|
||||
else
|
||||
msg_warn "$(translate "Update postponed. You can update later from the menu.")"
|
||||
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
main_menu() {
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
}
|
||||
local MAIN_MENU="$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
|
||||
exec bash "$MAIN_MENU"
|
||||
}
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
# Check updates doesn't make sense in offline mode
|
||||
# check_updates
|
||||
check_updates
|
||||
main_menu
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 06/07/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 17/08/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 2.0
|
||||
# Last Updated: 07/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Proxmox VE Update Script - Improved Version
|
||||
# Proxmox VE Update Script - Improved Version (with apt progress)
|
||||
# ==========================================================
|
||||
|
||||
# Configuration
|
||||
@@ -9,6 +9,7 @@ BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
@@ -35,11 +36,14 @@ download_common_functions() {
|
||||
}
|
||||
|
||||
update_pve9() {
|
||||
local pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||
local start_time=$(date +%s)
|
||||
local pve_version
|
||||
pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
||||
local changes_made=false
|
||||
local OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
local OS_CODENAME
|
||||
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
local TARGET_CODENAME="trixie"
|
||||
|
||||
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
|
||||
@@ -55,7 +59,8 @@ update_pve9() {
|
||||
} | tee -a "$screen_capture"
|
||||
|
||||
|
||||
local available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
local available_space
|
||||
available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
|
||||
echo -e
|
||||
@@ -162,7 +167,6 @@ EOF
|
||||
# Handle common apt errors
|
||||
if echo "$update_output" | grep -Eq "NO_PUBKEY|GPG error"; then
|
||||
|
||||
|
||||
# Extract first missing key (NO_PUBKEY ABCDEF... pattern)
|
||||
key=$(echo "$update_output" | sed -n 's/.*NO_PUBKEY \([0-9A-F]\{8,40\}\).*/\1/p' | head -1)
|
||||
|
||||
@@ -202,17 +206,29 @@ EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if apt policy 2>/dev/null | grep -q "${TARGET_CODENAME}.*pve-no-subscription"; then
|
||||
msg_ok "$(translate "Proxmox VE $pve_version repositories verified")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_warn "$(translate "Proxmox VE $pve_version repositories verification inconclusive, continuing...")"
|
||||
fi
|
||||
|
||||
local current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local upgradable=$(apt list --upgradable 2>/dev/null | grep -c "upgradable")
|
||||
local security_updates=$(apt list --upgradable 2>/dev/null | grep -c "security")
|
||||
local current_pve_version
|
||||
current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local available_pve_version
|
||||
available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
|
||||
|
||||
local upgradable
|
||||
upgradable=$($APT_ENV apt list --upgradable 2>/dev/null \
|
||||
| sed '1d' \
|
||||
| sed '/^\s*$/d' \
|
||||
| wc -l)
|
||||
|
||||
local security_updates
|
||||
security_updates=$($APT_ENV apt list --upgradable 2>/dev/null \
|
||||
| sed '1d' \
|
||||
| grep -ci '\-security')
|
||||
|
||||
|
||||
show_update_menu() {
|
||||
local current_version="$1"
|
||||
@@ -220,7 +236,8 @@ EOF
|
||||
local upgradable_count="$3"
|
||||
local security_count="$4"
|
||||
|
||||
local menu_text="$(translate "System Update Information")\n\n"
|
||||
local menu_text
|
||||
menu_text="$(translate "System Update Information")\n\n"
|
||||
menu_text+="$(translate "Current PVE Version"): $current_version\n"
|
||||
if [ -n "$target_version" ] && [ "$target_version" != "$current_version" ]; then
|
||||
menu_text+="$(translate "Available PVE Version"): $target_version\n"
|
||||
@@ -250,7 +267,6 @@ EOF
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [[ $MENU_RESULT -eq 1 ]]; then
|
||||
msg_info2 "$(translate "Update cancelled by user")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
@@ -273,20 +289,21 @@ EOF
|
||||
fi
|
||||
|
||||
echo -e
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y \
|
||||
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive apt -y \
|
||||
-o Dpkg::Options::='--force-confdef' \
|
||||
-o Dpkg::Options::='--force-confold' \
|
||||
dist-upgrade 2>&1 | tee -a "$log_file"
|
||||
|
||||
upgrade_exit_code=${PIPESTATUS[0]}
|
||||
full-upgrade 2> >(tee -a "$log_file" >&2)
|
||||
|
||||
upgrade_exit_code=$?
|
||||
echo -e
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [ $upgrade_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
rm -f "$screen_capture"
|
||||
@@ -309,7 +326,8 @@ EOF
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
|
||||
local end_time=$(date +%s)
|
||||
local end_time
|
||||
end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
local minutes=$((duration / 60))
|
||||
local seconds=$((duration % 60))
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Revision : @Blaspt (USB passthrough via udev rule with persistent /dev/coral)
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.2
|
||||
# Last Updated: 20/01/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script automates the configuration and installation of
|
||||
# Coral TPU and iGPU support in Proxmox VE containers. It:
|
||||
# - Configures a selected LXC container for hardware acceleration
|
||||
# - Installs and sets up Coral TPU drivers on the Proxmox host
|
||||
# - Installs necessary drivers inside the container
|
||||
# - Manages required system and container restarts
|
||||
#
|
||||
# Supports Coral USB and Coral M.2 (PCIe) devices.
|
||||
# Includes USB passthrough enhancement using persistent udev alias (/dev/coral).
|
||||
#
|
||||
# Changelog v1.2:
|
||||
# - Fixed symlink detection for /dev/coral (create=dir for symlinks)
|
||||
# - Fixed /dev/apex_0 not being mounted in PVE 9 (device existence not required)
|
||||
# - Fixed grep patterns to avoid matching commented lines
|
||||
# - Improved device type inference for non-existent devices
|
||||
# - Added duplicate entry cleanup
|
||||
# - Better error handling and logging
|
||||
# ==========================================================
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
# CONTAINER SELECTION AND VALIDATION
|
||||
# ==========================================================
|
||||
|
||||
select_container() {
|
||||
CONTAINERS=$(pct list | awk 'NR>1 {print $1, $3}' | xargs -n2)
|
||||
if [ -z "$CONTAINERS" ]; then
|
||||
msg_error "$(translate 'No containers available in Proxmox.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONTAINER_ID=$(whiptail --title "$(translate 'Select Container')" \
|
||||
--menu "$(translate 'Select the LXC container:')" 20 70 10 $CONTAINERS 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
msg_error "$(translate 'No container selected. Exiting.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! pct list | awk 'NR>1 {print $1}' | grep -qw "$CONTAINER_ID"; then
|
||||
msg_error "$(translate 'Container with ID') $CONTAINER_ID $(translate 'does not exist. Exiting.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Container selected:') $CONTAINER_ID"
|
||||
}
|
||||
|
||||
validate_container_id() {
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
msg_error "$(translate 'Container ID not defined. Make sure to select a container first.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if pct status "$CONTAINER_ID" | grep -q "running"; then
|
||||
msg_info "$(translate 'Stopping the container before applying configuration...')"
|
||||
pct stop "$CONTAINER_ID"
|
||||
msg_ok "$(translate 'Container stopped.')"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# UDEV RULES FOR CORAL USB
|
||||
# ==========================================================
|
||||
|
||||
add_udev_rule_for_coral_usb() {
|
||||
RULE_FILE="/etc/udev/rules.d/99-coral-usb.rules"
|
||||
RULE_CONTENT='# Coral USB Accelerator
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="9302", MODE="0666", TAG+="uaccess", SYMLINK+="coral"
|
||||
# Coral Dev Board / Mini PCIe
|
||||
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a6e", ATTRS{idProduct}=="089a", MODE="0666", TAG+="uaccess", SYMLINK+="coral"'
|
||||
|
||||
if [[ ! -f "$RULE_FILE" ]] || ! grep -q "18d1.*9302\|1a6e.*089a" "$RULE_FILE"; then
|
||||
echo "$RULE_CONTENT" > "$RULE_FILE"
|
||||
udevadm control --reload-rules && udevadm trigger
|
||||
msg_ok "$(translate 'Udev rules for Coral USB devices added and rules reloaded.')"
|
||||
else
|
||||
msg_ok "$(translate 'Udev rules for Coral USB devices already exist.')"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# MOUNT CONFIGURATION HELPER
|
||||
# ==========================================================
|
||||
|
||||
add_mount_if_needed() {
|
||||
local DEVICE="$1"
|
||||
local DEST="$2"
|
||||
local CONFIG_FILE="$3"
|
||||
|
||||
if grep -q "lxc.mount.entry: $DEVICE" "$CONFIG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local create_type="dir"
|
||||
|
||||
if [ -e "$DEVICE" ]; then
|
||||
if [ -L "$DEVICE" ]; then
|
||||
create_type="dir"
|
||||
elif [ -c "$DEVICE" ]; then
|
||||
create_type="file"
|
||||
elif [ -d "$DEVICE" ]; then
|
||||
create_type="dir"
|
||||
fi
|
||||
else
|
||||
case "$DEVICE" in
|
||||
*/apex_*|*/fb*|*/renderD*|*/card*)
|
||||
create_type="file"
|
||||
;;
|
||||
*/coral)
|
||||
create_type="dir"
|
||||
;;
|
||||
*/dri|*/bus/usb*)
|
||||
create_type="dir"
|
||||
;;
|
||||
*)
|
||||
create_type="dir"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "lxc.mount.entry: $DEVICE $DEST none bind,optional,create=$create_type" >> "$CONFIG_FILE"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# CLEANUP DUPLICATE ENTRIES
|
||||
# ==========================================================
|
||||
|
||||
cleanup_duplicate_entries() {
|
||||
local CONFIG_FILE="$1"
|
||||
local TEMP_FILE=$(mktemp)
|
||||
|
||||
awk '!seen[$0]++' "$CONFIG_FILE" > "$TEMP_FILE"
|
||||
|
||||
cat "$TEMP_FILE" > "$CONFIG_FILE"
|
||||
rm -f "$TEMP_FILE"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# CONFIGURE LXC HARDWARE PASSTHROUGH
|
||||
# ==========================================================
|
||||
|
||||
configure_lxc_hardware() {
|
||||
validate_container_id
|
||||
CONFIG_FILE="/etc/pve/lxc/${CONTAINER_ID}.conf"
|
||||
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
msg_error "$(translate 'Configuration file for container') $CONTAINER_ID $(translate 'not found.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup_duplicate_entries "$CONFIG_FILE"
|
||||
|
||||
# ============================================================
|
||||
# Convert to privileged container if needed
|
||||
# ============================================================
|
||||
if grep -q "^unprivileged: 1" "$CONFIG_FILE"; then
|
||||
msg_info "$(translate 'The container is unprivileged. Changing to privileged...')"
|
||||
sed -i "s/^unprivileged: 1/unprivileged: 0/" "$CONFIG_FILE"
|
||||
|
||||
STORAGE_TYPE=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk -F, '{print $2}' | cut -d'=' -f2)
|
||||
if [[ "$STORAGE_TYPE" == "dir" ]]; then
|
||||
STORAGE_PATH=$(pct config "$CONTAINER_ID" | grep "^rootfs:" | awk '{print $2}' | cut -d',' -f1)
|
||||
chown -R root:root "$STORAGE_PATH"
|
||||
fi
|
||||
msg_ok "$(translate 'Container changed to privileged.')"
|
||||
else
|
||||
msg_ok "$(translate 'The container is already privileged.')"
|
||||
fi
|
||||
|
||||
sed -i '/^dev[0-9]\+:/d' "$CONFIG_FILE"
|
||||
|
||||
# ============================================================
|
||||
# Enable nesting feature
|
||||
# ============================================================
|
||||
if ! grep -Pq "^features:.*nesting=1" "$CONFIG_FILE"; then
|
||||
if grep -Pq "^features:" "$CONFIG_FILE"; then
|
||||
|
||||
sed -i 's/^features: \(.*\)/features: nesting=1,\1/' "$CONFIG_FILE"
|
||||
else
|
||||
|
||||
echo "features: nesting=1" >> "$CONFIG_FILE"
|
||||
fi
|
||||
msg_ok "$(translate 'Nesting feature enabled')"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# iGPU support
|
||||
# ============================================================
|
||||
msg_info "$(translate 'Configuring iGPU support...')"
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 226:0 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 226:0 rwm # iGPU" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 226:128 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 226:128 rwm # iGPU" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
add_mount_if_needed "/dev/dri" "dev/dri" "$CONFIG_FILE"
|
||||
add_mount_if_needed "/dev/dri/renderD128" "dev/dri/renderD128" "$CONFIG_FILE"
|
||||
add_mount_if_needed "/dev/dri/card0" "dev/dri/card0" "$CONFIG_FILE"
|
||||
|
||||
msg_ok "$(translate 'iGPU configuration added')"
|
||||
|
||||
# ============================================================
|
||||
# Framebuffer support
|
||||
# ============================================================
|
||||
if [ -e "/dev/fb0" ]; then
|
||||
msg_info "$(translate 'Configuring Framebuffer support...')"
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 29:0 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 29:0 rwm # Framebuffer" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
add_mount_if_needed "/dev/fb0" "dev/fb0" "$CONFIG_FILE"
|
||||
msg_ok "$(translate 'Framebuffer configuration added')"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Coral USB passthrough
|
||||
# ============================================================
|
||||
msg_info "$(translate 'Configuring Coral USB support...')"
|
||||
|
||||
add_udev_rule_for_coral_usb
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 189:\\\* rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 189:* rwm # Coral USB" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
add_mount_if_needed "/dev/coral" "dev/coral" "$CONFIG_FILE"
|
||||
|
||||
if [ -L "/dev/coral" ]; then
|
||||
msg_ok "$(translate 'Coral USB configuration added - device detected')"
|
||||
else
|
||||
msg_ok "$(translate 'Coral USB configured but device not currently connected')"
|
||||
fi
|
||||
|
||||
# ============================================================
|
||||
# Coral M.2 (PCIe) support
|
||||
# ============================================================
|
||||
stop_spinner
|
||||
|
||||
if lspci | grep -iq "Global Unichip"; then
|
||||
msg_info "$(translate 'Coral M.2 Apex detected, configuring...')"
|
||||
|
||||
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 245:0 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 245:0 rwm # Coral M2 Apex" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
|
||||
add_mount_if_needed "/dev/apex_0" "dev/apex_0" "$CONFIG_FILE"
|
||||
|
||||
if [ -e "/dev/apex_0" ]; then
|
||||
msg_ok "$(translate 'Coral M.2 Apex configuration added - device ready')"
|
||||
else
|
||||
msg_ok "$(translate 'Coral M.2 Apex configuration added - device will be available after reboot')"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
cleanup_duplicate_entries "$CONFIG_FILE"
|
||||
|
||||
msg_ok "$(translate 'Hardware configuration completed for container') $CONTAINER_ID"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# INSTALL DRIVERS INSIDE CONTAINER
|
||||
# ==========================================================
|
||||
|
||||
install_coral_in_container() {
|
||||
msg_info "$(translate 'Installing iGPU and Coral TPU drivers inside the container...')"
|
||||
tput sc
|
||||
LOG_FILE=$(mktemp)
|
||||
|
||||
|
||||
if ! pct status "$CONTAINER_ID" | grep -q "running"; then
|
||||
pct start "$CONTAINER_ID"
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
|
||||
stop_spinner
|
||||
|
||||
# Determine driver package for Coral M.2
|
||||
CORAL_M2=$(lspci | grep -i "Global Unichip")
|
||||
if [[ -n "$CORAL_M2" ]]; then
|
||||
DRIVER_OPTION=$(whiptail --title "$(translate 'Select driver version')" \
|
||||
--menu "$(translate 'Choose the driver version for Coral M.2:\n\nCaution: Maximum mode generates more heat.')" 15 60 2 \
|
||||
1 "libedgetpu1-std ($(translate 'standard performance'))" \
|
||||
2 "libedgetpu1-max ($(translate 'maximum performance'))" 3>&1 1>&2 2>&3)
|
||||
|
||||
case "$DRIVER_OPTION" in
|
||||
1) DRIVER_PACKAGE="libedgetpu1-std" ;;
|
||||
2) DRIVER_PACKAGE="libedgetpu1-max" ;;
|
||||
*) DRIVER_PACKAGE="libedgetpu1-std" ;;
|
||||
esac
|
||||
else
|
||||
DRIVER_PACKAGE="libedgetpu1-std"
|
||||
fi
|
||||
|
||||
# Install drivers inside container
|
||||
script -q -c "pct exec \"$CONTAINER_ID\" -- bash -c '
|
||||
set -e
|
||||
|
||||
echo \"[1/6] Updating package lists...\"
|
||||
apt-get update -qq
|
||||
|
||||
echo \"[2/6] Installing iGPU drivers...\"
|
||||
apt-get install -y -qq va-driver-all ocl-icd-libopencl1 intel-opencl-icd vainfo intel-gpu-tools
|
||||
|
||||
echo \"[3/6] Configuring DRI permissions...\"
|
||||
if [ -e /dev/dri ]; then
|
||||
chgrp video /dev/dri 2>/dev/null || true
|
||||
chmod 755 /dev/dri 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo \"[4/6] Adding users to video/render groups...\"
|
||||
adduser root video 2>/dev/null || true
|
||||
adduser root render 2>/dev/null || true
|
||||
|
||||
echo \"[5/6] Installing Coral TPU dependencies...\"
|
||||
apt-get install -y -qq gnupg curl ca-certificates
|
||||
|
||||
echo \"[6/6] Adding Coral TPU repository...\"
|
||||
curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmor -o /usr/share/keyrings/coral-edgetpu.gpg
|
||||
echo \"deb [signed-by=/usr/share/keyrings/coral-edgetpu.gpg] https://packages.cloud.google.com/apt coral-edgetpu-stable main\" | tee /etc/apt/sources.list.d/coral-edgetpu.list >/dev/null
|
||||
|
||||
echo \"\"
|
||||
echo \"Updating package lists for Coral repository...\"
|
||||
apt-get update -qq
|
||||
|
||||
echo \"Installing Coral TPU driver ($DRIVER_PACKAGE)...\"
|
||||
apt-get install -y -qq $DRIVER_PACKAGE
|
||||
|
||||
'" "$LOG_FILE" 2>&1
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
tput rc
|
||||
tput ed
|
||||
rm -f "$LOG_FILE"
|
||||
msg_ok "$(translate 'iGPU and Coral TPU drivers installed successfully inside the container.')"
|
||||
else
|
||||
tput rc
|
||||
tput ed
|
||||
msg_error "$(translate 'Failed to install drivers inside the container.')"
|
||||
echo ""
|
||||
echo "$(translate 'Installation log:')"
|
||||
cat "$LOG_FILE"
|
||||
rm -f "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# VERIFICATION AND SUMMARY
|
||||
# ==========================================================
|
||||
|
||||
show_configuration_summary() {
|
||||
local CONFIG_FILE="/etc/pve/lxc/${CONTAINER_ID}.conf"
|
||||
|
||||
|
||||
# iGPU
|
||||
if grep -q "c 226:0 rwm" "$CONFIG_FILE"; then
|
||||
msg_ok2 "✓ iGPU support: $(translate 'Enabled')"
|
||||
fi
|
||||
|
||||
# Coral USB
|
||||
if grep -q "c 189:.*rwm.*Coral USB" "$CONFIG_FILE"; then
|
||||
if [ -L "/dev/coral" ]; then
|
||||
msg_ok2 "✓ Coral USB: $(translate 'Enabled and detected')"
|
||||
else
|
||||
msg_ok2 "⚠ Coral USB: $(translate 'Enabled but not connected')"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Coral M.2
|
||||
if grep -q "c 245:0 rwm.*Coral M2" "$CONFIG_FILE"; then
|
||||
if [ -e "/dev/apex_0" ]; then
|
||||
msg_ok2 "✓ Coral M.2: $(translate 'Enabled and ready')"
|
||||
else
|
||||
msg_ok2 "⚠ Coral M.2: $(translate 'Enabled (device pending)')"
|
||||
fi
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# MAIN EXECUTION
|
||||
# ==========================================================
|
||||
|
||||
main() {
|
||||
select_container
|
||||
show_proxmenux_logo
|
||||
configure_lxc_hardware
|
||||
install_coral_in_container
|
||||
show_configuration_summary
|
||||
|
||||
msg_ok "$(translate 'Configuration completed successfully!')"
|
||||
echo ""
|
||||
msg_success "$(translate 'Press Enter to return to menu...')"
|
||||
read -r
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main
|
||||
@@ -0,0 +1,931 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - NVIDIA Driver Installer (PVE 9.x)
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 0.9 (PVE9, fixed download issues)
|
||||
# Last Updated: 29/11/2025
|
||||
# ============================================
|
||||
|
||||
SCRIPT_TITLE="NVIDIA GPU Driver Installer for Proxmox VE"
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||
LOG_FILE="/tmp/nvidia_install.log"
|
||||
screen_capture="/tmp/proxmenux_nvidia_screen_capture_$$.txt"
|
||||
|
||||
NVIDIA_BASE_URL="https://download.nvidia.com/XFree86/Linux-x86_64"
|
||||
NVIDIA_WORKDIR="/opt/nvidia"
|
||||
|
||||
export BASE_DIR
|
||||
export COMPONENTS_STATUS_FILE
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
# GPU detection and current status
|
||||
# ==========================================================
|
||||
detect_nvidia_gpus() {
|
||||
# Only video controllers (not audio)
|
||||
local lspci_output
|
||||
lspci_output=$(lspci | grep -i "NVIDIA" \
|
||||
| grep -Ei "VGA compatible controller|3D controller|Display controller" || true)
|
||||
|
||||
if [[ -z "$lspci_output" ]]; then
|
||||
NVIDIA_GPU_PRESENT=false
|
||||
DETECTED_GPUS_TEXT="$(translate 'No NVIDIA GPU detected on this system.')"
|
||||
else
|
||||
NVIDIA_GPU_PRESENT=true
|
||||
DETECTED_GPUS_TEXT=""
|
||||
local i=1
|
||||
while IFS= read -r line; do
|
||||
DETECTED_GPUS_TEXT+=" ${i}. ${line}\n"
|
||||
((i++))
|
||||
done <<< "$lspci_output"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_driver_status() {
|
||||
CURRENT_DRIVER_INSTALLED=false
|
||||
CURRENT_DRIVER_VERSION=""
|
||||
|
||||
# First check if nvidia kernel module is actually loaded
|
||||
if lsmod | grep -q "^nvidia "; then
|
||||
|
||||
modprobe nvidia-uvm 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
|
||||
|
||||
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
|
||||
CURRENT_DRIVER_INSTALLED=true
|
||||
# Register the installed driver version in components_status.json
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
CURRENT_STATUS_TEXT="$(printf '%s %s' "$(translate 'NVIDIA driver installed:')" "$CURRENT_DRIVER_VERSION")"
|
||||
else
|
||||
CURRENT_STATUS_TEXT="$(translate 'No NVIDIA driver installed.')"
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
CURRENT_STATUS_COLORED="${CURRENT_STATUS_TEXT}"
|
||||
else
|
||||
CURRENT_STATUS_COLORED="${CURRENT_STATUS_TEXT}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# System preparation (repos, headers, etc.)
|
||||
# ==========================================================
|
||||
ensure_repos_and_headers() {
|
||||
msg_info "$(translate 'Checking kernel headers and build tools...')"
|
||||
|
||||
local kver
|
||||
kver=$(uname -r)
|
||||
|
||||
apt-get update -qq >>"$LOG_FILE" 2>&1
|
||||
|
||||
if ! dpkg -s "pve-headers-$kver" >/dev/null 2>&1 && \
|
||||
! dpkg -s "proxmox-headers-$kver" >/dev/null 2>&1; then
|
||||
apt-get install -y "pve-headers-$kver" "proxmox-headers-$kver" build-essential dkms >>"$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
apt-get install -y build-essential dkms >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Kernel headers and build tools verified.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
blacklist_nouveau() {
|
||||
msg_info "$(translate 'Blacklisting nouveau driver...')"
|
||||
if ! grep -q '^blacklist nouveau' /etc/modprobe.d/blacklist.conf 2>/dev/null; then
|
||||
echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
|
||||
fi
|
||||
msg_ok "$(translate 'nouveau driver has been blacklisted.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
ensure_modules_config() {
|
||||
msg_info "$(translate 'Configuring NVIDIA and VFIO modules...')"
|
||||
cat > /etc/modules-load.d/nvidia-vfio.conf <<'EOF'
|
||||
vfio
|
||||
vfio_iommu_type1
|
||||
vfio_pci
|
||||
vfio_virqfd
|
||||
nvidia
|
||||
nvidia_uvm
|
||||
EOF
|
||||
msg_ok "$(translate 'Modules configuration updated.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
stop_and_disable_nvidia_services() {
|
||||
local services=(
|
||||
"nvidia-persistenced.service"
|
||||
"nvidia-persistenced"
|
||||
"nvidia-powerd.service"
|
||||
)
|
||||
|
||||
local services_detected=0
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
if systemctl is-active --quiet "$service" 2>/dev/null || \
|
||||
systemctl is-enabled --quiet "$service" 2>/dev/null; then
|
||||
services_detected=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$services_detected" -eq 1 ]; then
|
||||
msg_info "$(translate 'Stopping and disabling NVIDIA services...')"
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
if systemctl is-active --quiet "$service" 2>/dev/null; then
|
||||
systemctl stop "$service" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if systemctl is-enabled --quiet "$service" 2>/dev/null; then
|
||||
systemctl disable "$service" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
sleep 2
|
||||
|
||||
msg_ok "$(translate 'NVIDIA services stopped and disabled.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
unload_nvidia_modules() {
|
||||
msg_info "$(translate 'Unloading NVIDIA kernel modules...')"
|
||||
|
||||
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
|
||||
modprobe -r "$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
|
||||
if lsmod | grep -qi '\bnvidia'; then
|
||||
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
|
||||
modprobe -r --force "$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
if lsmod | grep -qi '\bnvidia'; then
|
||||
msg_warn "$(translate 'Some NVIDIA modules could not be unloaded. Installation may fail. Ensure no processes are using the GPU.')"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
echo "$(translate 'Processes using NVIDIA:'):" >> "$LOG_FILE"
|
||||
lsof /dev/nvidia* 2>/dev/null >> "$LOG_FILE" || true
|
||||
fi
|
||||
else
|
||||
msg_ok "$(translate 'NVIDIA kernel modules unloaded successfully.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
complete_nvidia_uninstall() {
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
if command -v nvidia-uninstall >/dev/null 2>&1; then
|
||||
msg_info "$(translate 'Running NVIDIA uninstaller...')"
|
||||
nvidia-uninstall --silent >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'NVIDIA uninstaller completed.')"
|
||||
fi
|
||||
|
||||
cleanup_nvidia_dkms
|
||||
|
||||
msg_info "$(translate 'Removing NVIDIA packages...')"
|
||||
apt-get -y purge 'nvidia-*' 'libnvidia-*' 'cuda-*' 'libcudnn*' >>"$LOG_FILE" 2>&1 || true
|
||||
apt-get -y autoremove --purge >>"$LOG_FILE" 2>&1 || true
|
||||
apt-get -y autoclean >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
rm -f /etc/modules-load.d/nvidia-vfio.conf
|
||||
rm -f /etc/udev/rules.d/70-nvidia.rules
|
||||
rm -rf /usr/lib/modprobe.d/nvidia*.conf
|
||||
rm -rf /etc/modprobe.d/nvidia*.conf
|
||||
|
||||
if [[ -d "$NVIDIA_WORKDIR" ]]; then
|
||||
find "$NVIDIA_WORKDIR" -type d -name "nvidia-persistenced" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$NVIDIA_WORKDIR" -type d -name "nvidia-patch" -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
|
||||
update_component_status "nvidia_driver" "removed" "" "gpu" '{}'
|
||||
|
||||
msg_ok "$(translate 'Complete NVIDIA uninstallation finished.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
cleanup_nvidia_dkms() {
|
||||
local versions
|
||||
versions=$(dkms status 2>/dev/null | awk -F, '/nvidia/ {gsub(/ /,"",$2); print $2}' || true)
|
||||
|
||||
[[ -z "$versions" ]] && return 0
|
||||
|
||||
msg_info "$(translate 'Removing NVIDIA DKMS entries...')"
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
dkms remove -m nvidia -v "$ver" --all >/dev/null 2>&1 || true
|
||||
done <<< "$versions"
|
||||
msg_ok "$(translate 'NVIDIA DKMS entries removed.')"
|
||||
}
|
||||
|
||||
ensure_workdir() {
|
||||
mkdir -p "$NVIDIA_WORKDIR"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Kernel compatibility detection
|
||||
# ==========================================================
|
||||
get_kernel_compatibility_info() {
|
||||
local kernel_version
|
||||
kernel_version=$(uname -r)
|
||||
|
||||
# Determine Proxmox and kernel version
|
||||
if [[ -f /etc/pve/.version ]]; then
|
||||
PVE_VERSION=$(cat /etc/pve/.version)
|
||||
else
|
||||
PVE_VERSION="unknown"
|
||||
fi
|
||||
|
||||
# Extract kernel major version (6.x, 5.x, etc)
|
||||
KERNEL_MAJOR=$(echo "$kernel_version" | cut -d. -f1)
|
||||
KERNEL_MINOR=$(echo "$kernel_version" | cut -d. -f2)
|
||||
|
||||
# Define minimum compatible versions based on kernel
|
||||
# Based on https://docs.nvidia.com/datacenter/tesla/drivers/index.html
|
||||
if [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 17 ]]; then
|
||||
# Kernel 6.17+ (Proxmox 9.x) - Requires 580.82.07 or higher
|
||||
MIN_DRIVER_VERSION="580.82.07"
|
||||
RECOMMENDED_BRANCH="580"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version requires NVIDIA driver 580.82.07 or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 8 ]]; then
|
||||
# Kernel 6.8-6.16 (Proxmox 8.2+) - Works with 550.x or higher
|
||||
MIN_DRIVER_VERSION="550"
|
||||
RECOMMENDED_BRANCH="580"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works best with NVIDIA driver 550.x or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -ge 6 ]]; then
|
||||
# Kernel 6.2-6.7 (Proxmox 8.x initial) - Works with 535.x or higher
|
||||
MIN_DRIVER_VERSION="535"
|
||||
RECOMMENDED_BRANCH="550"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 535.x or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -eq 5 ]] && [[ "$KERNEL_MINOR" -ge 15 ]]; then
|
||||
# Kernel 5.15+ (Proxmox 7.x, 8.x legacy) - Works with 470.x or higher
|
||||
MIN_DRIVER_VERSION="470"
|
||||
RECOMMENDED_BRANCH="535"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 470.x or newer"
|
||||
else
|
||||
# Old kernels
|
||||
MIN_DRIVER_VERSION="450"
|
||||
RECOMMENDED_BRANCH="470"
|
||||
COMPATIBILITY_NOTE="For older kernels, compatibility may vary"
|
||||
fi
|
||||
}
|
||||
|
||||
is_version_compatible() {
|
||||
local version="$1"
|
||||
local ver_major ver_minor ver_patch
|
||||
|
||||
# Extract version components (major.minor.patch)
|
||||
ver_major=$(echo "$version" | cut -d. -f1)
|
||||
ver_minor=$(echo "$version" | cut -d. -f2)
|
||||
ver_patch=$(echo "$version" | cut -d. -f3)
|
||||
|
||||
if [[ "$MIN_DRIVER_VERSION" == "580.82.07" ]]; then
|
||||
# Compare full version: must be >= 580.82.07
|
||||
if [[ ${ver_major} -gt 580 ]]; then
|
||||
return 0
|
||||
elif [[ ${ver_major} -eq 580 ]]; then
|
||||
if [[ $((10#${ver_minor})) -gt 82 ]]; then
|
||||
return 0
|
||||
elif [[ $((10#${ver_minor})) -eq 82 ]]; then
|
||||
if [[ $((10#${ver_patch:-0})) -ge 7 ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if [[ ${ver_major} -ge ${MIN_DRIVER_VERSION} ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
version_le() {
|
||||
local v1="$1"
|
||||
local v2="$2"
|
||||
|
||||
IFS='.' read -r a1 b1 c1 <<<"$v1"
|
||||
IFS='.' read -r a2 b2 c2 <<<"$v2"
|
||||
|
||||
a1=${a1:-0}; b1=${b1:-0}; c1=${c1:-0}
|
||||
a2=${a2:-0}; b2=${b2:-0}; c2=${c2:-0}
|
||||
|
||||
a1=$((10#$a1)); b1=$((10#$b1)); c1=$((10#$c1))
|
||||
a2=$((10#$a2)); b2=$((10#$b2)); c2=$((10#$c2))
|
||||
|
||||
if (( a1 < a2 )); then
|
||||
return 0
|
||||
elif (( a1 > a2 )); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if (( b1 < b2 )); then
|
||||
return 0
|
||||
elif (( b1 > b2 )); then
|
||||
return 1
|
||||
fi
|
||||
|
||||
if (( c1 <= c2 )); then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ==========================================================
|
||||
# NVIDIA version management - FIXED VERSION
|
||||
# ==========================================================
|
||||
download_latest_version() {
|
||||
local latest_line version
|
||||
|
||||
latest_line=$(curl -fsSL "${NVIDIA_BASE_URL}/latest.txt" 2>&1)
|
||||
if [[ -z "$latest_line" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
version=$(echo "$latest_line" | awk '{print $1}' | tr -d '[:space:]')
|
||||
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
return 0
|
||||
}
|
||||
|
||||
list_available_versions() {
|
||||
local html_content versions
|
||||
|
||||
html_content=$(curl -s "$NVIDIA_BASE_URL/" 2>&1)
|
||||
|
||||
if [[ -z "$html_content" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
versions=$(echo "$html_content" \
|
||||
| grep -o 'href=[^ >]*' \
|
||||
| awk -F"'" '{print $2}' \
|
||||
| grep -E '^[0-9]' \
|
||||
| sed 's/\/$//' \
|
||||
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
|
||||
| sort -Vr \
|
||||
| uniq)
|
||||
|
||||
if [[ -z "$versions" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$versions"
|
||||
return 0
|
||||
}
|
||||
|
||||
verify_version_exists() {
|
||||
local version="$1"
|
||||
local url="${NVIDIA_BASE_URL}/${version}/"
|
||||
|
||||
if curl -fsSL --head "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
download_nvidia_installer() {
|
||||
ensure_workdir
|
||||
local version="$1"
|
||||
|
||||
version=$(echo "$version" | tr -d '[:space:]' | tr -d '\n' | tr -d '\r')
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
msg_error "Invalid version format: $version" >&2
|
||||
echo "ERROR: Invalid version format: '$version'" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local run_file="$NVIDIA_WORKDIR/NVIDIA-Linux-x86_64-${version}.run"
|
||||
|
||||
if [[ -f "$run_file" ]]; then
|
||||
echo "Found existing file: $run_file" >> "$LOG_FILE"
|
||||
local existing_size file_type
|
||||
existing_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
|
||||
file_type=$(file "$run_file" 2>/dev/null || echo "unknown")
|
||||
|
||||
echo "Existing file size: $existing_size bytes" >> "$LOG_FILE"
|
||||
echo "Existing file type: $file_type" >> "$LOG_FILE"
|
||||
|
||||
|
||||
if [[ $existing_size -gt 40000000 ]] && echo "$file_type" | grep -q "executable"; then
|
||||
|
||||
if sh "$run_file" --check 2>&1 | tee -a "$LOG_FILE" | grep -q "OK"; then
|
||||
echo "Existing file passed integrity check" >> "$LOG_FILE"
|
||||
msg_ok "$(translate 'Installer already downloaded and verified.')" >&2
|
||||
printf '%s\n' "$run_file"
|
||||
return 0
|
||||
else
|
||||
echo "Existing file FAILED integrity check, removing..." >> "$LOG_FILE"
|
||||
msg_warn "$(translate 'Existing file failed verification, re-downloading...')" >&2
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
else
|
||||
echo "Existing file invalid (size or type), removing..." >> "$LOG_FILE"
|
||||
msg_warn "$(translate 'Removing invalid existing file...')" >&2
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! verify_version_exists "$version"; then
|
||||
msg_error "Version $version does not exist on NVIDIA servers" >&2
|
||||
echo "ERROR: Version $version not found on server" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local urls=(
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}.run"
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}-no-compat32.run"
|
||||
)
|
||||
|
||||
local success=false
|
||||
local url_index=0
|
||||
|
||||
for url in "${urls[@]}"; do
|
||||
((url_index++))
|
||||
echo "Attempting download from: $url" >> "$LOG_FILE"
|
||||
|
||||
|
||||
rm -f "$run_file"
|
||||
|
||||
|
||||
if curl -fL --connect-timeout 30 --max-time 600 "$url" -o "$run_file" >> "$LOG_FILE" 2>&1; then
|
||||
echo "Download completed, verifying file..." >> "$LOG_FILE"
|
||||
|
||||
|
||||
if [[ ! -f "$run_file" ]]; then
|
||||
echo "ERROR: File not created after download" >> "$LOG_FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
local file_size
|
||||
file_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
|
||||
echo "Downloaded file size: $file_size bytes" >> "$LOG_FILE"
|
||||
|
||||
if [[ $file_size -lt 40000000 ]]; then
|
||||
echo "ERROR: File too small ($file_size bytes, expected >40MB)" >> "$LOG_FILE"
|
||||
head -c 200 "$run_file" >> "$LOG_FILE" 2>&1
|
||||
rm -f "$run_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
local file_type
|
||||
file_type=$(file "$run_file" 2>/dev/null)
|
||||
echo "File type: $file_type" >> "$LOG_FILE"
|
||||
|
||||
if echo "$file_type" | grep -q "executable"; then
|
||||
echo "SUCCESS: Valid executable downloaded" >> "$LOG_FILE"
|
||||
success=true
|
||||
break
|
||||
else
|
||||
echo "ERROR: Not a valid executable" >> "$LOG_FILE"
|
||||
head -c 200 "$run_file" | od -c >> "$LOG_FILE" 2>&1
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: curl failed for $url (exit code: $?)" >> "$LOG_FILE"
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $success; then
|
||||
msg_error "$(translate 'Download failed for all attempted URLs')" >&2
|
||||
msg_error "Version $version may not be available for your architecture" >&2
|
||||
echo "ERROR: All download attempts failed" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$run_file"
|
||||
echo "Installation file ready: $run_file" >> "$LOG_FILE"
|
||||
printf '%s\n' "$run_file"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Installation / uninstallation
|
||||
# ==========================================================
|
||||
run_nvidia_installer() {
|
||||
local installer="$1"
|
||||
|
||||
msg_info2 "$(translate 'Starting NVIDIA installer. This may take several minutes...')"
|
||||
echo "" >>"$LOG_FILE"
|
||||
echo "=== Running NVIDIA installer: $installer ===" >>"$LOG_FILE"
|
||||
|
||||
local tmp_extract_dir="$NVIDIA_WORKDIR/tmp_extract"
|
||||
mkdir -p "$tmp_extract_dir"
|
||||
|
||||
sh "$installer" --tmpdir="$tmp_extract_dir" --no-questions --ui=none --disable-nouveau --dkms 2>&1 | tee -a "$LOG_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
echo "" >>"$LOG_FILE"
|
||||
|
||||
rm -rf "$tmp_extract_dir"
|
||||
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
msg_error "$(translate 'NVIDIA installer reported an error. Check /tmp/nvidia_install.log')"
|
||||
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA driver installed successfully.')" | tee -a "$screen_capture"
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_nvidia_driver() {
|
||||
complete_nvidia_uninstall
|
||||
}
|
||||
|
||||
install_udev_rules_and_persistenced() {
|
||||
msg_info "$(translate 'Installing NVIDIA udev rules and persistence service...')"
|
||||
|
||||
cat >/etc/udev/rules.d/70-nvidia.rules <<'EOF'
|
||||
# /etc/udev/rules.d/70-nvidia.rules
|
||||
KERNEL=="nvidia", RUN+="/bin/bash -c '/usr/bin/nvidia-smi -L'"
|
||||
KERNEL=="nvidia_uvm", RUN+="/bin/bash -c '/usr/bin/nvidia-modprobe -c0 -u'"
|
||||
EOF
|
||||
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=drm --subsystem-match=pci || true
|
||||
|
||||
ensure_workdir
|
||||
cd "$NVIDIA_WORKDIR" || return 1
|
||||
if [[ ! -d nvidia-persistenced ]]; then
|
||||
git clone https://github.com/NVIDIA/nvidia-persistenced.git >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -d nvidia-persistenced/init ]]; then
|
||||
cd nvidia-persistenced/init || return 1
|
||||
./install.sh >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA udev rules and persistence service installed.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
apply_nvidia_patch_if_needed() {
|
||||
if ! hybrid_whiptail_yesno "$(translate 'NVIDIA Patch')" \
|
||||
"\n$(translate 'Do you want to apply the optional NVIDIA patch to remove some GPU limitations?')"; then
|
||||
msg_info2 "$(translate 'NVIDIA patch not applied.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_info "$(translate 'Cloning and applying NVIDIA patch (keylase/nvidia-patch)...')"
|
||||
ensure_workdir
|
||||
cd "$NVIDIA_WORKDIR" || return 1
|
||||
if [[ ! -d nvidia-patch ]]; then
|
||||
git clone https://github.com/keylase/nvidia-patch.git >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -x nvidia-patch/patch.sh ]]; then
|
||||
cd nvidia-patch || return 1
|
||||
./patch.sh >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'NVIDIA patch applied - check README for supported versions.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":true}'
|
||||
else
|
||||
msg_warn "$(translate 'Could not run NVIDIA patch script. Please verify repository and driver version.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
fi
|
||||
}
|
||||
|
||||
restart_prompt() {
|
||||
if hybrid_whiptail_yesno "$(translate 'NVIDIA Drivers')" \
|
||||
"\n$(translate 'The installation/changes require a server restart to apply correctly. Do you want to reboot now?')"; then
|
||||
msg_success "$(translate 'Installation completed. Press Enter to continue...')"
|
||||
read -r
|
||||
msg_warn "$(translate 'Restarting the server...')"
|
||||
rm -f "$screen_capture"
|
||||
reboot
|
||||
else
|
||||
msg_success "$(translate 'Installation completed. Please reboot the server manually as soon as possible.')"
|
||||
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
|
||||
read -r
|
||||
rm -f "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Dialog menus
|
||||
# ==========================================================
|
||||
show_action_menu_if_installed() {
|
||||
if ! $CURRENT_DRIVER_INSTALLED; then
|
||||
ACTION="install"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local menu_choices=(
|
||||
"install" "$(translate 'Reinstall/Update NVIDIA drivers')"
|
||||
"remove" "$(translate 'Uninstall NVIDIA drivers and configuration')"
|
||||
)
|
||||
|
||||
ACTION=$(hybrid_menu "ProxMenux" "$(translate 'NVIDIA Actions')\n\n$(translate 'Choose an action:')" 14 80 8 "${menu_choices[@]}") || ACTION="cancel"
|
||||
}
|
||||
|
||||
show_install_overview() {
|
||||
local overview
|
||||
overview="\n$(translate 'This installation will:')\n\n"
|
||||
overview+=" • $(translate 'Install NVIDIA proprietary drivers')\n"
|
||||
overview+=" • $(translate 'Configure GPU passthrough with VFIO')\n"
|
||||
overview+=" • $(translate 'Blacklist nouveau driver')\n"
|
||||
overview+=" • $(translate 'Enable IOMMU support if not enabled')\n\n"
|
||||
|
||||
overview+="$(translate 'Detected GPU(s):')\n"
|
||||
overview+="\Zb\Z4$DETECTED_GPUS_TEXT\Zn\n"
|
||||
|
||||
overview+="\n\Zn$(translate 'Current status: ') "
|
||||
overview+="\Zb${CURRENT_STATUS_TEXT}\Zn\n\n"
|
||||
|
||||
overview+="$(translate 'After confirming, you will be asked to choose the NVIDIA driver version to install.')\n\n"
|
||||
overview+="$(translate 'Do you want to continue?')"
|
||||
|
||||
hybrid_yesno "$(translate 'NVIDIA GPU Driver Installation')" "$overview" 22 90
|
||||
}
|
||||
|
||||
show_version_menu() {
|
||||
local latest versions_list
|
||||
local kernel_version
|
||||
kernel_version=$(uname -r)
|
||||
|
||||
|
||||
latest=$(download_latest_version 2>/dev/null)
|
||||
|
||||
|
||||
versions_list=$(list_available_versions 2>/dev/null)
|
||||
|
||||
|
||||
if [[ -z "$latest" ]] && [[ -z "$versions_list" ]]; then
|
||||
hybrid_msgbox "$(translate 'Error')" \
|
||||
"$(translate 'Could not retrieve versions list from NVIDIA. Please check your internet connection.')\n\nURL: ${NVIDIA_BASE_URL}" 10 80
|
||||
DRIVER_VERSION="cancel"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$latest" ]] && [[ -n "$versions_list" ]]; then
|
||||
latest=$(echo "$versions_list" | head -n1)
|
||||
fi
|
||||
|
||||
|
||||
if [[ -n "$latest" ]] && [[ -z "$versions_list" ]]; then
|
||||
versions_list="$latest"
|
||||
fi
|
||||
|
||||
# Clean latest version
|
||||
latest=$(echo "$latest" | tr -d '[:space:]')
|
||||
|
||||
local current_list="$versions_list"
|
||||
|
||||
# Apply kernel compatibility filter if needed
|
||||
if [[ -n "$MIN_DRIVER_VERSION" ]]; then
|
||||
local filtered_list=""
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
if is_version_compatible "$ver"; then
|
||||
filtered_list+="$ver"$'\n'
|
||||
fi
|
||||
done <<< "$current_list"
|
||||
current_list="$filtered_list"
|
||||
fi
|
||||
|
||||
if [[ -n "$latest" ]]; then
|
||||
local filtered_max_list=""
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
if version_le "$ver" "$latest"; then
|
||||
filtered_max_list+="$ver"$'\n'
|
||||
fi
|
||||
done <<< "$current_list"
|
||||
current_list="$filtered_max_list"
|
||||
fi
|
||||
|
||||
local menu_text="$(translate 'Select the NVIDIA driver version to install:')\n\n"
|
||||
menu_text+="$(translate 'Versions shown are compatible with your kernel. Latest available is recommended in most cases.')"
|
||||
|
||||
local choices=()
|
||||
choices+=("latest" "$(translate 'Latest available') (${latest:-unknown})")
|
||||
choices+=("" "")
|
||||
|
||||
if [[ -n "$current_list" ]]; then
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
ver=$(echo "$ver" | tr -d '[:space:]')
|
||||
[[ -z "$ver" ]] && continue
|
||||
|
||||
choices+=("$ver" "$ver")
|
||||
done <<< "$current_list"
|
||||
else
|
||||
choices+=("" "$(translate 'No compatible versions found for your kernel')")
|
||||
fi
|
||||
|
||||
local selection=$(hybrid_menu "$(translate 'NVIDIA Driver Version')" "$menu_text" 26 90 16 "${choices[@]}") || { DRIVER_VERSION="cancel"; return 1; }
|
||||
|
||||
case "$selection" in
|
||||
"")
|
||||
DRIVER_VERSION="cancel"
|
||||
return 1
|
||||
;;
|
||||
latest)
|
||||
DRIVER_VERSION="$latest"
|
||||
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
DRIVER_VERSION="$selection"
|
||||
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Main flow
|
||||
# ==========================================================
|
||||
main() {
|
||||
: >"$LOG_FILE"
|
||||
: >"$screen_capture"
|
||||
|
||||
detect_nvidia_gpus
|
||||
detect_driver_status
|
||||
|
||||
if ! $NVIDIA_GPU_PRESENT; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \
|
||||
"\n$(translate 'No NVIDIA GPU has been detected on this system. The installer will now exit.')" 20 70
|
||||
exit 1
|
||||
fi
|
||||
|
||||
show_action_menu_if_installed
|
||||
|
||||
case "$ACTION" in
|
||||
install)
|
||||
if ! show_install_overview; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
get_kernel_compatibility_info
|
||||
|
||||
show_version_menu
|
||||
if [[ "$DRIVER_VERSION" == "cancel" || -z "$DRIVER_VERSION" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
if [[ "$CURRENT_DRIVER_VERSION" == "$DRIVER_VERSION" ]]; then
|
||||
local confirm_text
|
||||
confirm_text="\n\n\n$(translate 'Version') \Zb\Z4$DRIVER_VERSION\Zn\n\n$(translate 'is already installed. Do you want to reinstall it? This will perform a clean uninstall first.')"
|
||||
if ! hybrid_yesno "$(translate 'Same Version Detected')" "$confirm_text" 14 70; then
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
local confirm_text
|
||||
confirm_text="\n\n$(translate 'Current version:') \Zb$CURRENT_DRIVER_VERSION\Zn\n"
|
||||
confirm_text+="$(translate 'New version:') \Zb\Z4$DRIVER_VERSION\Zn\n\n"
|
||||
confirm_text+="$(translate 'The current driver will be completely uninstalled before installing the new version. Continue?')"
|
||||
if ! hybrid_yesno "$(translate 'Version Change Detected')" "$confirm_text" 20 70; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate 'Uninstalling current NVIDIA driver before installing new version...')"
|
||||
complete_nvidia_uninstall
|
||||
|
||||
sleep 2
|
||||
|
||||
CURRENT_DRIVER_INSTALLED=false
|
||||
CURRENT_DRIVER_VERSION=""
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
ensure_repos_and_headers
|
||||
blacklist_nouveau
|
||||
ensure_modules_config
|
||||
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
msg_info "$(translate 'Downloading NVIDIA driver version:') $DRIVER_VERSION"
|
||||
|
||||
local installer
|
||||
installer=$(download_nvidia_installer "$DRIVER_VERSION" 2>>"$LOG_FILE")
|
||||
local download_result=$?
|
||||
|
||||
if [[ $download_result -ne 0 ]]; then
|
||||
msg_error "$(translate 'Failed to download NVIDIA installer')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA installer downloaded successfully')"
|
||||
|
||||
if [[ -z "$installer" || ! -f "$installer" ]]; then
|
||||
msg_error "$(translate 'Internal error: NVIDIA installer path is empty or file not found.')"
|
||||
rm -f "$screen_capture"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! run_nvidia_installer "$installer"; then
|
||||
rm -f "$screen_capture"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$LOG_FILE${CL}"
|
||||
|
||||
install_udev_rules_and_persistenced
|
||||
|
||||
msg_info "$(translate 'Updating initramfs for all kernels...')"
|
||||
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'initramfs updated.')"
|
||||
|
||||
msg_info2 "$(translate 'Checking NVIDIA driver status with nvidia-smi')"
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
nvidia-smi || true
|
||||
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
|
||||
CURRENT_DRIVER_INSTALLED=true
|
||||
else
|
||||
msg_warn "$(translate 'nvidia-smi not found in PATH. Please verify the driver installation.')"
|
||||
fi
|
||||
|
||||
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
|
||||
msg_ok "$(translate 'NVIDIA driver') $CURRENT_DRIVER_VERSION $(translate 'installed successfully.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
msg_success "$(translate 'Driver installed successfully. Press Enter to continue...')"
|
||||
read -r
|
||||
else
|
||||
msg_error "$(translate 'Failed to detect installed NVIDIA driver version.')"
|
||||
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
|
||||
fi
|
||||
|
||||
apply_nvidia_patch_if_needed
|
||||
restart_prompt
|
||||
;;
|
||||
remove)
|
||||
if hybrid_yesno "$(translate 'NVIDIA Driver Uninstall')" \
|
||||
"\n\n\n$(translate 'This will remove NVIDIA drivers and related configuration. Do you want to continue?')" 14 70; then
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
remove_nvidia_driver
|
||||
|
||||
msg_info "$(translate 'Updating initramfs for all kernels...')"
|
||||
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'initramfs updated.')"
|
||||
|
||||
restart_prompt
|
||||
fi
|
||||
;;
|
||||
cancel|*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main
|
||||
fi
|
||||
@@ -0,0 +1,916 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - NVIDIA Driver Installer (PVE 9.x)
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 0.9 (PVE9, fixed download issues)
|
||||
# Last Updated: 29/11/2025
|
||||
# ============================================
|
||||
|
||||
SCRIPT_TITLE="NVIDIA GPU Driver Installer for Proxmox VE"
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||
LOG_FILE="/tmp/nvidia_install.log"
|
||||
screen_capture="/tmp/proxmenux_nvidia_screen_capture_$$.txt"
|
||||
|
||||
NVIDIA_BASE_URL="https://download.nvidia.com/XFree86/Linux-x86_64"
|
||||
NVIDIA_WORKDIR="/opt/nvidia"
|
||||
|
||||
export BASE_DIR
|
||||
export COMPONENTS_STATUS_FILE
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]]; then
|
||||
echo "{}" > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
# GPU detection and current status
|
||||
# ==========================================================
|
||||
detect_nvidia_gpus() {
|
||||
# Only video controllers (not audio)
|
||||
local lspci_output
|
||||
lspci_output=$(lspci | grep -i "NVIDIA" \
|
||||
| grep -Ei "VGA compatible controller|3D controller|Display controller" || true)
|
||||
|
||||
if [[ -z "$lspci_output" ]]; then
|
||||
NVIDIA_GPU_PRESENT=false
|
||||
DETECTED_GPUS_TEXT="$(translate 'No NVIDIA GPU detected on this system.')"
|
||||
else
|
||||
NVIDIA_GPU_PRESENT=true
|
||||
DETECTED_GPUS_TEXT=""
|
||||
local i=1
|
||||
while IFS= read -r line; do
|
||||
DETECTED_GPUS_TEXT+=" ${i}. ${line}\n"
|
||||
((i++))
|
||||
done <<< "$lspci_output"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_driver_status() {
|
||||
CURRENT_DRIVER_INSTALLED=false
|
||||
CURRENT_DRIVER_VERSION=""
|
||||
|
||||
# First check if nvidia kernel module is actually loaded
|
||||
if lsmod | grep -q "^nvidia "; then
|
||||
|
||||
modprobe nvidia-uvm 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
|
||||
|
||||
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
|
||||
CURRENT_DRIVER_INSTALLED=true
|
||||
# Register the installed driver version in components_status.json
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
CURRENT_STATUS_TEXT="$(printf '%s %s' "$(translate 'NVIDIA driver installed:')" "$CURRENT_DRIVER_VERSION")"
|
||||
else
|
||||
CURRENT_STATUS_TEXT="$(translate 'No NVIDIA driver installed.')"
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
CURRENT_STATUS_COLORED="\Z2${CURRENT_STATUS_TEXT}\Zn"
|
||||
else
|
||||
CURRENT_STATUS_COLORED="\Z3${CURRENT_STATUS_TEXT}\Zn"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# System preparation (repos, headers, etc.)
|
||||
# ==========================================================
|
||||
ensure_repos_and_headers() {
|
||||
msg_info "$(translate 'Checking kernel headers and build tools...')"
|
||||
|
||||
local kver
|
||||
kver=$(uname -r)
|
||||
|
||||
apt-get update -qq >>"$LOG_FILE" 2>&1
|
||||
|
||||
if ! dpkg -s "pve-headers-$kver" >/dev/null 2>&1 && \
|
||||
! dpkg -s "proxmox-headers-$kver" >/dev/null 2>&1; then
|
||||
apt-get install -y "pve-headers-$kver" "proxmox-headers-$kver" build-essential dkms >>"$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
apt-get install -y build-essential dkms >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Kernel headers and build tools verified.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
blacklist_nouveau() {
|
||||
msg_info "$(translate 'Blacklisting nouveau driver...')"
|
||||
if ! grep -q '^blacklist nouveau' /etc/modprobe.d/blacklist.conf 2>/dev/null; then
|
||||
echo "blacklist nouveau" >> /etc/modprobe.d/blacklist.conf
|
||||
fi
|
||||
msg_ok "$(translate 'nouveau driver has been blacklisted.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
ensure_modules_config() {
|
||||
msg_info "$(translate 'Configuring NVIDIA and VFIO modules...')"
|
||||
cat > /etc/modules-load.d/nvidia-vfio.conf <<'EOF'
|
||||
vfio
|
||||
vfio_iommu_type1
|
||||
vfio_pci
|
||||
vfio_virqfd
|
||||
nvidia
|
||||
nvidia_uvm
|
||||
EOF
|
||||
msg_ok "$(translate 'Modules configuration updated.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
stop_and_disable_nvidia_services() {
|
||||
local services=(
|
||||
"nvidia-persistenced.service"
|
||||
"nvidia-persistenced"
|
||||
"nvidia-powerd.service"
|
||||
)
|
||||
|
||||
local services_detected=0
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
if systemctl is-active --quiet "$service" 2>/dev/null || \
|
||||
systemctl is-enabled --quiet "$service" 2>/dev/null; then
|
||||
services_detected=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$services_detected" -eq 1 ]; then
|
||||
msg_info "$(translate 'Stopping and disabling NVIDIA services...')"
|
||||
|
||||
for service in "${services[@]}"; do
|
||||
if systemctl is-active --quiet "$service" 2>/dev/null; then
|
||||
systemctl stop "$service" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if systemctl is-enabled --quiet "$service" 2>/dev/null; then
|
||||
systemctl disable "$service" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
sleep 2
|
||||
|
||||
msg_ok "$(translate 'NVIDIA services stopped and disabled.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
unload_nvidia_modules() {
|
||||
msg_info "$(translate 'Unloading NVIDIA kernel modules...')"
|
||||
|
||||
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
|
||||
modprobe -r "$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
|
||||
if lsmod | grep -qi '\bnvidia'; then
|
||||
for mod in nvidia_uvm nvidia_drm nvidia_modeset nvidia; do
|
||||
modprobe -r --force "$mod" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
if lsmod | grep -qi '\bnvidia'; then
|
||||
msg_warn "$(translate 'Some NVIDIA modules could not be unloaded. Installation may fail. Ensure no processes are using the GPU.')"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
echo "$(translate 'Processes using NVIDIA:'):" >> "$LOG_FILE"
|
||||
lsof /dev/nvidia* 2>/dev/null >> "$LOG_FILE" || true
|
||||
fi
|
||||
else
|
||||
msg_ok "$(translate 'NVIDIA kernel modules unloaded successfully.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
complete_nvidia_uninstall() {
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
if command -v nvidia-uninstall >/dev/null 2>&1; then
|
||||
msg_info "$(translate 'Running NVIDIA uninstaller...')"
|
||||
nvidia-uninstall --silent >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'NVIDIA uninstaller completed.')"
|
||||
fi
|
||||
|
||||
cleanup_nvidia_dkms
|
||||
|
||||
msg_info "$(translate 'Removing NVIDIA packages...')"
|
||||
apt-get -y purge 'nvidia-*' 'libnvidia-*' 'cuda-*' 'libcudnn*' >>"$LOG_FILE" 2>&1 || true
|
||||
apt-get -y autoremove --purge >>"$LOG_FILE" 2>&1 || true
|
||||
apt-get -y autoclean >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
rm -f /etc/modules-load.d/nvidia-vfio.conf
|
||||
rm -f /etc/udev/rules.d/70-nvidia.rules
|
||||
rm -rf /usr/lib/modprobe.d/nvidia*.conf
|
||||
rm -rf /etc/modprobe.d/nvidia*.conf
|
||||
|
||||
if [[ -d "$NVIDIA_WORKDIR" ]]; then
|
||||
find "$NVIDIA_WORKDIR" -type d -name "nvidia-persistenced" -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$NVIDIA_WORKDIR" -type d -name "nvidia-patch" -exec rm -rf {} + 2>/dev/null || true
|
||||
fi
|
||||
|
||||
update_component_status "nvidia_driver" "removed" "" "gpu" '{}'
|
||||
|
||||
msg_ok "$(translate 'Complete NVIDIA uninstallation finished.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
cleanup_nvidia_dkms() {
|
||||
local versions
|
||||
versions=$(dkms status 2>/dev/null | awk -F, '/nvidia/ {gsub(/ /,"",$2); print $2}' || true)
|
||||
|
||||
[[ -z "$versions" ]] && return 0
|
||||
|
||||
msg_info "$(translate 'Removing NVIDIA DKMS entries...')"
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
dkms remove -m nvidia -v "$ver" --all >/dev/null 2>&1 || true
|
||||
done <<< "$versions"
|
||||
msg_ok "$(translate 'NVIDIA DKMS entries removed.')"
|
||||
}
|
||||
|
||||
ensure_workdir() {
|
||||
mkdir -p "$NVIDIA_WORKDIR"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Kernel compatibility detection
|
||||
# ==========================================================
|
||||
get_kernel_compatibility_info() {
|
||||
local kernel_version
|
||||
kernel_version=$(uname -r)
|
||||
|
||||
# Determine Proxmox and kernel version
|
||||
if [[ -f /etc/pve/.version ]]; then
|
||||
PVE_VERSION=$(cat /etc/pve/.version)
|
||||
else
|
||||
PVE_VERSION="unknown"
|
||||
fi
|
||||
|
||||
# Extract kernel major version (6.x, 5.x, etc)
|
||||
KERNEL_MAJOR=$(echo "$kernel_version" | cut -d. -f1)
|
||||
KERNEL_MINOR=$(echo "$kernel_version" | cut -d. -f2)
|
||||
|
||||
# Define minimum compatible versions based on kernel
|
||||
# Based on https://docs.nvidia.com/datacenter/tesla/drivers/index.html
|
||||
if [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 17 ]]; then
|
||||
# Kernel 6.17+ (Proxmox 9.x) - Requires 580.82.07 or higher
|
||||
MIN_DRIVER_VERSION="580.82.07"
|
||||
RECOMMENDED_BRANCH="580"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version requires NVIDIA driver 580.82.07 or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -ge 6 ]] && [[ "$KERNEL_MINOR" -ge 8 ]]; then
|
||||
# Kernel 6.8-6.16 (Proxmox 8.2+) - Works with 550.x or higher
|
||||
MIN_DRIVER_VERSION="550"
|
||||
RECOMMENDED_BRANCH="580"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works best with NVIDIA driver 550.x or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -ge 6 ]]; then
|
||||
# Kernel 6.2-6.7 (Proxmox 8.x initial) - Works with 535.x or higher
|
||||
MIN_DRIVER_VERSION="535"
|
||||
RECOMMENDED_BRANCH="550"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 535.x or newer"
|
||||
elif [[ "$KERNEL_MAJOR" -eq 5 ]] && [[ "$KERNEL_MINOR" -ge 15 ]]; then
|
||||
# Kernel 5.15+ (Proxmox 7.x, 8.x legacy) - Works with 470.x or higher
|
||||
MIN_DRIVER_VERSION="470"
|
||||
RECOMMENDED_BRANCH="535"
|
||||
COMPATIBILITY_NOTE="Kernel $kernel_version works with NVIDIA driver 470.x or newer"
|
||||
else
|
||||
# Old kernels
|
||||
MIN_DRIVER_VERSION="450"
|
||||
RECOMMENDED_BRANCH="470"
|
||||
COMPATIBILITY_NOTE="For older kernels, compatibility may vary"
|
||||
fi
|
||||
}
|
||||
|
||||
is_version_compatible() {
|
||||
local version="$1"
|
||||
local ver_major ver_minor ver_patch
|
||||
|
||||
# Extract version components (major.minor.patch)
|
||||
ver_major=$(echo "$version" | cut -d. -f1)
|
||||
ver_minor=$(echo "$version" | cut -d. -f2)
|
||||
ver_patch=$(echo "$version" | cut -d. -f3)
|
||||
|
||||
if [[ "$MIN_DRIVER_VERSION" == "580.82.07" ]]; then
|
||||
# Compare full version: must be >= 580.82.07
|
||||
if [[ ${ver_major} -gt 580 ]]; then
|
||||
return 0
|
||||
elif [[ ${ver_major} -eq 580 ]]; then
|
||||
if [[ $((10#${ver_minor})) -gt 82 ]]; then
|
||||
return 0
|
||||
elif [[ $((10#${ver_minor})) -eq 82 ]]; then
|
||||
if [[ $((10#${ver_patch:-0})) -ge 7 ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if [[ ${ver_major} -ge ${MIN_DRIVER_VERSION} ]]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# NVIDIA version management - FIXED VERSION
|
||||
# ==========================================================
|
||||
download_latest_version() {
|
||||
local latest_line version
|
||||
|
||||
latest_line=$(curl -fsSL "${NVIDIA_BASE_URL}/latest.txt" 2>&1)
|
||||
if [[ -z "$latest_line" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
version=$(echo "$latest_line" | awk '{print $1}' | tr -d '[:space:]')
|
||||
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$version"
|
||||
return 0
|
||||
}
|
||||
|
||||
list_available_versions() {
|
||||
local html_content versions
|
||||
|
||||
html_content=$(curl -s "$NVIDIA_BASE_URL/" 2>&1)
|
||||
|
||||
if [[ -z "$html_content" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
versions=$(echo "$html_content" \
|
||||
| grep -o 'href=[^ >]*' \
|
||||
| awk -F"'" '{print $2}' \
|
||||
| grep -E '^[0-9]' \
|
||||
| sed 's/\/$//' \
|
||||
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
|
||||
| sort -Vr \
|
||||
| uniq)
|
||||
|
||||
if [[ -z "$versions" ]]; then
|
||||
echo "" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$versions"
|
||||
return 0
|
||||
}
|
||||
|
||||
verify_version_exists() {
|
||||
local version="$1"
|
||||
local url="${NVIDIA_BASE_URL}/${version}/"
|
||||
|
||||
if curl -fsSL --head "$url" >/dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
download_nvidia_installer() {
|
||||
ensure_workdir
|
||||
local version="$1"
|
||||
|
||||
version=$(echo "$version" | tr -d '[:space:]' | tr -d '\n' | tr -d '\r')
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
msg_error "Invalid version format: $version" >&2
|
||||
echo "ERROR: Invalid version format: '$version'" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local run_file="$NVIDIA_WORKDIR/NVIDIA-Linux-x86_64-${version}.run"
|
||||
|
||||
if [[ -f "$run_file" ]]; then
|
||||
echo "Found existing file: $run_file" >> "$LOG_FILE"
|
||||
local existing_size file_type
|
||||
existing_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
|
||||
file_type=$(file "$run_file" 2>/dev/null || echo "unknown")
|
||||
|
||||
echo "Existing file size: $existing_size bytes" >> "$LOG_FILE"
|
||||
echo "Existing file type: $file_type" >> "$LOG_FILE"
|
||||
|
||||
|
||||
if [[ $existing_size -gt 40000000 ]] && echo "$file_type" | grep -q "executable"; then
|
||||
|
||||
if sh "$run_file" --check 2>&1 | tee -a "$LOG_FILE" | grep -q "OK"; then
|
||||
echo "Existing file passed integrity check" >> "$LOG_FILE"
|
||||
msg_ok "$(translate 'Installer already downloaded and verified.')" >&2
|
||||
printf '%s\n' "$run_file"
|
||||
return 0
|
||||
else
|
||||
echo "Existing file FAILED integrity check, removing..." >> "$LOG_FILE"
|
||||
msg_warn "$(translate 'Existing file failed verification, re-downloading...')" >&2
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
else
|
||||
echo "Existing file invalid (size or type), removing..." >> "$LOG_FILE"
|
||||
msg_warn "$(translate 'Removing invalid existing file...')" >&2
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! verify_version_exists "$version"; then
|
||||
msg_error "Version $version does not exist on NVIDIA servers" >&2
|
||||
echo "ERROR: Version $version not found on server" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local urls=(
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}.run"
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}-no-compat32.run"
|
||||
)
|
||||
|
||||
local success=false
|
||||
local url_index=0
|
||||
|
||||
for url in "${urls[@]}"; do
|
||||
((url_index++))
|
||||
echo "Attempting download from: $url" >> "$LOG_FILE"
|
||||
|
||||
|
||||
rm -f "$run_file"
|
||||
|
||||
|
||||
if curl -fL --connect-timeout 30 --max-time 600 "$url" -o "$run_file" >> "$LOG_FILE" 2>&1; then
|
||||
echo "Download completed, verifying file..." >> "$LOG_FILE"
|
||||
|
||||
|
||||
if [[ ! -f "$run_file" ]]; then
|
||||
echo "ERROR: File not created after download" >> "$LOG_FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
local file_size
|
||||
file_size=$(stat -c%s "$run_file" 2>/dev/null || stat -f%z "$run_file" 2>/dev/null || echo "0")
|
||||
echo "Downloaded file size: $file_size bytes" >> "$LOG_FILE"
|
||||
|
||||
if [[ $file_size -lt 40000000 ]]; then
|
||||
echo "ERROR: File too small ($file_size bytes, expected >40MB)" >> "$LOG_FILE"
|
||||
head -c 200 "$run_file" >> "$LOG_FILE" 2>&1
|
||||
rm -f "$run_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
local file_type
|
||||
file_type=$(file "$run_file" 2>/dev/null)
|
||||
echo "File type: $file_type" >> "$LOG_FILE"
|
||||
|
||||
if echo "$file_type" | grep -q "executable"; then
|
||||
echo "SUCCESS: Valid executable downloaded" >> "$LOG_FILE"
|
||||
success=true
|
||||
break
|
||||
else
|
||||
echo "ERROR: Not a valid executable" >> "$LOG_FILE"
|
||||
head -c 200 "$run_file" | od -c >> "$LOG_FILE" 2>&1
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: curl failed for $url (exit code: $?)" >> "$LOG_FILE"
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $success; then
|
||||
msg_error "$(translate 'Download failed for all attempted URLs')" >&2
|
||||
msg_error "Version $version may not be available for your architecture" >&2
|
||||
echo "ERROR: All download attempts failed" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
chmod +x "$run_file"
|
||||
echo "Installation file ready: $run_file" >> "$LOG_FILE"
|
||||
printf '%s\n' "$run_file"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Installation / uninstallation
|
||||
# ==========================================================
|
||||
run_nvidia_installer() {
|
||||
local installer="$1"
|
||||
|
||||
msg_info2 "$(translate 'Starting NVIDIA installer. This may take several minutes...')"
|
||||
echo "" >>"$LOG_FILE"
|
||||
echo "=== Running NVIDIA installer: $installer ===" >>"$LOG_FILE"
|
||||
|
||||
local tmp_extract_dir="$NVIDIA_WORKDIR/tmp_extract"
|
||||
mkdir -p "$tmp_extract_dir"
|
||||
|
||||
sh "$installer" --tmpdir="$tmp_extract_dir" --no-questions --ui=none --disable-nouveau --dkms 2>&1 | tee -a "$LOG_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
echo "" >>"$LOG_FILE"
|
||||
|
||||
rm -rf "$tmp_extract_dir"
|
||||
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
msg_error "$(translate 'NVIDIA installer reported an error. Check /tmp/nvidia_install.log')"
|
||||
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA driver installed successfully.')" | tee -a "$screen_capture"
|
||||
return 0
|
||||
}
|
||||
|
||||
remove_nvidia_driver() {
|
||||
complete_nvidia_uninstall
|
||||
}
|
||||
|
||||
install_udev_rules_and_persistenced() {
|
||||
msg_info "$(translate 'Installing NVIDIA udev rules and persistence service...')"
|
||||
|
||||
cat >/etc/udev/rules.d/70-nvidia.rules <<'EOF'
|
||||
# /etc/udev/rules.d/70-nvidia.rules
|
||||
KERNEL=="nvidia", RUN+="/bin/bash -c '/usr/bin/nvidia-smi -L'"
|
||||
KERNEL=="nvidia_uvm", RUN+="/bin/bash -c '/usr/bin/nvidia-modprobe -c0 -u'"
|
||||
EOF
|
||||
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=drm --subsystem-match=pci || true
|
||||
|
||||
ensure_workdir
|
||||
cd "$NVIDIA_WORKDIR" || return 1
|
||||
if [[ ! -d nvidia-persistenced ]]; then
|
||||
git clone https://github.com/NVIDIA/nvidia-persistenced.git >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -d nvidia-persistenced/init ]]; then
|
||||
cd nvidia-persistenced/init || return 1
|
||||
./install.sh >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA udev rules and persistence service installed.')" | tee -a "$screen_capture"
|
||||
}
|
||||
|
||||
apply_nvidia_patch_if_needed() {
|
||||
if ! whiptail --title "$(translate 'NVIDIA Patch')" --yesno \
|
||||
"\n$(translate 'Do you want to apply the optional NVIDIA patch to remove some GPU limitations?')" 10 70; then
|
||||
msg_info2 "$(translate 'NVIDIA patch not applied.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_info "$(translate 'Cloning and applying NVIDIA patch (keylase/nvidia-patch)...')"
|
||||
ensure_workdir
|
||||
cd "$NVIDIA_WORKDIR" || return 1
|
||||
if [[ ! -d nvidia-patch ]]; then
|
||||
git clone https://github.com/keylase/nvidia-patch.git >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -x nvidia-patch/patch.sh ]]; then
|
||||
cd nvidia-patch || return 1
|
||||
./patch.sh >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'NVIDIA patch applied - check README for supported versions.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":true}'
|
||||
else
|
||||
msg_warn "$(translate 'Could not run NVIDIA patch script. Please verify repository and driver version.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
fi
|
||||
}
|
||||
|
||||
restart_prompt() {
|
||||
if whiptail --title "$(translate 'NVIDIA Drivers')" --yesno \
|
||||
"\n$(translate 'The installation/changes require a server restart to apply correctly. Do you want to reboot now?')" 10 70; then
|
||||
msg_success "$(translate 'Installation completed. Press Enter to continue...')"
|
||||
read -r
|
||||
msg_warn "$(translate 'Restarting the server...')"
|
||||
rm -f "$screen_capture"
|
||||
reboot
|
||||
else
|
||||
msg_success "$(translate 'Installation completed. Please reboot the server manually as soon as possible.')"
|
||||
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
|
||||
read -r
|
||||
rm -f "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Dialog menus
|
||||
# ==========================================================
|
||||
show_action_menu_if_installed() {
|
||||
if ! $CURRENT_DRIVER_INSTALLED; then
|
||||
ACTION="install"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local menu_choices=(
|
||||
"install" "$(translate 'Reinstall/Update NVIDIA drivers')"
|
||||
"remove" "$(translate 'Uninstall NVIDIA drivers and configuration')"
|
||||
)
|
||||
|
||||
ACTION=$(dialog --clear --stdout \
|
||||
--backtitle "ProxMenux" \
|
||||
--title "$(translate 'NVIDIA GPU Driver Management')" \
|
||||
--menu "$(translate 'Choose an action:')" 14 80 8 \
|
||||
"${menu_choices[@]}") || ACTION="cancel"
|
||||
}
|
||||
|
||||
show_install_overview() {
|
||||
local overview
|
||||
overview="\n$(translate 'This installation will:')\n\n"
|
||||
overview+=" • $(translate 'Install NVIDIA proprietary drivers')\n"
|
||||
overview+=" • $(translate 'Configure GPU passthrough with VFIO')\n"
|
||||
overview+=" • $(translate 'Blacklist nouveau driver')\n"
|
||||
overview+=" • $(translate 'Enable IOMMU support if not enabled')\n\n"
|
||||
|
||||
overview+="$(translate 'Detected GPU(s):')\n"
|
||||
overview+="\Zb\Z4$DETECTED_GPUS_TEXT\Zn\n"
|
||||
|
||||
overview+="\n\Zn$(translate 'Current status: ') "
|
||||
overview+="\Zb${CURRENT_STATUS_TEXT}\Zn\n\n"
|
||||
|
||||
overview+="$(translate 'After confirming, you will be asked to choose the NVIDIA driver version to install.')\n\n"
|
||||
overview+="$(translate 'Do you want to continue?')"
|
||||
|
||||
dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$(translate 'NVIDIA GPU Driver Installation')" \
|
||||
--yesno "$overview" 22 90
|
||||
}
|
||||
|
||||
show_version_menu() {
|
||||
local latest versions_list
|
||||
local kernel_version
|
||||
kernel_version=$(uname -r)
|
||||
|
||||
|
||||
latest=$(download_latest_version 2>/dev/null)
|
||||
|
||||
|
||||
versions_list=$(list_available_versions 2>/dev/null)
|
||||
|
||||
|
||||
if [[ -z "$latest" ]] && [[ -z "$versions_list" ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate 'Error')" --msgbox \
|
||||
"$(translate 'Could not retrieve versions list from NVIDIA. Please check your internet connection.')\n\nURL: ${NVIDIA_BASE_URL}" 10 80
|
||||
DRIVER_VERSION="cancel"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
if [[ -z "$latest" ]] && [[ -n "$versions_list" ]]; then
|
||||
latest=$(echo "$versions_list" | head -n1)
|
||||
fi
|
||||
|
||||
|
||||
if [[ -n "$latest" ]] && [[ -z "$versions_list" ]]; then
|
||||
versions_list="$latest"
|
||||
fi
|
||||
|
||||
# Clean latest version
|
||||
latest=$(echo "$latest" | tr -d '[:space:]')
|
||||
|
||||
local filter=""
|
||||
local selection
|
||||
local choices
|
||||
local current_list
|
||||
local menu_text
|
||||
|
||||
while true; do
|
||||
current_list="$versions_list"
|
||||
|
||||
if [[ -n "$MIN_DRIVER_VERSION" ]]; then
|
||||
local filtered_list=""
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
if is_version_compatible "$ver"; then
|
||||
filtered_list+="$ver"$'\n'
|
||||
fi
|
||||
done <<< "$current_list"
|
||||
current_list="$filtered_list"
|
||||
fi
|
||||
|
||||
|
||||
if [[ -n "$filter" ]]; then
|
||||
current_list=$(echo "$current_list" | grep "$filter" || true)
|
||||
fi
|
||||
|
||||
menu_text="$(translate 'Select the NVIDIA driver version to install:')\n\n"
|
||||
menu_text+="$(translate 'Use the filter entry to narrow the list. Latest available (recommended in most cases), or choose a specific version from the list.')"
|
||||
|
||||
choices=()
|
||||
choices+=("latest" "$(translate 'Latest available') (${latest:-unknown})")
|
||||
choices+=("" "")
|
||||
choices+=("filter" "$(translate 'Filter versions')${filter:+: $filter}")
|
||||
|
||||
|
||||
if [[ -n "$current_list" ]]; then
|
||||
while IFS= read -r ver; do
|
||||
[[ -z "$ver" ]] && continue
|
||||
ver=$(echo "$ver" | tr -d '[:space:]')
|
||||
[[ -z "$ver" ]] && continue
|
||||
|
||||
choices+=("$ver" "$ver")
|
||||
done <<< "$current_list"
|
||||
else
|
||||
choices+=("" "$(translate 'No versions match the current filter')")
|
||||
fi
|
||||
|
||||
selection=$(dialog --clear --stdout \
|
||||
--backtitle "ProxMenux" \
|
||||
--title "$(translate 'NVIDIA Driver Version')" \
|
||||
--menu "$menu_text" 26 90 16 \
|
||||
"${choices[@]}") || { DRIVER_VERSION="cancel"; return 1; }
|
||||
|
||||
case "$selection" in
|
||||
"")
|
||||
continue
|
||||
;;
|
||||
filter)
|
||||
filter=$(dialog --clear --stdout \
|
||||
--backtitle "ProxMenux" \
|
||||
--title "$(translate 'Filter NVIDIA versions')" \
|
||||
--inputbox "$(translate 'Enter a filter (e.g., 560, 570, 580). Leave empty to show all.')" 10 80 "$filter") || true
|
||||
;;
|
||||
latest)
|
||||
DRIVER_VERSION="$latest"
|
||||
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
DRIVER_VERSION="$selection"
|
||||
DRIVER_VERSION=$(echo "$DRIVER_VERSION" | tr -d '[:space:]')
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# Main flow
|
||||
# ==========================================================
|
||||
main() {
|
||||
: >"$LOG_FILE"
|
||||
: >"$screen_capture"
|
||||
|
||||
detect_nvidia_gpus
|
||||
detect_driver_status
|
||||
|
||||
if ! $NVIDIA_GPU_PRESENT; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \
|
||||
"\n$(translate 'No NVIDIA GPU has been detected on this system. The installer will now exit.')" 20 70
|
||||
exit 1
|
||||
fi
|
||||
|
||||
show_action_menu_if_installed
|
||||
|
||||
case "$ACTION" in
|
||||
install)
|
||||
if ! show_install_overview; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
get_kernel_compatibility_info
|
||||
|
||||
show_version_menu
|
||||
if [[ "$DRIVER_VERSION" == "cancel" || -z "$DRIVER_VERSION" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if $CURRENT_DRIVER_INSTALLED; then
|
||||
if [[ "$CURRENT_DRIVER_VERSION" == "$DRIVER_VERSION" ]]; then
|
||||
if ! dialog --colors --backtitle "ProxMenux" --title "$(translate 'Same Version Detected')" --yesno \
|
||||
"$(printf '\n\n\n%s \Zb%s\Zn\n\n%s' \
|
||||
"$(translate 'Version')" "$DRIVER_VERSION" \
|
||||
"$(translate 'is already installed. Do you want to reinstall it? This will perform a clean uninstall first.')")" 14 70; then
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
if ! dialog --colors --backtitle "ProxMenux" --title "$(translate 'Version Change Detected')" --yesno \
|
||||
"$(printf '\n\n%s \Zb%s\Zn\n%s \Zb\Z4%s\Zn\n\n%s' \
|
||||
"$(translate 'Current version:')" "$CURRENT_DRIVER_VERSION" \
|
||||
"$(translate 'New version:')" "$DRIVER_VERSION" \
|
||||
"$(translate 'The current driver will be completely uninstalled before installing the new version. Continue?')")" 20 70; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
msg_info2 "$(translate 'Uninstalling current NVIDIA driver before installing new version...')"
|
||||
complete_nvidia_uninstall
|
||||
|
||||
sleep 2
|
||||
|
||||
CURRENT_DRIVER_INSTALLED=false
|
||||
CURRENT_DRIVER_VERSION=""
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
ensure_repos_and_headers
|
||||
blacklist_nouveau
|
||||
ensure_modules_config
|
||||
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
msg_info "$(translate 'Downloading NVIDIA driver version:') $DRIVER_VERSION"
|
||||
|
||||
local installer
|
||||
installer=$(download_nvidia_installer "$DRIVER_VERSION" 2>>"$LOG_FILE")
|
||||
local download_result=$?
|
||||
|
||||
if [[ $download_result -ne 0 ]]; then
|
||||
msg_error "$(translate 'Failed to download NVIDIA installer')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'NVIDIA installer downloaded successfully')"
|
||||
|
||||
if [[ -z "$installer" || ! -f "$installer" ]]; then
|
||||
msg_error "$(translate 'Internal error: NVIDIA installer path is empty or file not found.')"
|
||||
rm -f "$screen_capture"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! run_nvidia_installer "$installer"; then
|
||||
rm -f "$screen_capture"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$LOG_FILE${CL}"
|
||||
|
||||
install_udev_rules_and_persistenced
|
||||
|
||||
msg_info "$(translate 'Updating initramfs for all kernels...')"
|
||||
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'initramfs updated.')"
|
||||
|
||||
msg_info2 "$(translate 'Checking NVIDIA driver status with nvidia-smi')"
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
nvidia-smi || true
|
||||
CURRENT_DRIVER_VERSION=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -n1)
|
||||
CURRENT_DRIVER_INSTALLED=true
|
||||
else
|
||||
msg_warn "$(translate 'nvidia-smi not found in PATH. Please verify the driver installation.')"
|
||||
fi
|
||||
|
||||
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
|
||||
msg_ok "$(translate 'NVIDIA driver') $CURRENT_DRIVER_VERSION $(translate 'installed successfully.')"
|
||||
update_component_status "nvidia_driver" "installed" "$CURRENT_DRIVER_VERSION" "gpu" '{"patched":false}'
|
||||
msg_success "$(translate 'Driver installed successfully. Press Enter to continue...')"
|
||||
read -r
|
||||
else
|
||||
msg_error "$(translate 'Failed to detect installed NVIDIA driver version.')"
|
||||
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
|
||||
fi
|
||||
|
||||
apply_nvidia_patch_if_needed
|
||||
restart_prompt
|
||||
;;
|
||||
remove)
|
||||
if dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA Driver Uninstall')" --yesno \
|
||||
"\n\n\n$(translate 'This will remove NVIDIA drivers and related configuration. Do you want to continue?')" 14 70; then
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
|
||||
remove_nvidia_driver
|
||||
|
||||
msg_info "$(translate 'Updating initramfs for all kernels...')"
|
||||
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'initramfs updated.')"
|
||||
|
||||
restart_prompt
|
||||
fi
|
||||
;;
|
||||
cancel|*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main
|
||||
fi
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 29/05/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# Author : MacRimi
|
||||
# Revision : @Blaspt (USB passthrough via udev rule with persistent /dev/coral)
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 16/05/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 19/08/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 19/08/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 2.0
|
||||
# Last Updated: 19/08/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# Contributors : cod378
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 04/07/2025
|
||||
# ==========================================================
|
||||
@@ -342,7 +342,7 @@ show_version_info() {
|
||||
fi
|
||||
local translated_status=$(translate "$status")
|
||||
case "$status" in
|
||||
"installed"|"already_installed"|"created"|"already_exists"|"upgraded")
|
||||
"installed"|"already_installed"|"created"|"already_exists"|"upgraded"|"updated")
|
||||
info_message+="✓ $component: $translated_status\n"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 07/05/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 19/08/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 2.0
|
||||
# Last Updated: 04/04/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 2.0
|
||||
# Last Updated: 04/04/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.2
|
||||
# Last Updated: 14/11/2025
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.3
|
||||
# Last Updated: 14/03/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script provides a simple and efficient way to access and execute Proxmox VE scripts
|
||||
@@ -33,8 +33,9 @@ load_language
|
||||
initialize_cache
|
||||
# ==========================================================
|
||||
|
||||
# New unified cache — categories and mirror URLs are embedded,
|
||||
# metadata.json is no longer needed.
|
||||
HELPERS_JSON_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/refs/heads/main/json/helpers_cache.json"
|
||||
METADATA_URL="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/frontend/public/json/metadata.json"
|
||||
|
||||
for cmd in curl jq dialog; do
|
||||
if ! command -v "$cmd" >/dev/null; then
|
||||
@@ -44,63 +45,78 @@ for cmd in curl jq dialog; do
|
||||
done
|
||||
|
||||
CACHE_JSON=$(curl -s "$HELPERS_JSON_URL")
|
||||
META_JSON=$(curl -s "$METADATA_URL")
|
||||
|
||||
# Validate that the JSON loaded correctly
|
||||
if ! echo "$CACHE_JSON" | jq -e 'if type == "array" and length > 0 then true else false end' >/dev/null 2>&1; then
|
||||
dialog --title "Helper Scripts" \
|
||||
--msgbox "Error: Could not load helpers cache.\nCheck your internet connection and try again.\n\nURL: $HELPERS_JSON_URL" 10 70
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build category map directly from the cache (id → name).
|
||||
# Uses transpose to pair categories[] and category_names[] arrays — no
|
||||
# dependency on metadata.json, which no longer exists upstream.
|
||||
# ---------------------------------------------------------------------------
|
||||
declare -A CATEGORY_NAMES
|
||||
while read -r id name; do
|
||||
CATEGORY_NAMES[$id]="$name"
|
||||
done < <(echo "$META_JSON" | jq -r '.categories[] | "\(.id)\t\(.name)"')
|
||||
while IFS=$'\t' read -r id name; do
|
||||
[[ -n "$id" && -n "$name" ]] && CATEGORY_NAMES["$id"]="$name"
|
||||
done < <(echo "$CACHE_JSON" | jq -r '
|
||||
[.[] | [.categories, .category_names] | transpose[] | @tsv]
|
||||
| unique[]')
|
||||
|
||||
# Count scripts per category (deduplicated by slug)
|
||||
declare -A CATEGORY_COUNT
|
||||
for id in $(echo "$CACHE_JSON" | jq -r '
|
||||
group_by(.slug) | map(.[0])[] | .categories[]'); do
|
||||
while read -r id; do
|
||||
((CATEGORY_COUNT[$id]++))
|
||||
done
|
||||
done < <(echo "$CACHE_JSON" | jq -r '
|
||||
group_by(.slug) | map(.[0])[] | .categories[]')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Type label — updated to match new type values (lxc instead of ct)
|
||||
# ---------------------------------------------------------------------------
|
||||
get_type_label() {
|
||||
local type="$1"
|
||||
case "$type" in
|
||||
ct) echo $'\Z1LXC\Zn' ;;
|
||||
vm) echo $'\Z4VM\Zn' ;;
|
||||
pve) echo $'\Z3PVE\Zn' ;;
|
||||
addon) echo $'\Z2ADDON\Zn' ;;
|
||||
*) echo $'\Z7GEN\Zn' ;;
|
||||
lxc) echo $'\Z1LXC\Zn' ;;
|
||||
vm) echo $'\Z4VM\Zn' ;;
|
||||
pve) echo $'\Z3PVE\Zn' ;;
|
||||
addon) echo $'\Z2ADDON\Zn' ;;
|
||||
turnkey) echo $'\Z5TK\Zn' ;;
|
||||
*) echo $'\Z7GEN\Zn' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Download and execute a script URL, with optional mirror fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
download_script() {
|
||||
local url="$1"
|
||||
local fallback_pve="${url/misc\/tools\/pve}"
|
||||
local fallback_addon="${url/misc\/tools\/addon}"
|
||||
local fallback_copydata="${url/misc\/tools\/copy-data}"
|
||||
|
||||
if curl --silent --head --fail "$url" >/dev/null; then
|
||||
bash <(curl -s "$url")
|
||||
elif curl --silent --head --fail "$fallback_pve" >/dev/null; then
|
||||
bash <(curl -s "$fallback_pve")
|
||||
elif curl --silent --head --fail "$fallback_addon" >/dev/null; then
|
||||
bash <(curl -s "$fallback_addon")
|
||||
elif curl --silent --head --fail "$fallback_copydata" >/dev/null; then
|
||||
bash <(curl -s "$fallback_copydata")
|
||||
bash <(curl -s "$url")
|
||||
else
|
||||
dialog --title "Helper Scripts" --msgbox "Error: Failed to download the script." 12 70
|
||||
dialog --title "Helper Scripts" --msgbox "$(translate "Error: Failed to download the script.")" 8 70
|
||||
fi
|
||||
}
|
||||
|
||||
RETURN_TO_MAIN=false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Format default credentials for display
|
||||
# ---------------------------------------------------------------------------
|
||||
format_credentials() {
|
||||
local script_info="$1"
|
||||
local credentials_info=""
|
||||
|
||||
|
||||
local has_credentials
|
||||
has_credentials=$(echo "$script_info" | base64 --decode | jq -r 'has("default_credentials")')
|
||||
|
||||
|
||||
if [[ "$has_credentials" == "true" ]]; then
|
||||
local username password
|
||||
username=$(echo "$script_info" | base64 --decode | jq -r '.default_credentials.username // empty')
|
||||
password=$(echo "$script_info" | base64 --decode | jq -r '.default_credentials.password // empty')
|
||||
|
||||
|
||||
if [[ -n "$username" && -n "$password" ]]; then
|
||||
credentials_info="Username: $username | Password: $password"
|
||||
elif [[ -n "$username" ]]; then
|
||||
@@ -109,30 +125,41 @@ format_credentials() {
|
||||
credentials_info="Password: $password"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
echo "$credentials_info"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Run a script identified by its slug.
|
||||
#
|
||||
# A slug can have multiple entries when a script supports several OS variants
|
||||
# (e.g. Debian + Alpine). Each entry carries its own script_url / mirror and
|
||||
# the os field already normalised to lowercase by generate_helpers_cache.py.
|
||||
# The menu lets the user pick OS variant × source (GitHub / Mirror).
|
||||
# ---------------------------------------------------------------------------
|
||||
run_script_by_slug() {
|
||||
local slug="$1"
|
||||
local -a script_infos
|
||||
mapfile -t script_infos < <(echo "$CACHE_JSON" | jq -r --arg slug "$slug" '.[] | select(.slug == $slug) | @base64')
|
||||
mapfile -t script_infos < <(echo "$CACHE_JSON" | jq -r --arg slug "$slug" \
|
||||
'.[] | select(.slug == $slug) | @base64')
|
||||
|
||||
if [[ ${#script_infos[@]} -eq 0 ]]; then
|
||||
dialog --title "Helper Scripts" --msgbox "Error: No script data found for slug: $slug" 8 60
|
||||
dialog --title "Helper Scripts" \
|
||||
--msgbox "$(translate "Error: No script data found for slug:") $slug" 8 60
|
||||
return
|
||||
fi
|
||||
|
||||
decode() {
|
||||
echo "$1" | base64 --decode | jq -r "$2"
|
||||
}
|
||||
decode() { echo "$1" | base64 --decode | jq -r "$2"; }
|
||||
|
||||
local first="${script_infos[0]}"
|
||||
local name desc notes
|
||||
local name desc notes port website
|
||||
name=$(decode "$first" ".name")
|
||||
desc=$(decode "$first" ".desc")
|
||||
notes=$(decode "$first" ".notes | join(\"\n\")")
|
||||
notes=$(decode "$first" '.notes | join("\n")')
|
||||
port=$(decode "$first" ".port // 0")
|
||||
website=$(decode "$first" ".website // empty")
|
||||
|
||||
# Build notes block
|
||||
local notes_dialog=""
|
||||
if [[ -n "$notes" ]]; then
|
||||
while IFS= read -r line; do
|
||||
@@ -145,18 +172,21 @@ run_script_by_slug() {
|
||||
local credentials
|
||||
credentials=$(format_credentials "$first")
|
||||
|
||||
local msg="\Zb\Z4Descripción:\Zn\n$desc"
|
||||
[[ -n "$notes_dialog" ]] && msg+="\n\n\Zb\Z4Notes:\Zn\n$notes_dialog"
|
||||
[[ -n "$credentials" ]] && msg+="\n\n\Zb\Z4Default Credentials:\Zn\n$credentials"
|
||||
|
||||
# Add separator before menu options
|
||||
# Build info message
|
||||
local msg="\Zb\Z4$(translate "Description"):\Zn\n$desc"
|
||||
[[ -n "$notes_dialog" ]] && msg+="\n\n\Zb\Z4$(translate "Notes"):\Zn\n$notes_dialog"
|
||||
[[ -n "$credentials" ]] && msg+="\n\n\Zb\Z4$(translate "Default Credentials"):\Zn\n$credentials"
|
||||
[[ "$port" -gt 0 ]] && msg+="\n\n\Zb\Z4$(translate "Default Port"):\Zn $port"
|
||||
[[ -n "$website" ]] && msg+="\n\Zb\Z4$(translate "Website"):\Zn $website"
|
||||
|
||||
msg+="\n\n$(translate "Choose how to run the script:"):"
|
||||
|
||||
# Build menu: one or two entries per script_info (GH + optional Mirror)
|
||||
declare -a MENU_OPTS=()
|
||||
local idx=0
|
||||
for s in "${script_infos[@]}"; do
|
||||
local os script_url script_url_mirror script_name
|
||||
os=$(decode "$s" ".os // empty")
|
||||
os=$(decode "$s" '.os // empty')
|
||||
[[ -z "$os" ]] && os="$(translate "default")"
|
||||
script_name=$(decode "$s" ".name")
|
||||
script_url=$(decode "$s" ".script_url")
|
||||
@@ -196,7 +226,8 @@ run_script_by_slug() {
|
||||
if [[ -n "$mirror_url" ]]; then
|
||||
download_script "$mirror_url"
|
||||
else
|
||||
dialog --title "Helper Scripts" --msgbox "$(translate "Mirror URL not available for this script.")" 8 60
|
||||
dialog --title "Helper Scripts" \
|
||||
--msgbox "$(translate "Mirror URL not available for this script.")" 8 60
|
||||
RETURN_TO_MAIN=false
|
||||
return
|
||||
fi
|
||||
@@ -206,10 +237,10 @@ run_script_by_slug() {
|
||||
echo
|
||||
|
||||
if [[ -n "$desc" || -n "$notes" || -n "$credentials" ]]; then
|
||||
echo -e "$TAB\e[1;36mScript Information:\e[0m"
|
||||
echo -e "$TAB\e[1;36m$(translate "Script Information"):\e[0m"
|
||||
|
||||
if [[ -n "$notes" ]]; then
|
||||
echo -e "$TAB\e[1;33mNotes:\e[0m"
|
||||
echo -e "$TAB\e[1;33m$(translate "Notes"):\e[0m"
|
||||
while IFS= read -r line; do
|
||||
[[ -z "$line" ]] && continue
|
||||
echo -e "$TAB• $line"
|
||||
@@ -218,26 +249,30 @@ run_script_by_slug() {
|
||||
fi
|
||||
|
||||
if [[ -n "$credentials" ]]; then
|
||||
echo -e "$TAB\e[1;32mDefault Credentials:\e[0m"
|
||||
echo -e "$TAB\e[1;32m$(translate "Default Credentials"):\e[0m"
|
||||
echo "$TAB$credentials"
|
||||
echo
|
||||
fi
|
||||
fi
|
||||
|
||||
msg_success "Press Enter to return to the main menu..."
|
||||
msg_success "$(translate "Press Enter to return to the main menu...")"
|
||||
read -r
|
||||
RETURN_TO_MAIN=true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search / filter scripts by name or description
|
||||
# ---------------------------------------------------------------------------
|
||||
search_and_filter_scripts() {
|
||||
local search_term=""
|
||||
|
||||
|
||||
while true; do
|
||||
search_term=$(dialog --inputbox "Enter search term (leave empty to show all scripts):" \
|
||||
8 65 "$search_term" 3>&1 1>&2 2>&3)
|
||||
|
||||
search_term=$(dialog --inputbox \
|
||||
"$(translate "Enter search term (leave empty to show all scripts):"):" \
|
||||
8 65 "$search_term" 3>&1 1>&2 2>&3)
|
||||
|
||||
[[ $? -ne 0 ]] && return
|
||||
|
||||
|
||||
local filtered_json
|
||||
if [[ -z "$search_term" ]]; then
|
||||
filtered_json="$CACHE_JSON"
|
||||
@@ -250,12 +285,14 @@ search_and_filter_scripts() {
|
||||
(.desc | ascii_downcase | contains($term))
|
||||
)]')
|
||||
fi
|
||||
|
||||
|
||||
local count
|
||||
count=$(echo "$filtered_json" | jq 'group_by(.slug) | length')
|
||||
|
||||
if [[ $count -eq 0 ]]; then
|
||||
dialog --msgbox "No scripts found for: '$search_term'\n\nTry a different search term." 8 50
|
||||
|
||||
if [[ "$count" -eq 0 ]]; then
|
||||
dialog --msgbox \
|
||||
"$(translate "No scripts found for:") '$search_term'\n\n$(translate "Try a different search term.")" \
|
||||
8 50
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -263,43 +300,41 @@ search_and_filter_scripts() {
|
||||
declare -A index_to_slug
|
||||
local menu_items=()
|
||||
local i=1
|
||||
|
||||
|
||||
while IFS=$'\t' read -r slug name type; do
|
||||
index_to_slug[$i]="$slug"
|
||||
local label
|
||||
label=$(get_type_label "$type")
|
||||
local padded_name
|
||||
padded_name=$(printf "%-42s" "$name")
|
||||
local entry="$padded_name $label"
|
||||
menu_items+=("$i" "$entry")
|
||||
menu_items+=("$i" "$padded_name $label")
|
||||
((i++))
|
||||
done < <(echo "$filtered_json" | jq -r '
|
||||
group_by(.slug) | map(.[0]) | sort_by(.name)[] | [.slug, .name, .type] | @tsv')
|
||||
|
||||
group_by(.slug) | map(.[0]) | sort_by(.name)[]
|
||||
| [.slug, .name, .type] | @tsv')
|
||||
|
||||
menu_items+=("" "")
|
||||
menu_items+=("new_search" "New Search")
|
||||
menu_items+=("show_all" "Show All Scripts")
|
||||
|
||||
local title="Search Results"
|
||||
menu_items+=("new_search" "$(translate "New Search")")
|
||||
menu_items+=("show_all" "$(translate "Show All Scripts")")
|
||||
|
||||
local title
|
||||
if [[ -n "$search_term" ]]; then
|
||||
title="Search Results for: '$search_term' ($count found)"
|
||||
title="$(translate "Search Results for:") '$search_term' ($count $(translate "found"))"
|
||||
else
|
||||
title="All Available Scripts ($count total)"
|
||||
title="$(translate "All Available Scripts") ($count $(translate "total"))"
|
||||
fi
|
||||
|
||||
|
||||
local selected
|
||||
selected=$(dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$title" \
|
||||
--menu "Select a script or action:" \
|
||||
--menu "$(translate "Select a script or action:"):" \
|
||||
22 75 15 "${menu_items[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [[ $? -ne 0 ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
|
||||
[[ $? -ne 0 ]] && return
|
||||
|
||||
case "$selected" in
|
||||
"new_search")
|
||||
break
|
||||
break
|
||||
;;
|
||||
"show_all")
|
||||
search_term=""
|
||||
@@ -308,7 +343,7 @@ search_and_filter_scripts() {
|
||||
continue
|
||||
;;
|
||||
"back"|"")
|
||||
return
|
||||
return
|
||||
;;
|
||||
*)
|
||||
if [[ -n "${index_to_slug[$selected]}" ]]; then
|
||||
@@ -321,48 +356,64 @@ search_and_filter_scripts() {
|
||||
done
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main loop — category list built from embedded category data.
|
||||
# We map scriptcatXXXXX IDs to short numeric indices so dialog doesn't show
|
||||
# the long ID string as the visible tag in the menu column.
|
||||
# ---------------------------------------------------------------------------
|
||||
while true; do
|
||||
MENU_ITEMS=()
|
||||
|
||||
MENU_ITEMS+=("search" "Search/Filter Scripts")
|
||||
MENU_ITEMS+=("search" "$(translate "Search/Filter Scripts")")
|
||||
MENU_ITEMS+=("" "")
|
||||
|
||||
for id in $(printf "%s\n" "${!CATEGORY_COUNT[@]}" | sort -n); do
|
||||
|
||||
# Map scriptcatXXXXX IDs to short numeric indices (1, 2, 3…) so dialog
|
||||
# doesn't render the long ID string as the visible tag column.
|
||||
declare -A CAT_IDX_TO_ID
|
||||
local_idx=1
|
||||
for id in $(printf "%s\n" "${!CATEGORY_COUNT[@]}" | sort); do
|
||||
CAT_IDX_TO_ID[$local_idx]="$id"
|
||||
name="${CATEGORY_NAMES[$id]:-Category $id}"
|
||||
count="${CATEGORY_COUNT[$id]}"
|
||||
padded_name=$(printf "%-35s" "$name")
|
||||
padded_count=$(printf "(%2d)" "$count")
|
||||
MENU_ITEMS+=("$id" "$padded_name $padded_count")
|
||||
MENU_ITEMS+=("$local_idx" "$padded_name $padded_count")
|
||||
((local_idx++))
|
||||
done
|
||||
|
||||
SELECTED=$(dialog --backtitle "ProxMenux" --title "Proxmox VE Helper-Scripts" --menu \
|
||||
"Select a category or search for scripts:" 20 70 14 \
|
||||
"${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
|
||||
dialog --clear --title "ProxMenux" \
|
||||
--msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70
|
||||
SELECTED_IDX=$(dialog --backtitle "ProxMenux" \
|
||||
--title "Proxmox VE Helper-Scripts" \
|
||||
--menu "$(translate "Select a category or search for scripts:"):" \
|
||||
20 70 14 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
|
||||
dialog --clear --title "ProxMenux" \
|
||||
--msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
}
|
||||
|
||||
if [[ "$SELECTED" == "search" ]]; then
|
||||
|
||||
if [[ "$SELECTED_IDX" == "search" ]]; then
|
||||
search_and_filter_scripts
|
||||
continue
|
||||
fi
|
||||
|
||||
# Resolve numeric index back to the real category ID
|
||||
SELECTED="${CAT_IDX_TO_ID[$SELECTED_IDX]}"
|
||||
[[ -z "$SELECTED" ]] && continue
|
||||
|
||||
# ---- Scripts within the selected category --------------------------------
|
||||
while true; do
|
||||
declare -A INDEX_TO_SLUG
|
||||
SCRIPTS=()
|
||||
i=1
|
||||
|
||||
while IFS=$'\t' read -r slug name type; do
|
||||
INDEX_TO_SLUG[$i]="$slug"
|
||||
label=$(get_type_label "$type")
|
||||
padded_name=$(printf "%-42s" "$name")
|
||||
entry="$padded_name $label"
|
||||
SCRIPTS+=("$i" "$entry")
|
||||
SCRIPTS+=("$i" "$padded_name $label")
|
||||
((i++))
|
||||
done < <(echo "$CACHE_JSON" | jq -r --argjson id "$SELECTED" '
|
||||
done < <(echo "$CACHE_JSON" | jq -r --arg id "$SELECTED" '
|
||||
[
|
||||
.[]
|
||||
| select(.categories | index($id))
|
||||
.[]
|
||||
| select(.categories | index($id))
|
||||
| {slug, name, type}
|
||||
]
|
||||
| group_by(.slug)
|
||||
@@ -371,13 +422,14 @@ while true; do
|
||||
| [.slug, .name, .type]
|
||||
| @tsv')
|
||||
|
||||
SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" --title "Scripts in ${CATEGORY_NAMES[$SELECTED]}" --menu \
|
||||
"Choose a script to execute:" 20 70 14 \
|
||||
"${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
|
||||
SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$(translate "Scripts in") ${CATEGORY_NAMES[$SELECTED]}" \
|
||||
--menu "$(translate "Choose a script to execute:"):" \
|
||||
20 70 14 "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
|
||||
|
||||
SCRIPT_SELECTED="${INDEX_TO_SLUG[$SCRIPT_INDEX]}"
|
||||
run_script_by_slug "$SCRIPT_SELECTED"
|
||||
|
||||
|
||||
[[ "$RETURN_TO_MAIN" == true ]] && { RETURN_TO_MAIN=false; break; }
|
||||
done
|
||||
done
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.2
|
||||
# Last Updated: 06/07/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 08/07/2025
|
||||
# ==========================================================
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 15/04/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 02/07/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 06/07/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.3
|
||||
# Last Updated: 30/06/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 06/07/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.5
|
||||
# Last Updated: 04/08/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.2
|
||||
# Last Updated: 30/07/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 29/05/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 08/04/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# Description : Allows unmounting a previously mounted disk
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 07/05/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 04/06/2025
|
||||
# ==========================================================
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 13/03/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 13/03/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 13/08/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 04/07/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 14/08/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 30/07/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 14/08/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 30/06/2025
|
||||
# ==========================================================
|
||||
|
||||
+231
-1
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
@@ -42,6 +42,7 @@ CACHE_FILE="$BASE_DIR/cache.json"
|
||||
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||
MENU_SCRIPT="menu"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||
|
||||
|
||||
# Translation context
|
||||
@@ -112,6 +113,16 @@ cleanup() {
|
||||
fi
|
||||
}
|
||||
|
||||
stop_spinner() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
|
||||
kill $SPINNER_PID > /dev/null 2>&1
|
||||
wait $SPINNER_PID 2>/dev/null
|
||||
fi
|
||||
printf "\r\033[K"
|
||||
printf "\e[?25h"
|
||||
SPINNER_PID=""
|
||||
}
|
||||
|
||||
# Display trnaslate message with spinner
|
||||
msg_lang() {
|
||||
local msg="$1"
|
||||
@@ -398,3 +409,222 @@ echo -e
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
|
||||
########################################################
|
||||
|
||||
|
||||
ensure_components_status_file() {
|
||||
mkdir -p "$BASE_DIR"
|
||||
if [[ ! -f "$COMPONENTS_STATUS_FILE" ]] || ! jq empty "$COMPONENTS_STATUS_FILE" >/dev/null 2>&1; then
|
||||
echo '{}' > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
update_component_status() {
|
||||
local comp="$1"
|
||||
local stat="$2"
|
||||
local ver="$3"
|
||||
local category="$4"
|
||||
local extra_json="$5"
|
||||
if [ -z "$extra_json" ]; then
|
||||
extra_json="{}"
|
||||
fi
|
||||
|
||||
ensure_components_status_file
|
||||
|
||||
local ts
|
||||
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
local tmp_file
|
||||
tmp_file=$(mktemp)
|
||||
|
||||
if jq --arg comp "$comp" \
|
||||
--arg stat "$stat" \
|
||||
--arg ver "$ver" \
|
||||
--arg category "$category" \
|
||||
--arg time "$ts" \
|
||||
--argjson extra "$extra_json" \
|
||||
'.[$comp] = ({status:$stat, version:$ver, category:$category, timestamp:$time} + $extra)' \
|
||||
"$COMPONENTS_STATUS_FILE" > "$tmp_file" 2>/dev/null; then
|
||||
mv "$tmp_file" "$COMPONENTS_STATUS_FILE"
|
||||
else
|
||||
rm -f "$tmp_file"
|
||||
echo '{}' > "$COMPONENTS_STATUS_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# Hybrid Dialog Functions (Web/Terminal)
|
||||
# ============================================
|
||||
|
||||
# Detect if running in web mode
|
||||
is_web_mode() {
|
||||
[[ "$EXECUTION_MODE" == "web" ]]
|
||||
}
|
||||
|
||||
# Generate unique interaction ID
|
||||
generate_interaction_id() {
|
||||
echo "$(date +%s%N)_$$"
|
||||
}
|
||||
|
||||
# Wait for web response with timeout
|
||||
wait_for_web_response() {
|
||||
local interaction_id="$1"
|
||||
local response_file="/tmp/proxmenux_response_${interaction_id}"
|
||||
local timeout=300 # 5 minutes
|
||||
local elapsed=0
|
||||
|
||||
while [[ ! -f "$response_file" ]] && [[ $elapsed -lt $timeout ]]; do
|
||||
sleep 0.1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
|
||||
if [[ -f "$response_file" ]]; then
|
||||
cat "$response_file"
|
||||
rm -f "$response_file"
|
||||
return 0
|
||||
else
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Hybrid menu function
|
||||
hybrid_menu() {
|
||||
local title="$1"
|
||||
local text="$2"
|
||||
local height="${3:-20}"
|
||||
local width="${4:-70}"
|
||||
local menu_height="${5:-10}"
|
||||
shift 5
|
||||
local items=("$@")
|
||||
|
||||
if is_web_mode; then
|
||||
local interaction_id=$(generate_interaction_id)
|
||||
local clean_text=$(echo -e "$text" | sed 's/\\Z[0-9bn]//g')
|
||||
local options_json="["
|
||||
for ((i=0; i<${#items[@]}; i+=2)); do
|
||||
if [ $i -gt 0 ]; then options_json+=","; fi
|
||||
options_json+="{\"value\":\"${items[i]}\",\"label\":\"${items[i+1]}\"}"
|
||||
done
|
||||
options_json+="]"
|
||||
|
||||
echo "WEB_INTERACTION:menu:${interaction_id}:$(echo -n "$title" | base64 -w0):$(echo -n "$clean_text" | base64 -w0):$options_json" >> "${WEB_LOG:-/tmp/proxmenux_web.log}"
|
||||
wait_for_web_response "$interaction_id"
|
||||
else
|
||||
dialog --colors --title "$title" --menu "$text" "$height" "$width" "$menu_height" "${items[@]}" 3>&1 1>&2 2>&3
|
||||
fi
|
||||
}
|
||||
|
||||
# Hybrid yes/no prompt
|
||||
hybrid_yesno() {
|
||||
local title="$1"
|
||||
local text="$2"
|
||||
local height="${3:-10}"
|
||||
local width="${4:-60}"
|
||||
|
||||
if is_web_mode; then
|
||||
local interaction_id=$(generate_interaction_id)
|
||||
local clean_text=$(echo -e "$text" | sed 's/\\Z[0-9bn]//g')
|
||||
echo "WEB_INTERACTION:yesno:${interaction_id}:$(echo -n "$title" | base64 -w0):$(echo -n "$clean_text" | base64 -w0)" >> "${WEB_LOG:-/tmp/proxmenux_web.log}"
|
||||
local response=$(wait_for_web_response "$interaction_id")
|
||||
[[ "$response" == "yes" ]] && return 0 || return 1
|
||||
else
|
||||
dialog --colors --title "$title" --yesno "$text" "$height" "$width"
|
||||
fi
|
||||
}
|
||||
|
||||
# Hybrid message box
|
||||
hybrid_msgbox() {
|
||||
local title="$1"
|
||||
local text="$2"
|
||||
local height="${3:-10}"
|
||||
local width="${4:-60}"
|
||||
|
||||
if is_web_mode; then
|
||||
local interaction_id=$(generate_interaction_id)
|
||||
local clean_text=$(echo -e "$text" | sed 's/\\Z[0-9bn]//g')
|
||||
echo "WEB_INTERACTION:msgbox:${interaction_id}:$(echo -n "$title" | base64 -w0):$(echo -n "$clean_text" | base64 -w0)" >> "${WEB_LOG:-/tmp/proxmenux_web.log}"
|
||||
wait_for_web_response "$interaction_id" > /dev/null
|
||||
else
|
||||
dialog --colors --title "$title" --msgbox "$text" "$height" "$width"
|
||||
fi
|
||||
}
|
||||
|
||||
# Hybrid input box
|
||||
hybrid_inputbox() {
|
||||
local title="$1"
|
||||
local text="$2"
|
||||
local height="${3:-10}"
|
||||
local width="${4:-60}"
|
||||
local default="${5:-}"
|
||||
|
||||
if is_web_mode; then
|
||||
local interaction_id=$(generate_interaction_id)
|
||||
echo "WEB_INTERACTION:inputbox:${interaction_id}:$(echo -n "$title" | base64 -w0):$(echo -n "$text" | base64 -w0):$(echo -n "$default" | base64 -w0)" >> "${WEB_LOG:-/tmp/proxmenux_web.log}"
|
||||
wait_for_web_response "$interaction_id"
|
||||
else
|
||||
dialog --title "$title" --inputbox "$text" "$height" "$width" "$default" 3>&1 1>&2 2>&3
|
||||
fi
|
||||
}
|
||||
|
||||
# Hybrid whiptail menu (used during installation - doesn't hide terminal output)
|
||||
hybrid_whiptail_menu() {
|
||||
local title="$1"
|
||||
local text="$2"
|
||||
local height="${3:-20}"
|
||||
local width="${4:-70}"
|
||||
local menu_height="${5:-10}"
|
||||
shift 5
|
||||
local items=("$@")
|
||||
|
||||
if is_web_mode; then
|
||||
local interaction_id=$(generate_interaction_id)
|
||||
local options_json="["
|
||||
for ((i=0; i<${#items[@]}; i+=2)); do
|
||||
if [ $i -gt 0 ]; then options_json+=","; fi
|
||||
options_json+="{\"value\":\"${items[i]}\",\"label\":\"${items[i+1]}\"}"
|
||||
done
|
||||
options_json+="]"
|
||||
|
||||
echo "WEB_INTERACTION:menu:${interaction_id}:$(echo -n "$title" | base64 -w0):$(echo -n "$text" | base64 -w0):$options_json" >> "${WEB_LOG:-/tmp/proxmenux_web.log}"
|
||||
wait_for_web_response "$interaction_id"
|
||||
else
|
||||
whiptail --title "$title" --menu "$text" "$height" "$width" "$menu_height" "${items[@]}" 3>&1 1>&2 2>&3
|
||||
fi
|
||||
}
|
||||
|
||||
# Hybrid whiptail yes/no (used during installation)
|
||||
hybrid_whiptail_yesno() {
|
||||
local title="$1"
|
||||
local text="$2"
|
||||
local height="${3:-10}"
|
||||
local width="${4:-70}"
|
||||
|
||||
if is_web_mode; then
|
||||
local interaction_id=$(generate_interaction_id)
|
||||
echo "WEB_INTERACTION:yesno:${interaction_id}:$(echo -n "$title" | base64 -w0):$(echo -n "$text" | base64 -w0)" >> "${WEB_LOG:-/tmp/proxmenux_web.log}"
|
||||
local response=$(wait_for_web_response "$interaction_id")
|
||||
[[ "$response" == "yes" ]] && return 0 || return 1
|
||||
else
|
||||
whiptail --title "$title" --yesno "$text" "$height" "$width"
|
||||
fi
|
||||
}
|
||||
|
||||
# Hybrid whiptail message box (used during installation)
|
||||
hybrid_whiptail_msgbox() {
|
||||
local title="$1"
|
||||
local text="$2"
|
||||
local height="${3:-10}"
|
||||
local width="${4:-70}"
|
||||
|
||||
if is_web_mode; then
|
||||
local interaction_id=$(generate_interaction_id)
|
||||
echo "WEB_INTERACTION:msgbox:${interaction_id}:$(echo -n "$title" | base64 -w0):$(echo -n "$text" | base64 -w0)" >> "${WEB_LOG:-/tmp/proxmenux_web.log}"
|
||||
wait_for_web_response "$interaction_id" > /dev/null
|
||||
else
|
||||
whiptail --title "$title" --msgbox "$text" "$height" "$width"
|
||||
fi
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 07/05/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 07/05/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 07/05/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 07/05/2025
|
||||
# ==========================================================
|
||||
@@ -41,7 +41,7 @@ function select_nas_iso() {
|
||||
|
||||
local NAS_OPTIONS=(
|
||||
"1" "Synology DSM VM (Loader Linux-based)"
|
||||
"2" "TrueNAS SCALE VM (Fangtooth)"
|
||||
"2" "TrueNAS SCALE VM (Goldeye)"
|
||||
"3" "TrueNAS CORE VM (FreeBSD based)"
|
||||
"4" "OpenMediaVault VM (Debian based)"
|
||||
"5" "XigmaNAS VM (FreeBSD based)"
|
||||
@@ -68,9 +68,9 @@ function select_nas_iso() {
|
||||
return 1
|
||||
;;
|
||||
2)
|
||||
ISO_NAME="TrueNAS SCALE 25 (Fangtooth)"
|
||||
ISO_URL="https://download.truenas.com/TrueNAS-SCALE-Fangtooth/25.04.0/TrueNAS-SCALE-25.04.0.iso"
|
||||
ISO_FILE="TrueNAS-SCALE-25.04.0.iso"
|
||||
ISO_NAME="TrueNAS SCALE 25 (Goldeye)"
|
||||
ISO_URL="https://download.sys.truenas.net/TrueNAS-SCALE-Goldeye/25.10.0.1/TrueNAS-SCALE-25.10.0.1.iso"
|
||||
ISO_FILE="TrueNAS-SCALE-25.10.0.1.iso"
|
||||
ISO_PATH="$ISO_DIR/$ISO_FILE"
|
||||
HN="TrueNAS-Scale"
|
||||
;;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 07/05/2025
|
||||
# ==========================================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (CC BY-NC 4.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 13/03/2025
|
||||
# ==========================================================
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user