Compare commits
514 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccb924f8f6 | |||
| 58ac4ee743 | |||
| d515448113 | |||
| ec3de30b14 | |||
| 4837b66089 | |||
| c312cf3d38 | |||
| 46f5af1966 | |||
| 85f98dff9a | |||
| bd56964860 | |||
| 481a653b75 | |||
| f94b59954f | |||
| 4f7977b5ca | |||
| 512cc11894 | |||
| 51be0bd3bd | |||
| 0f6095f8c3 | |||
| 831bf67ee4 | |||
| e7cca5e532 | |||
| 75b08de934 | |||
| 987b665115 | |||
| 68ca68c6f1 | |||
| 5c4ca290fb | |||
| 1d61442a49 | |||
| 0e2ede5e66 | |||
| 0db74814be | |||
| 75c6f74fc4 | |||
| e6faec24fa | |||
| 35b7d01d7e | |||
| 415bc439bb | |||
| fe47522275 | |||
| 24b97831a4 | |||
| ac95a5afba | |||
| 03850d2958 | |||
| c7b49cfc4a | |||
| 5398211ab5 | |||
| dc8ebb651a | |||
| 039e35f3c5 | |||
| ffadb2c508 | |||
| 019e98e6b6 | |||
| c998e39038 | |||
| baa2ff4fa9 | |||
| 4b6a91e74c | |||
| 37f56c8a16 | |||
| 09bb47f408 | |||
| 5db6762690 | |||
| b1cc880253 | |||
| 7d4ea806a2 | |||
| 2b306c9033 | |||
| a776d6b746 | |||
| 07f87de742 | |||
| cf871da880 | |||
| 774d42d5be | |||
| 6660122e69 | |||
| 1ef4bc4fed | |||
| ee1204c566 | |||
| 7f2b0c5de1 | |||
| 9737ffd996 | |||
| be60b7e17c | |||
| 97368a6f44 | |||
| c3d7f01b40 | |||
| cbebd5147c | |||
| 4611be734f | |||
| 528b57664f | |||
| cc86d68507 | |||
| 7c8da462db | |||
| b7963c3b70 | |||
| d51dd35376 | |||
| 196086498e | |||
| f6ff76f9ce | |||
| 20a2db6739 | |||
| aa3b8ebe82 | |||
| d03afa1793 | |||
| 056cee2f94 | |||
| f80e087429 | |||
| eea765300e | |||
| c2396d7e81 | |||
| 0b94acf7f6 | |||
| 324cb23f75 | |||
| b341ba8297 | |||
| f5b9da0908 | |||
| c83672a4bc | |||
| af8e3f6a71 | |||
| 5025d38a76 | |||
| f3c7fb97fb | |||
| cb2ab5f67b | |||
| 003c8850b7 | |||
| d9461c170d | |||
| 57b5de4a4a | |||
| c406c52086 | |||
| df4855ec47 | |||
| 8c7f4a4c20 | |||
| 3ec733d9c6 | |||
| 71c64d1ae5 | |||
| 98becfd368 | |||
| 4eea90bd97 | |||
| 2344935357 | |||
| 550279ec68 | |||
| 44aefb5d3b | |||
| 7d3cf4d364 | |||
| f6ef383598 | |||
| a6149e3cd8 | |||
| 07f1098418 | |||
| 3d00f33dbf | |||
| 98c859fbf8 | |||
| 9460bee72f | |||
| 5a957fe904 | |||
| 59a4b6e4ca | |||
| 9000316224 | |||
| da96c57819 | |||
| b3cef0b009 | |||
| 78ace237dd | |||
| e94e065eca | |||
| 4ffe0f3f46 | |||
| 7af4150e44 | |||
| 71950369e1 | |||
| adb4815c9b | |||
| 1841feb643 | |||
| f14a0393b7 | |||
| 710f77764b | |||
| ae2e86d1d1 | |||
| 167fcb2921 | |||
| 47145ab9d1 | |||
| 441ee8e948 | |||
| 9c4528dfcb | |||
| d7c60631b4 | |||
| 530f5c2dbc | |||
| 03dc2afe8d | |||
| 7d35d91415 | |||
| 4843fae0eb | |||
| 3dfbeac541 | |||
| 4fa4bbb08b | |||
| 4204e619db | |||
| a663b83daa | |||
| fb218a9331 | |||
| 75b677576e | |||
| 04bda0bf10 | |||
| 8ca33dec6f | |||
| eed9303e41 | |||
| 5277e7b47d | |||
| 727c86a804 | |||
| 21edae5944 | |||
| a0d48a1191 | |||
| 086ba9e577 | |||
| bda5fdbecd | |||
| 13d2eeb9b2 | |||
| 1bbf814ea3 | |||
| 6d0e5add0b | |||
| 06849ca666 | |||
| 4346a5554f | |||
| 2a174df697 | |||
| 28df51092c | |||
| ca70852060 | |||
| 86df5cda6e | |||
| 7db7b7d98f | |||
| 3cede88a3d | |||
| af63b71ab8 | |||
| 2805c46a22 | |||
| 104353f013 | |||
| 435f346d98 | |||
| 2b8caa924f | |||
| dd22f303ac | |||
| 02141ae16f | |||
| b9b10a69d5 | |||
| d8631a8594 | |||
| 463769aba9 | |||
| a9fbaf15b2 | |||
| b04c3b9f78 | |||
| f2229c2393 | |||
| b4aadfee3e | |||
| 840e8a774e | |||
| c429d391f5 | |||
| 2fda3dd1d5 | |||
| 4018093c9d | |||
| 2f5b8ce4ed | |||
| 2174c04e4f | |||
| 109a4d7f54 | |||
| 080ee5cff0 | |||
| 257668ab96 | |||
| d747ac7659 | |||
| d7c04ebbc7 | |||
| c5b01b4bb7 | |||
| e8cc90b83d | |||
| d3974018d8 | |||
| 10ce9dbcde | |||
| 5be4bd2ae9 | |||
| ea1d8ab037 | |||
| adde2ce5b9 | |||
| 9d37a7293b | |||
| 975d4aab60 | |||
| 5ead9ee661 | |||
| 95e876b37f | |||
| 4c72d0b3ef | |||
| e7dc030304 | |||
| c9d5c84d35 | |||
| 4b01ba1d2f | |||
| 723e56ada2 | |||
| e9851da12f | |||
| 5826b0419b | |||
| 77124c4549 | |||
| 00dbd1c87e | |||
| e0e732dd2c | |||
| ce69c0ba1f | |||
| 58c4e115ba | |||
| 44478057dd | |||
| d1efae37a4 | |||
| 8d8e4bab26 | |||
| db113f0433 | |||
| 5a66167709 | |||
| 7326201b0a | |||
| f116a6b0f2 | |||
| 3087ab36da | |||
| 28a7189905 | |||
| 0f8e215706 | |||
| fd92bed465 | |||
| 281f6975ec | |||
| e2c40eae48 | |||
| 26968b02a1 | |||
| 0d8070455d | |||
| 2537e964a5 | |||
| ca7134e610 | |||
| a8c591affd | |||
| e3842f200d | |||
| cc952d8c79 | |||
| e11daa0b36 | |||
| 873c77d659 | |||
| 1756a6eb28 | |||
| 9636d3671c | |||
| 22e2ebb96c | |||
| c53d3807e7 | |||
| 23a6392979 | |||
| fa599ad183 | |||
| e79fdcfe58 | |||
| 3746f356b9 | |||
| a5f3362d6e | |||
| 2072264918 | |||
| a5cb01133e | |||
| 62b55cbf16 | |||
| 7ea0c4d36c | |||
| 546200844e | |||
| 007e3d1c0e | |||
| 74a508e3a8 | |||
| 5f5dc171be | |||
| bccba6e9b9 | |||
| c2073a5db5 | |||
| 52ad229d93 | |||
| f6a7352672 | |||
| 46e0322e6f | |||
| 618538a854 | |||
| d62396717a | |||
| a734fa5566 | |||
| f98b302b94 | |||
| 215d36900a | |||
| 2df55d2839 | |||
| e00051caa7 | |||
| 5138b2f1d5 | |||
| 65dfb9103f | |||
| 39bbc036cd | |||
| aaf6dd36f0 | |||
| e7519e68a3 | |||
| ad5803ef9c | |||
| c04b514a2a | |||
| cb9f567154 | |||
| 80afa789e7 | |||
| 43f2ce52a5 | |||
| cf2e24269e | |||
| 6899650bf8 | |||
| c16df51892 | |||
| c549737ad0 | |||
| a85b51843a | |||
| 60d401f5ea | |||
| ca02b9001f | |||
| 2fc5e2865d | |||
| 261b2bfb3c | |||
| 30e32e89b2 | |||
| 8b1a2b9bff | |||
| f71289b248 | |||
| 54eab9af49 | |||
| a05546e811 | |||
| 276c648f29 | |||
| 8c389f4790 | |||
| a09144d21a | |||
| cb9a43f496 | |||
| 30606e4743 | |||
| d05913fbdf | |||
| a34efb50e0 | |||
| a26c69fc8d | |||
| 107803705c | |||
| 858b1689bf | |||
| 641acbd1f4 | |||
| 1de76ae6c1 | |||
| 94f535b8ec | |||
| f072e285fc | |||
| 2c363bbb8e | |||
| 1c2b87d584 | |||
| e55154bd5e | |||
| 71098abb65 | |||
| 7a3a4d1413 | |||
| 7858fb0283 | |||
| 2f9959c009 | |||
| e7d3b20295 | |||
| 264fa4982f | |||
| 9636614761 | |||
| ac6561ca52 | |||
| 923172d39b | |||
| d46c42d26b | |||
| d628233982 | |||
| e9e1d471ec | |||
| f4740916f5 | |||
| 3a2c9b1b05 | |||
| 4cc1147579 | |||
| 976f23a90e | |||
| 8ed500adf7 | |||
| 8658044c0c | |||
| 0edc2cc3af | |||
| f4db4cde13 | |||
| 6bb9313b95 | |||
| 6447dfef50 | |||
| 55cb3a1267 | |||
| 7c5e7208b9 | |||
| aad4b13fda | |||
| 839a20df97 | |||
| d497763e38 | |||
| 12c088c10b | |||
| 8c6a6bece6 | |||
| 819ca8a212 | |||
| 8d1becbd8c | |||
| ebb7491c58 | |||
| 4f1278c37a | |||
| 8ddee9013b | |||
| 7fe233ae2e | |||
| f7b37b1559 | |||
| 0b3624dbd5 | |||
| fb066eb2e9 | |||
| 22fadbb87b | |||
| 3b119d528c | |||
| bb3d3d759e | |||
| 3c08ae9399 | |||
| 7a6fa2afa5 | |||
| ba4e3c3adb | |||
| 66892f69ce | |||
| e37469ac2b | |||
| cdc2d7bbcb | |||
| 92c0e6ff09 | |||
| 9b3fd324c3 | |||
| 23bd692f8e | |||
| 29b4573ca9 | |||
| 8b6755d866 | |||
| 6da20aab05 | |||
| 5aa5942bcd | |||
| 68872d0e06 | |||
| d53c1dc402 | |||
| 6c2b03ae76 | |||
| 9f79d2b737 | |||
| 2241b125d6 | |||
| 152624302c | |||
| 6a703ee6a4 | |||
| 0c0caa422d | |||
| 6fa7c1d4eb | |||
| 509fff3972 | |||
| bcacd8b98e | |||
| d2c8178772 | |||
| 365a246461 | |||
| 098ae13f94 | |||
| 6a92225630 | |||
| c98044be9a | |||
| e6eb81cc61 | |||
| 0e50caadec | |||
| aad44ad42f | |||
| 59fd055526 | |||
| 5fce75a60d | |||
| 74390726b4 | |||
| 10f37b88c3 | |||
| 9bfacd9da9 | |||
| a286770fd2 | |||
| 3101e84830 | |||
| 815b3bebda | |||
| c71eda1229 | |||
| 60518be5bd | |||
| 83254d9d70 | |||
| d34cebc90d | |||
| c7ef51a73c | |||
| ab34fb08c1 | |||
| 6f99e1e8c1 | |||
| 5fe87a04f0 | |||
| 4ac71381da | |||
| f95e1ad4b8 | |||
| 168726c131 | |||
| 4545aeb9c6 | |||
| 84cf3e6a15 | |||
| cd123b3479 | |||
| 4b99de8841 | |||
| 147ca0a41a | |||
| 3f24d55945 | |||
| 70871330d3 | |||
| 08bc354fa5 | |||
| 54c7322b23 | |||
| 4d2ceee26b | |||
| b6321e9698 | |||
| dd197c9826 | |||
| 01427f4926 | |||
| c47a7ba2a5 | |||
| 04564bc9cf | |||
| 04661ce340 | |||
| fcc54e2d6a | |||
| 52fc3b03b7 | |||
| 70c29ed7b6 | |||
| d33741a90d | |||
| 484f117b8e | |||
| 317739b508 | |||
| d8062b4859 | |||
| 452eb70faf | |||
| 83889d7e3c | |||
| 2eb970a6a2 | |||
| 7838762a4e | |||
| f370a670ad | |||
| 41fa6f4b10 | |||
| 18aa9a77dd | |||
| e53f6c0c52 | |||
| 642539cdfc | |||
| 6534fa7171 | |||
| ff08f4c0b5 | |||
| 134c62d543 | |||
| 1243842c68 | |||
| c1093be548 | |||
| b6f58758f2 | |||
| 1dbb59bc3f | |||
| 5e32857729 | |||
| e3a611f33d | |||
| 8fb2a9094e | |||
| 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 |
@@ -0,0 +1,117 @@
|
||||
title: "[Prompt] "
|
||||
labels:
|
||||
- custom-prompt
|
||||
- community
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Share Your Custom Prompt
|
||||
|
||||
Thank you for sharing your custom prompt with the community!
|
||||
|
||||
**Title format suggestion:** Include the provider in the title for easy filtering.
|
||||
Example: `[Gemini] Clean Spanish - Structured, no emojis`
|
||||
|
||||
This helps others find prompts for their specific AI provider.
|
||||
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: AI Provider
|
||||
description: Which AI provider did you test this prompt with?
|
||||
options:
|
||||
- OpenAI
|
||||
- Gemini
|
||||
- Groq
|
||||
- Ollama
|
||||
- Anthropic
|
||||
- OpenRouter
|
||||
- DeepSeek
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Model
|
||||
description: The specific model you tested with
|
||||
placeholder: "e.g., gpt-4o-mini, gemini-2.0-flash, llama3.2:3b"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: Describe what your prompt does, main features, and output language
|
||||
placeholder: |
|
||||
This prompt generates concise notifications in Spanish.
|
||||
|
||||
Features:
|
||||
- Brief format (2-3 lines)
|
||||
- Includes severity indicators
|
||||
- Uses emojis for visual clarity
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: prompt-content
|
||||
attributes:
|
||||
label: Prompt Content
|
||||
description: Paste your complete custom prompt here
|
||||
render: text
|
||||
placeholder: |
|
||||
You are a notification formatter for ProxMenux Monitor.
|
||||
|
||||
Your task is to...
|
||||
|
||||
RULES:
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
OUTPUT FORMAT:
|
||||
[TITLE]
|
||||
...
|
||||
[BODY]
|
||||
...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: example-output
|
||||
attributes:
|
||||
label: Example Output
|
||||
description: Show an example of how a notification looks with your prompt
|
||||
placeholder: |
|
||||
**Input notification:**
|
||||
CPU usage high on node pve01
|
||||
|
||||
**Output with this prompt:**
|
||||
pve01: High CPU Usage
|
||||
CPU at 95% for 5 minutes. Check running processes.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-notes
|
||||
attributes:
|
||||
label: Additional Notes
|
||||
description: Any tips, variations, or known limitations
|
||||
placeholder: |
|
||||
- Works best with models that support system prompts
|
||||
- May need adjustment for very long notifications
|
||||
- Tested with Proxmox VE 8.x
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: Confirmation
|
||||
options:
|
||||
- label: I have tested this prompt and it works correctly
|
||||
required: true
|
||||
- label: I am sharing this prompt for the community to use freely
|
||||
required: true
|
||||
@@ -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
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout develop
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: develop
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
cat ProxMenux-Monitor.AppImage.sha256
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-beta-AppImage
|
||||
path: |
|
||||
|
||||
@@ -18,10 +18,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ProxMenux-${{ steps.version.outputs.VERSION }}-AppImage
|
||||
path: AppImage/dist/*.AppImage
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
cd04577b4860ad1b66a7b906c381fa4c9ad384ce6e0cf0769ee7aa358399bc41 ProxMenux-1.0.2-beta.AppImage
|
||||
3b28537fe679b87f166bd5b01de05c71d1b4303f765c87f3a23b216d433120c2 ProxMenux-1.2.0.AppImage
|
||||
|
||||
@@ -163,3 +163,15 @@
|
||||
.xterm-rows {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* ===================== */
|
||||
/* Progress Animations */
|
||||
/* ===================== */
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -29,6 +29,17 @@ export default function Home() {
|
||||
const response = await fetch(getApiUrl("/api/auth/status"), {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
|
||||
// Check if response is valid JSON before parsing
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
throw new Error("Response is not JSON")
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const authenticated = data.auth_enabled ? data.authenticated : true
|
||||
@@ -39,8 +50,8 @@ export default function Home() {
|
||||
authConfigured: data.auth_configured,
|
||||
authenticated,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to check auth status:", error)
|
||||
} catch {
|
||||
// API not available - assume no auth configured (silent fail, no console error)
|
||||
setAuthStatus({
|
||||
loading: false,
|
||||
authEnabled: false,
|
||||
|
||||
@@ -27,18 +27,26 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/auth/status"))
|
||||
|
||||
// Check if response is valid JSON before parsing
|
||||
if (!response.ok) {
|
||||
// API not available - don't show modal in preview
|
||||
return
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
console.log("[v0] Auth status for modal check:", data)
|
||||
|
||||
// Show modal if auth is not configured and not declined
|
||||
if (!data.auth_configured) {
|
||||
setTimeout(() => setOpen(true), 500)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to check auth status:", error)
|
||||
// Fail-safe: show modal if we can't check status
|
||||
setTimeout(() => setOpen(true), 500)
|
||||
} catch {
|
||||
// API not available (preview environment) - don't show modal
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface GpuSwitchModeIndicatorProps {
|
||||
mode: "lxc" | "vm" | "unknown"
|
||||
isEditing?: boolean
|
||||
pendingMode?: "lxc" | "vm" | null
|
||||
onToggle?: (e: React.MouseEvent) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GpuSwitchModeIndicator({
|
||||
mode,
|
||||
isEditing = false,
|
||||
pendingMode = null,
|
||||
onToggle,
|
||||
className,
|
||||
}: GpuSwitchModeIndicatorProps) {
|
||||
const displayMode = pendingMode ?? mode
|
||||
const isLxcActive = displayMode === "lxc"
|
||||
const isVmActive = displayMode === "vm"
|
||||
const hasChanged = pendingMode !== null && pendingMode !== mode
|
||||
|
||||
// Colors
|
||||
const activeColor = isLxcActive ? "#3b82f6" : isVmActive ? "#a855f7" : "#6b7280"
|
||||
const inactiveColor = "#374151" // gray-700 for dark theme
|
||||
const lxcColor = isLxcActive ? "#3b82f6" : inactiveColor
|
||||
const vmColor = isVmActive ? "#a855f7" : inactiveColor
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Only stop propagation and handle toggle when in editing mode
|
||||
if (isEditing) {
|
||||
e.stopPropagation()
|
||||
if (onToggle) {
|
||||
onToggle(e)
|
||||
}
|
||||
}
|
||||
// When not editing, let the click propagate to the card to open the modal
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-6",
|
||||
isEditing && "cursor-pointer",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Large SVG Diagram */}
|
||||
<svg
|
||||
viewBox="0 0 220 100"
|
||||
className="h-24 w-56 flex-shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* GPU Chip - Large with "GPU" text */}
|
||||
<g transform="translate(0, 22)">
|
||||
{/* Main chip body */}
|
||||
<rect
|
||||
x="4"
|
||||
y="8"
|
||||
width="44"
|
||||
height="36"
|
||||
rx="6"
|
||||
fill={`${activeColor}20`}
|
||||
stroke={activeColor}
|
||||
strokeWidth="2.5"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Chip pins - top */}
|
||||
<line x1="14" y1="2" x2="14" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="26" y1="2" x2="26" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="38" y1="2" x2="38" y2="8" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
{/* Chip pins - bottom */}
|
||||
<line x1="14" y1="44" x2="14" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="26" y1="44" x2="26" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
<line x1="38" y1="44" x2="38" y2="50" stroke={activeColor} strokeWidth="2.5" strokeLinecap="round" className="transition-all duration-300" />
|
||||
{/* GPU text */}
|
||||
<text
|
||||
x="26"
|
||||
y="32"
|
||||
textAnchor="middle"
|
||||
fill={activeColor}
|
||||
className="text-[14px] font-bold transition-all duration-300"
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
GPU
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Connection line from GPU to switch */}
|
||||
<line
|
||||
x1="52"
|
||||
y1="50"
|
||||
x2="78"
|
||||
y2="50"
|
||||
stroke={activeColor}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* Central Switch Node - Large circle with inner dot */}
|
||||
<circle
|
||||
cx="95"
|
||||
cy="50"
|
||||
r="14"
|
||||
fill={isEditing ? "#f59e0b20" : `${activeColor}20`}
|
||||
stroke={isEditing ? "#f59e0b" : activeColor}
|
||||
strokeWidth="3"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
<circle
|
||||
cx="95"
|
||||
cy="50"
|
||||
r="6"
|
||||
fill={isEditing ? "#f59e0b" : activeColor}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* LXC Branch Line - going up-right */}
|
||||
<path
|
||||
d="M 109 42 L 135 20"
|
||||
fill="none"
|
||||
stroke={lxcColor}
|
||||
strokeWidth={isLxcActive ? "3.5" : "2"}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* VM Branch Line - going down-right */}
|
||||
<path
|
||||
d="M 109 58 L 135 80"
|
||||
fill="none"
|
||||
stroke={vmColor}
|
||||
strokeWidth={isVmActive ? "3.5" : "2"}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
|
||||
{/* LXC Container Icon - Server/Stack icon */}
|
||||
<g transform="translate(138, 2)">
|
||||
{/* Container box */}
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="28"
|
||||
rx="4"
|
||||
fill={isLxcActive ? `${lxcColor}25` : "transparent"}
|
||||
stroke={lxcColor}
|
||||
strokeWidth={isLxcActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Container layers/lines */}
|
||||
<line x1="0" y1="10" x2="32" y2="10" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
<line x1="0" y1="19" x2="32" y2="19" stroke={lxcColor} strokeWidth={isLxcActive ? "1.5" : "1"} className="transition-all duration-300" />
|
||||
{/* Status dots */}
|
||||
<circle cx="7" cy="5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="14.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
<circle cx="7" cy="23.5" r="2" fill={lxcColor} className="transition-all duration-300" />
|
||||
</g>
|
||||
|
||||
{/* LXC Label */}
|
||||
<text
|
||||
x="188"
|
||||
y="22"
|
||||
textAnchor="start"
|
||||
fill={lxcColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isLxcActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
LXC
|
||||
</text>
|
||||
|
||||
{/* VM Monitor Icon */}
|
||||
<g transform="translate(138, 65)">
|
||||
{/* Monitor screen */}
|
||||
<rect
|
||||
x="2"
|
||||
y="0"
|
||||
width="28"
|
||||
height="18"
|
||||
rx="3"
|
||||
fill={isVmActive ? `${vmColor}25` : "transparent"}
|
||||
stroke={vmColor}
|
||||
strokeWidth={isVmActive ? "2.5" : "1.5"}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Screen inner/shine */}
|
||||
<rect
|
||||
x="5"
|
||||
y="3"
|
||||
width="22"
|
||||
height="12"
|
||||
rx="1"
|
||||
fill={isVmActive ? `${vmColor}30` : `${vmColor}10`}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Monitor stand */}
|
||||
<line x1="16" y1="18" x2="16" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
{/* Monitor base */}
|
||||
<line x1="8" y1="24" x2="24" y2="24" stroke={vmColor} strokeWidth={isVmActive ? "2.5" : "1.5"} strokeLinecap="round" className="transition-all duration-300" />
|
||||
</g>
|
||||
|
||||
{/* VM Label */}
|
||||
<text
|
||||
x="188"
|
||||
y="84"
|
||||
textAnchor="start"
|
||||
fill={vmColor}
|
||||
className={cn(
|
||||
"transition-all duration-300",
|
||||
isVmActive ? "text-[14px] font-bold" : "text-[12px] font-medium"
|
||||
)}
|
||||
style={{ fontFamily: 'system-ui, sans-serif' }}
|
||||
>
|
||||
VM
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{/* Status Text - Large like GPU name */}
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-base font-semibold transition-all duration-300",
|
||||
isLxcActive ? "text-blue-500" : isVmActive ? "text-purple-500" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isLxcActive
|
||||
? "Ready for LXC containers"
|
||||
: isVmActive
|
||||
? "Ready for VM passthrough"
|
||||
: "Mode unknown"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isLxcActive
|
||||
? "Native driver active"
|
||||
: isVmActive
|
||||
? "VFIO-PCI driver active"
|
||||
: "No driver detected"}
|
||||
</span>
|
||||
{hasChanged && (
|
||||
<span className="text-sm text-amber-500 font-medium animate-pulse">
|
||||
Change pending...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+772
-137
File diff suppressed because it is too large
Load Diff
@@ -375,12 +375,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
body: JSON.stringify({ error_key: errorKey }),
|
||||
})
|
||||
|
||||
const responseData = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || `Failed to dismiss error (${response.status})`)
|
||||
throw new Error(responseData.error || `Failed to dismiss error (${response.status})`)
|
||||
}
|
||||
|
||||
await fetchHealthDetails()
|
||||
// Optimistically update local state to avoid slow re-fetch
|
||||
// Add the dismissed item to the local list immediately
|
||||
if (responseData.result || responseData.success) {
|
||||
const dismissedItem = {
|
||||
error_key: errorKey,
|
||||
category: responseData.result?.category || responseData.category || '',
|
||||
severity: responseData.result?.original_severity || 'WARNING',
|
||||
reason: 'Dismissed by user',
|
||||
dismissed: true,
|
||||
acknowledged_at: new Date().toISOString()
|
||||
}
|
||||
setDismissedItems(prev => [...prev, dismissedItem])
|
||||
}
|
||||
|
||||
// Fetch fresh data in background (non-blocking)
|
||||
fetchHealthDetails().catch(() => {})
|
||||
} catch (err) {
|
||||
console.error("Error dismissing:", err)
|
||||
} finally {
|
||||
@@ -501,7 +517,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-1.5 shrink-0">
|
||||
{(checkStatus === "WARNING" || checkStatus === "CRITICAL") && isDismissable && !checkData.dismissed && (
|
||||
{(checkStatus === "WARNING" || checkStatus === "CRITICAL" || checkStatus === "UNKNOWN") && isDismissable && !checkData.dismissed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -661,7 +677,31 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border/50 bg-muted/5 px-1.5 sm:px-2 py-1.5 overflow-hidden">
|
||||
{reason && (
|
||||
<p className="text-xs text-muted-foreground px-3 py-1.5 mb-1 break-words whitespace-pre-wrap">{reason}</p>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5 mb-1">
|
||||
<p className="text-xs text-muted-foreground break-words whitespace-pre-wrap flex-1">{reason}</p>
|
||||
{/* Show dismiss button for UNKNOWN status at category level when dismissable */}
|
||||
{status === "UNKNOWN" && categoryData?.dismissable && !hasChecks && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 shrink-0 hover:bg-red-500/10 hover:border-red-500/50 bg-transparent text-[10px]"
|
||||
disabled={dismissingKey === `category_${key}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAcknowledge(`category_${key}_unknown`, e)
|
||||
}}
|
||||
>
|
||||
{dismissingKey === `category_${key}` ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<X className="h-3 w-3 sm:mr-0.5" />
|
||||
<span className="hidden sm:inline">Dismiss</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasChecks ? (
|
||||
renderChecks(checks, key)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Lock, User, AlertCircle, Server, Shield } from "lucide-react"
|
||||
import { Lock, User, AlertCircle, Server, Shield, Eye, EyeOff } from "lucide-react"
|
||||
import { getApiUrl } from "../lib/api-config"
|
||||
import Image from "next/image"
|
||||
|
||||
@@ -21,6 +21,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
const [totpCode, setTotpCode] = useState("")
|
||||
const [requiresTotp, setRequiresTotp] = useState(false)
|
||||
const [rememberMe, setRememberMe] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -161,14 +162,27 @@ export function Login({ onLogin }: LoginProps) {
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 text-base"
|
||||
className="pl-10 pr-10 text-base"
|
||||
disabled={loading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
disabled={loading}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,7 +251,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.0.2-beta</p>
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.0</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -142,8 +142,8 @@ export function NetworkMetrics() {
|
||||
error,
|
||||
isLoading,
|
||||
} = useSWR<NetworkData>("/api/network", fetcher, {
|
||||
refreshInterval: 53000,
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: 15000,
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -9,13 +9,14 @@ import { Label } from "./ui/label"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import {
|
||||
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
||||
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
||||
Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook,
|
||||
Copy, Server, Shield, ExternalLink, RefreshCw
|
||||
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
|
||||
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb
|
||||
} from "lucide-react"
|
||||
|
||||
interface ChannelConfig {
|
||||
@@ -23,6 +24,7 @@ interface ChannelConfig {
|
||||
rich_format?: boolean
|
||||
bot_token?: string
|
||||
chat_id?: string
|
||||
topic_id?: string // Telegram topic ID for supergroups with topics
|
||||
url?: string
|
||||
token?: string
|
||||
webhook_url?: string
|
||||
@@ -63,6 +65,9 @@ interface NotificationConfig {
|
||||
ai_language: string
|
||||
ai_ollama_url: string
|
||||
ai_openai_base_url: string
|
||||
ai_prompt_mode: string // 'default' or 'custom'
|
||||
ai_custom_prompt: string // User's custom prompt
|
||||
ai_allow_suggestions: string | boolean // Enable AI suggestions (experimental)
|
||||
channel_ai_detail: Record<string, string>
|
||||
hostname: string
|
||||
webhook_secret: string
|
||||
@@ -180,6 +185,30 @@ const AI_DETAIL_LEVELS = [
|
||||
{ value: "detailed", label: "Detailed", desc: "Complete technical details" },
|
||||
]
|
||||
|
||||
// Example custom prompt for users to adapt
|
||||
const EXAMPLE_CUSTOM_PROMPT = `You are a notification formatter for ProxMenux Monitor.
|
||||
|
||||
Your task is to translate and format server notifications.
|
||||
|
||||
RULES:
|
||||
1. Translate to the user's preferred language
|
||||
2. Use plain text only (no markdown, no bold, no italic)
|
||||
3. Be concise and factual
|
||||
4. Do not add recommendations or suggestions
|
||||
5. Present only the facts from the input
|
||||
6. Keep hostname prefix in titles (e.g., "pve01: ")
|
||||
|
||||
OUTPUT FORMAT:
|
||||
[TITLE]
|
||||
your translated title here
|
||||
[BODY]
|
||||
your translated message here
|
||||
|
||||
Detail levels:
|
||||
- brief: 2-3 lines, essential only
|
||||
- standard: short paragraph with key details
|
||||
- detailed: full technical breakdown`
|
||||
|
||||
const DEFAULT_CONFIG: NotificationConfig = {
|
||||
enabled: false,
|
||||
channels: {
|
||||
@@ -222,6 +251,9 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
ai_language: "en",
|
||||
ai_ollama_url: "http://localhost:11434",
|
||||
ai_openai_base_url: "",
|
||||
ai_prompt_mode: "default",
|
||||
ai_custom_prompt: "",
|
||||
ai_allow_suggestions: "false",
|
||||
channel_ai_detail: {
|
||||
telegram: "brief",
|
||||
gotify: "brief",
|
||||
@@ -259,17 +291,33 @@ export function NotificationSettings() {
|
||||
const [aiTestResult, setAiTestResult] = useState<{ success: boolean; message: string; model?: string } | null>(null)
|
||||
const [providerModels, setProviderModels] = useState<string[]>([])
|
||||
const [loadingProviderModels, setLoadingProviderModels] = useState(false)
|
||||
const [showCustomPromptInfo, setShowCustomPromptInfo] = useState(false)
|
||||
const [editingCustomPrompt, setEditingCustomPrompt] = useState(false)
|
||||
const [customPromptDraft, setCustomPromptDraft] = useState("")
|
||||
const [webhookSetup, setWebhookSetup] = useState<{
|
||||
status: "idle" | "running" | "success" | "failed"
|
||||
fallback_commands: string[]
|
||||
error: string
|
||||
}>({ status: "idle", fallback_commands: [], error: "" })
|
||||
const [systemHostname, setSystemHostname] = useState<string>("")
|
||||
|
||||
// Load system hostname for display name placeholder
|
||||
const loadSystemHostname = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchApi<{ hostname?: string }>("/api/system")
|
||||
if (data.hostname) {
|
||||
setSystemHostname(data.hostname)
|
||||
}
|
||||
} catch {
|
||||
// Ignore - will show generic placeholder
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchApi<{ success: boolean; config: NotificationConfig }>("/api/notifications/settings")
|
||||
if (data.success && data.config) {
|
||||
// Ensure ai_api_keys and ai_models objects exist (fallback for older configs)
|
||||
// Ensure ai_api_keys, ai_models, and prompt settings exist (fallback for older configs)
|
||||
const configWithDefaults = {
|
||||
...data.config,
|
||||
ai_api_keys: data.config.ai_api_keys || {
|
||||
@@ -287,7 +335,10 @@ export function NotificationSettings() {
|
||||
anthropic: "",
|
||||
openai: "",
|
||||
openrouter: "",
|
||||
}
|
||||
},
|
||||
ai_prompt_mode: data.config.ai_prompt_mode || "default",
|
||||
ai_custom_prompt: data.config.ai_custom_prompt || "",
|
||||
ai_allow_suggestions: data.config.ai_allow_suggestions || "false",
|
||||
}
|
||||
// If ai_model exists but ai_models doesn't have it, save it
|
||||
if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) {
|
||||
@@ -328,12 +379,20 @@ export function NotificationSettings() {
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
loadStatus()
|
||||
}, [loadConfig, loadStatus])
|
||||
loadSystemHostname()
|
||||
}, [loadConfig, loadStatus, loadSystemHostname])
|
||||
|
||||
useEffect(() => {
|
||||
if (showHistory) loadHistory()
|
||||
}, [showHistory, loadHistory])
|
||||
|
||||
// Auto-expand AI section when AI is enabled
|
||||
useEffect(() => {
|
||||
if (config.ai_enabled) {
|
||||
setShowAdvanced(true)
|
||||
}
|
||||
}, [config.ai_enabled])
|
||||
|
||||
const updateConfig = (updater: (prev: NotificationConfig) => NotificationConfig) => {
|
||||
setConfig(prev => {
|
||||
const next = updater(prev)
|
||||
@@ -495,21 +554,24 @@ export function NotificationSettings() {
|
||||
|
||||
/** Flatten the nested NotificationConfig into the flat key-value map the backend expects. */
|
||||
const flattenConfig = (cfg: NotificationConfig): Record<string, string> => {
|
||||
const flat: Record<string, string> = {
|
||||
enabled: String(cfg.enabled),
|
||||
ai_enabled: String(cfg.ai_enabled),
|
||||
ai_provider: cfg.ai_provider,
|
||||
ai_model: cfg.ai_model,
|
||||
ai_language: cfg.ai_language,
|
||||
ai_ollama_url: cfg.ai_ollama_url,
|
||||
ai_openai_base_url: cfg.ai_openai_base_url,
|
||||
hostname: cfg.hostname,
|
||||
webhook_secret: cfg.webhook_secret,
|
||||
webhook_allowed_ips: cfg.webhook_allowed_ips,
|
||||
pbs_host: cfg.pbs_host,
|
||||
pve_host: cfg.pve_host,
|
||||
pbs_trusted_sources: cfg.pbs_trusted_sources,
|
||||
}
|
||||
const flat: Record<string, string> = {
|
||||
enabled: String(cfg.enabled),
|
||||
ai_enabled: String(cfg.ai_enabled),
|
||||
ai_provider: cfg.ai_provider,
|
||||
ai_model: cfg.ai_model,
|
||||
ai_language: cfg.ai_language,
|
||||
ai_ollama_url: cfg.ai_ollama_url,
|
||||
ai_openai_base_url: cfg.ai_openai_base_url,
|
||||
ai_prompt_mode: cfg.ai_prompt_mode || "default",
|
||||
ai_custom_prompt: cfg.ai_custom_prompt || "",
|
||||
ai_allow_suggestions: String(cfg.ai_allow_suggestions === "true" || cfg.ai_allow_suggestions === true),
|
||||
hostname: cfg.hostname,
|
||||
webhook_secret: cfg.webhook_secret,
|
||||
webhook_allowed_ips: cfg.webhook_allowed_ips,
|
||||
pbs_host: cfg.pbs_host,
|
||||
pve_host: cfg.pve_host,
|
||||
pbs_trusted_sources: cfg.pbs_trusted_sources,
|
||||
}
|
||||
// Flatten per-provider API keys
|
||||
if (cfg.ai_api_keys) {
|
||||
for (const [provider, key] of Object.entries(cfg.ai_api_keys)) {
|
||||
@@ -1061,10 +1123,11 @@ export function NotificationSettings() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type={showSecrets["tg_token"] ? "text" : "password"}
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="7595377878:AAGE6Fb2cy... (with or without 'bot' prefix)"
|
||||
value={config.channels.telegram?.bot_token || ""}
|
||||
onChange={e => updateChannel("telegram", "bot_token", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
||||
@@ -1077,12 +1140,24 @@ export function NotificationSettings() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">Chat ID</Label>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="-1001234567890"
|
||||
value={config.channels.telegram?.chat_id || ""}
|
||||
onChange={e => updateChannel("telegram", "chat_id", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">Topic ID <span className="text-muted-foreground/60">(optional)</span></Label>
|
||||
<Input
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="123456"
|
||||
value={config.channels.telegram?.topic_id || ""}
|
||||
onChange={e => updateChannel("telegram", "topic_id", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">For supergroups with topics enabled. Leave empty for regular chats.</p>
|
||||
</div>
|
||||
{/* Message format */}
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
@@ -1143,10 +1218,11 @@ export function NotificationSettings() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">Server URL</Label>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="https://gotify.example.com"
|
||||
value={config.channels.gotify?.url || ""}
|
||||
onChange={e => updateChannel("gotify", "url", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -1154,10 +1230,11 @@ export function NotificationSettings() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type={showSecrets["gt_token"] ? "text" : "password"}
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="A_valid_gotify_token"
|
||||
value={config.channels.gotify?.token || ""}
|
||||
onChange={e => updateChannel("gotify", "token", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
||||
@@ -1229,10 +1306,11 @@ export function NotificationSettings() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type={showSecrets["dc_hook"] ? "text" : "password"}
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
value={config.channels.discord?.webhook_url || ""}
|
||||
onChange={e => updateChannel("discord", "webhook_url", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
||||
@@ -1303,19 +1381,21 @@ export function NotificationSettings() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">SMTP Host</Label>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="smtp.gmail.com"
|
||||
value={config.channels.email?.host || ""}
|
||||
onChange={e => updateChannel("email", "host", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">Port</Label>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="587"
|
||||
value={config.channels.email?.port || ""}
|
||||
onChange={e => updateChannel("email", "port", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1324,8 +1404,9 @@ export function NotificationSettings() {
|
||||
<Select
|
||||
value={config.channels.email?.tls_mode || "starttls"}
|
||||
onValueChange={v => updateChannel("email", "tls_mode", v)}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectTrigger className={`h-7 text-xs ${!editMode ? "opacity-50" : ""}`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1339,10 +1420,11 @@ export function NotificationSettings() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">Username</Label>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="user@example.com"
|
||||
value={config.channels.email?.username || ""}
|
||||
onChange={e => updateChannel("email", "username", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@@ -1350,10 +1432,11 @@ export function NotificationSettings() {
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type={showSecrets["em_pass"] ? "text" : "password"}
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="App password"
|
||||
value={config.channels.email?.password || ""}
|
||||
onChange={e => updateChannel("email", "password", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors shrink-0"
|
||||
@@ -1367,28 +1450,31 @@ export function NotificationSettings() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">From Address</Label>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="proxmenux@yourdomain.com"
|
||||
value={config.channels.email?.from_address || ""}
|
||||
onChange={e => updateChannel("email", "from_address", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">To Addresses (comma-separated)</Label>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="admin@example.com, ops@example.com"
|
||||
value={config.channels.email?.to_addresses || ""}
|
||||
onChange={e => updateChannel("email", "to_addresses", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">Subject Prefix</Label>
|
||||
<Input
|
||||
className="h-7 text-xs font-mono"
|
||||
className={`h-7 text-xs font-mono ${!editMode ? "opacity-50" : ""}`}
|
||||
placeholder="[ProxMenux]"
|
||||
value={config.channels.email?.subject_prefix || "[ProxMenux]"}
|
||||
onChange={e => updateChannel("email", "subject_prefix", e.target.value)}
|
||||
disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
|
||||
@@ -1433,29 +1519,84 @@ export function NotificationSettings() {
|
||||
</div>{/* close bordered channel container */}
|
||||
</div>
|
||||
|
||||
{/* ── Display Name ── */}
|
||||
<div className="space-y-2 pb-3 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-blue-400" />
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Display Name</Label>
|
||||
</div>
|
||||
<Input
|
||||
className={`h-9 text-sm ${!editMode ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
placeholder={systemHostname || "System hostname"}
|
||||
value={config.hostname || (editMode ? "" : systemHostname)}
|
||||
onChange={e => updateConfig(p => ({ ...p, hostname: e.target.value }))}
|
||||
disabled={!editMode}
|
||||
readOnly={!editMode}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Name shown in notifications. Edit to customize, or leave empty to use the system hostname.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Advanced: AI Enhancement ── */}
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full py-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
<span className="font-medium uppercase tracking-wider">Advanced: AI Enhancement</span>
|
||||
{config.ai_enabled && (
|
||||
<Badge variant="outline" className="text-[9px] border-purple-500/30 text-purple-400 ml-1">
|
||||
ON
|
||||
</Badge>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<button
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
<span className="font-medium uppercase tracking-wider">Advanced: AI Enhancement</span>
|
||||
{config.ai_enabled && (
|
||||
<Badge variant="outline" className="text-[9px] border-purple-500/30 text-purple-400 ml-1">
|
||||
ON
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="flex items-center gap-2">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
className="h-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors text-muted-foreground"
|
||||
onClick={handleCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="h-6 px-2 text-xs rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50 flex items-center gap-1"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin" /> : <CheckCircle2 className="h-3 w-3" />}
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="h-6 px-2 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors flex items-center gap-1"
|
||||
onClick={() => setEditMode(true)}
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 mt-3 p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium">AI-Enhanced Messages</span>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">Use AI to generate contextual notification messages</p>
|
||||
</div>
|
||||
<button
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 mt-3 p-4 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<span className="text-sm font-medium">AI-Enhanced Messages</span>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">Use AI to generate contextual notification messages</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||
config.ai_enabled ? "bg-purple-600" : "bg-muted-foreground/20 border border-muted-foreground/40"
|
||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
@@ -1475,6 +1616,7 @@ export function NotificationSettings() {
|
||||
{/* Provider + Info button */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="h-4 w-4 text-purple-400" />
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Provider</Label>
|
||||
<button
|
||||
onClick={() => setShowProviderInfo(true)}
|
||||
@@ -1594,7 +1736,10 @@ export function NotificationSettings() {
|
||||
|
||||
{/* Model - selector with Load button for all providers */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="h-4 w-4 text-blue-400" />
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Model</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={config.ai_model || ""}
|
||||
@@ -1648,101 +1793,291 @@ export function NotificationSettings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language selector */}
|
||||
{/* Prompt Mode section */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Language</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquareText className="h-4 w-4 text-amber-400" />
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Prompt Mode</Label>
|
||||
</div>
|
||||
<Select
|
||||
value={config.ai_language || "en"}
|
||||
onValueChange={v => updateConfig(p => ({ ...p, ai_language: v }))}
|
||||
value={config.ai_prompt_mode || "default"}
|
||||
onValueChange={v => {
|
||||
updateConfig(p => ({ ...p, ai_prompt_mode: v }))
|
||||
// Show info modal when switching to custom for the first time
|
||||
if (v === "custom" && !config.ai_custom_prompt) {
|
||||
setShowCustomPromptInfo(true)
|
||||
}
|
||||
}}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select language">
|
||||
{AI_LANGUAGES.find(l => l.value === (config.ai_language || "en"))?.label || "English"}
|
||||
</SelectValue>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_LANGUAGES.map(l => (
|
||||
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="default">Default Prompt</SelectItem>
|
||||
<SelectItem value="custom">Custom Prompt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Test Connection button */}
|
||||
<button
|
||||
onClick={handleTestAI}
|
||||
disabled={
|
||||
!editMode ||
|
||||
testingAI ||
|
||||
!config.ai_model ||
|
||||
(config.ai_provider !== "ollama" && !config.ai_api_keys?.[config.ai_provider])
|
||||
}
|
||||
className="w-full h-9 flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testingAI ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" /> Testing...</>
|
||||
) : (
|
||||
<><Zap className="h-4 w-4" /> Test Connection</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Test result */}
|
||||
{aiTestResult && (
|
||||
<div className={`flex items-start gap-2 p-3 rounded-md ${
|
||||
aiTestResult.success
|
||||
? "bg-green-500/10 border border-green-500/20"
|
||||
: "bg-red-500/10 border border-red-500/20"
|
||||
}`}>
|
||||
{aiTestResult.success
|
||||
? <CheckCircle2 className="h-4 w-4 text-green-400 shrink-0 mt-0.5" />
|
||||
: <XCircle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||
}
|
||||
<p className={`text-xs sm:text-sm leading-relaxed ${
|
||||
aiTestResult.success ? "text-green-400/90" : "text-red-400/90"
|
||||
}`}>
|
||||
{aiTestResult.message}
|
||||
{aiTestResult.model && ` (${aiTestResult.model})`}
|
||||
</p>
|
||||
{/* Default mode options: Language and Detail Level per Channel */}
|
||||
{(config.ai_prompt_mode || "default") === "default" && (
|
||||
<div className="space-y-3 pt-3 border-t border-border/50">
|
||||
{/* Language selector - only for default mode */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-green-400" />
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Language</Label>
|
||||
</div>
|
||||
<Select
|
||||
value={config.ai_language || "en"}
|
||||
onValueChange={v => updateConfig(p => ({ ...p, ai_language: v }))}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select language">
|
||||
{AI_LANGUAGES.find(l => l.value === (config.ai_language || "en"))?.label || "English"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_LANGUAGES.map(l => (
|
||||
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Detail Level per Channel */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Detail Level per Channel</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{CHANNEL_TYPES.map(ch => (
|
||||
<div key={ch} className="flex items-center justify-between gap-2 px-3 py-2 rounded bg-muted/30">
|
||||
<span className="text-xs sm:text-sm text-foreground/70 capitalize">{ch}</span>
|
||||
<Select
|
||||
value={config.channel_ai_detail?.[ch] || "standard"}
|
||||
onValueChange={v => updateConfig(p => ({
|
||||
...p,
|
||||
channel_ai_detail: { ...p.channel_ai_detail, [ch]: v }
|
||||
}))}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[90px] text-xs px-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_DETAIL_LEVELS.map(l => (
|
||||
<SelectItem key={l.value} value={l.value} className="text-xs">
|
||||
{l.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
|
||||
AI translates and formats notifications to your selected language. Each channel can have different detail levels.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Experimental: AI Suggestions toggle */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<Lightbulb className="h-5 w-5 text-purple-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">AI Suggestions</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-400 font-medium">BETA</span>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Allow AI to add brief troubleshooting tips based on log context
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`relative w-9 h-[18px] rounded-full transition-colors ${
|
||||
config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true
|
||||
? "bg-purple-600"
|
||||
: "bg-muted-foreground/20 border border-muted-foreground/40"
|
||||
} ${!editMode ? "opacity-60 cursor-not-allowed" : "cursor-pointer"}`}
|
||||
onClick={() => {
|
||||
if (editMode) {
|
||||
const newValue = config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true ? "false" : "true"
|
||||
updateConfig(p => ({ ...p, ai_allow_suggestions: newValue }))
|
||||
}
|
||||
}}
|
||||
disabled={!editMode}
|
||||
role="switch"
|
||||
aria-checked={config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true}
|
||||
>
|
||||
<span className={`absolute top-[1px] left-[1px] h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
config.ai_allow_suggestions === "true" || config.ai_allow_suggestions === true ? "translate-x-[18px]" : "translate-x-0"
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-channel detail level */}
|
||||
<div className="space-y-3 pt-3 border-t border-border/50">
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Detail Level per Channel</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{CHANNEL_TYPES.map(ch => (
|
||||
<div key={ch} className="flex items-center justify-between gap-2 px-3 py-2 rounded bg-muted/30">
|
||||
<span className="text-xs sm:text-sm text-foreground/70 capitalize">{ch}</span>
|
||||
<Select
|
||||
value={config.channel_ai_detail?.[ch] || "standard"}
|
||||
onValueChange={v => updateConfig(p => ({
|
||||
...p,
|
||||
channel_ai_detail: { ...p.channel_ai_detail, [ch]: v }
|
||||
}))}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[90px] text-xs px-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AI_DETAIL_LEVELS.map(l => (
|
||||
<SelectItem key={l.value} value={l.value} className="text-xs">
|
||||
{l.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Custom mode: Editable prompt textarea */}
|
||||
{config.ai_prompt_mode === "custom" && (
|
||||
<div className="space-y-3 pt-3 border-t border-border/50">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">Custom Prompt</Label>
|
||||
<div className="flex gap-1">
|
||||
{!editingCustomPrompt ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCustomPromptDraft(config.ai_custom_prompt || "")
|
||||
setEditingCustomPrompt(true)
|
||||
}}
|
||||
className="h-7 px-2 text-xs flex items-center gap-1"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingCustomPrompt(false)
|
||||
setCustomPromptDraft("")
|
||||
}}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateConfig(p => ({ ...p, ai_custom_prompt: customPromptDraft }))
|
||||
setEditingCustomPrompt(false)
|
||||
handleSave()
|
||||
}}
|
||||
className="h-7 px-2 text-xs flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={editingCustomPrompt ? customPromptDraft : (config.ai_custom_prompt || "")}
|
||||
onChange={e => setCustomPromptDraft(e.target.value)}
|
||||
disabled={!editingCustomPrompt}
|
||||
placeholder="Enter your custom prompt instructions for the AI..."
|
||||
className="w-full h-48 px-3 py-2 text-sm rounded-md border border-border bg-background resize-y focus:outline-none focus:ring-2 focus:ring-purple-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={editingCustomPrompt}
|
||||
onClick={() => {
|
||||
const blob = new Blob([config.ai_custom_prompt || ""], { type: "text/plain" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "proxmenux_custom_prompt.txt"
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={editingCustomPrompt}
|
||||
onClick={() => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = ".txt,.md"
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
const text = await file.text()
|
||||
updateConfig(p => ({ ...p, ai_custom_prompt: text }))
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Import
|
||||
</Button>
|
||||
<a
|
||||
href="https://github.com/MacRimi/ProxMenux/discussions/categories/share-custom-prompts-for-ai-notifications"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
|
||||
>
|
||||
Community prompts <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
|
||||
Define your own prompt rules and format. You control the detail level and style of all notifications. Export to share with others or import prompts from the community.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||
<Info className="h-4 w-4 text-purple-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs sm:text-sm text-purple-400/90 leading-relaxed">
|
||||
AI enhancement translates and formats notifications to your selected language. Each channel can have different detail levels. If the AI service is unavailable, standard templates are used as fallback.
|
||||
</p>
|
||||
{/* Test Connection button - moved to end */}
|
||||
<div className="space-y-3 pt-3 border-t border-border/50">
|
||||
<button
|
||||
onClick={handleTestAI}
|
||||
disabled={
|
||||
!editMode ||
|
||||
testingAI ||
|
||||
!config.ai_model ||
|
||||
(config.ai_provider !== "ollama" && !config.ai_api_keys?.[config.ai_provider])
|
||||
}
|
||||
className="w-full h-9 flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-purple-600 hover:bg-purple-700 text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{testingAI ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin" /> Testing...</>
|
||||
) : (
|
||||
<><Zap className="h-4 w-4" /> Test Connection</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Test result */}
|
||||
{aiTestResult && (
|
||||
<div className={`flex items-start gap-2 p-3 rounded-md ${
|
||||
aiTestResult.success
|
||||
? "bg-green-500/10 border border-green-500/20"
|
||||
: "bg-red-500/10 border border-red-500/20"
|
||||
}`}>
|
||||
{aiTestResult.success
|
||||
? <CheckCircle2 className="h-4 w-4 text-green-400 shrink-0 mt-0.5" />
|
||||
: <XCircle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||
}
|
||||
<p className={`text-xs sm:text-sm leading-relaxed ${
|
||||
aiTestResult.success ? "text-green-400/90" : "text-red-400/90"
|
||||
}`}>
|
||||
{aiTestResult.message}
|
||||
{aiTestResult.model && ` (${aiTestResult.model})`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1903,6 +2238,87 @@ export function NotificationSettings() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Custom Prompt Info Modal */}
|
||||
<Dialog open={showCustomPromptInfo} onOpenChange={setShowCustomPromptInfo}>
|
||||
<DialogContent className="max-w-[90vw] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
<Settings2 className="h-5 w-5 text-purple-400" />
|
||||
Custom Prompt Mode
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Create your own AI prompt for ProxMenux Monitor notifications
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-foreground/90">What is a custom prompt?</h4>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
The prompt defines how the AI formats your notifications. With a custom prompt, you control the style, detail level, and format of all messages.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-foreground/90">Important requirements</h4>
|
||||
<ul className="text-muted-foreground text-xs space-y-1.5">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-400 mt-0.5">1.</span>
|
||||
<span>Your prompt must output in this format:<br/>
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[TITLE]</code> followed by the title, then <code className="bg-muted px-1.5 py-0.5 rounded text-[11px]">[BODY]</code> followed by the message
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-400 mt-0.5">2.</span>
|
||||
<span>Use plain text only (no markdown) for compatibility with all channels</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-400 mt-0.5">3.</span>
|
||||
<span>The prompt receives raw Proxmox event data as input</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-purple-400 mt-0.5">4.</span>
|
||||
<span>Define the output language in your prompt (the Language selector only applies to Default mode)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-foreground/90">Getting started</h4>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
We have added an example prompt to get you started. You can adapt it, export it to share with others, or import prompts from the community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateConfig(p => ({ ...p, ai_custom_prompt: EXAMPLE_CUSTOM_PROMPT }))
|
||||
setCustomPromptDraft(EXAMPLE_CUSTOM_PROMPT)
|
||||
setEditingCustomPrompt(true)
|
||||
setShowCustomPromptInfo(false)
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Load Example
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCustomPromptDraft("")
|
||||
setEditingCustomPrompt(true)
|
||||
setShowCustomPromptInfo(false)
|
||||
}}
|
||||
className="flex-1 bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
Start from Scratch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export function ProxmoxDashboard() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState("overview")
|
||||
const [infoCount, setInfoCount] = useState(0)
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
const [showNavigation, setShowNavigation] = useState(true)
|
||||
const [lastScrollY, setLastScrollY] = useState(0)
|
||||
const [showHealthModal, setShowHealthModal] = useState(false)
|
||||
@@ -99,6 +100,19 @@ export function ProxmoxDashboard() {
|
||||
{ key: "security", category: "security" },
|
||||
]
|
||||
|
||||
// Fetch ProxMenux update status
|
||||
const fetchUpdateStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchApi("/api/proxmenux/update-status")
|
||||
if (response?.success && response?.update_available) {
|
||||
const { stable, beta } = response.update_available
|
||||
setUpdateAvailable(stable || beta)
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail - updateAvailable will remain false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch health info count independently (for initial load and refresh)
|
||||
const fetchHealthInfoCount = useCallback(async () => {
|
||||
try {
|
||||
@@ -178,9 +192,10 @@ export function ProxmoxDashboard() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Siempre fetch inicial
|
||||
fetchSystemData()
|
||||
fetchHealthInfoCount() // Fetch info count on initial load
|
||||
// Siempre fetch inicial
|
||||
fetchSystemData()
|
||||
fetchHealthInfoCount()
|
||||
fetchUpdateStatus()
|
||||
|
||||
// En overview: cada 30 segundos para actualización frecuente del estado de salud
|
||||
// En otras tabs: cada 60 segundos para reducir carga
|
||||
@@ -198,7 +213,7 @@ export function ProxmoxDashboard() {
|
||||
if (interval) clearInterval(interval)
|
||||
if (healthInterval) clearInterval(healthInterval)
|
||||
}
|
||||
}, [fetchSystemData, fetchHealthInfoCount, activeTab])
|
||||
}, [fetchSystemData, fetchHealthInfoCount, fetchUpdateStatus, activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
const handleChangeTab = (event: CustomEvent) => {
|
||||
@@ -213,6 +228,24 @@ export function ProxmoxDashboard() {
|
||||
window.removeEventListener("changeTab", handleChangeTab as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-refresh terminal on mobile devices
|
||||
// This fixes the issue where terminal doesn't connect properly on mobile/VPN
|
||||
useEffect(() => {
|
||||
if (activeTab === "terminal") {
|
||||
const isMobileDevice = window.innerWidth < 768 ||
|
||||
('ontouchstart' in window && navigator.maxTouchPoints > 0)
|
||||
|
||||
if (isMobileDevice) {
|
||||
// Delay to allow initial connection attempt, then refresh to ensure proper connection
|
||||
const timeoutId = setTimeout(() => {
|
||||
setComponentKey(prev => prev + 1)
|
||||
}, 500)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
const handleHealthStatusUpdate = (event: CustomEvent) => {
|
||||
@@ -376,14 +409,13 @@ export function ProxmoxDashboard() {
|
||||
<div className="flex items-center space-x-2 md:space-x-3 min-w-0">
|
||||
<div className="w-16 h-16 md:w-10 md:h-10 relative flex items-center justify-center bg-primary/10 flex-shrink-0">
|
||||
<Image
|
||||
src="/images/proxmenux-logo.png"
|
||||
src={updateAvailable ? "/images/proxmenux_update-logo.png" : "/images/proxmenux-logo.png"}
|
||||
alt="ProxMenux Logo"
|
||||
width={64}
|
||||
height={64}
|
||||
className="object-contain md:w-10 md:h-10"
|
||||
priority
|
||||
onError={(e) => {
|
||||
console.log("[v0] Logo failed to load, using fallback icon")
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.parentElement?.querySelector(".fallback-icon")
|
||||
@@ -491,14 +523,14 @@ export function ProxmoxDashboard() {
|
||||
|
||||
<div
|
||||
className={`sticky z-40 bg-background
|
||||
top-[120px] md:top-[76px]
|
||||
top-[120px] lg:top-[76px]
|
||||
transition-all duration-700 ease-in-out
|
||||
${showNavigation ? "translate-y-0 opacity-100" : "-translate-y-[120%] opacity-0 pointer-events-none"}
|
||||
`}
|
||||
>
|
||||
<div className="container mx-auto px-4 md:px-6 pt-4 md:pt-6">
|
||||
<div className="container mx-auto px-4 lg:px-6 pt-4 lg:pt-6">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
|
||||
<TabsList className="hidden md:grid w-full grid-cols-9 bg-card border border-border">
|
||||
<TabsList className="hidden lg:grid w-full grid-cols-9 bg-card border border-border">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
|
||||
@@ -556,7 +588,7 @@ export function ProxmoxDashboard() {
|
||||
</TabsList>
|
||||
|
||||
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
|
||||
<div className="md:hidden">
|
||||
<div className="lg:hidden">
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -753,7 +785,7 @@ export function ProxmoxDashboard() {
|
||||
</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.2-beta</p>
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.0</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Thermometer, Terminal, Activity, HardDrive, Bell, Shield, Globe, Cpu, Zap } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.0.2-beta" // Sync with AppImage/package.json
|
||||
const APP_VERSION = "1.2.0" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
|
||||
@@ -43,6 +43,8 @@ interface ScriptTerminalModalProps {
|
||||
scriptPath: string
|
||||
title: string
|
||||
description: string
|
||||
scriptName?: string
|
||||
params?: Record<string, string>
|
||||
}
|
||||
|
||||
export function ScriptTerminalModal({
|
||||
@@ -51,6 +53,7 @@ export function ScriptTerminalModal({
|
||||
scriptPath,
|
||||
title,
|
||||
description,
|
||||
params = { EXECUTION_MODE: "web" },
|
||||
}: ScriptTerminalModalProps) {
|
||||
const termRef = useRef<any>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
@@ -77,6 +80,12 @@ export function ScriptTerminalModal({
|
||||
const modalHeightRef = useRef(600)
|
||||
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null)
|
||||
const paramsRef = useRef(params)
|
||||
|
||||
// Keep paramsRef updated with latest params
|
||||
useEffect(() => {
|
||||
paramsRef.current = params
|
||||
}, [params])
|
||||
|
||||
const attemptReconnect = useCallback(() => {
|
||||
if (!isOpen || isComplete || reconnectAttemptsRef.current >= 3) {
|
||||
@@ -113,13 +122,11 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: {
|
||||
EXECUTION_MODE: "web",
|
||||
},
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: paramsRef.current,
|
||||
}
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
@@ -131,6 +138,11 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses from heartbeat
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "web_interaction" && msg.interaction) {
|
||||
@@ -277,11 +289,8 @@ export function ScriptTerminalModal({
|
||||
|
||||
const initMessage = {
|
||||
script_path: scriptPath,
|
||||
params: {
|
||||
EXECUTION_MODE: "web",
|
||||
},
|
||||
params: paramsRef.current,
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(initMessage))
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -300,6 +309,11 @@ export function ScriptTerminalModal({
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Filter out pong responses from heartbeat - don't display in terminal
|
||||
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
|
||||
@@ -1204,41 +1204,47 @@ export function SecureGatewaySetup() {
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||
Secure Gateway Setup
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] sm:max-h-[85vh] flex flex-col p-0 gap-0">
|
||||
{/* Fixed Header */}
|
||||
<div className="shrink-0 px-6 pt-6 pb-4 border-b border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-cyan-500" />
|
||||
Secure Gateway Setup
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Progress indicator - filter out "options" step if using Proxmox Only */}
|
||||
<div className="flex items-center gap-1 mb-4">
|
||||
{wizardSteps
|
||||
.filter((step) => !(config.access_mode === "host_only" && step.id === "options"))
|
||||
.map((step, idx) => {
|
||||
// Recalculate the actual step index accounting for skipped steps
|
||||
const actualIdx = wizardSteps.findIndex((s) => s.id === step.id)
|
||||
const adjustedCurrentStep = config.access_mode === "host_only"
|
||||
? (currentStep > wizardSteps.findIndex((s) => s.id === "options") ? currentStep - 1 : currentStep)
|
||||
: currentStep
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`flex-1 h-1 rounded-full transition-colors ${
|
||||
idx < adjustedCurrentStep ? "bg-cyan-500" :
|
||||
idx === adjustedCurrentStep ? "bg-cyan-500" :
|
||||
"bg-muted"
|
||||
}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{/* Progress indicator - filter out "options" step if using Proxmox Only */}
|
||||
<div className="flex items-center gap-1 mt-4">
|
||||
{wizardSteps
|
||||
.filter((step) => !(config.access_mode === "host_only" && step.id === "options"))
|
||||
.map((step, idx) => {
|
||||
// Recalculate the actual step index accounting for skipped steps
|
||||
const actualIdx = wizardSteps.findIndex((s) => s.id === step.id)
|
||||
const adjustedCurrentStep = config.access_mode === "host_only"
|
||||
? (currentStep > wizardSteps.findIndex((s) => s.id === "options") ? currentStep - 1 : currentStep)
|
||||
: currentStep
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`flex-1 h-1 rounded-full transition-colors ${
|
||||
idx < adjustedCurrentStep ? "bg-cyan-500" :
|
||||
idx === adjustedCurrentStep ? "bg-cyan-500" :
|
||||
"bg-muted"
|
||||
}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderWizardContent()}
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
{renderWizardContent()}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4 border-t border-border">
|
||||
{/* Fixed Footer with Navigation */}
|
||||
<div className="shrink-0 flex justify-between px-6 py-4 border-t border-border bg-background">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -109,6 +109,10 @@ export function Security() {
|
||||
} | null>(null)
|
||||
const [showFail2banInstaller, setShowFail2banInstaller] = useState(false)
|
||||
const [showLynisInstaller, setShowLynisInstaller] = useState(false)
|
||||
const [uninstallingFail2ban, setUninstallingFail2ban] = useState(false)
|
||||
const [uninstallingLynis, setUninstallingLynis] = useState(false)
|
||||
const [showFail2banUninstallConfirm, setShowFail2banUninstallConfirm] = useState(false)
|
||||
const [showLynisUninstallConfirm, setShowLynisUninstallConfirm] = useState(false)
|
||||
|
||||
// Lynis audit state
|
||||
interface LynisWarning { test_id: string; severity: string; description: string; solution: string; proxmox_context?: string; proxmox_expected?: boolean; proxmox_severity?: string }
|
||||
@@ -251,6 +255,52 @@ export function Security() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUninstallFail2ban = async () => {
|
||||
setUninstallingFail2ban(true)
|
||||
setError("")
|
||||
setSuccess("")
|
||||
setShowFail2banUninstallConfirm(false)
|
||||
try {
|
||||
const data = await fetchApi("/api/security/fail2ban/uninstall", {
|
||||
method: "POST",
|
||||
})
|
||||
if (data.success) {
|
||||
setSuccess(data.message || "Fail2Ban has been uninstalled")
|
||||
loadSecurityTools()
|
||||
setF2bDetails(null)
|
||||
} else {
|
||||
setError(data.message || "Failed to uninstall Fail2Ban")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to uninstall Fail2Ban")
|
||||
} finally {
|
||||
setUninstallingFail2ban(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUninstallLynis = async () => {
|
||||
setUninstallingLynis(true)
|
||||
setError("")
|
||||
setSuccess("")
|
||||
setShowLynisUninstallConfirm(false)
|
||||
try {
|
||||
const data = await fetchApi("/api/security/lynis/uninstall", {
|
||||
method: "POST",
|
||||
})
|
||||
if (data.success) {
|
||||
setSuccess(data.message || "Lynis has been uninstalled")
|
||||
loadSecurityTools()
|
||||
setLynisReport(null)
|
||||
} else {
|
||||
setError(data.message || "Failed to uninstall Lynis")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to uninstall Lynis")
|
||||
} finally {
|
||||
setUninstallingLynis(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFail2banDetails = async () => {
|
||||
try {
|
||||
setF2bDetailsLoading(true)
|
||||
@@ -591,11 +641,18 @@ export function Security() {
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(getApiUrl("/api/auth/status"))
|
||||
|
||||
// Check if response is valid JSON before parsing
|
||||
if (!response.ok) return
|
||||
|
||||
const contentType = response.headers.get("content-type")
|
||||
if (!contentType || !contentType.includes("application/json")) return
|
||||
|
||||
const data = await response.json()
|
||||
setAuthEnabled(data.auth_enabled || false)
|
||||
setTotpEnabled(data.totp_enabled || false)
|
||||
} catch (err) {
|
||||
console.error("Failed to check auth status:", err)
|
||||
} catch {
|
||||
// API not available (preview environment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,23 +948,31 @@ export function Security() {
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
|
||||
// so we catch and fall through to the textarea fallback.
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = text
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.left = "-9999px"
|
||||
textarea.style.top = "-9999px"
|
||||
textarea.style.opacity = "0"
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
return true
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
// fall through to execCommand fallback
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = text
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.left = "-9999px"
|
||||
textarea.style.top = "-9999px"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.readOnly = true
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
const ok = document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
return ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -2956,16 +3021,34 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
<Bug className="h-5 w-5 text-red-500" />
|
||||
<CardTitle>Fail2Ban</CardTitle>
|
||||
</div>
|
||||
{fail2banInfo?.installed && fail2banInfo?.active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { loadFail2banDetails(); loadSecurityTools(); }}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
{fail2banInfo?.installed && (
|
||||
<div className="flex items-center gap-1">
|
||||
{fail2banInfo?.active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { loadFail2banDetails(); loadSecurityTools(); }}
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFail2banUninstallConfirm(true)}
|
||||
disabled={uninstallingFail2ban}
|
||||
className="h-8 px-3 text-xs border-red-500/30 text-red-500 hover:bg-red-500/10 hover:text-red-400 hover:border-red-500/50"
|
||||
>
|
||||
{uninstallingFail2ban ? (
|
||||
<div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full mr-2" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Uninstall
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
@@ -2980,20 +3063,15 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
) : !fail2banInfo?.installed ? (
|
||||
/* --- NOT INSTALLED --- */
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-500/10 flex items-center justify-center">
|
||||
<Bug className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Fail2Ban Not Installed</p>
|
||||
<p className="text-sm text-muted-foreground">Protect SSH, Proxmox web interface, and ProxMenux Monitor from brute force attacks</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 rounded-full text-sm font-medium bg-gray-500/10 text-gray-500">
|
||||
Not Installed
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-500/10 flex items-center justify-center shrink-0">
|
||||
<Bug className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Fail2Ban Not Installed</p>
|
||||
<p className="text-sm text-muted-foreground">Protect SSH, Proxmox web interface, and ProxMenux Monitor from brute force attacks</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -3417,9 +3495,27 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
{/* Lynis */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-cyan-500" />
|
||||
<CardTitle>Lynis Security Audit</CardTitle>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5 text-cyan-500" />
|
||||
<CardTitle>Lynis Security Audit</CardTitle>
|
||||
</div>
|
||||
{lynisInfo?.installed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowLynisUninstallConfirm(true)}
|
||||
disabled={uninstallingLynis}
|
||||
className="h-8 px-3 text-xs border-red-500/30 text-red-500 hover:bg-red-500/10 hover:text-red-400 hover:border-red-500/50"
|
||||
>
|
||||
{uninstallingLynis ? (
|
||||
<div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full mr-2" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
System security auditing tool that performs comprehensive security scans
|
||||
@@ -3432,20 +3528,15 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
</div>
|
||||
) : !lynisInfo?.installed ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-500/10 flex items-center justify-center">
|
||||
<Search className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Lynis Not Installed</p>
|
||||
<p className="text-sm text-muted-foreground">Comprehensive security auditing and hardening tool</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1 rounded-full text-sm font-medium bg-gray-500/10 text-gray-500">
|
||||
Not Installed
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/50 rounded-lg">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-500/10 flex items-center justify-center shrink-0">
|
||||
<Search className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Lynis Not Installed</p>
|
||||
<p className="text-sm text-muted-foreground">Comprehensive security auditing and hardening tool</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -3678,6 +3769,9 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">PDF</span>
|
||||
</Button>
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${lynisShowReport ? "rotate-180" : ""}`} />
|
||||
{/* Delete button separated with divider to prevent accidental clicks */}
|
||||
<div className="hidden sm:block w-px h-5 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -3694,12 +3788,11 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
.catch(() => setError("Failed to delete report"))
|
||||
}
|
||||
}}
|
||||
className="h-7 px-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10"
|
||||
className="h-7 px-2 text-xs text-red-500 hover:text-red-400 hover:bg-red-500/10 ml-2 sm:ml-0"
|
||||
title="Delete report"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${lynisShowReport ? "rotate-180" : ""}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -3726,26 +3819,34 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Report tabs */}
|
||||
<div className="flex gap-0 border-t border-border">
|
||||
{/* Report tabs - responsive with shorter labels on mobile */}
|
||||
<div className="flex gap-0 border-t border-border overflow-x-auto">
|
||||
{(["overview", "checks", "warnings", "suggestions"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setLynisActiveTab(tab)}
|
||||
className={`flex-1 px-3 py-2 text-xs font-medium transition-all flex items-center justify-center gap-1.5 border-r last:border-r-0 border-border ${
|
||||
className={`flex-1 min-w-0 px-2 sm:px-3 py-2 text-xs font-medium transition-all flex items-center justify-center gap-1 sm:gap-1.5 border-r last:border-r-0 border-border ${
|
||||
lynisActiveTab === tab
|
||||
? "bg-cyan-500 text-white"
|
||||
: "bg-muted/20 text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{tab === "overview" && <BarChart3 className="h-3 w-3" />}
|
||||
{tab === "checks" && <Search className="h-3 w-3" />}
|
||||
{tab === "warnings" && <TriangleAlert className="h-3 w-3" />}
|
||||
{tab === "suggestions" && <Info className="h-3 w-3" />}
|
||||
{tab === "overview" ? "Overview"
|
||||
: tab === "checks" ? `Checks (${lynisReport.sections?.length || 0})`
|
||||
: tab === "warnings" ? `Warnings (${lynisReport.warnings.length})`
|
||||
: `Suggestions (${lynisReport.suggestions.length})`}
|
||||
{tab === "overview" && <BarChart3 className="h-3 w-3 shrink-0" />}
|
||||
{tab === "checks" && <Search className="h-3 w-3 shrink-0" />}
|
||||
{tab === "warnings" && <TriangleAlert className="h-3 w-3 shrink-0" />}
|
||||
{tab === "suggestions" && <Info className="h-3 w-3 shrink-0" />}
|
||||
<span className="hidden sm:inline">
|
||||
{tab === "overview" ? "Overview"
|
||||
: tab === "checks" ? `Checks (${lynisReport.sections?.length || 0})`
|
||||
: tab === "warnings" ? `Warnings (${lynisReport.warnings.length})`
|
||||
: `Suggestions (${lynisReport.suggestions.length})`}
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
{tab === "overview" ? ""
|
||||
: tab === "checks" ? `(${lynisReport.sections?.length || 0})`
|
||||
: tab === "warnings" ? `(${lynisReport.warnings.length})`
|
||||
: `(${lynisReport.suggestions.length})`}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -4019,6 +4120,107 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
description="Installing Lynis security auditing tool from GitHub..."
|
||||
/>
|
||||
|
||||
{/* Uninstall Confirmation Dialogs */}
|
||||
{showFail2banUninstallConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background border border-border rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">Uninstall Fail2Ban?</h3>
|
||||
<p className="text-sm text-muted-foreground">This action cannot be undone</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
This will completely remove Fail2Ban and all its configuration, including:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground mb-6 list-disc list-inside space-y-1">
|
||||
<li>SSH protection jail</li>
|
||||
<li>Proxmox web interface protection</li>
|
||||
<li>ProxMenux Monitor protection</li>
|
||||
<li>All custom jail configurations</li>
|
||||
<li>Auth logger services</li>
|
||||
</ul>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowFail2banUninstallConfirm(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUninstallFail2ban}
|
||||
disabled={uninstallingFail2ban}
|
||||
>
|
||||
{uninstallingFail2ban ? (
|
||||
<>
|
||||
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2" />
|
||||
Uninstalling...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Uninstall
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLynisUninstallConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-background border border-border rounded-lg p-6 max-w-md w-full mx-4 shadow-xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-red-500/10 flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">Uninstall Lynis?</h3>
|
||||
<p className="text-sm text-muted-foreground">This action cannot be undone</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
This will completely remove Lynis and all audit data, including:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground mb-6 list-disc list-inside space-y-1">
|
||||
<li>Lynis installation (/opt/lynis)</li>
|
||||
<li>Wrapper script (/usr/local/bin/lynis)</li>
|
||||
<li>All audit reports and logs</li>
|
||||
</ul>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowLynisUninstallConfirm(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleUninstallLynis}
|
||||
disabled={uninstallingLynis}
|
||||
>
|
||||
{uninstallingLynis ? (
|
||||
<>
|
||||
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-2" />
|
||||
Uninstalling...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Uninstall
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TwoFactorSetup
|
||||
open={show2FASetup}
|
||||
onClose={() => setShow2FASetup(false)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff } from "lucide-react"
|
||||
import { Wrench, Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy } from "lucide-react"
|
||||
import { NotificationSettings } from "./notification-settings"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Switch } from "./ui/switch"
|
||||
@@ -11,6 +11,149 @@ import { Badge } from "./ui/badge"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
|
||||
// GitHub Dark color palette for bash syntax highlighting
|
||||
const BASH_KEYWORDS = new Set([
|
||||
'if','then','else','elif','fi','for','while','until','do','done','case','esac',
|
||||
'function','return','local','readonly','export','declare','typeset','unset',
|
||||
'source','alias','exit','break','continue','in','select','time','trap',
|
||||
])
|
||||
const BASH_BUILTINS = new Set([
|
||||
'echo','printf','read','cd','pwd','ls','cat','grep','sed','awk','cut','sort','uniq','tee','wc',
|
||||
'head','tail','find','xargs','chmod','chown','chgrp','mkdir','rmdir','rm','cp','mv','ln','touch',
|
||||
'ps','kill','killall','pkill','pgrep','top','htop','df','du','free','uptime','uname','hostname',
|
||||
'systemctl','journalctl','service','apt','apt-get','dpkg','dnf','yum','zypper','pacman',
|
||||
'curl','wget','ssh','scp','rsync','tar','gzip','gunzip','bzip2','zip','unzip',
|
||||
'mount','umount','lsblk','blkid','fdisk','parted','mkfs','fsck','swapon','swapoff',
|
||||
'ip','ifconfig','iptables','netstat','ss','ping','traceroute','dig','nslookup','nc',
|
||||
'sudo','su','whoami','id','groups','passwd','useradd','userdel','usermod','groupadd',
|
||||
'test','true','false','sleep','wait','eval','exec','command','type','which','hash',
|
||||
'set','getopts','shift','let','expr','jq','sed','grep','awk','tr',
|
||||
'modprobe','lsmod','rmmod','insmod','dmesg','sysctl','ulimit','nohup','disown','bg','fg',
|
||||
'zpool','zfs','qm','pct','pvesh','pvesm','pvenode','pveam','pveversion','vzdump',
|
||||
'smartctl','nvme','ipmitool','sensors','upsc','dkms','modinfo','lspci','lsusb','lscpu',
|
||||
])
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function highlightBash(code: string): string {
|
||||
// Token-based highlighter — processes line by line to avoid cross-line state issues
|
||||
const lines = code.split('\n')
|
||||
const out: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
let i = 0
|
||||
let result = ''
|
||||
|
||||
while (i < line.length) {
|
||||
const ch = line[i]
|
||||
|
||||
// Comments (# to end of line, but not inside strings — simple heuristic)
|
||||
if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) {
|
||||
result += `<span style="color:#8b949e">${escapeHtml(line.slice(i))}</span>`
|
||||
i = line.length
|
||||
continue
|
||||
}
|
||||
|
||||
// Strings: double-quoted (may contain $variables)
|
||||
if (ch === '"') {
|
||||
let j = i + 1
|
||||
let content = ''
|
||||
while (j < line.length && line[j] !== '"') {
|
||||
if (line[j] === '\\' && j + 1 < line.length) {
|
||||
content += line[j] + line[j + 1]
|
||||
j += 2
|
||||
} else {
|
||||
content += line[j]
|
||||
j++
|
||||
}
|
||||
}
|
||||
const str = '"' + content + (line[j] === '"' ? '"' : '')
|
||||
// Highlight $vars inside strings
|
||||
const strHtml = escapeHtml(str).replace(
|
||||
/(\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|\$[0-9@#?*$!-])/g,
|
||||
'<span style="color:#79c0ff">$1</span>'
|
||||
)
|
||||
result += `<span style="color:#a5d6ff">${strHtml}</span>`
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Strings: single-quoted (literal, no interpolation)
|
||||
if (ch === "'") {
|
||||
let j = i + 1
|
||||
while (j < line.length && line[j] !== "'") j++
|
||||
const str = line.slice(i, j + 1)
|
||||
result += `<span style="color:#a5d6ff">${escapeHtml(str)}</span>`
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Variables outside strings
|
||||
if (ch === '$') {
|
||||
const rest = line.slice(i)
|
||||
let m = rest.match(/^\$\{[^}]+\}/)
|
||||
if (!m) m = rest.match(/^\$[A-Za-z_][A-Za-z0-9_]*/)
|
||||
if (!m) m = rest.match(/^\$[0-9@#?*$!-]/)
|
||||
if (m) {
|
||||
result += `<span style="color:#79c0ff">${escapeHtml(m[0])}</span>`
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Numbers
|
||||
if (/[0-9]/.test(ch) && (i === 0 || /[\s=(\[,:;+\-*/]/.test(line[i - 1]))) {
|
||||
const rest = line.slice(i)
|
||||
const m = rest.match(/^[0-9]+/)
|
||||
if (m) {
|
||||
result += `<span style="color:#79c0ff">${m[0]}</span>`
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Identifiers — check if keyword, builtin, or function definition
|
||||
if (/[A-Za-z_]/.test(ch)) {
|
||||
const rest = line.slice(i)
|
||||
const m = rest.match(/^[A-Za-z_][A-Za-z0-9_-]*/)
|
||||
if (m) {
|
||||
const word = m[0]
|
||||
const after = line.slice(i + word.length)
|
||||
if (BASH_KEYWORDS.has(word)) {
|
||||
result += `<span style="color:#ff7b72">${word}</span>`
|
||||
} else if (/^\s*\(\)\s*\{?/.test(after)) {
|
||||
// function definition: name() { ... }
|
||||
result += `<span style="color:#d2a8ff">${word}</span>`
|
||||
} else if (BASH_BUILTINS.has(word) && (i === 0 || /[\s|;&(]/.test(line[i - 1]))) {
|
||||
result += `<span style="color:#ffa657">${word}</span>`
|
||||
} else {
|
||||
result += escapeHtml(word)
|
||||
}
|
||||
i += word.length
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Operators and special chars
|
||||
if (/[|&;<>(){}[\]=!+*\/%~^]/.test(ch)) {
|
||||
result += `<span style="color:#ff7b72">${escapeHtml(ch)}</span>`
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Default: escape and append
|
||||
result += escapeHtml(ch)
|
||||
i++
|
||||
}
|
||||
|
||||
out.push(result)
|
||||
}
|
||||
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
interface SuppressionCategory {
|
||||
key: string
|
||||
label: string
|
||||
@@ -46,6 +189,9 @@ interface ProxMenuxTool {
|
||||
key: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
version?: string
|
||||
has_source?: boolean
|
||||
deprecated?: boolean
|
||||
}
|
||||
|
||||
interface RemoteStorage {
|
||||
@@ -62,11 +208,36 @@ interface RemoteStorage {
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface NetworkInterface {
|
||||
name: string
|
||||
type: string
|
||||
is_up: boolean
|
||||
speed: number
|
||||
ip_address: string | null
|
||||
exclude_health: boolean
|
||||
exclude_notifications: boolean
|
||||
excluded_at?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const [proxmenuxTools, setProxmenuxTools] = useState<ProxMenuxTool[]>([])
|
||||
const [loadingTools, setLoadingTools] = useState(true)
|
||||
const [networkUnitSettings, setNetworkUnitSettings] = useState<"Bytes" | "Bits">("Bytes")
|
||||
const [loadingUnitSettings, setLoadingUnitSettings] = useState(true)
|
||||
// Code viewer modal state
|
||||
const [codeModal, setCodeModal] = useState<{
|
||||
open: boolean
|
||||
loading: boolean
|
||||
toolName: string
|
||||
version: string
|
||||
functionName: string
|
||||
source: string
|
||||
script: string
|
||||
error: string
|
||||
deprecated: boolean
|
||||
}>({ open: false, loading: false, toolName: '', version: '', functionName: '', source: '', script: '', error: '', deprecated: false })
|
||||
const [codeCopied, setCodeCopied] = useState(false)
|
||||
|
||||
// Health Monitor suppression settings
|
||||
const [suppressionCategories, setSuppressionCategories] = useState<SuppressionCategory[]>([])
|
||||
@@ -81,12 +252,18 @@ export function Settings() {
|
||||
const [remoteStorages, setRemoteStorages] = useState<RemoteStorage[]>([])
|
||||
const [loadingStorages, setLoadingStorages] = useState(true)
|
||||
const [savingStorage, setSavingStorage] = useState<string | null>(null)
|
||||
|
||||
// Network Interface Exclusions
|
||||
const [networkInterfaces, setNetworkInterfaces] = useState<NetworkInterface[]>([])
|
||||
const [loadingInterfaces, setLoadingInterfaces] = useState(true)
|
||||
const [savingInterface, setSavingInterface] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadProxmenuxTools()
|
||||
getUnitsSettings()
|
||||
loadHealthSettings()
|
||||
loadRemoteStorages()
|
||||
loadProxmenuxTools()
|
||||
getUnitsSettings()
|
||||
loadHealthSettings()
|
||||
loadRemoteStorages()
|
||||
loadNetworkInterfaces()
|
||||
}, [])
|
||||
|
||||
const loadProxmenuxTools = async () => {
|
||||
@@ -102,6 +279,60 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const viewToolSource = async (tool: ProxMenuxTool) => {
|
||||
setCodeModal({ open: true, loading: true, toolName: tool.name, version: tool.version || '1.0', functionName: '', source: '', script: '', error: '', deprecated: !!tool.deprecated })
|
||||
try {
|
||||
const data = await fetchApi(`/api/proxmenux/tool-source/${tool.key}`)
|
||||
if (data.success) {
|
||||
setCodeModal(prev => ({ ...prev, loading: false, functionName: data.function, source: data.source, script: data.script, deprecated: !!data.deprecated }))
|
||||
} else {
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: data.error || 'Source code not available' }))
|
||||
}
|
||||
} catch {
|
||||
setCodeModal(prev => ({ ...prev, loading: false, error: 'Failed to load source code' }))
|
||||
}
|
||||
}
|
||||
|
||||
const copySourceCode = async () => {
|
||||
const text = codeModal.source
|
||||
let ok = false
|
||||
|
||||
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
|
||||
// so we catch and fall through to the textarea fallback.
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ok = true
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
try {
|
||||
const ta = document.createElement("textarea")
|
||||
ta.value = text
|
||||
ta.style.position = "fixed"
|
||||
ta.style.left = "-9999px"
|
||||
ta.style.top = "-9999px"
|
||||
ta.style.opacity = "0"
|
||||
ta.readOnly = true
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
ok = document.execCommand("copy")
|
||||
document.body.removeChild(ta)
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
setCodeCopied(true)
|
||||
setTimeout(() => setCodeCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const changeNetworkUnit = (unit: string) => {
|
||||
const networkUnit = unit as "Bytes" | "Bits"
|
||||
localStorage.setItem("proxmenux-network-unit", networkUnit)
|
||||
@@ -177,11 +408,53 @@ export function Settings() {
|
||||
))
|
||||
} catch (err) {
|
||||
console.error("Failed to update storage exclusion:", err)
|
||||
} finally {
|
||||
setSavingStorage(null)
|
||||
}
|
||||
} finally {
|
||||
setSavingStorage(null)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const loadNetworkInterfaces = async () => {
|
||||
try {
|
||||
const data = await fetchApi("/api/health/interfaces")
|
||||
if (data.interfaces) {
|
||||
setNetworkInterfaces(data.interfaces)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load network interfaces:", err)
|
||||
} finally {
|
||||
setLoadingInterfaces(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInterfaceExclusionChange = async (interfaceName: string, interfaceType: string, excludeHealth: boolean, excludeNotifications: boolean) => {
|
||||
setSavingInterface(interfaceName)
|
||||
try {
|
||||
// If both are false, remove the exclusion
|
||||
if (!excludeHealth && !excludeNotifications) {
|
||||
await fetchApi(`/api/health/interface-exclusions/${encodeURIComponent(interfaceName)}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
} else {
|
||||
await fetchApi("/api/health/interface-exclusions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
interface_name: interfaceName,
|
||||
interface_type: interfaceType,
|
||||
exclude_health: excludeHealth,
|
||||
exclude_notifications: excludeNotifications
|
||||
})
|
||||
})
|
||||
}
|
||||
// Reload interfaces to get updated state
|
||||
await loadNetworkInterfaces()
|
||||
} catch (err) {
|
||||
console.error("Failed to update interface exclusion:", err)
|
||||
} finally {
|
||||
setSavingInterface(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getSelectValue = (hours: number, key: string): string => {
|
||||
if (hours === -1) return "-1"
|
||||
const preset = SUPPRESSION_OPTIONS.find(o => o.value === String(hours))
|
||||
@@ -541,8 +814,8 @@ export function Settings() {
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span>
|
||||
</div>
|
||||
|
||||
{/* Storage rows */}
|
||||
<div className="divide-y divide-border/50">
|
||||
{/* Storage rows - scrollable container */}
|
||||
<div className="max-h-[320px] overflow-y-auto divide-y divide-border/50">
|
||||
{remoteStorages.map((storage) => {
|
||||
const isExcluded = storage.exclude_health || storage.exclude_notifications
|
||||
const isSaving = savingStorage === storage.name
|
||||
@@ -581,6 +854,7 @@ export function Settings() {
|
||||
storage.exclude_notifications
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -599,6 +873,7 @@ export function Settings() {
|
||||
!checked
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -621,6 +896,133 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network Interface Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-blue-500" />
|
||||
<CardTitle>Network Interface Exclusions</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Exclude network interfaces (bridges, bonds, physical NICs) from health monitoring and notifications.
|
||||
Use this for interfaces that are intentionally disabled or unused.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingInterfaces ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : networkInterfaces.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-3 opacity-50" />
|
||||
<p className="text-muted-foreground">No network interfaces detected</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 pb-2 mb-1 border-b border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground">Interface</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">Health</span>
|
||||
<span className="text-xs font-medium text-muted-foreground text-center w-20">Alerts</span>
|
||||
</div>
|
||||
|
||||
{/* Interface rows - scrollable container */}
|
||||
<div className="max-h-[320px] overflow-y-auto divide-y divide-border/50">
|
||||
{networkInterfaces.map((iface) => {
|
||||
const isExcluded = iface.exclude_health || iface.exclude_notifications
|
||||
const isSaving = savingInterface === iface.name
|
||||
const isDown = !iface.is_up
|
||||
|
||||
return (
|
||||
<div key={iface.name} className="grid grid-cols-[1fr_auto_auto] gap-4 py-3 items-center">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${
|
||||
isDown ? 'bg-red-500' : 'bg-green-500'
|
||||
}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium truncate ${isExcluded ? 'text-muted-foreground' : ''}`}>
|
||||
{iface.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{iface.type}
|
||||
</Badge>
|
||||
{isDown && !isExcluded && (
|
||||
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
|
||||
DOWN
|
||||
</Badge>
|
||||
)}
|
||||
{isExcluded && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 bg-blue-500/10 text-blue-400">
|
||||
Excluded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{iface.ip_address || 'No IP'} {iface.speed > 0 ? `- ${iface.speed} Mbps` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!iface.exclude_health}
|
||||
onCheckedChange={(checked) => {
|
||||
handleInterfaceExclusionChange(
|
||||
iface.name,
|
||||
iface.type,
|
||||
!checked,
|
||||
iface.exclude_notifications
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications toggle */}
|
||||
<div className="flex justify-center w-20">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<Switch
|
||||
checked={!iface.exclude_notifications}
|
||||
onCheckedChange={(checked) => {
|
||||
handleInterfaceExclusionChange(
|
||||
iface.name,
|
||||
iface.type,
|
||||
iface.exclude_health,
|
||||
!checked
|
||||
)
|
||||
}}
|
||||
className="data-[state=checked]:bg-blue-600 data-[state=unchecked]:bg-input border border-border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info footer */}
|
||||
<div className="flex items-start gap-2 mt-3 pt-3 border-t border-border">
|
||||
<Info className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
<strong>Health:</strong> When OFF, the interface won't trigger warnings/critical alerts in the Health Monitor.
|
||||
<br />
|
||||
<strong>Alerts:</strong> When OFF, no notifications will be sent for this interface.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<NotificationSettings />
|
||||
|
||||
@@ -651,20 +1053,104 @@ export function Settings() {
|
||||
<span className="text-sm font-semibold text-orange-500">{proxmenuxTools.length} active</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{proxmenuxTools.map((tool) => (
|
||||
<div
|
||||
key={tool.key}
|
||||
className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<span className="text-sm font-medium">{tool.name}</span>
|
||||
</div>
|
||||
))}
|
||||
{proxmenuxTools.map((tool) => {
|
||||
const clickable = !!tool.has_source
|
||||
const isDeprecated = !!tool.deprecated
|
||||
return (
|
||||
<div
|
||||
key={tool.key}
|
||||
onClick={clickable ? () => viewToolSource(tool) : undefined}
|
||||
className={`flex items-center justify-between gap-2 p-3 bg-muted/50 rounded-lg border border-border transition-colors ${clickable ? 'hover:bg-muted hover:border-orange-500/40 cursor-pointer' : ''}`}
|
||||
title={clickable ? (isDeprecated ? 'Legacy optimization — click to view source' : 'Click to view source code') : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${isDeprecated ? 'bg-amber-500' : 'bg-green-500'}`} />
|
||||
<span className="text-sm font-medium truncate">{tool.name}</span>
|
||||
{isDeprecated && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
legacy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded font-mono flex-shrink-0">v{tool.version || '1.0'}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Code Viewer Modal */}
|
||||
{codeModal.open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}>
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Code className={`h-5 w-5 flex-shrink-0 ${codeModal.deprecated ? 'text-amber-500' : 'text-orange-500'}`} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
|
||||
{codeModal.deprecated && (
|
||||
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
|
||||
legacy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{codeModal.functionName && <span className="font-mono">{codeModal.functionName}()</span>}
|
||||
{codeModal.script && <span> — {codeModal.script}</span>}
|
||||
{codeModal.version && <span className="ml-2 bg-muted px-1.5 py-0.5 rounded font-mono">v{codeModal.version}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{codeModal.source && (
|
||||
<button
|
||||
onClick={copySourceCode}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md bg-muted hover:bg-muted/80 transition-colors"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{codeCopied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{codeCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-0">
|
||||
{codeModal.loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : codeModal.error ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Code className="h-10 w-10 mb-3 opacity-40" />
|
||||
<p className="text-sm">{codeModal.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
className="text-xs leading-relaxed font-mono p-4 overflow-x-auto whitespace-pre bg-[#0d1117] text-[#e6edf3]"
|
||||
style={{ tabSize: 4 }}
|
||||
dangerouslySetInnerHTML={{ __html: `<code>${highlightBash(codeModal.source)}</code>` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -553,7 +553,7 @@ export function SystemLogs() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 w-full max-w-full overflow-hidden">
|
||||
{loading && (logs.length > 0 || events.length > 0) && (
|
||||
<div className="fixed inset-0 bg-background/60 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-card border border-border shadow-xl">
|
||||
@@ -616,7 +616,7 @@ export function SystemLogs() {
|
||||
</div>
|
||||
|
||||
{/* Main Content with Tabs */}
|
||||
<Card className="bg-card border-border">
|
||||
<Card className="bg-card border-border w-full max-w-full overflow-hidden">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-foreground flex items-center">
|
||||
@@ -630,7 +630,7 @@ export function SystemLogs() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="max-w-full overflow-hidden">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full max-w-full">
|
||||
<TabsList className="hidden md:grid w-full grid-cols-3">
|
||||
<TabsTrigger value="logs" className="data-[state=active]:bg-blue-500 data-[state=active]:text-white">
|
||||
<Terminal className="h-4 w-4 mr-2" />
|
||||
@@ -794,8 +794,8 @@ export function SystemLogs() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[600px] w-full rounded-md border border-border overflow-x-hidden">
|
||||
<div className="space-y-2 p-4 w-full box-border">
|
||||
<ScrollArea className="h-[600px] w-full rounded-md border border-border overflow-hidden [&>div]:!max-w-full [&>div>div]:!max-w-full">
|
||||
<div className="space-y-2 p-4 w-full min-w-0">
|
||||
{displayedLogs.map((log, index) => {
|
||||
// Generate a more stable unique key
|
||||
const timestampMs = new Date(log.timestamp).getTime()
|
||||
@@ -806,7 +806,7 @@ export function SystemLogs() {
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden box-border"
|
||||
className="flex flex-col md:flex-row md:items-start space-y-2 md:space-y-0 md:space-x-4 p-3 rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 transition-colors cursor-pointer overflow-hidden w-full max-w-full min-w-0"
|
||||
onClick={() => {
|
||||
if (log.eventData) {
|
||||
setSelectedEvent(log.eventData)
|
||||
@@ -830,17 +830,17 @@ export function SystemLogs() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-hidden box-border">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-1 gap-1">
|
||||
<div className="text-sm font-medium text-foreground truncate min-w-0">{log.service}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate sm:ml-2 sm:flex-shrink-0">
|
||||
{log.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-foreground mb-1 line-clamp-2 break-all overflow-hidden">
|
||||
<div className="text-sm text-foreground mb-1 line-clamp-2 break-words overflow-hidden">
|
||||
{log.message}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate break-all overflow-hidden">
|
||||
<div className="text-xs text-muted-foreground truncate overflow-hidden">
|
||||
{log.source}
|
||||
{log.unit && log.unit !== log.service && ` • Unit: ${log.unit}`}
|
||||
{log.pid && ` • PID: ${log.pid}`}
|
||||
@@ -859,7 +859,7 @@ export function SystemLogs() {
|
||||
)}
|
||||
|
||||
{hasMoreLogs && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<div className="flex justify-center pt-4 w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDisplayedLogsCount((prev) => prev + 200)}
|
||||
|
||||
@@ -111,9 +111,9 @@ const fetchSystemData = async (retries = 3, delayMs = 500): Promise<SystemData |
|
||||
try {
|
||||
const data = await fetchApi<SystemData>("/api/system")
|
||||
return data
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (attempt === retries - 1) {
|
||||
console.error("[v0] Failed to fetch system data after retries:", error)
|
||||
// Silent fail - API not available (expected in preview environment)
|
||||
return null
|
||||
}
|
||||
// Wait before retry
|
||||
@@ -127,8 +127,8 @@ const fetchVMData = async (): Promise<VMData[]> => {
|
||||
try {
|
||||
const data = await fetchApi<any>("/api/vms")
|
||||
return Array.isArray(data) ? data : data.vms || []
|
||||
} catch (error) {
|
||||
console.error("[v0] Failed to fetch VM data:", error)
|
||||
} catch {
|
||||
// Silent fail - API not available
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -137,8 +137,7 @@ const fetchStorageData = async (): Promise<StorageData | null> => {
|
||||
try {
|
||||
const data = await fetchApi<StorageData>("/api/storage/summary")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Storage API not available (this is normal if not configured)")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -147,18 +146,16 @@ const fetchNetworkData = async (): Promise<NetworkData | null> => {
|
||||
try {
|
||||
const data = await fetchApi<NetworkData>("/api/network/summary")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Network API not available (this is normal if not configured)")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorageData | null> => {
|
||||
const fetchProxmoxStorageData = async (): Promise<ProxmoxStorage[] | null> => {
|
||||
try {
|
||||
const data = await fetchApi<ProxmoxStorageData>("/api/proxmox-storage")
|
||||
const data = await fetchApi<ProxmoxStorage[]>("/api/proxmox-storage")
|
||||
return data
|
||||
} catch (error) {
|
||||
console.log("[v0] Proxmox storage API not available")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -225,7 +222,7 @@ export function SystemOverview() {
|
||||
const systemInterval = setInterval(async () => {
|
||||
const data = await fetchSystemData()
|
||||
if (data) setSystemData(data)
|
||||
}, 9000)
|
||||
}, 5000)
|
||||
|
||||
const vmInterval = setInterval(async () => {
|
||||
const data = await fetchVMData()
|
||||
@@ -262,19 +259,13 @@ export function SystemOverview() {
|
||||
|
||||
if (!hasAttemptedLoad || loadingStates.system) {
|
||||
return (
|
||||
<div className="space-y-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">
|
||||
<div className="h-4 bg-muted rounded w-1/2 mb-4"></div>
|
||||
<div className="h-8 bg-muted rounded w-3/4 mb-2"></div>
|
||||
<div className="h-2 bg-muted rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-muted rounded w-2/3"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<div className="relative">
|
||||
<div className="h-12 w-12 rounded-full border-2 border-muted"></div>
|
||||
<div className="absolute inset-0 h-12 w-12 rounded-full border-2 border-transparent border-t-primary animate-spin"></div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">Loading system overview...</div>
|
||||
<p className="text-xs text-muted-foreground">Fetching system status and metrics</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -724,13 +724,13 @@ const handleClose = () => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
|
||||
const activeTerminal = terminals.find((t) => t.id === activeTerminalId)
|
||||
if (activeTerminal?.ws && activeTerminal.ws.readyState === WebSocket.OPEN) {
|
||||
activeTerminal.ws.send(seq)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getLayoutClass = () => {
|
||||
const count = terminals.length
|
||||
if (isMobile || count === 1) return "grid grid-cols-1"
|
||||
|
||||
@@ -90,33 +90,49 @@ export function TwoFactorSetup({ open, onClose, onSuccess }: TwoFactorSetupProps
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string, type: "secret" | "codes") => {
|
||||
let ok = false
|
||||
|
||||
// Preferred path (HTTPS / localhost). On plain HTTP the Promise rejects,
|
||||
// so we catch and fall through to the textarea fallback.
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
// Fallback for non-secure contexts (HTTP)
|
||||
ok = true
|
||||
}
|
||||
} catch {
|
||||
// fall through to execCommand fallback
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
try {
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = text
|
||||
textarea.style.position = "fixed"
|
||||
textarea.style.left = "-9999px"
|
||||
textarea.style.top = "-9999px"
|
||||
textarea.style.opacity = "0"
|
||||
textarea.readOnly = true
|
||||
document.body.appendChild(textarea)
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
document.execCommand("copy")
|
||||
ok = document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
} catch {
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "secret") {
|
||||
setCopiedSecret(true)
|
||||
setTimeout(() => setCopiedSecret(false), 2000)
|
||||
} else {
|
||||
setCopiedCodes(true)
|
||||
setTimeout(() => setCopiedCodes(false), 2000)
|
||||
}
|
||||
} catch {
|
||||
if (!ok) {
|
||||
console.error("Failed to copy to clipboard")
|
||||
return
|
||||
}
|
||||
|
||||
if (type === "secret") {
|
||||
setCopiedSecret(true)
|
||||
setTimeout(() => setCopiedSecret(false), 2000)
|
||||
} else {
|
||||
setCopiedCodes(true)
|
||||
setTimeout(() => setCopiedCodes(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2 } from 'lucide-react'
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity } from 'lucide-react'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Textarea } from "./ui/textarea"
|
||||
@@ -295,10 +295,10 @@ export function VirtualMachines() {
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR<VMData[]>("/api/vms", fetcher, {
|
||||
refreshInterval: 23000,
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: 2500,
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
dedupingInterval: 10000,
|
||||
dedupingInterval: 1000,
|
||||
errorRetryCount: 2,
|
||||
})
|
||||
|
||||
@@ -335,6 +335,25 @@ export function VirtualMachines() {
|
||||
const [backupNotification, setBackupNotification] = useState<string>("auto")
|
||||
const [backupNotes, setBackupNotes] = useState<string>("{{guestname}}")
|
||||
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
|
||||
|
||||
// Tab state for modal
|
||||
const [activeModalTab, setActiveModalTab] = useState<"status" | "backups">("status")
|
||||
|
||||
// Detect standalone mode (webapp vs browser)
|
||||
const [isStandalone, setIsStandalone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkStandalone = () => {
|
||||
const standalone = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
setIsStandalone(standalone)
|
||||
}
|
||||
checkStandalone()
|
||||
|
||||
const mediaQuery = window.matchMedia('(display-mode: standalone)')
|
||||
mediaQuery.addEventListener('change', checkStandalone)
|
||||
return () => mediaQuery.removeEventListener('change', checkStandalone)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLXCIPs = async () => {
|
||||
@@ -404,6 +423,16 @@ export function VirtualMachines() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Keep the open modal's VM in sync with the /api/vms poll so CPU/RAM/I-O values
|
||||
// don't stay frozen at click-time. Single data source (/cluster/resources) shared
|
||||
// with the list — no source mismatch, no flicker.
|
||||
useEffect(() => {
|
||||
if (!selectedVM || !vmData) return
|
||||
const updated = vmData.find((v) => v.vmid === selectedVM.vmid)
|
||||
if (!updated || updated === selectedVM) return
|
||||
setSelectedVM(updated)
|
||||
}, [vmData])
|
||||
|
||||
const handleVMClick = async (vm: VMData) => {
|
||||
setSelectedVM(vm)
|
||||
setCurrentView("main")
|
||||
@@ -602,7 +631,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const safeVMData = vmData || []
|
||||
// Ensure vmData is always an array (backend may return object on error)
|
||||
const safeVMData = Array.isArray(vmData) ? vmData : []
|
||||
|
||||
// Total allocated RAM for ALL VMs/LXCs (running + stopped)
|
||||
const totalAllocatedMemoryGB = useMemo(() => {
|
||||
@@ -1226,10 +1256,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setShowNotes(false)
|
||||
setIsEditingNotes(false)
|
||||
setEditedNotes("")
|
||||
setActiveModalTab("status")
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-4xl h-[95vh] sm:h-[90vh] flex flex-col p-0 overflow-hidden"
|
||||
className={`max-w-4xl flex flex-col p-0 overflow-hidden ${
|
||||
isStandalone
|
||||
? "h-[95vh] sm:h-[90vh]"
|
||||
: "h-[85vh] sm:h-[85vh] max-h-[calc(100dvh-env(safe-area-inset-top)-env(safe-area-inset-bottom)-40px)]"
|
||||
}`}
|
||||
key={selectedVM?.vmid || "no-vm"}
|
||||
>
|
||||
{currentView === "main" ? (
|
||||
@@ -1289,7 +1324,38 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4" style={{ maxHeight: 'calc(100vh - 280px)' }}>
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-border px-6 shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveModalTab("status")}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeModalTab === "status"
|
||||
? "border-cyan-500 text-cyan-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
Status
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveModalTab("backups")}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeModalTab === "backups"
|
||||
? "border-amber-500 text-amber-500"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
Backups
|
||||
{vmBackups.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs h-5 ml-1">{vmBackups.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
{/* Status Tab */}
|
||||
{activeModalTab === "status" && (
|
||||
<div className="space-y-4">
|
||||
{selectedVM && (
|
||||
<>
|
||||
@@ -1302,7 +1368,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* CPU Usage */}
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2">CPU Usage</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
<span>CPU Usage</span>
|
||||
{vmDetails?.config?.cores && (
|
||||
<span className="text-muted-foreground/60">({vmDetails.config.cores} cores)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-base font-semibold mb-2 ${getUsageColor(selectedVM.cpu * 100)}`}>
|
||||
{(selectedVM.cpu * 100).toFixed(1)}%
|
||||
</div>
|
||||
@@ -1314,7 +1386,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{/* Memory */}
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2">Memory</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<MemoryStick className="h-3.5 w-3.5" />
|
||||
<span>Memory</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-base font-semibold mb-2 ${getUsageColor((selectedVM.mem / selectedVM.maxmem) * 100)}`}
|
||||
>
|
||||
@@ -1329,7 +1404,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{/* Disk */}
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2">Disk</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
<span>Disk</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-base font-semibold mb-2 ${getUsageColor((selectedVM.disk / selectedVM.maxdisk) * 100)}`}
|
||||
>
|
||||
@@ -1344,7 +1422,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{/* Disk I/O */}
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2">Disk I/O</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
<span>Disk I/O</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-green-500 flex items-center gap-1">
|
||||
<span>↓</span>
|
||||
@@ -1359,7 +1440,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{/* Network I/O */}
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2">Network I/O</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
<span>Network I/O</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-green-500 flex items-center gap-1">
|
||||
<span>↓</span>
|
||||
@@ -1380,78 +1464,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Backups Section */}
|
||||
<Card className="border border-border bg-card/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-amber-500/10">
|
||||
<Archive className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Backups</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-amber-600/20 border border-amber-600/50 text-amber-400 hover:bg-amber-600/30 gap-1"
|
||||
onClick={openBackupModal}
|
||||
disabled={creatingBackup}
|
||||
>
|
||||
{creatingBackup ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
<span>Create Backup</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border/50 mb-4" />
|
||||
|
||||
{/* Backup List */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-muted-foreground">Available backups</span>
|
||||
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
|
||||
</div>
|
||||
|
||||
{loadingBackups ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm">Loading backups...</span>
|
||||
</div>
|
||||
) : vmBackups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground">
|
||||
<Archive className="h-8 w-8 mb-2 opacity-30" />
|
||||
<span className="text-sm">No backups found</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-[216px] overflow-y-auto">
|
||||
{vmBackups.map((backup, index) => (
|
||||
<div
|
||||
key={`backup-${backup.volid}-${index}`}
|
||||
className="flex items-center justify-between p-2.5 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-foreground">{backup.date}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
|
||||
>
|
||||
{backup.storage}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs font-mono ml-2 flex-shrink-0">
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{detailsLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Loading configuration...</div>
|
||||
) : vmDetails?.config ? (
|
||||
@@ -1508,19 +1520,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="grid grid-cols-3 lg:grid-cols-4 gap-3 lg:gap-4">
|
||||
{vmDetails.config.cores && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">CPU Cores</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
<span>CPU Cores</span>
|
||||
</div>
|
||||
<div className="font-semibold text-blue-500">{vmDetails.config.cores}</div>
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.memory && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Memory</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<MemoryStick className="h-3.5 w-3.5" />
|
||||
<span>Memory</span>
|
||||
</div>
|
||||
<div className="font-semibold text-blue-500">{vmDetails.config.memory} MB</div>
|
||||
</div>
|
||||
)}
|
||||
{vmDetails.config.swap && (
|
||||
{vmDetails.config.swap !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Swap</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span>Swap</span>
|
||||
</div>
|
||||
<div className="font-semibold text-foreground">{vmDetails.config.swap} MB</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1529,7 +1550,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{/* IP Addresses with proper keys */}
|
||||
{selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && (
|
||||
<div className="mt-4 lg:mt-6 pt-4 lg:pt-6 border-t border-border">
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<Network className="h-4 w-4" />
|
||||
IP Addresses
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -1632,7 +1654,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="mt-6 pt-6 border-t border-border space-y-6">
|
||||
{selectedVM?.type === "lxc" && vmDetails?.hardware_info && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<Container className="h-4 w-4" />
|
||||
Container Configuration
|
||||
</h4>
|
||||
<div className="space-y-4">
|
||||
@@ -1640,7 +1663,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{vmDetails.hardware_info.privileged !== null &&
|
||||
vmDetails.hardware_info.privileged !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2">Privilege Level</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
<span>Privilege Level</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
@@ -1658,7 +1684,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{vmDetails.hardware_info.gpu_passthrough &&
|
||||
vmDetails.hardware_info.gpu_passthrough.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2">GPU Passthrough</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
<span>GPU Passthrough</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{vmDetails.hardware_info.gpu_passthrough.map((gpu, index) => (
|
||||
<Badge
|
||||
@@ -1681,7 +1710,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{vmDetails.hardware_info.devices &&
|
||||
vmDetails.hardware_info.devices.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-2">Hardware Devices</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-2">
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
<span>Hardware Devices</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{vmDetails.hardware_info.devices.map((device, index) => (
|
||||
<Badge
|
||||
@@ -1701,7 +1733,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{/* Hardware Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Hardware
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -1802,7 +1835,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{/* Storage Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Storage
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
@@ -1867,7 +1901,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
|
||||
{/* Network Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<Network className="h-4 w-4" />
|
||||
Network
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
@@ -1916,7 +1951,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{/* PCI Devices with proper keys */}
|
||||
{Object.keys(vmDetails.config).some((key) => key.match(/^hostpci\d+$/)) && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<Cpu className="h-4 w-4" />
|
||||
PCI Passthrough
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
@@ -1939,7 +1975,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{/* USB Devices with proper keys */}
|
||||
{Object.keys(vmDetails.config).some((key) => key.match(/^usb\d+$/)) && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<Server className="h-4 w-4" />
|
||||
USB Devices
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
@@ -1962,7 +1999,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{/* Serial Ports with proper keys */}
|
||||
{Object.keys(vmDetails.config).some((key) => key.match(/^serial\d+$/)) && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
|
||||
<Terminal className="h-4 w-4" />
|
||||
Serial Ports
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
@@ -1990,9 +2028,87 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backups Tab */}
|
||||
{activeModalTab === "backups" && (
|
||||
<div className="space-y-4">
|
||||
<Card className="border border-border bg-card/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-md bg-amber-500/10">
|
||||
<Archive className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">Backups</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs bg-amber-600/20 border border-amber-600/50 text-amber-400 hover:bg-amber-600/30 gap-1"
|
||||
onClick={openBackupModal}
|
||||
disabled={creatingBackup}
|
||||
>
|
||||
{creatingBackup ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
<span>Create Backup</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border/50 mb-4" />
|
||||
|
||||
{/* Backup List */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-muted-foreground">Available backups</span>
|
||||
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
|
||||
</div>
|
||||
|
||||
{loadingBackups ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm">Loading backups...</span>
|
||||
</div>
|
||||
) : vmBackups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Archive className="h-12 w-12 mb-3 opacity-30" />
|
||||
<span className="text-sm">No backups found</span>
|
||||
<span className="text-xs mt-1">Create your first backup using the button above</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{vmBackups.map((backup, index) => (
|
||||
<div
|
||||
key={`backup-${backup.volid}-${index}`}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-foreground">{backup.date}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
|
||||
>
|
||||
{backup.storage}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs font-mono ml-2 flex-shrink-0">
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border bg-background px-6 py-4 mt-auto">
|
||||
<div className="border-t border-border bg-background px-6 py-4 mt-auto shrink-0">
|
||||
{/* Terminal button for LXC containers - only when running */}
|
||||
{selectedVM?.type === "lxc" && selectedVM?.status === "running" && (
|
||||
<div className="mb-3">
|
||||
|
||||
@@ -17,10 +17,13 @@
|
||||
|
||||
"gemini": {
|
||||
"models": [
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-flash-lite-latest"
|
||||
"gemini-2.5-pro"
|
||||
],
|
||||
"recommended": "gemini-2.5-flash-lite"
|
||||
"recommended": "gemini-2.5-flash",
|
||||
"_note": "gemini-2.5-flash-lite is cheaper but may struggle with complex prompts. Use with simple/custom prompts.",
|
||||
"_deprecated": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-flash", "gemini-1.0-pro", "gemini-pro"]
|
||||
},
|
||||
|
||||
"openai": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.0.2-beta",
|
||||
"version": "1.2.0",
|
||||
"description": "Proxmox System Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AI Context Enrichment Module
|
||||
|
||||
Enriches notification context with additional information to help AI provide
|
||||
more accurate and helpful responses:
|
||||
|
||||
1. Event frequency - how often this error has occurred
|
||||
2. System uptime - helps distinguish startup issues from runtime failures
|
||||
3. SMART disk data - for disk-related errors
|
||||
4. Known error matching - from proxmox_known_errors database
|
||||
|
||||
Author: MacRimi
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
# Import known errors database
|
||||
try:
|
||||
from proxmox_known_errors import get_error_context, find_matching_error
|
||||
except ImportError:
|
||||
def get_error_context(*args, **kwargs):
|
||||
return None
|
||||
def find_matching_error(*args, **kwargs):
|
||||
return None
|
||||
|
||||
DB_PATH = Path('/usr/local/share/proxmenux/health_monitor.db')
|
||||
|
||||
|
||||
def get_system_uptime() -> str:
|
||||
"""Get system uptime in human-readable format.
|
||||
|
||||
Returns:
|
||||
String like "2 minutes (recently booted)" or "89 days, 4 hours (stable system)"
|
||||
"""
|
||||
try:
|
||||
with open('/proc/uptime', 'r') as f:
|
||||
uptime_seconds = float(f.readline().split()[0])
|
||||
|
||||
days = int(uptime_seconds // 86400)
|
||||
hours = int((uptime_seconds % 86400) // 3600)
|
||||
minutes = int((uptime_seconds % 3600) // 60)
|
||||
|
||||
# Build human-readable string
|
||||
parts = []
|
||||
if days > 0:
|
||||
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||
if hours > 0:
|
||||
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||
if not parts: # Less than an hour
|
||||
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||
|
||||
uptime_str = ", ".join(parts)
|
||||
|
||||
# Add context hint
|
||||
if uptime_seconds < 600: # Less than 10 minutes
|
||||
return f"{uptime_str} (just booted - likely startup issue)"
|
||||
elif uptime_seconds < 3600: # Less than 1 hour
|
||||
return f"{uptime_str} (recently booted)"
|
||||
elif days >= 30:
|
||||
return f"{uptime_str} (stable system)"
|
||||
else:
|
||||
return uptime_str
|
||||
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_event_frequency(error_id: str = None, error_key: str = None,
|
||||
category: str = None, hours: int = 24) -> Optional[Dict[str, Any]]:
|
||||
"""Get frequency information for an error from the database.
|
||||
|
||||
Args:
|
||||
error_id: Specific error ID to look up
|
||||
error_key: Alternative error key
|
||||
category: Error category
|
||||
hours: Time window to check (default 24h)
|
||||
|
||||
Returns:
|
||||
Dict with frequency info or None
|
||||
"""
|
||||
if not DB_PATH.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=5)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Try to find the error
|
||||
if error_id:
|
||||
cursor.execute('''
|
||||
SELECT first_seen, last_seen, occurrences, category
|
||||
FROM errors WHERE error_key = ? OR error_id = ?
|
||||
ORDER BY last_seen DESC LIMIT 1
|
||||
''', (error_id, error_id))
|
||||
elif error_key:
|
||||
cursor.execute('''
|
||||
SELECT first_seen, last_seen, occurrences, category
|
||||
FROM errors WHERE error_key = ?
|
||||
ORDER BY last_seen DESC LIMIT 1
|
||||
''', (error_key,))
|
||||
elif category:
|
||||
cursor.execute('''
|
||||
SELECT first_seen, last_seen, occurrences, category
|
||||
FROM errors WHERE category = ? AND resolved_at IS NULL
|
||||
ORDER BY last_seen DESC LIMIT 1
|
||||
''', (category,))
|
||||
else:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
first_seen, last_seen, occurrences, cat = row
|
||||
|
||||
# Calculate age
|
||||
try:
|
||||
first_dt = datetime.fromisoformat(first_seen) if first_seen else None
|
||||
last_dt = datetime.fromisoformat(last_seen) if last_seen else None
|
||||
now = datetime.now()
|
||||
|
||||
result = {
|
||||
'occurrences': occurrences or 1,
|
||||
'category': cat
|
||||
}
|
||||
|
||||
if first_dt:
|
||||
age = now - first_dt
|
||||
if age.total_seconds() < 3600:
|
||||
result['first_seen_ago'] = f"{int(age.total_seconds() / 60)} minutes ago"
|
||||
elif age.total_seconds() < 86400:
|
||||
result['first_seen_ago'] = f"{int(age.total_seconds() / 3600)} hours ago"
|
||||
else:
|
||||
result['first_seen_ago'] = f"{age.days} days ago"
|
||||
|
||||
if last_dt and first_dt and occurrences and occurrences > 1:
|
||||
# Calculate average interval
|
||||
span = (last_dt - first_dt).total_seconds()
|
||||
if span > 0 and occurrences > 1:
|
||||
avg_interval = span / (occurrences - 1)
|
||||
if avg_interval < 60:
|
||||
result['pattern'] = f"recurring every ~{int(avg_interval)} seconds"
|
||||
elif avg_interval < 3600:
|
||||
result['pattern'] = f"recurring every ~{int(avg_interval / 60)} minutes"
|
||||
else:
|
||||
result['pattern'] = f"recurring every ~{int(avg_interval / 3600)} hours"
|
||||
|
||||
return result
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return {'occurrences': occurrences or 1, 'category': cat}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[AIContext] Error getting frequency: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_smart_data(disk_device: str) -> Optional[str]:
|
||||
"""Get SMART health data for a disk.
|
||||
|
||||
Args:
|
||||
disk_device: Device path like /dev/sda or just sda
|
||||
|
||||
Returns:
|
||||
Formatted SMART summary or None
|
||||
"""
|
||||
if not disk_device:
|
||||
return None
|
||||
|
||||
# Normalize device path
|
||||
if not disk_device.startswith('/dev/'):
|
||||
disk_device = f'/dev/{disk_device}'
|
||||
|
||||
# Check device exists
|
||||
if not os.path.exists(disk_device):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get health status
|
||||
result = subprocess.run(
|
||||
['smartctl', '-H', disk_device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
|
||||
health_status = "UNKNOWN"
|
||||
if "PASSED" in result.stdout:
|
||||
health_status = "PASSED"
|
||||
elif "FAILED" in result.stdout:
|
||||
health_status = "FAILED"
|
||||
|
||||
# Get key attributes
|
||||
result = subprocess.run(
|
||||
['smartctl', '-A', disk_device],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
|
||||
attributes = {}
|
||||
critical_attrs = [
|
||||
'Reallocated_Sector_Ct', 'Current_Pending_Sector',
|
||||
'Offline_Uncorrectable', 'UDMA_CRC_Error_Count',
|
||||
'Reallocated_Event_Count', 'Reported_Uncorrect'
|
||||
]
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
for attr in critical_attrs:
|
||||
if attr in line:
|
||||
parts = line.split()
|
||||
# Typical format: ID ATTRIBUTE_NAME FLAGS VALUE WORST THRESH TYPE UPDATED RAW_VALUE
|
||||
if len(parts) >= 10:
|
||||
raw_value = parts[-1]
|
||||
attributes[attr] = raw_value
|
||||
|
||||
# Build summary
|
||||
lines = [f"SMART Health: {health_status}"]
|
||||
|
||||
# Add critical attributes if non-zero
|
||||
for attr, value in attributes.items():
|
||||
try:
|
||||
if int(value) > 0:
|
||||
lines.append(f" {attr}: {value}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return "\n".join(lines) if len(lines) > 1 or health_status == "FAILED" else f"SMART Health: {health_status}"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return None
|
||||
except FileNotFoundError:
|
||||
# smartctl not installed
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def extract_disk_device(text: str) -> Optional[str]:
|
||||
"""Extract disk device name from error text.
|
||||
|
||||
Args:
|
||||
text: Error message or log content
|
||||
|
||||
Returns:
|
||||
Device name like 'sda' or None
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
# Common patterns for disk devices in errors
|
||||
patterns = [
|
||||
r'/dev/(sd[a-z]\d*)',
|
||||
r'/dev/(nvme\d+n\d+(?:p\d+)?)',
|
||||
r'/dev/(hd[a-z]\d*)',
|
||||
r'/dev/(vd[a-z]\d*)',
|
||||
r'\b(sd[a-z])\b',
|
||||
r'disk[_\s]+(sd[a-z])',
|
||||
r'ata\d+\.\d+: (sd[a-z])',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def enrich_context_for_ai(
|
||||
title: str,
|
||||
body: str,
|
||||
event_type: str,
|
||||
data: Dict[str, Any],
|
||||
journal_context: str = '',
|
||||
detail_level: str = 'standard'
|
||||
) -> str:
|
||||
"""Build enriched context string for AI processing.
|
||||
|
||||
Combines:
|
||||
- Original journal context
|
||||
- Event frequency information
|
||||
- System uptime
|
||||
- SMART data (for disk errors)
|
||||
- Known error matching
|
||||
|
||||
Args:
|
||||
title: Notification title
|
||||
body: Notification body
|
||||
event_type: Type of event
|
||||
data: Event data dict
|
||||
journal_context: Original journal log context
|
||||
detail_level: Level of detail (minimal, standard, detailed)
|
||||
|
||||
Returns:
|
||||
Enriched context string
|
||||
"""
|
||||
context_parts = []
|
||||
combined_text = f"{title} {body} {journal_context}"
|
||||
|
||||
# 1. System uptime - ONLY for critical system-level failures
|
||||
# Uptime helps distinguish startup issues from runtime failures
|
||||
# BUT it's noise for disk errors, warnings, or routine operations
|
||||
# Only include for: system crash, kernel panic, OOM, cluster failures
|
||||
uptime_critical_types = [
|
||||
'crash', 'panic', 'oom', 'kernel',
|
||||
'split_brain', 'quorum_lost', 'node_offline', 'node_fail',
|
||||
'system_fail', 'boot_fail'
|
||||
]
|
||||
|
||||
# Check if this is a critical system-level event (not disk/service/hardware)
|
||||
event_lower = event_type.lower()
|
||||
is_critical_system_event = any(t in event_lower for t in uptime_critical_types)
|
||||
|
||||
# Only add uptime for critical system failures, nothing else
|
||||
if is_critical_system_event:
|
||||
uptime = get_system_uptime()
|
||||
if uptime and uptime != "unknown":
|
||||
context_parts.append(f"System uptime: {uptime}")
|
||||
|
||||
# 2. Event frequency
|
||||
error_key = data.get('error_key') or data.get('error_id')
|
||||
category = data.get('category')
|
||||
|
||||
freq = get_event_frequency(error_id=error_key, category=category)
|
||||
if freq:
|
||||
freq_line = f"Event frequency: {freq.get('occurrences', 1)} occurrence(s)"
|
||||
if freq.get('first_seen_ago'):
|
||||
freq_line += f", first seen {freq['first_seen_ago']}"
|
||||
if freq.get('pattern'):
|
||||
freq_line += f", {freq['pattern']}"
|
||||
context_parts.append(freq_line)
|
||||
|
||||
# 3. SMART data for disk-related events
|
||||
disk_related = any(x in event_type.lower() for x in ['disk', 'smart', 'storage', 'io_error'])
|
||||
if not disk_related:
|
||||
disk_related = any(x in combined_text.lower() for x in ['disk', 'smart', '/dev/sd', 'ata', 'i/o error'])
|
||||
|
||||
if disk_related:
|
||||
disk_device = extract_disk_device(combined_text)
|
||||
if disk_device:
|
||||
smart_data = get_smart_data(disk_device)
|
||||
if smart_data:
|
||||
context_parts.append(smart_data)
|
||||
|
||||
# 4. Known error matching
|
||||
known_error_ctx = get_error_context(combined_text, category=category, detail_level=detail_level)
|
||||
if known_error_ctx:
|
||||
context_parts.append(known_error_ctx)
|
||||
|
||||
# 5. Add original journal context
|
||||
if journal_context:
|
||||
context_parts.append(f"Journal logs:\n{journal_context}")
|
||||
|
||||
# Combine all parts
|
||||
if context_parts:
|
||||
return "\n\n".join(context_parts)
|
||||
|
||||
return journal_context or ""
|
||||
|
||||
|
||||
def get_enriched_context(
|
||||
event: 'NotificationEvent',
|
||||
detail_level: str = 'standard'
|
||||
) -> str:
|
||||
"""Convenience function to enrich context from a NotificationEvent.
|
||||
|
||||
Args:
|
||||
event: NotificationEvent object
|
||||
detail_level: Level of detail
|
||||
|
||||
Returns:
|
||||
Enriched context string
|
||||
"""
|
||||
journal_context = event.data.get('_journal_context', '')
|
||||
|
||||
return enrich_context_for_ai(
|
||||
title=event.data.get('title', ''),
|
||||
body=event.data.get('body', event.data.get('message', '')),
|
||||
event_type=event.event_type,
|
||||
data=event.data,
|
||||
journal_context=journal_context,
|
||||
detail_level=detail_level
|
||||
)
|
||||
@@ -65,7 +65,7 @@ class AIProvider(ABC):
|
||||
response = self.generate(
|
||||
system_prompt="You are a test assistant. Respond with exactly: CONNECTION_OK",
|
||||
user_message="Test connection",
|
||||
max_tokens=20
|
||||
max_tokens=50 # Some providers (Gemini) need more tokens to return any content
|
||||
)
|
||||
if response:
|
||||
# Check if response contains our expected text
|
||||
@@ -152,6 +152,10 @@ class AIProvider(ABC):
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
# Ensure User-Agent is set (Cloudflare blocks requests without it - error 1010)
|
||||
if 'User-Agent' not in headers:
|
||||
headers['User-Agent'] = 'ProxMenux/1.0'
|
||||
|
||||
data = json.dumps(payload).encode('utf-8')
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@ class GeminiProvider(AIProvider):
|
||||
'learnlm', 'imagen', 'veo'
|
||||
]
|
||||
|
||||
# Deprecated models that may still appear in API but return 404
|
||||
DEPRECATED_MODELS = [
|
||||
'gemini-2.0-flash',
|
||||
'gemini-1.0-pro',
|
||||
'gemini-pro',
|
||||
]
|
||||
|
||||
def list_models(self) -> List[str]:
|
||||
"""List available Gemini models that support generateContent.
|
||||
|
||||
@@ -41,7 +48,7 @@ class GeminiProvider(AIProvider):
|
||||
|
||||
try:
|
||||
url = f"{self.API_BASE}?key={self.api_key}"
|
||||
req = urllib.request.Request(url, method='GET')
|
||||
req = urllib.request.Request(url, method='GET', headers={'User-Agent': 'ProxMenux/1.0'})
|
||||
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
@@ -65,6 +72,10 @@ class GeminiProvider(AIProvider):
|
||||
if any(pattern in model_lower for pattern in self.EXCLUDED_PATTERNS):
|
||||
continue
|
||||
|
||||
# Exclude deprecated models that return 404
|
||||
if model_id in self.DEPRECATED_MODELS:
|
||||
continue
|
||||
|
||||
models.append(model_id)
|
||||
|
||||
# Sort with recommended models first (flash-lite, flash, pro)
|
||||
@@ -132,11 +143,39 @@ class GeminiProvider(AIProvider):
|
||||
try:
|
||||
# Gemini returns candidates array with content parts
|
||||
candidates = result.get('candidates', [])
|
||||
if candidates:
|
||||
content = candidates[0].get('content', {})
|
||||
parts = content.get('parts', [])
|
||||
if parts:
|
||||
return parts[0].get('text', '').strip()
|
||||
raise AIProviderError("No content in response")
|
||||
if not candidates:
|
||||
# Check for blocked content or other issues
|
||||
prompt_feedback = result.get('promptFeedback', {})
|
||||
block_reason = prompt_feedback.get('blockReason', '')
|
||||
if block_reason:
|
||||
raise AIProviderError(f"Content blocked by Gemini: {block_reason}")
|
||||
raise AIProviderError("No candidates in response - model may be overloaded")
|
||||
|
||||
# Check if response was blocked
|
||||
finish_reason = candidates[0].get('finishReason', '')
|
||||
if finish_reason == 'SAFETY':
|
||||
safety_ratings = candidates[0].get('safetyRatings', [])
|
||||
blocked_categories = [r.get('category', 'UNKNOWN') for r in safety_ratings
|
||||
if r.get('blocked', False)]
|
||||
raise AIProviderError(f"Response blocked by safety filter: {blocked_categories}")
|
||||
|
||||
content = candidates[0].get('content', {})
|
||||
parts = content.get('parts', [])
|
||||
if parts:
|
||||
text = parts[0].get('text', '').strip()
|
||||
if text:
|
||||
return text
|
||||
|
||||
# No text content - check if it's a known issue
|
||||
if finish_reason == 'MAX_TOKENS':
|
||||
# MAX_TOKENS with no content could mean prompt too long OR model overload
|
||||
raise AIProviderError("No response generated (MAX_TOKENS). Model may be overloaded - try again.")
|
||||
elif finish_reason == 'STOP':
|
||||
# Normal stop but no content - unusual
|
||||
raise AIProviderError("Model returned empty response")
|
||||
else:
|
||||
raise AIProviderError(f"No response from model (reason: {finish_reason}). Try again later.")
|
||||
except AIProviderError:
|
||||
raise
|
||||
except (KeyError, IndexError) as e:
|
||||
raise AIProviderError(f"Unexpected response format: {e}")
|
||||
|
||||
@@ -38,7 +38,10 @@ class GroqProvider(AIProvider):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
self.MODELS_URL,
|
||||
headers={'Authorization': f'Bearer {self.api_key}'},
|
||||
headers={
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'User-Agent': 'ProxMenux/1.0' # Cloudflare blocks requests without User-Agent
|
||||
},
|
||||
method='GET'
|
||||
)
|
||||
|
||||
|
||||
@@ -63,8 +63,10 @@ class OllamaProvider(AIProvider):
|
||||
|
||||
# Cloud models (e.g., kimi-k2.5:cloud, minimax-m2.7:cloud) need longer timeout
|
||||
# because requests go through: ProxMenux -> Ollama -> Cloud Provider -> back
|
||||
# Local models also need generous timeout for slower hardware (e.g., low-end CPUs,
|
||||
# no GPU acceleration, larger models like 8B parameters)
|
||||
is_cloud_model = ':cloud' in self.model.lower()
|
||||
timeout = 120 if is_cloud_model else 30 # 2 minutes for cloud, 30s for local
|
||||
timeout = 120 if is_cloud_model else 90 # 2 minutes for cloud, 90s for local
|
||||
|
||||
try:
|
||||
result = self._make_request(url, payload, headers, timeout=timeout)
|
||||
@@ -94,7 +96,7 @@ class OllamaProvider(AIProvider):
|
||||
# First check if server is running
|
||||
try:
|
||||
url = f"{self.base_url.rstrip('/')}/api/tags"
|
||||
req = urllib.request.Request(url, method='GET')
|
||||
req = urllib.request.Request(url, method='GET', headers={'User-Agent': 'ProxMenux/1.0'})
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
data = json.loads(resp.read().decode('utf-8'))
|
||||
|
||||
|
||||
@@ -95,6 +95,9 @@ cp "$SCRIPT_DIR/notification_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo
|
||||
cp "$SCRIPT_DIR/notification_channels.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_channels.py not found"
|
||||
cp "$SCRIPT_DIR/notification_templates.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_templates.py not found"
|
||||
cp "$SCRIPT_DIR/notification_events.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ notification_events.py not found"
|
||||
cp "$SCRIPT_DIR/proxmox_known_errors.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_known_errors.py not found"
|
||||
cp "$SCRIPT_DIR/ai_context_enrichment.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ ai_context_enrichment.py not found"
|
||||
cp "$SCRIPT_DIR/startup_grace.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ startup_grace.py not found"
|
||||
cp "$SCRIPT_DIR/flask_notification_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_notification_routes.py not found"
|
||||
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
|
||||
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
|
||||
@@ -110,12 +113,13 @@ else
|
||||
echo "⚠️ ai_providers directory not found"
|
||||
fi
|
||||
|
||||
# Copy config files (verified AI models, etc.)
|
||||
# Copy config files (verified AI models, prompts, etc.)
|
||||
echo "📋 Copying config files..."
|
||||
CONFIG_DIR="$APPIMAGE_ROOT/config"
|
||||
if [ -d "$CONFIG_DIR" ]; then
|
||||
mkdir -p "$APP_DIR/usr/bin/config"
|
||||
cp "$CONFIG_DIR/"*.json "$APP_DIR/usr/bin/config/" 2>/dev/null || true
|
||||
cp "$CONFIG_DIR/"*.txt "$APP_DIR/usr/bin/config/" 2>/dev/null || true
|
||||
echo "✅ Config files copied"
|
||||
else
|
||||
echo "⚠️ config directory not found"
|
||||
|
||||
@@ -456,3 +456,145 @@ def delete_storage_exclusion(storage_name):
|
||||
return jsonify({'error': 'Storage not found in exclusions'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# NETWORK INTERFACE EXCLUSION ROUTES
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@health_bp.route('/api/health/interfaces', methods=['GET'])
|
||||
def get_network_interfaces():
|
||||
"""Get all network interfaces with their exclusion status."""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
# Get all interfaces
|
||||
net_if_stats = psutil.net_if_stats()
|
||||
net_if_addrs = psutil.net_if_addrs()
|
||||
|
||||
# Get current exclusions
|
||||
exclusions = {e['interface_name']: e for e in health_persistence.get_excluded_interfaces()}
|
||||
|
||||
result = []
|
||||
for iface, stats in net_if_stats.items():
|
||||
if iface == 'lo':
|
||||
continue
|
||||
|
||||
# Determine interface type
|
||||
if iface.startswith('vmbr'):
|
||||
iface_type = 'bridge'
|
||||
elif iface.startswith('bond'):
|
||||
iface_type = 'bond'
|
||||
elif iface.startswith(('vlan', 'veth')):
|
||||
iface_type = 'vlan'
|
||||
elif iface.startswith(('eth', 'ens', 'enp', 'eno')):
|
||||
iface_type = 'physical'
|
||||
else:
|
||||
iface_type = 'other'
|
||||
|
||||
# Get IP address if any
|
||||
ip_addr = None
|
||||
if iface in net_if_addrs:
|
||||
for addr in net_if_addrs[iface]:
|
||||
if addr.family == 2: # IPv4
|
||||
ip_addr = addr.address
|
||||
break
|
||||
|
||||
exclusion = exclusions.get(iface, {})
|
||||
result.append({
|
||||
'name': iface,
|
||||
'type': iface_type,
|
||||
'is_up': stats.isup,
|
||||
'speed': stats.speed,
|
||||
'ip_address': ip_addr,
|
||||
'exclude_health': exclusion.get('exclude_health', 0) == 1,
|
||||
'exclude_notifications': exclusion.get('exclude_notifications', 0) == 1,
|
||||
'excluded_at': exclusion.get('excluded_at'),
|
||||
'reason': exclusion.get('reason')
|
||||
})
|
||||
|
||||
# Sort: bridges first, then physical, then others
|
||||
type_order = {'bridge': 0, 'bond': 1, 'physical': 2, 'vlan': 3, 'other': 4}
|
||||
result.sort(key=lambda x: (type_order.get(x['type'], 5), x['name']))
|
||||
|
||||
return jsonify({'interfaces': result})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/interface-exclusions', methods=['GET'])
|
||||
def get_interface_exclusions():
|
||||
"""Get all interface exclusions."""
|
||||
try:
|
||||
exclusions = health_persistence.get_excluded_interfaces()
|
||||
return jsonify({'exclusions': exclusions})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/interface-exclusions', methods=['POST'])
|
||||
def save_interface_exclusion():
|
||||
"""
|
||||
Add or update an interface exclusion.
|
||||
|
||||
Request body:
|
||||
{
|
||||
"interface_name": "vmbr0",
|
||||
"interface_type": "bridge",
|
||||
"exclude_health": true,
|
||||
"exclude_notifications": true,
|
||||
"reason": "Intentionally disabled bridge"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'interface_name' not in data:
|
||||
return jsonify({'error': 'interface_name is required'}), 400
|
||||
|
||||
interface_name = data['interface_name']
|
||||
interface_type = data.get('interface_type', 'unknown')
|
||||
exclude_health = data.get('exclude_health', True)
|
||||
exclude_notifications = data.get('exclude_notifications', True)
|
||||
reason = data.get('reason')
|
||||
|
||||
# Check if already excluded
|
||||
existing = health_persistence.get_excluded_interfaces()
|
||||
exists = any(e['interface_name'] == interface_name for e in existing)
|
||||
|
||||
if exists:
|
||||
# Update existing
|
||||
success = health_persistence.update_interface_exclusion(
|
||||
interface_name, exclude_health, exclude_notifications
|
||||
)
|
||||
else:
|
||||
# Add new
|
||||
success = health_persistence.exclude_interface(
|
||||
interface_name, interface_type, exclude_health, exclude_notifications, reason
|
||||
)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Interface {interface_name} exclusion saved',
|
||||
'interface_name': interface_name
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': 'Failed to save exclusion'}), 500
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@health_bp.route('/api/health/interface-exclusions/<interface_name>', methods=['DELETE'])
|
||||
def delete_interface_exclusion(interface_name):
|
||||
"""Remove an interface from the exclusion list."""
|
||||
try:
|
||||
success = health_persistence.remove_interface_exclusion(interface_name)
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'Interface {interface_name} removed from exclusions'
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': 'Interface not found in exclusions'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -951,3 +951,46 @@ def proxmox_webhook():
|
||||
except Exception as e:
|
||||
# Still return 200 to avoid PVE flagging the webhook as broken
|
||||
return jsonify({'accepted': False, 'error': 'internal_error', 'detail': str(e)}), 200
|
||||
|
||||
|
||||
# ─── Internal Shutdown Event Endpoint ─────────────────────────────
|
||||
|
||||
@notification_bp.route('/api/internal/shutdown-event', methods=['POST'])
|
||||
def internal_shutdown_event():
|
||||
"""
|
||||
Internal endpoint called by systemd ExecStop script to emit shutdown/reboot notification.
|
||||
This allows the service to send a notification BEFORE it terminates.
|
||||
|
||||
Only accepts requests from localhost (127.0.0.1) for security.
|
||||
"""
|
||||
# Security: Only allow localhost
|
||||
remote_addr = request.remote_addr
|
||||
if remote_addr not in ('127.0.0.1', '::1', 'localhost'):
|
||||
return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
event_type = data.get('event_type', 'system_shutdown')
|
||||
hostname = data.get('hostname', 'unknown')
|
||||
reason = data.get('reason', 'System is shutting down.')
|
||||
|
||||
# Validate event type
|
||||
if event_type not in ('system_shutdown', 'system_reboot'):
|
||||
return jsonify({'error': 'invalid_event_type'}), 400
|
||||
|
||||
# Emit the notification directly through notification_manager
|
||||
notification_manager.emit_event(
|
||||
event_type=event_type,
|
||||
severity='INFO',
|
||||
data={
|
||||
'hostname': hostname,
|
||||
'reason': reason,
|
||||
},
|
||||
source='systemd',
|
||||
entity='node',
|
||||
entity_id='',
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'event_type': event_type}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
|
||||
|
||||
@@ -1,33 +1,198 @@
|
||||
from flask import Blueprint, jsonify
|
||||
from flask import Blueprint, jsonify, request
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
proxmenux_bp = Blueprint('proxmenux', __name__)
|
||||
|
||||
# Tool descriptions mapping
|
||||
TOOL_DESCRIPTIONS = {
|
||||
'lvm_repair': 'LVM PV Headers Repair',
|
||||
'repo_cleanup': 'Repository Cleanup',
|
||||
'subscription_banner': 'Subscription Banner Removal',
|
||||
'time_sync': 'Time Synchronization',
|
||||
'apt_languages': 'APT Language Skip',
|
||||
'journald': 'Journald Optimization',
|
||||
'logrotate': 'Logrotate Optimization',
|
||||
'system_limits': 'System Limits Increase',
|
||||
'entropy': 'Entropy Generation (haveged)',
|
||||
'memory_settings': 'Memory Settings Optimization',
|
||||
'kernel_panic': 'Kernel Panic Configuration',
|
||||
'apt_ipv4': 'APT IPv4 Force',
|
||||
'kexec': 'kexec for quick reboots',
|
||||
'network_optimization': 'Network Optimizations',
|
||||
'bashrc_custom': 'Bashrc Customization',
|
||||
'figurine': 'Figurine',
|
||||
'fastfetch': 'Fastfetch',
|
||||
'log2ram': 'Log2ram (SSD Protection)',
|
||||
'amd_fixes': 'AMD CPU (Ryzen/EPYC) fixes',
|
||||
'persistent_network': 'Setting persistent network interfaces'
|
||||
# Tool metadata: description, function name in bash script, and version
|
||||
# version: current version of the optimization function
|
||||
# function: the bash function name that implements this optimization
|
||||
TOOL_METADATA = {
|
||||
'subscription_banner': {'name': 'Subscription Banner Removal', 'function': 'remove_subscription_banner', 'version': '1.0'},
|
||||
'time_sync': {'name': 'Time Synchronization', 'function': 'configure_time_sync', 'version': '1.0'},
|
||||
'apt_languages': {'name': 'APT Language Skip', 'function': 'skip_apt_languages', 'version': '1.0'},
|
||||
'journald': {'name': 'Journald Optimization', 'function': 'optimize_journald', 'version': '1.1'},
|
||||
'logrotate': {'name': 'Logrotate Optimization', 'function': 'optimize_logrotate', 'version': '1.1'},
|
||||
'system_limits': {'name': 'System Limits Increase', 'function': 'increase_system_limits', 'version': '1.1'},
|
||||
# entropy removed — modern kernels 5.6+ have built-in entropy generation, haveged no longer needed
|
||||
'memory_settings': {'name': 'Memory Settings Optimization', 'function': 'optimize_memory_settings', 'version': '1.1'},
|
||||
'kernel_panic': {'name': 'Kernel Panic Configuration', 'function': 'configure_kernel_panic', 'version': '1.0'},
|
||||
'apt_ipv4': {'name': 'APT IPv4 Force', 'function': 'force_apt_ipv4', 'version': '1.0'},
|
||||
'kexec': {'name': 'kexec for quick reboots', 'function': 'enable_kexec', 'version': '1.0'},
|
||||
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
|
||||
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
|
||||
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
|
||||
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
|
||||
'amd_fixes': {'name': 'AMD CPU (Ryzen/EPYC) fixes', 'function': 'apply_amd_fixes', 'version': '1.0'},
|
||||
'persistent_network': {'name': 'Setting persistent network interfaces', 'function': 'setup_persistent_network', 'version': '1.0'},
|
||||
'vfio_iommu': {'name': 'VFIO/IOMMU Passthrough', 'function': 'enable_vfio_iommu', 'version': '1.0'},
|
||||
'lvm_repair': {'name': 'LVM PV Headers Repair', 'function': 'repair_lvm_headers', 'version': '1.0'},
|
||||
'repo_cleanup': {'name': 'Repository Cleanup', 'function': 'cleanup_repos', 'version': '1.0'},
|
||||
# ── Legacy / Deprecated entries ──
|
||||
# These optimizations were applied by previous ProxMenux versions but are
|
||||
# no longer needed or have been removed from the current scripts. We still
|
||||
# expose their source code for transparency with existing users.
|
||||
'entropy': {'name': 'Entropy Generation (haveged)', 'function': 'configure_entropy', 'version': '1.0', 'deprecated': True},
|
||||
}
|
||||
|
||||
# Backward-compatible description mapping (used by get_installed_tools)
|
||||
TOOL_DESCRIPTIONS = {k: v['name'] for k, v in TOOL_METADATA.items()}
|
||||
|
||||
# Source code preserved for deprecated/removed optimization functions.
|
||||
# When a function is removed from the active bash scripts (because it's
|
||||
# no longer needed, e.g. obsoleted by kernel improvements), keep its code
|
||||
# here so users who installed it in the past can still inspect what ran.
|
||||
DEPRECATED_SOURCES = {
|
||||
'configure_entropy': {
|
||||
'script': 'customizable_post_install.sh (legacy)',
|
||||
'source': '''# ─────────────────────────────────────────────────────────────────
|
||||
# NOTE: This optimization has been REMOVED from current ProxMenux versions.
|
||||
# Modern Linux kernels (5.6+, shipped with Proxmox VE 7.x and 8.x) include
|
||||
# built-in entropy generation via the Jitter RNG and CRNG, making haveged
|
||||
# unnecessary. The function below is preserved here for transparency so
|
||||
# users who applied it in the past can see exactly what was installed.
|
||||
# New ProxMenux installations no longer include this optimization.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
configure_entropy() {
|
||||
msg_info2 "$(translate "Configuring entropy generation to prevent slowdowns...")"
|
||||
|
||||
# Install haveged
|
||||
msg_info "$(translate "Installing haveged...")"
|
||||
/usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install haveged > /dev/null 2>&1
|
||||
msg_ok "$(translate "haveged installed successfully")"
|
||||
|
||||
# Configure haveged
|
||||
msg_info "$(translate "Configuring haveged...")"
|
||||
cat <<EOF > /etc/default/haveged
|
||||
# -w sets low entropy watermark (in bits)
|
||||
DAEMON_ARGS="-w 1024"
|
||||
EOF
|
||||
|
||||
# Reload systemd daemon
|
||||
systemctl daemon-reload > /dev/null 2>&1
|
||||
|
||||
# Enable haveged service
|
||||
systemctl enable haveged > /dev/null 2>&1
|
||||
msg_ok "$(translate "haveged service enabled successfully")"
|
||||
|
||||
register_tool "entropy" true
|
||||
msg_success "$(translate "Entropy generation configuration completed")"
|
||||
}
|
||||
''',
|
||||
},
|
||||
}
|
||||
|
||||
# Scripts to search for function source code (in order of preference)
|
||||
_SCRIPT_PATHS = [
|
||||
'/usr/local/share/proxmenux/scripts/post_install/customizable_post_install.sh',
|
||||
'/usr/local/share/proxmenux/scripts/post_install/auto_post_install.sh',
|
||||
]
|
||||
|
||||
|
||||
def _extract_bash_function(function_name: str) -> dict:
|
||||
"""Extract a bash function's source code.
|
||||
|
||||
Checks DEPRECATED_SOURCES first (for functions removed from active scripts),
|
||||
then searches the live bash scripts for `function_name() {` and captures
|
||||
everything until the matching closing `}`, respecting brace nesting.
|
||||
|
||||
Returns {'source': str, 'script': str, 'line_start': int, 'line_end': int}
|
||||
or {'source': '', 'error': '...'} on failure.
|
||||
"""
|
||||
# Check preserved deprecated source code first
|
||||
if function_name in DEPRECATED_SOURCES:
|
||||
entry = DEPRECATED_SOURCES[function_name]
|
||||
source = entry['source']
|
||||
return {
|
||||
'source': source,
|
||||
'script': entry['script'],
|
||||
'line_start': 1,
|
||||
'line_end': len(source.split('\n')),
|
||||
}
|
||||
|
||||
for script_path in _SCRIPT_PATHS:
|
||||
if not os.path.isfile(script_path):
|
||||
continue
|
||||
try:
|
||||
with open(script_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find function start: "function_name() {" or "function_name () {"
|
||||
pattern = re.compile(rf'^{re.escape(function_name)}\s*\(\)\s*\{{')
|
||||
start_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if pattern.match(line):
|
||||
start_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
continue # Try next script
|
||||
|
||||
# Capture until the closing } at indent level 0
|
||||
brace_depth = 0
|
||||
end_idx = start_idx
|
||||
for i in range(start_idx, len(lines)):
|
||||
brace_depth += lines[i].count('{') - lines[i].count('}')
|
||||
if brace_depth <= 0:
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
source = ''.join(lines[start_idx:end_idx + 1])
|
||||
script_name = os.path.basename(script_path)
|
||||
|
||||
return {
|
||||
'source': source,
|
||||
'script': script_name,
|
||||
'line_start': start_idx + 1,
|
||||
'line_end': end_idx + 1,
|
||||
}
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return {'source': '', 'error': 'Function not found in available scripts'}
|
||||
|
||||
@proxmenux_bp.route('/api/proxmenux/update-status', methods=['GET'])
|
||||
def get_update_status():
|
||||
"""Get ProxMenux update availability status from config.json"""
|
||||
config_path = '/usr/local/share/proxmenux/config.json'
|
||||
|
||||
try:
|
||||
if not os.path.exists(config_path):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'update_available': {
|
||||
'stable': False,
|
||||
'stable_version': '',
|
||||
'beta': False,
|
||||
'beta_version': ''
|
||||
}
|
||||
})
|
||||
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
update_status = config.get('update_available', {
|
||||
'stable': False,
|
||||
'stable_version': '',
|
||||
'beta': False,
|
||||
'beta_version': ''
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'update_available': update_status
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@proxmenux_bp.route('/api/proxmenux/installed-tools', methods=['GET'])
|
||||
def get_installed_tools():
|
||||
"""Get list of installed ProxMenux tools/optimizations"""
|
||||
@@ -44,14 +209,18 @@ def get_installed_tools():
|
||||
with open(installed_tools_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Convert to list format with descriptions
|
||||
# Convert to list format with descriptions and version
|
||||
tools = []
|
||||
for tool_key, enabled in data.items():
|
||||
if enabled: # Only include enabled tools
|
||||
meta = TOOL_METADATA.get(tool_key, {})
|
||||
tools.append({
|
||||
'key': tool_key,
|
||||
'name': TOOL_DESCRIPTIONS.get(tool_key, tool_key.replace('_', ' ').title()),
|
||||
'enabled': enabled
|
||||
'name': meta.get('name', tool_key.replace('_', ' ').title()),
|
||||
'enabled': enabled,
|
||||
'version': meta.get('version', '1.0'),
|
||||
'has_source': bool(meta.get('function')),
|
||||
'deprecated': bool(meta.get('deprecated', False)),
|
||||
})
|
||||
|
||||
# Sort alphabetically by name
|
||||
@@ -73,3 +242,55 @@ def get_installed_tools():
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@proxmenux_bp.route('/api/proxmenux/tool-source/<tool_key>', methods=['GET'])
|
||||
def get_tool_source(tool_key):
|
||||
"""Get the bash source code of a specific optimization function.
|
||||
|
||||
Returns the function body extracted from the post-install scripts,
|
||||
so users can see exactly what code was executed on their server.
|
||||
"""
|
||||
try:
|
||||
meta = TOOL_METADATA.get(tool_key)
|
||||
if not meta:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Unknown tool: {tool_key}'
|
||||
}), 404
|
||||
|
||||
func_name = meta.get('function')
|
||||
if not func_name:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'No function mapping for {tool_key}'
|
||||
}), 404
|
||||
|
||||
result = _extract_bash_function(func_name)
|
||||
|
||||
if not result.get('source'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': result.get('error', 'Source code not available'),
|
||||
'tool': tool_key,
|
||||
'function': func_name,
|
||||
}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'tool': tool_key,
|
||||
'name': meta['name'],
|
||||
'version': meta.get('version', '1.0'),
|
||||
'deprecated': bool(meta.get('deprecated', False)),
|
||||
'function': func_name,
|
||||
'source': result['source'],
|
||||
'script': result['script'],
|
||||
'line_start': result['line_start'],
|
||||
'line_end': result['line_end'],
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@@ -308,6 +308,34 @@ def lynis_report_delete():
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Security Tools Uninstall
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@security_bp.route('/api/security/fail2ban/uninstall', methods=['POST'])
|
||||
def fail2ban_uninstall():
|
||||
"""Uninstall Fail2Ban and clean up configuration"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
success, message = security_manager.uninstall_fail2ban()
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@security_bp.route('/api/security/lynis/uninstall', methods=['POST'])
|
||||
def lynis_uninstall():
|
||||
"""Uninstall Lynis and clean up files"""
|
||||
if not security_manager:
|
||||
return jsonify({"success": False, "message": "Security manager not available"}), 500
|
||||
try:
|
||||
success, message = security_manager.uninstall_lynis()
|
||||
return jsonify({"success": success, "message": message})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Security Tools Detection
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
+3062
-311
File diff suppressed because it is too large
Load Diff
+714
-318
File diff suppressed because it is too large
Load Diff
+1409
-600
File diff suppressed because it is too large
Load Diff
@@ -135,7 +135,7 @@ class TelegramChannel(NotificationChannel):
|
||||
'UNKNOWN': '\u26AA', # white circle
|
||||
}
|
||||
|
||||
def __init__(self, bot_token: str, chat_id: str):
|
||||
def __init__(self, bot_token: str, chat_id: str, topic_id: str = ''):
|
||||
super().__init__()
|
||||
token = bot_token.strip()
|
||||
# Strip 'bot' prefix if user included it (API_BASE already adds it)
|
||||
@@ -143,6 +143,8 @@ class TelegramChannel(NotificationChannel):
|
||||
token = token[3:]
|
||||
self.bot_token = token
|
||||
self.chat_id = chat_id.strip()
|
||||
# Topic ID for supergroups with topics enabled (message_thread_id)
|
||||
self.topic_id = topic_id.strip() if topic_id else ''
|
||||
|
||||
def validate_config(self) -> Tuple[bool, str]:
|
||||
if not self.bot_token:
|
||||
@@ -177,6 +179,12 @@ class TelegramChannel(NotificationChannel):
|
||||
'chat_id': self.chat_id,
|
||||
'photo': photo_url,
|
||||
}
|
||||
# Add topic ID for supergroups with topics enabled
|
||||
if self.topic_id:
|
||||
try:
|
||||
payload['message_thread_id'] = int(self.topic_id)
|
||||
except ValueError:
|
||||
pass
|
||||
if caption:
|
||||
payload['caption'] = caption[:1024] # Telegram caption limit
|
||||
payload['parse_mode'] = 'HTML'
|
||||
@@ -204,13 +212,20 @@ class TelegramChannel(NotificationChannel):
|
||||
|
||||
def _post_message(self, text: str) -> Tuple[int, str]:
|
||||
url = self.API_BASE.format(token=self.bot_token)
|
||||
payload = json.dumps({
|
||||
payload_dict = {
|
||||
'chat_id': self.chat_id,
|
||||
'text': text,
|
||||
'parse_mode': 'HTML',
|
||||
'disable_web_page_preview': True,
|
||||
}).encode('utf-8')
|
||||
}
|
||||
# Add topic ID for supergroups with topics enabled
|
||||
if self.topic_id:
|
||||
try:
|
||||
payload_dict['message_thread_id'] = int(self.topic_id)
|
||||
except ValueError:
|
||||
pass # Invalid topic_id, skip
|
||||
|
||||
payload = json.dumps(payload_dict).encode('utf-8')
|
||||
return self._http_request(url, payload, {'Content-Type': 'application/json'})
|
||||
|
||||
def _split_message(self, text: str) -> list:
|
||||
@@ -859,7 +874,7 @@ class EmailChannel(NotificationChannel):
|
||||
CHANNEL_TYPES = {
|
||||
'telegram': {
|
||||
'name': 'Telegram',
|
||||
'config_keys': ['bot_token', 'chat_id'],
|
||||
'config_keys': ['bot_token', 'chat_id', 'topic_id'],
|
||||
'class': TelegramChannel,
|
||||
},
|
||||
'gotify': {
|
||||
@@ -895,7 +910,8 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
|
||||
if channel_type == 'telegram':
|
||||
return TelegramChannel(
|
||||
bot_token=config.get('bot_token', ''),
|
||||
chat_id=config.get('chat_id', '')
|
||||
chat_id=config.get('chat_id', ''),
|
||||
topic_id=config.get('topic_id', '')
|
||||
)
|
||||
elif channel_type == 'gotify':
|
||||
return GotifyChannel(
|
||||
|
||||
@@ -26,6 +26,59 @@ from typing import Optional, Dict, Any, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ─── Shared State for Cross-Watcher Coordination ──────────────────
|
||||
|
||||
# ─── Startup Grace Period ────────────────────────────────────────────────────
|
||||
# Import centralized startup grace management
|
||||
# This provides a single source of truth for all grace period logic
|
||||
import startup_grace
|
||||
|
||||
class _SharedState:
|
||||
"""Wrapper around centralized startup_grace module for backwards compatibility.
|
||||
|
||||
All grace period logic is now in startup_grace.py for consistency across:
|
||||
- notification_events.py (this file)
|
||||
- health_monitor.py
|
||||
- flask_server.py
|
||||
"""
|
||||
|
||||
def mark_shutdown(self):
|
||||
"""Called when system_shutdown or system_reboot is detected."""
|
||||
startup_grace.mark_shutdown()
|
||||
|
||||
def is_host_shutting_down(self) -> bool:
|
||||
"""Check if we're within the shutdown grace period."""
|
||||
return startup_grace.is_host_shutting_down()
|
||||
|
||||
def is_startup_period(self) -> bool:
|
||||
"""Check if we're within the startup VM aggregation period (3 min)."""
|
||||
return startup_grace.is_startup_vm_period()
|
||||
|
||||
def is_startup_health_grace(self) -> bool:
|
||||
"""Check if we're within the startup health grace period (5 min)."""
|
||||
return startup_grace.is_startup_health_grace()
|
||||
|
||||
def add_startup_vm(self, vmid: str, vmname: str, vm_type: str):
|
||||
"""Record a VM/CT start during startup period for later aggregation."""
|
||||
startup_grace.add_startup_vm(vmid, vmname, vm_type)
|
||||
|
||||
def get_and_clear_startup_vms(self) -> list:
|
||||
"""Get all recorded startup VMs and clear the list."""
|
||||
return startup_grace.get_and_clear_startup_vms()
|
||||
|
||||
def has_startup_vms(self) -> bool:
|
||||
"""Check if there are any startup VMs recorded."""
|
||||
return startup_grace.has_startup_vms()
|
||||
|
||||
def was_startup_aggregated(self) -> bool:
|
||||
"""Check if startup aggregation already happened."""
|
||||
return startup_grace.was_startup_aggregated()
|
||||
|
||||
|
||||
# Global shared state instance
|
||||
_shared_state = _SharedState()
|
||||
|
||||
|
||||
# ─── Event Object ─────────────────────────────────────────────────
|
||||
|
||||
class NotificationEvent:
|
||||
@@ -84,6 +137,30 @@ class NotificationEvent:
|
||||
|
||||
|
||||
def _hostname() -> str:
|
||||
"""Get display hostname for notifications.
|
||||
|
||||
Returns the custom display name from notification settings if configured,
|
||||
otherwise falls back to the system hostname.
|
||||
"""
|
||||
# Try to read custom display name from notification settings
|
||||
try:
|
||||
db_path = Path('/usr/local/share/proxmenux/health_monitor.db')
|
||||
if db_path.exists():
|
||||
conn = sqlite3.connect(str(db_path), timeout=5)
|
||||
conn.execute('PRAGMA busy_timeout=3000')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT setting_value FROM user_settings WHERE setting_key = ?",
|
||||
('notification.hostname',)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if row and row[0] and row[0].strip():
|
||||
return row[0].strip()
|
||||
except Exception:
|
||||
pass # Fall back to system hostname
|
||||
|
||||
# Fall back to system hostname
|
||||
try:
|
||||
return socket.gethostname().split('.')[0]
|
||||
except Exception:
|
||||
@@ -121,8 +198,9 @@ def capture_journal_context(keywords: list, lines: int = 30,
|
||||
return ""
|
||||
|
||||
# Use journalctl with grep to filter relevant lines
|
||||
# Use -b 0 to only include logs from the current boot (not previous boots)
|
||||
cmd = (
|
||||
f"journalctl --since='{since}' --no-pager -n 500 2>/dev/null | "
|
||||
f"journalctl -b 0 --since='{since}' --no-pager -n 500 2>/dev/null | "
|
||||
f"grep -iE '{pattern}' | tail -n {lines}"
|
||||
)
|
||||
|
||||
@@ -241,6 +319,41 @@ class JournalWatcher:
|
||||
except Exception as e:
|
||||
print(f"[JournalWatcher] Failed to save disk_io_notified: {e}")
|
||||
|
||||
def _get_disk_io_cooldown_from_db(self, device: str) -> Optional[float]:
|
||||
"""
|
||||
Get disk I/O cooldown timestamp from DB for a device.
|
||||
|
||||
Used to re-check DB when user might have dismissed the error,
|
||||
which clears the DB entry via health_persistence._clear_disk_io_cooldown().
|
||||
|
||||
Returns the timestamp if found and within 24h window, None otherwise.
|
||||
"""
|
||||
try:
|
||||
db_path = Path('/usr/local/share/proxmenux/health_monitor.db')
|
||||
if not db_path.exists():
|
||||
return None
|
||||
conn = sqlite3.connect(str(db_path), timeout=5)
|
||||
conn.execute('PRAGMA busy_timeout=3000')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check for the device with various prefixes
|
||||
# JournalWatcher uses direct device names as keys
|
||||
cursor.execute(
|
||||
"SELECT last_sent_ts FROM notification_last_sent WHERE fingerprint = ?",
|
||||
(device,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
ts = float(row[0])
|
||||
# Only return if within 24h window
|
||||
if time.time() - ts < self._DISK_IO_COOLDOWN:
|
||||
return ts
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def stop(self):
|
||||
"""Stop the journal watcher."""
|
||||
self._running = False
|
||||
@@ -350,13 +463,30 @@ class JournalWatcher:
|
||||
|
||||
def _check_fail2ban(self, msg: str, syslog_id: str):
|
||||
"""Detect Fail2Ban IP bans."""
|
||||
if 'fail2ban' not in msg.lower() and syslog_id != 'fail2ban-server':
|
||||
return
|
||||
# Only process actual fail2ban action messages, not systemd service events
|
||||
if syslog_id not in ('fail2ban-server', 'fail2ban.actions', 'fail2ban'):
|
||||
if 'fail2ban' not in msg.lower():
|
||||
return
|
||||
# Skip systemd service lifecycle messages (start/stop/restart/reload)
|
||||
msg_lower = msg.lower()
|
||||
if any(x in msg_lower for x in ['service', 'started', 'stopped', 'starting',
|
||||
'stopping', 'reloading', 'reloaded', 'unit',
|
||||
'deactivated', 'activated']):
|
||||
return
|
||||
|
||||
# Ban detected
|
||||
ban_match = re.search(r'Ban\s+(\S+)', msg)
|
||||
# Ban detected - match only valid IPv4 or IPv6 addresses
|
||||
# IPv4: 192.168.1.100, IPv6: 2001:db8::1 or ::ffff:192.168.1.1
|
||||
ban_match = re.search(r'Ban\s+((?:\d{1,3}\.){3}\d{1,3}|[0-9a-fA-F:]{2,})', msg)
|
||||
if ban_match:
|
||||
ip = ban_match.group(1)
|
||||
# Validate it's a real IP address format
|
||||
# IPv4: must have 4 octets separated by dots
|
||||
# IPv6: must contain at least one colon
|
||||
is_ipv4 = re.match(r'^(\d{1,3}\.){3}\d{1,3}$', ip)
|
||||
is_ipv6 = ':' in ip and re.match(r'^[0-9a-fA-F:]+$', ip)
|
||||
if not is_ipv4 and not is_ipv6:
|
||||
return # Not a valid IP (e.g., "Service.", "Ban", etc.)
|
||||
|
||||
jail_match = re.search(r'\[(\w+)\]', msg)
|
||||
jail = jail_match.group(1) if jail_match else 'unknown'
|
||||
|
||||
@@ -494,7 +624,14 @@ class JournalWatcher:
|
||||
fs_dedup_key = f'fs_{device}'
|
||||
last_fs_notified = self._disk_io_notified.get(fs_dedup_key, 0)
|
||||
if now_fs - last_fs_notified < self._DISK_IO_COOLDOWN:
|
||||
return # Already notified for this device recently
|
||||
# In-memory says cooldown active. Re-check DB in case
|
||||
# user dismissed the error (which clears DB cooldowns).
|
||||
db_ts = self._get_disk_io_cooldown_from_db(fs_dedup_key)
|
||||
if db_ts is not None and now_fs - db_ts < self._DISK_IO_COOLDOWN:
|
||||
return # DB confirms cooldown is still active
|
||||
# DB says cooldown was cleared - proceed
|
||||
if fs_dedup_key in self._disk_io_notified:
|
||||
del self._disk_io_notified[fs_dedup_key]
|
||||
|
||||
# ── Device existence gating ──
|
||||
device_exists = base_dev and _os.path.exists(f'/dev/{base_dev}')
|
||||
@@ -539,12 +676,10 @@ class JournalWatcher:
|
||||
if inode:
|
||||
inode_hint = 'root directory' if inode == '2' else f'inode #{inode}'
|
||||
parts.append(f'Affected: {inode_hint}')
|
||||
if smart_health == 'FAILED':
|
||||
parts.append(f'Action: Disk is failing. Run "fsck /dev/{device}" (unmount first) and plan replacement')
|
||||
elif smart_health == 'PASSED':
|
||||
# Note: Specific recommendations are provided by AI when AI Suggestions is enabled
|
||||
# Only include SMART status note (not an action)
|
||||
if smart_health == 'PASSED':
|
||||
parts.append(f'Note: SMART reports disk is healthy. This may be a transient error.')
|
||||
else:
|
||||
parts.append(f'Action: Run "fsck /dev/{device}" (unmount first) and check "smartctl -a /dev/{base_dev}"')
|
||||
enriched = '\n'.join(parts)
|
||||
|
||||
else:
|
||||
@@ -749,10 +884,24 @@ class JournalWatcher:
|
||||
return
|
||||
|
||||
# ── Gate 2: 24-hour dedup per device ──
|
||||
# Check both in-memory cache AND the DB (user dismiss clears DB cooldowns).
|
||||
# If user dismissed the error, _clear_disk_io_cooldown() removed the DB
|
||||
# entry, so we should refresh from DB to get the real state.
|
||||
now = time.time()
|
||||
|
||||
# First check in-memory cache
|
||||
last_notified = self._disk_io_notified.get(resolved, 0)
|
||||
|
||||
if now - last_notified < self._DISK_IO_COOLDOWN:
|
||||
return # Already notified for this disk recently
|
||||
# In-memory says we already notified. But user might have dismissed
|
||||
# the error, which clears the DB. Re-check DB to be sure.
|
||||
db_ts = self._get_disk_io_cooldown_from_db(resolved)
|
||||
if db_ts is not None and now - db_ts < self._DISK_IO_COOLDOWN:
|
||||
return # DB confirms cooldown is still active
|
||||
# DB says cooldown was cleared (user dismissed) - proceed to notify
|
||||
# Update in-memory cache
|
||||
del self._disk_io_notified[resolved]
|
||||
|
||||
self._disk_io_notified[resolved] = now
|
||||
self._save_disk_io_notified(resolved, now)
|
||||
|
||||
@@ -896,10 +1045,7 @@ class JournalWatcher:
|
||||
raw_message=raw_msg,
|
||||
severity='warning',
|
||||
)
|
||||
|
||||
# Update worst_health for permanent tracking (record_disk_observation
|
||||
# already does this, but we ensure it here for safety)
|
||||
health_persistence.update_disk_worst_health(base_dev, serial, 'warning')
|
||||
# Observation recorded - worst_health no longer used (badge shows current SMART status)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DiskIOEventProcessor] Error recording smartd observation: {e}")
|
||||
@@ -1173,11 +1319,15 @@ class JournalWatcher:
|
||||
break
|
||||
|
||||
if is_reboot:
|
||||
# Mark shutdown state to suppress VM/CT stop events
|
||||
_shared_state.mark_shutdown()
|
||||
self._emit('system_reboot', 'INFO', {
|
||||
'reason': 'The system is rebooting.',
|
||||
'hostname': self._hostname,
|
||||
}, entity='node', entity_id='')
|
||||
elif is_shutdown:
|
||||
# Mark shutdown state to suppress VM/CT stop events
|
||||
_shared_state.mark_shutdown()
|
||||
self._emit('system_shutdown', 'INFO', {
|
||||
'reason': 'The system is shutting down.',
|
||||
'hostname': self._hostname,
|
||||
@@ -1250,6 +1400,74 @@ class TaskWatcher:
|
||||
"""
|
||||
|
||||
TASK_LOG = '/var/log/pve/tasks/index'
|
||||
TASK_DIR = '/var/log/pve/tasks'
|
||||
|
||||
def _get_task_log_reason(self, upid: str, status: str) -> str:
|
||||
"""Read the task log file to extract the actual error/warning reason.
|
||||
|
||||
Returns a human-readable reason extracted from the task log,
|
||||
or falls back to the status code if log cannot be read.
|
||||
"""
|
||||
try:
|
||||
# Parse UPID to find log file
|
||||
# UPID format: UPID:node:pid:pstart:starttime:type:id:user:
|
||||
# Example: UPID:pve:0000F234:0000B890:67890ABC:qmstart:100:root@pam:
|
||||
parts = upid.split(':')
|
||||
if len(parts) < 5:
|
||||
return status
|
||||
|
||||
# Task logs are stored in /var/log/pve/tasks/X/UPID
|
||||
# where X is the LAST character of starttime hex (uppercase)
|
||||
# Example: starttime=69CE20CF -> subdirectory is "F"
|
||||
# The starttime field (parts[4]) is a hex timestamp
|
||||
starttime_hex = parts[4]
|
||||
if starttime_hex:
|
||||
# LAST character of hex starttime determines subdirectory
|
||||
subdir = starttime_hex[-1].upper()
|
||||
# The log filename is the full UPID INCLUDING the trailing colon
|
||||
# Proxmox names the file exactly as the UPID (with colon at end)
|
||||
log_path = os.path.join(self.TASK_DIR, subdir, upid)
|
||||
|
||||
if os.path.exists(log_path):
|
||||
with open(log_path, 'r', errors='replace') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Look for error/warning messages in the log
|
||||
# Proxmox uses various patterns: "WARN:", "warning:", "error:", etc.
|
||||
error_lines = []
|
||||
for line in lines:
|
||||
line_strip = line.strip()
|
||||
line_lower = line_strip.lower()
|
||||
|
||||
# Skip empty lines and status lines at the end
|
||||
if not line_strip or line_strip.startswith('TASK '):
|
||||
continue
|
||||
|
||||
# Capture warning/error lines with various patterns
|
||||
# Proxmox uses: "WARN: ...", "warning: ...", "error: ...", "ERROR: ..."
|
||||
is_warning_error = any(kw in line_lower for kw in [
|
||||
'warn:', 'warning:', 'error:', 'failed', 'failure',
|
||||
'unable to', 'cannot', 'exception', 'critical',
|
||||
'certificate', 'expired', 'expires' # EFI cert warnings
|
||||
])
|
||||
|
||||
# Also check for lines starting with common prefixes
|
||||
starts_with_prefix = any(line_strip.upper().startswith(p) for p in [
|
||||
'WARN:', 'WARNING:', 'ERROR:', 'CRITICAL:', 'FATAL:'
|
||||
])
|
||||
|
||||
if is_warning_error or starts_with_prefix:
|
||||
if len(line_strip) < 300: # Reasonable length
|
||||
error_lines.append(line_strip)
|
||||
|
||||
if error_lines:
|
||||
# Return the most relevant lines (up to 5 for better context)
|
||||
return '; '.join(error_lines[:5])
|
||||
|
||||
return status
|
||||
except Exception as e:
|
||||
# Log error for debugging but return status as fallback
|
||||
return status
|
||||
|
||||
# Map PVE task types to our event types
|
||||
TASK_MAP = {
|
||||
@@ -1399,7 +1617,7 @@ class TaskWatcher:
|
||||
except Exception as e:
|
||||
print(f"[TaskWatcher] Error reading task log: {e}")
|
||||
|
||||
time.sleep(2) # Check every 2 seconds
|
||||
time.sleep(5) # Check every 5 seconds (reduced from 2s for efficiency)
|
||||
|
||||
def _check_active_tasks(self):
|
||||
"""Scan /var/log/pve/tasks/active to track vzdump for VM suppression.
|
||||
@@ -1457,7 +1675,10 @@ class TaskWatcher:
|
||||
return
|
||||
|
||||
upid = parts[0]
|
||||
status = parts[2] if len(parts) >= 3 else ''
|
||||
# Status can be multi-word like "WARNINGS: 1" or "OK"
|
||||
# Format: UPID TIMESTAMP STATUS [...]
|
||||
# Join everything after timestamp as status
|
||||
status = ' '.join(parts[2:]) if len(parts) >= 3 else ''
|
||||
|
||||
# Parse UPID
|
||||
upid_parts = upid.split(':')
|
||||
@@ -1490,16 +1711,32 @@ class TaskWatcher:
|
||||
# Backup just finished -- start grace period for VM restarts
|
||||
self._vzdump_running_since = time.time() # will expire via grace_period
|
||||
|
||||
# Check if task failed
|
||||
is_error = status and status != 'OK' and status != ''
|
||||
# Check if task failed or completed with warnings
|
||||
# WARNINGS means the task completed but with non-fatal issues (e.g., EFI cert warnings)
|
||||
# The VM/CT DID start successfully, just with caveats
|
||||
# Status format can be "WARNINGS: N" where N is the count, so use startswith
|
||||
is_warning = status and status.upper().startswith('WARNINGS')
|
||||
is_error = status and status != 'OK' and not is_warning and status != ''
|
||||
|
||||
if is_error:
|
||||
# Override to failure event
|
||||
# Override to failure event - task actually failed
|
||||
if 'start' in event_type:
|
||||
event_type = event_type.replace('_start', '_fail')
|
||||
elif 'complete' in event_type:
|
||||
event_type = event_type.replace('_complete', '_fail')
|
||||
severity = 'CRITICAL'
|
||||
elif is_warning:
|
||||
# Task completed with warnings - VM/CT started but has issues
|
||||
# Use specific warning event types for better messaging
|
||||
if event_type == 'vm_start':
|
||||
event_type = 'vm_start_warning'
|
||||
elif event_type == 'ct_start':
|
||||
event_type = 'ct_start_warning'
|
||||
elif event_type == 'backup_start':
|
||||
event_type = 'backup_warning' # Backup finished with warnings
|
||||
elif event_type == 'migration_start':
|
||||
event_type = 'migration_warning' # Migration finished with warnings
|
||||
severity = 'WARNING'
|
||||
elif status == 'OK':
|
||||
# Task completed successfully
|
||||
if event_type == 'backup_start':
|
||||
@@ -1511,12 +1748,18 @@ class TaskWatcher:
|
||||
# Task just started (no status yet)
|
||||
severity = default_severity
|
||||
|
||||
# Get the actual reason from task log if error or warning
|
||||
if is_error or is_warning:
|
||||
reason = self._get_task_log_reason(upid, status)
|
||||
else:
|
||||
reason = ''
|
||||
|
||||
data = {
|
||||
'vmid': vmid,
|
||||
'vmname': vmname or f'ID {vmid}',
|
||||
'hostname': self._hostname,
|
||||
'user': user,
|
||||
'reason': status if is_error else '',
|
||||
'reason': reason,
|
||||
'target_node': '',
|
||||
'size': '',
|
||||
'snapshot_name': '',
|
||||
@@ -1529,9 +1772,9 @@ class TaskWatcher:
|
||||
# EXCLUSIVELY by the PVE webhook, which delivers richer data (full
|
||||
# logs, sizes, durations, filenames). TaskWatcher skips these to
|
||||
# avoid duplicates.
|
||||
# NOTE: backup_start is NOT in this set -- PVE's webhook only fires
|
||||
# when a backup FINISHES, so TaskWatcher is the only source for
|
||||
# the "backup started" notification.
|
||||
# NOTE: backup_start and backup_warning are NOT in this set --
|
||||
# PVE's webhook only fires when backup FINISHES with OK or ERROR,
|
||||
# but WARNINGS come through TaskWatcher with richer context.
|
||||
_WEBHOOK_EXCLUSIVE = {'backup_complete', 'backup_fail',
|
||||
'replication_complete', 'replication_fail'}
|
||||
if event_type in _WEBHOOK_EXCLUSIVE:
|
||||
@@ -1539,13 +1782,33 @@ class TaskWatcher:
|
||||
|
||||
# Suppress VM/CT start/stop/shutdown while a vzdump is active.
|
||||
# These are backup-induced operations (mode=stop), not user actions.
|
||||
# Exception: if a VM/CT FAILS to start after backup, that IS important.
|
||||
# Exception: if a VM/CT FAILS or has WARNINGS, that IS important.
|
||||
_BACKUP_NOISE = {'vm_start', 'vm_stop', 'vm_shutdown', 'vm_restart',
|
||||
'ct_start', 'ct_stop', 'ct_shutdown', 'ct_restart'}
|
||||
if event_type in _BACKUP_NOISE and not is_error:
|
||||
if event_type in _BACKUP_NOISE and not is_error and not is_warning:
|
||||
if self._is_vzdump_active():
|
||||
return
|
||||
|
||||
# Suppress VM/CT stop/shutdown during host shutdown/reboot.
|
||||
# When the host shuts down, all VMs/CTs stop - that's expected behavior,
|
||||
# not something that needs individual notifications.
|
||||
# Exception: errors and warnings should still be notified.
|
||||
_SHUTDOWN_NOISE = {'vm_stop', 'vm_shutdown', 'ct_stop', 'ct_shutdown'}
|
||||
if event_type in _SHUTDOWN_NOISE and not is_error and not is_warning:
|
||||
if _shared_state.is_host_shutting_down():
|
||||
return
|
||||
|
||||
# During startup period, aggregate VM/CT starts into a single message.
|
||||
# Instead of N individual "VM X started" messages, collect them and
|
||||
# let PollingCollector emit one "System startup: X VMs, Y CTs started".
|
||||
# Exception: errors and warnings should NOT be aggregated - notify immediately.
|
||||
_STARTUP_EVENTS = {'vm_start', 'ct_start'}
|
||||
if event_type in _STARTUP_EVENTS and not is_error and not is_warning:
|
||||
if _shared_state.is_startup_period():
|
||||
vm_type = 'ct' if event_type == 'ct_start' else 'vm'
|
||||
_shared_state.add_startup_vm(vmid, vmname or f'ID {vmid}', vm_type)
|
||||
return
|
||||
|
||||
self._queue.put(NotificationEvent(
|
||||
event_type, severity, data, source='tasks',
|
||||
entity=entity, entity_id=vmid,
|
||||
@@ -1618,6 +1881,8 @@ class PollingCollector:
|
||||
# Key = health_persistence category name
|
||||
# Value = minimum seconds between notifications for the same error_key
|
||||
_CATEGORY_COOLDOWNS = {
|
||||
# Category cooldown: minimum time between DIFFERENT errors of the same category
|
||||
# This prevents notification storms when multiple issues arise together
|
||||
'disks': 86400, # 24h - I/O errors are persistent hardware issues
|
||||
'smart': 86400, # 24h - SMART errors same as I/O
|
||||
'zfs': 86400, # 24h - ZFS pool issues are persistent
|
||||
@@ -1627,6 +1892,7 @@ class PollingCollector:
|
||||
'temperature': 3600, # 1h - temp can fluctuate near thresholds
|
||||
'logs': 3600, # 1h - repeated log patterns
|
||||
'vms': 1800, # 30m - VM state oscillation
|
||||
'vmct': 1800, # 30m - VM/CT state oscillation
|
||||
'security': 3600, # 1h - auth failures tend to be bursty
|
||||
'cpu': 1800, # 30m - CPU spikes can be transient
|
||||
'memory': 1800, # 30m - memory pressure oscillation
|
||||
@@ -1634,6 +1900,10 @@ class PollingCollector:
|
||||
'updates': 86400, # 24h - update info doesn't change fast
|
||||
}
|
||||
|
||||
# Global cooldown: minimum time before the SAME error can be re-notified
|
||||
# This is independent of category - same error_key cannot repeat before this time
|
||||
SAME_ERROR_COOLDOWN = 86400 # 24 hours
|
||||
|
||||
_ENTITY_MAP = {
|
||||
'cpu': ('node', ''), 'memory': ('node', ''), 'temperature': ('node', ''),
|
||||
'load': ('node', ''),
|
||||
@@ -1673,7 +1943,11 @@ class PollingCollector:
|
||||
self._poll_interval = poll_interval
|
||||
self._hostname = _hostname()
|
||||
self._last_update_check = 0
|
||||
self._last_proxmenux_check = 0
|
||||
self._last_ai_model_check = 0
|
||||
# Track notified ProxMenux versions to avoid duplicates
|
||||
self._notified_proxmenux_version: str | None = None
|
||||
self._notified_proxmenux_beta_version: str | None = None
|
||||
# In-memory cache: error_key -> last notification timestamp
|
||||
self._last_notified: Dict[str, float] = {}
|
||||
# Track known error keys + metadata so we can detect new ones AND emit recovery
|
||||
@@ -1693,25 +1967,73 @@ class PollingCollector:
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
def _sleep_until_offset(self, cycle_start: float, offset: float):
|
||||
"""Sleep until the specified offset within the current cycle."""
|
||||
target = cycle_start + offset
|
||||
now = time.time()
|
||||
if now < target:
|
||||
time.sleep(target - now)
|
||||
|
||||
# ── Main loop ──────────────────────────────────────────────
|
||||
|
||||
# Categories where transient errors are suppressed during startup grace period.
|
||||
# Now using centralized startup_grace module for consistency.
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main polling loop."""
|
||||
# Initial delay to let health monitor warm up
|
||||
for _ in range(15):
|
||||
# Initial delay to let health monitor and external services warm up.
|
||||
# PBS storage, NFS mounts, VMs with guest agent all need time after boot.
|
||||
for _ in range(60):
|
||||
if not self._running:
|
||||
return
|
||||
time.sleep(1)
|
||||
|
||||
# Staggered execution: spread checks across the polling interval
|
||||
# to avoid CPU spikes when multiple checks run simultaneously.
|
||||
# Schedule: health=10s, updates=30s, proxmenux=45s, ai_model=50s
|
||||
STAGGER_HEALTH = 10
|
||||
STAGGER_UPDATES = 30
|
||||
STAGGER_PROXMENUX = 45
|
||||
STAGGER_AI_MODEL = 50
|
||||
|
||||
while self._running:
|
||||
cycle_start = time.time()
|
||||
|
||||
try:
|
||||
# Health check at offset 10s
|
||||
self._sleep_until_offset(cycle_start, STAGGER_HEALTH)
|
||||
if not self._running:
|
||||
return
|
||||
self._check_persistent_health()
|
||||
|
||||
# Updates check at offset 30s
|
||||
self._sleep_until_offset(cycle_start, STAGGER_UPDATES)
|
||||
if not self._running:
|
||||
return
|
||||
self._check_updates()
|
||||
|
||||
# ProxMenux check at offset 45s
|
||||
self._sleep_until_offset(cycle_start, STAGGER_PROXMENUX)
|
||||
if not self._running:
|
||||
return
|
||||
self._check_proxmenux_updates()
|
||||
|
||||
# AI model check at offset 50s
|
||||
self._sleep_until_offset(cycle_start, STAGGER_AI_MODEL)
|
||||
if not self._running:
|
||||
return
|
||||
self._check_ai_model_availability()
|
||||
|
||||
# Check if startup period ended and we have aggregated VMs to report
|
||||
self._check_startup_aggregation()
|
||||
|
||||
except Exception as e:
|
||||
print(f"[PollingCollector] Error: {e}")
|
||||
|
||||
for _ in range(self._poll_interval):
|
||||
# Sleep remaining time until next cycle
|
||||
elapsed = time.time() - cycle_start
|
||||
remaining = max(self._poll_interval - elapsed, 1)
|
||||
for _ in range(int(remaining)):
|
||||
if not self._running:
|
||||
return
|
||||
time.sleep(1)
|
||||
@@ -1763,6 +2085,13 @@ class PollingCollector:
|
||||
if error.get('acknowledged') == 1:
|
||||
continue
|
||||
|
||||
# Startup grace period: ignore transient errors from categories that
|
||||
# typically need time to stabilize after boot (storage, VMs, network).
|
||||
# PBS storage, NFS mounts, VMs with qemu-guest-agent need time to connect.
|
||||
# Uses centralized startup_grace module for consistency.
|
||||
if startup_grace.should_suppress_category(category):
|
||||
continue
|
||||
|
||||
# On first poll, seed _last_notified for all existing errors so we
|
||||
# don't re-notify old persistent errors that were already sent before
|
||||
# a service restart. Only genuinely NEW errors (appearing after the
|
||||
@@ -1791,15 +2120,28 @@ class PollingCollector:
|
||||
# Determine if we should notify
|
||||
is_new = error_key not in self._known_errors
|
||||
last_sent = self._last_notified.get(error_key, 0)
|
||||
cat_cooldown = self._CATEGORY_COOLDOWNS.get(category, self.DIGEST_INTERVAL)
|
||||
is_due = (now - last_sent) >= cat_cooldown
|
||||
time_since_last = now - last_sent
|
||||
|
||||
# ── SAME ERROR COOLDOWN (24h) ──
|
||||
# The SAME error_key cannot be re-notified before 24 hours.
|
||||
# This is the PRIMARY deduplication mechanism.
|
||||
# EXCEPTION: If user dismissed the error, the cooldown is cleared in DB
|
||||
# and we should re-check DB to see if cooldown still applies.
|
||||
if time_since_last < self.SAME_ERROR_COOLDOWN:
|
||||
# Check if user dismissed this - clears DB cooldown
|
||||
db_ts = self._get_cooldown_from_db(error_key)
|
||||
if db_ts is not None and now - db_ts < self.SAME_ERROR_COOLDOWN:
|
||||
continue # DB confirms cooldown still active
|
||||
# DB says cooldown was cleared (user dismissed) - remove from memory
|
||||
self._last_notified.pop(error_key, None)
|
||||
# Continue to the next checks (category cooldown etc.)
|
||||
|
||||
# ── CATEGORY COOLDOWN (varies) ──
|
||||
# DIFFERENT errors within the same category respect category cooldown.
|
||||
# This prevents notification storms when multiple issues arise together.
|
||||
cat_cooldown = self._CATEGORY_COOLDOWNS.get(category, self.DIGEST_INTERVAL)
|
||||
is_due = time_since_last >= cat_cooldown
|
||||
|
||||
# Anti-oscillation: even if "new" (resolved then reappeared),
|
||||
# respect the per-category cooldown interval. This prevents
|
||||
# "semi-cascades" where the same root cause generates multiple
|
||||
# slightly different notifications across health check cycles.
|
||||
# Each category has its own appropriate cooldown (30m for network,
|
||||
# 24h for disks, 1h for temperature, etc.).
|
||||
if not is_due:
|
||||
continue
|
||||
|
||||
@@ -2009,6 +2351,113 @@ class PollingCollector:
|
||||
self._known_errors = current_keys
|
||||
self._first_poll_done = True
|
||||
|
||||
def _check_startup_aggregation(self):
|
||||
"""Check if startup period ended and emit comprehensive startup report.
|
||||
|
||||
At the end of the health grace period, collects:
|
||||
- VMs/CTs that started successfully
|
||||
- VMs/CTs that failed to start
|
||||
- Service status
|
||||
- Storage status
|
||||
- Journal errors (for AI enrichment)
|
||||
|
||||
Emits a single "system_startup" notification with full report data.
|
||||
|
||||
IMPORTANT: Only emits if this is a REAL system boot, not a service restart.
|
||||
Checks system uptime to distinguish between the two cases.
|
||||
"""
|
||||
# Wait until health grace period is over (5 min) for complete picture
|
||||
if startup_grace.is_startup_health_grace():
|
||||
return
|
||||
|
||||
# Only emit once
|
||||
if startup_grace.was_startup_aggregated():
|
||||
return
|
||||
|
||||
# CRITICAL: Check if this is a real system boot
|
||||
# If the system was already running for > 10 min when service started,
|
||||
# this is just a service restart, not a system boot - skip notification
|
||||
if not startup_grace.is_real_system_boot():
|
||||
# Mark as aggregated to prevent future checks, but don't send notification
|
||||
startup_grace.mark_startup_aggregated()
|
||||
return
|
||||
|
||||
# Collect comprehensive startup report
|
||||
report = startup_grace.collect_startup_report()
|
||||
|
||||
# Generate human-readable summary
|
||||
summary = startup_grace.format_startup_summary(report)
|
||||
|
||||
# Count totals
|
||||
vms_ok = len(report.get('vms_started', []))
|
||||
cts_ok = len(report.get('cts_started', []))
|
||||
vms_fail = len(report.get('vms_failed', []))
|
||||
cts_fail = len(report.get('cts_failed', []))
|
||||
total_ok = vms_ok + cts_ok
|
||||
total_fail = vms_fail + cts_fail
|
||||
|
||||
# Build entity list for backwards compatibility
|
||||
entity_names = []
|
||||
for vm in report.get('vms_started', [])[:5]:
|
||||
entity_names.append(f"{vm['name']} ({vm['vmid']})")
|
||||
for ct in report.get('cts_started', [])[:5]:
|
||||
entity_names.append(f"{ct['name']} ({ct['vmid']})")
|
||||
if total_ok > 10:
|
||||
entity_names.append(f"...and {total_ok - 10} more")
|
||||
|
||||
# Determine severity based on issues
|
||||
has_issues = (
|
||||
total_fail > 0 or
|
||||
not report.get('services_ok', True) or
|
||||
not report.get('storage_ok', True) or
|
||||
report.get('health_status') in ['CRITICAL', 'WARNING']
|
||||
)
|
||||
severity = 'WARNING' if has_issues else 'INFO'
|
||||
|
||||
# Build notification data
|
||||
data = {
|
||||
'hostname': self._hostname,
|
||||
'summary': summary,
|
||||
|
||||
# VM/CT counts (backwards compatible)
|
||||
'vm_count': vms_ok,
|
||||
'ct_count': cts_ok,
|
||||
'total_count': total_ok,
|
||||
'entity_list': ', '.join(entity_names),
|
||||
|
||||
# New: failure counts
|
||||
'vms_failed_count': vms_fail,
|
||||
'cts_failed_count': cts_fail,
|
||||
'total_failed': total_fail,
|
||||
|
||||
# New: detailed lists
|
||||
'vms_started': report.get('vms_started', []),
|
||||
'cts_started': report.get('cts_started', []),
|
||||
'vms_failed': report.get('vms_failed', []),
|
||||
'cts_failed': report.get('cts_failed', []),
|
||||
|
||||
# New: system status
|
||||
'services_ok': report.get('services_ok', True),
|
||||
'services_failed': report.get('services_failed', []),
|
||||
'storage_ok': report.get('storage_ok', True),
|
||||
'storage_unavailable': report.get('storage_unavailable', []),
|
||||
'health_status': report.get('health_status', 'UNKNOWN'),
|
||||
'health_issues': report.get('health_issues', []),
|
||||
|
||||
# For AI enrichment
|
||||
'_journal_context': report.get('_journal_context', ''),
|
||||
|
||||
# Metadata
|
||||
'startup_duration_seconds': report.get('startup_duration_seconds', 0),
|
||||
'has_issues': has_issues,
|
||||
'reason': summary.split('\n')[0], # First line as reason
|
||||
}
|
||||
|
||||
self._queue.put(NotificationEvent(
|
||||
'system_startup', severity, data, source='polling',
|
||||
entity='node', entity_id='',
|
||||
))
|
||||
|
||||
# ── Update check (enriched) ────────────────────────────────
|
||||
|
||||
# Proxmox-related package prefixes used for categorisation
|
||||
@@ -2102,7 +2551,7 @@ class PollingCollector:
|
||||
for pkg in all_pkgs:
|
||||
if pkg['name'] in self._IMPORTANT_PKGS and pkg['cur']:
|
||||
important_lines.append(
|
||||
f"{pkg['name']} ({pkg['cur']} -> {pkg['new']})"
|
||||
f"{pkg['name']} ({pkg['cur']} → {pkg['new']})"
|
||||
)
|
||||
|
||||
# ── Emit structured update_summary ─────────────────────
|
||||
@@ -2128,7 +2577,7 @@ class PollingCollector:
|
||||
'current_version': pve_manager_info['cur'],
|
||||
'new_version': pve_manager_info['new'],
|
||||
'version': pve_manager_info['new'],
|
||||
'details': f"pve-manager {pve_manager_info['cur']} -> {pve_manager_info['new']}",
|
||||
'details': f"pve-manager {pve_manager_info['cur']} → {pve_manager_info['new']}",
|
||||
}
|
||||
self._queue.put(NotificationEvent(
|
||||
'pve_update', 'INFO', pve_data,
|
||||
@@ -2137,6 +2586,135 @@ class PollingCollector:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── ProxMenux update check ────────────────────────────────
|
||||
|
||||
PROXMENUX_VERSION_FILE = '/usr/local/share/proxmenux/version.txt'
|
||||
PROXMENUX_BETA_VERSION_FILE = '/usr/local/share/proxmenux/beta_version.txt'
|
||||
REPO_MAIN_VERSION_URL = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/version.txt'
|
||||
REPO_DEVELOP_VERSION_URL = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/beta_version.txt'
|
||||
|
||||
def _check_proxmenux_updates(self):
|
||||
"""Check for ProxMenux updates (main and beta channels).
|
||||
|
||||
Compares local version files with remote GitHub repository versions
|
||||
and emits notifications when updates are available.
|
||||
Uses same 24h interval as system updates.
|
||||
"""
|
||||
import urllib.request
|
||||
|
||||
now = time.time()
|
||||
if now - self._last_proxmenux_check < self.UPDATE_CHECK_INTERVAL:
|
||||
return
|
||||
|
||||
self._last_proxmenux_check = now
|
||||
|
||||
def read_local_version(path: str) -> str | None:
|
||||
"""Read version from local file."""
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
with open(path, 'r') as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def read_remote_version(url: str) -> str | None:
|
||||
"""Fetch version from remote URL."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'ProxMenux-Monitor/1.0'})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.read().decode('utf-8').strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def version_tuple(v: str) -> tuple:
|
||||
"""Convert version string to tuple for comparison."""
|
||||
try:
|
||||
return tuple(int(x) for x in v.split('.'))
|
||||
except Exception:
|
||||
return (0,)
|
||||
|
||||
def update_config_json(stable: bool = None, stable_version: str = None,
|
||||
beta: bool = None, beta_version: str = None):
|
||||
"""Update update_available status in config.json."""
|
||||
config_path = Path('/usr/local/share/proxmenux/config.json')
|
||||
try:
|
||||
config = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r') as f:
|
||||
config = json.load(f)
|
||||
|
||||
if 'update_available' not in config:
|
||||
config['update_available'] = {
|
||||
'stable': False, 'stable_version': '',
|
||||
'beta': False, 'beta_version': ''
|
||||
}
|
||||
|
||||
if stable is not None:
|
||||
config['update_available']['stable'] = stable
|
||||
config['update_available']['stable_version'] = stable_version or ''
|
||||
if beta is not None:
|
||||
config['update_available']['beta'] = beta
|
||||
config['update_available']['beta_version'] = beta_version or ''
|
||||
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[PollingCollector] Failed to update config.json: {e}")
|
||||
|
||||
try:
|
||||
# Check main version
|
||||
local_main = read_local_version(self.PROXMENUX_VERSION_FILE)
|
||||
if local_main:
|
||||
remote_main = read_remote_version(self.REPO_MAIN_VERSION_URL)
|
||||
if remote_main and version_tuple(remote_main) > version_tuple(local_main):
|
||||
# Update config.json with stable update status
|
||||
update_config_json(stable=True, stable_version=remote_main)
|
||||
# Only notify if we haven't already notified for this version
|
||||
if self._notified_proxmenux_version != remote_main:
|
||||
self._notified_proxmenux_version = remote_main
|
||||
data = {
|
||||
'hostname': self._hostname,
|
||||
'current_version': local_main,
|
||||
'new_version': remote_main,
|
||||
}
|
||||
self._queue.put(NotificationEvent(
|
||||
'proxmenux_update', 'INFO', data,
|
||||
source='polling', entity='node', entity_id='',
|
||||
))
|
||||
else:
|
||||
# No update available - reset the flag
|
||||
update_config_json(stable=False, stable_version='')
|
||||
self._notified_proxmenux_version = None
|
||||
|
||||
# Check beta version (only if user has beta file)
|
||||
local_beta = read_local_version(self.PROXMENUX_BETA_VERSION_FILE)
|
||||
if local_beta:
|
||||
remote_beta = read_remote_version(self.REPO_DEVELOP_VERSION_URL)
|
||||
if remote_beta and version_tuple(remote_beta) > version_tuple(local_beta):
|
||||
# Update config.json with beta update status
|
||||
update_config_json(beta=True, beta_version=remote_beta)
|
||||
# Only notify if we haven't already notified for this version
|
||||
if self._notified_proxmenux_beta_version != remote_beta:
|
||||
self._notified_proxmenux_beta_version = remote_beta
|
||||
data = {
|
||||
'hostname': self._hostname,
|
||||
'current_version': local_beta,
|
||||
'new_version': f'{remote_beta} (Beta)',
|
||||
}
|
||||
# Use same event_type - single toggle controls both
|
||||
self._queue.put(NotificationEvent(
|
||||
'proxmenux_update', 'INFO', data,
|
||||
source='polling', entity='node', entity_id='',
|
||||
))
|
||||
else:
|
||||
# No beta update available - reset the flag
|
||||
update_config_json(beta=False, beta_version='')
|
||||
self._notified_proxmenux_beta_version = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── AI Model availability check ────────────────────────────
|
||||
|
||||
def _check_ai_model_availability(self):
|
||||
@@ -2221,6 +2799,41 @@ class PollingCollector:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_cooldown_from_db(self, error_key: str) -> Optional[float]:
|
||||
"""
|
||||
Get cooldown timestamp from DB for an error_key.
|
||||
|
||||
Used to re-check DB when user might have dismissed the error,
|
||||
which clears the DB entry via health_persistence._clear_disk_io_cooldown().
|
||||
|
||||
Returns the timestamp if found and within 24h window, None otherwise.
|
||||
"""
|
||||
try:
|
||||
db_path = Path('/usr/local/share/proxmenux/health_monitor.db')
|
||||
if not db_path.exists():
|
||||
return None
|
||||
conn = sqlite3.connect(str(db_path), timeout=5)
|
||||
conn.execute('PRAGMA busy_timeout=3000')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# PollingCollector uses 'health_' prefix for its fingerprints
|
||||
fp = f'health_{error_key}'
|
||||
cursor.execute(
|
||||
"SELECT last_sent_ts FROM notification_last_sent WHERE fingerprint = ?",
|
||||
(fp,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
if row:
|
||||
ts = float(row[0])
|
||||
# Only return if within 24h window
|
||||
if time.time() - ts < self.SAME_ERROR_COOLDOWN:
|
||||
return ts
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ─── Proxmox Webhook Receiver ───────────────────────────────────
|
||||
|
||||
@@ -44,6 +44,13 @@ from notification_events import (
|
||||
ProxmoxHookWatcher,
|
||||
)
|
||||
|
||||
# AI context enrichment (uptime, frequency, SMART data, known errors)
|
||||
try:
|
||||
from ai_context_enrichment import enrich_context_for_ai
|
||||
except ImportError:
|
||||
def enrich_context_for_ai(title, body, event_type, data, journal_context='', detail_level='standard'):
|
||||
return journal_context
|
||||
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────
|
||||
|
||||
@@ -653,9 +660,10 @@ class NotificationManager:
|
||||
# Suppress VM/CT start/stop during active backups (second layer of defense).
|
||||
# The primary filter is in TaskWatcher, but timing gaps can let events
|
||||
# slip through. This catch-all filter checks at dispatch time.
|
||||
# Exception: CRITICAL and WARNING events should always be notified.
|
||||
_BACKUP_NOISE_TYPES = {'vm_start', 'vm_stop', 'vm_shutdown', 'vm_restart',
|
||||
'ct_start', 'ct_stop', 'ct_shutdown', 'ct_restart'}
|
||||
if event.event_type in _BACKUP_NOISE_TYPES and event.severity != 'CRITICAL':
|
||||
if event.event_type in _BACKUP_NOISE_TYPES and event.severity not in ('CRITICAL', 'WARNING'):
|
||||
if self._is_backup_running():
|
||||
return
|
||||
|
||||
@@ -739,10 +747,12 @@ class NotificationManager:
|
||||
'ai_model': self._config.get('ai_model', ''),
|
||||
'ai_language': self._config.get('ai_language', 'en'),
|
||||
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
|
||||
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
|
||||
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
|
||||
}
|
||||
|
||||
# Get journal context if available
|
||||
journal_context = data.get('_journal_context', '')
|
||||
# Get journal context if available (will be enriched per-channel based on detail_level)
|
||||
raw_journal_context = data.get('_journal_context', '')
|
||||
|
||||
for ch_name, channel in channels.items():
|
||||
# ── Per-channel category check ──
|
||||
@@ -761,9 +771,12 @@ class NotificationManager:
|
||||
ch_title, ch_body = title, body
|
||||
|
||||
# ── Per-channel settings ──
|
||||
# Email defaults to 'detailed' (technical report), others to 'standard'
|
||||
detail_level_key = f'{ch_name}.ai_detail_level'
|
||||
detail_level = self._config.get(detail_level_key, 'standard')
|
||||
default_detail = 'detailed' if ch_name == 'email' else 'standard'
|
||||
detail_level = self._config.get(detail_level_key, default_detail)
|
||||
|
||||
# Rich format (emojis) is a user preference per channel
|
||||
rich_key = f'{ch_name}.rich_format'
|
||||
use_rich_format = self._config.get(rich_key, 'false') == 'true'
|
||||
|
||||
@@ -772,10 +785,21 @@ class NotificationManager:
|
||||
# If AI is enabled AND rich_format is on, AI will include emojis directly
|
||||
# Pass channel_type so AI knows whether to append original (email only)
|
||||
channel_ai_config = {**ai_config, 'channel_type': ch_name}
|
||||
|
||||
# Enrich context with uptime, frequency, SMART data, and known errors
|
||||
enriched_context = enrich_context_for_ai(
|
||||
title=ch_title,
|
||||
body=ch_body,
|
||||
event_type=event_type,
|
||||
data=data,
|
||||
journal_context=raw_journal_context,
|
||||
detail_level=detail_level
|
||||
)
|
||||
|
||||
ai_result = format_with_ai_full(
|
||||
ch_title, ch_body, severity, channel_ai_config,
|
||||
detail_level=detail_level,
|
||||
journal_context=journal_context,
|
||||
journal_context=enriched_context,
|
||||
use_emojis=use_rich_format
|
||||
)
|
||||
ch_title = ai_result.get('title', ch_title)
|
||||
@@ -1013,6 +1037,45 @@ class NotificationManager:
|
||||
|
||||
# ─── Public API (used by Flask routes and CLI) ──────────────
|
||||
|
||||
def emit_event(self, event_type: str, severity: str, data: Dict,
|
||||
source: str = 'api', entity: str = 'node', entity_id: str = '') -> Dict[str, Any]:
|
||||
"""Emit an event through the notification system.
|
||||
|
||||
This creates a NotificationEvent and processes it through the normal pipeline,
|
||||
including toggle checks, template rendering, and cooldown.
|
||||
|
||||
Used by internal endpoints like the shutdown notification hook.
|
||||
|
||||
Args:
|
||||
event_type: Type of event (must match TEMPLATES key)
|
||||
severity: INFO, WARNING, CRITICAL
|
||||
data: Event data for template rendering
|
||||
source: Origin of event
|
||||
entity: Entity type (node, vm, ct, storage, etc.)
|
||||
entity_id: Entity identifier
|
||||
"""
|
||||
from notification_events import NotificationEvent
|
||||
|
||||
event = NotificationEvent(
|
||||
event_type=event_type,
|
||||
severity=severity,
|
||||
data=data,
|
||||
source=source,
|
||||
entity=entity,
|
||||
entity_id=entity_id,
|
||||
)
|
||||
|
||||
# For urgent events (shutdown/reboot), dispatch directly to ensure
|
||||
# immediate delivery before the system goes down.
|
||||
# For other events, use the normal pipeline with aggregation.
|
||||
_URGENT_EVENTS = {'system_shutdown', 'system_reboot'}
|
||||
if event_type in _URGENT_EVENTS:
|
||||
self._dispatch_event(event)
|
||||
return {'success': True, 'event_type': event_type, 'dispatched': 'direct'}
|
||||
else:
|
||||
self._process_event(event)
|
||||
return {'success': True, 'event_type': event_type, 'dispatched': 'queued'}
|
||||
|
||||
def send_notification(self, event_type: str, severity: str,
|
||||
title: str, message: str,
|
||||
data: Optional[Dict] = None,
|
||||
@@ -1070,6 +1133,8 @@ class NotificationManager:
|
||||
'ai_model': self._config.get('ai_model', ''),
|
||||
'ai_language': self._config.get('ai_language', 'en'),
|
||||
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
|
||||
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
|
||||
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
|
||||
}
|
||||
|
||||
results = {}
|
||||
@@ -1166,12 +1231,21 @@ class NotificationManager:
|
||||
'ai_model': self._config.get('ai_model', ''),
|
||||
'ai_language': self._config.get('ai_language', 'en'),
|
||||
'ai_ollama_url': self._config.get('ai_ollama_url', ''),
|
||||
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
|
||||
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
|
||||
}
|
||||
|
||||
ai_enabled = self._config.get('ai_enabled', 'false')
|
||||
if isinstance(ai_enabled, str):
|
||||
ai_enabled = ai_enabled.lower() == 'true'
|
||||
ai_language = self._config.get('ai_language', 'en')
|
||||
ai_prompt_mode = self._config.get('ai_prompt_mode', 'default')
|
||||
|
||||
# Determine AI info string based on prompt mode
|
||||
if ai_prompt_mode == 'custom':
|
||||
ai_info = f'{ai_provider} / custom prompt'
|
||||
else:
|
||||
ai_info = f'{ai_provider} / {ai_language}'
|
||||
|
||||
# ProxMenux logo for welcome message
|
||||
logo_url = 'https://proxmenux.com/telegram.png'
|
||||
@@ -1189,10 +1263,10 @@ class NotificationManager:
|
||||
# Build status indicators for icons and AI, adapted to channel format
|
||||
if use_rich_format:
|
||||
icon_status = '✅ Icons: enabled'
|
||||
ai_status = f'✅ AI: enabled ({ai_provider} / {ai_language})' if ai_enabled else '❌ AI: disabled'
|
||||
ai_status = f'✅ AI: enabled ({ai_info})' if ai_enabled else '❌ AI: disabled'
|
||||
else:
|
||||
icon_status = 'Icons: disabled'
|
||||
ai_status = f'AI: enabled ({ai_provider} / {ai_language})' if ai_enabled else 'AI: disabled'
|
||||
ai_status = f'AI: enabled ({ai_info})' if ai_enabled else 'AI: disabled'
|
||||
|
||||
# Base test message — shows current channel config
|
||||
# NOTE: narrative lines are intentionally unlabeled so the AI
|
||||
@@ -1559,6 +1633,9 @@ class NotificationManager:
|
||||
'ai_language': self._config.get('ai_language', 'en'),
|
||||
'ai_ollama_url': self._config.get('ai_ollama_url', 'http://localhost:11434'),
|
||||
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
|
||||
'ai_prompt_mode': self._config.get('ai_prompt_mode', 'default'),
|
||||
'ai_custom_prompt': self._config.get('ai_custom_prompt', ''),
|
||||
'ai_allow_suggestions': self._config.get('ai_allow_suggestions', 'false') == 'true',
|
||||
'ai_detail_levels': ai_detail_levels,
|
||||
'hostname': self._config.get('hostname', ''),
|
||||
'webhook_secret': self._config.get('webhook_secret', ''),
|
||||
|
||||
@@ -17,7 +17,7 @@ import socket
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Dict, Any, Optional, List
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
|
||||
# ─── vzdump message parser ───────────────────────────────────────
|
||||
@@ -314,6 +314,90 @@ def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool) -> str:
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def _format_system_startup(data: Dict[str, Any]) -> Tuple[str, str]:
|
||||
"""
|
||||
Format comprehensive system startup report.
|
||||
|
||||
Returns (title, body) tuple for the notification.
|
||||
Handles both simple startups (all OK) and those with issues.
|
||||
"""
|
||||
hostname = data.get('hostname', 'unknown')
|
||||
has_issues = data.get('has_issues', False)
|
||||
|
||||
# Build title
|
||||
if has_issues:
|
||||
total_issues = (
|
||||
data.get('total_failed', 0) +
|
||||
len(data.get('services_failed', [])) +
|
||||
len(data.get('storage_unavailable', []))
|
||||
)
|
||||
title = f"{hostname}: System startup - {total_issues} issue(s) detected"
|
||||
else:
|
||||
title = f"{hostname}: System startup completed"
|
||||
|
||||
# Build body
|
||||
parts = []
|
||||
|
||||
# Overall status
|
||||
if not has_issues:
|
||||
parts.append("All systems operational.")
|
||||
|
||||
# VMs/CTs started
|
||||
vms_ok = len(data.get('vms_started', []))
|
||||
cts_ok = len(data.get('cts_started', []))
|
||||
if vms_ok or cts_ok:
|
||||
count_parts = []
|
||||
if vms_ok:
|
||||
count_parts.append(f"{vms_ok} VM{'s' if vms_ok > 1 else ''}")
|
||||
if cts_ok:
|
||||
count_parts.append(f"{cts_ok} CT{'s' if cts_ok > 1 else ''}")
|
||||
|
||||
# List names (up to 5)
|
||||
names = []
|
||||
for vm in data.get('vms_started', [])[:3]:
|
||||
names.append(f"{vm['name']} ({vm['vmid']})")
|
||||
for ct in data.get('cts_started', [])[:3]:
|
||||
names.append(f"{ct['name']} ({ct['vmid']})")
|
||||
|
||||
line = f"\u2705 {' and '.join(count_parts)} started"
|
||||
if names:
|
||||
if len(names) <= 5:
|
||||
line += f": {', '.join(names)}"
|
||||
else:
|
||||
line += f": {', '.join(names[:5])}..."
|
||||
parts.append(line)
|
||||
|
||||
# Failed VMs/CTs
|
||||
for vm in data.get('vms_failed', []):
|
||||
reason = vm.get('reason', 'unknown error')
|
||||
parts.append(f"\u274C VM failed: {vm['name']} - {reason}")
|
||||
|
||||
for ct in data.get('cts_failed', []):
|
||||
reason = ct.get('reason', 'unknown error')
|
||||
parts.append(f"\u274C CT failed: {ct['name']} - {reason}")
|
||||
|
||||
# Storage issues
|
||||
storage_unavailable = data.get('storage_unavailable', [])
|
||||
if storage_unavailable:
|
||||
names = [s['name'] for s in storage_unavailable[:3]]
|
||||
parts.append(f"\u26A0\uFE0F Storage: {len(storage_unavailable)} unavailable ({', '.join(names)})")
|
||||
|
||||
# Service issues
|
||||
services_failed = data.get('services_failed', [])
|
||||
if services_failed:
|
||||
names = [s['name'] for s in services_failed[:3]]
|
||||
parts.append(f"\u26A0\uFE0F Services: {len(services_failed)} failed ({', '.join(names)})")
|
||||
|
||||
# Startup duration
|
||||
duration = data.get('startup_duration_seconds', 0)
|
||||
if duration:
|
||||
minutes = int(duration // 60)
|
||||
parts.append(f"\u23F1\uFE0F Startup completed in {minutes} min")
|
||||
|
||||
body = '\n'.join(parts)
|
||||
return title, body
|
||||
|
||||
|
||||
# ─── Severity Icons ──────────────────────────────────────────────
|
||||
|
||||
SEVERITY_ICONS = {
|
||||
@@ -387,6 +471,13 @@ TEMPLATES = {
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'vm_start_warning': {
|
||||
'title': '{hostname}: VM {vmname} ({vmid}) started with warnings',
|
||||
'body': 'Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}',
|
||||
'label': 'VM started (warnings)',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'vm_stop': {
|
||||
'title': '{hostname}: VM {vmname} ({vmid}) stopped',
|
||||
'body': 'Virtual machine {vmname} (ID: {vmid}) has been stopped.',
|
||||
@@ -422,6 +513,13 @@ TEMPLATES = {
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'ct_start_warning': {
|
||||
'title': '{hostname}: CT {vmname} ({vmid}) started with warnings',
|
||||
'body': 'Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}',
|
||||
'label': 'CT started (warnings)',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'ct_stop': {
|
||||
'title': '{hostname}: CT {vmname} ({vmid}) stopped',
|
||||
'body': 'Container {vmname} (ID: {vmid}) has been stopped.',
|
||||
@@ -464,6 +562,13 @@ TEMPLATES = {
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'migration_warning': {
|
||||
'title': '{hostname}: Migration complete with warnings — {vmname} ({vmid})',
|
||||
'body': '{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}',
|
||||
'label': 'Migration (warnings)',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'migration_fail': {
|
||||
'title': '{hostname}: Migration FAILED — {vmname} ({vmid})',
|
||||
'body': 'Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}',
|
||||
@@ -501,6 +606,13 @@ TEMPLATES = {
|
||||
'group': 'backup',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'backup_warning': {
|
||||
'title': '{hostname}: Backup complete with warnings — {vmname} ({vmid})',
|
||||
'body': 'Backup of {vmname} (ID: {vmid}) completed but encountered warnings.\nWarnings: {reason}',
|
||||
'label': 'Backup (warnings)',
|
||||
'group': 'backup',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'backup_fail': {
|
||||
'title': '{hostname}: Backup FAILED — {vmname} ({vmid})',
|
||||
'body': 'Backup of {vmname} (ID: {vmid}) failed.\nReason: {reason}',
|
||||
@@ -566,6 +678,59 @@ TEMPLATES = {
|
||||
'group': 'storage',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'smart_test_complete': {
|
||||
'title': '{hostname}: SMART test completed — {device}',
|
||||
'body': 'SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}',
|
||||
'label': 'SMART test completed',
|
||||
'group': 'storage',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'smart_test_failed': {
|
||||
'title': '{hostname}: SMART test FAILED — {device}',
|
||||
'body': 'SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}',
|
||||
'label': 'SMART test FAILED',
|
||||
'group': 'storage',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── GPU / PCIe passthrough events ──
|
||||
'gpu_mode_switch': {
|
||||
'title': '{hostname}: GPU mode changed to {new_mode}',
|
||||
'body': (
|
||||
'GPU passthrough mode has been switched.\n'
|
||||
'GPU: {gpu_name} ({gpu_pci})\n'
|
||||
'Previous mode: {old_mode}\n'
|
||||
'New mode: {new_mode}\n'
|
||||
'{details}'
|
||||
),
|
||||
'label': 'GPU mode switched',
|
||||
'group': 'hardware',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'gpu_passthrough_blocked': {
|
||||
'title': '{hostname}: {guest_type} {guest_id} blocked at startup',
|
||||
'body': (
|
||||
'PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\n'
|
||||
'Reason: {reason}\n'
|
||||
'{details}'
|
||||
),
|
||||
'label': 'GPU passthrough blocked',
|
||||
'group': 'hardware',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'pci_passthrough_conflict': {
|
||||
'title': '{hostname}: PCIe device conflict detected',
|
||||
'body': (
|
||||
'A PCIe device is assigned to multiple guests.\n'
|
||||
'Device: {device_pci}\n'
|
||||
'Conflicting guests: {guest_list}\n'
|
||||
'Action required: Stop one of the guests or reassign the device.'
|
||||
),
|
||||
'label': 'PCIe device conflict',
|
||||
'group': 'hardware',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
'load_high': {
|
||||
'title': '{hostname}: High system load — {value}',
|
||||
'body': 'System load average is {value} on {cores} cores.\n{details}',
|
||||
@@ -644,6 +809,14 @@ TEMPLATES = {
|
||||
},
|
||||
|
||||
# ── Services events ──
|
||||
'system_startup': {
|
||||
'title': '{hostname}: {reason}',
|
||||
'body': '{summary}',
|
||||
'label': 'System startup report',
|
||||
'group': 'services',
|
||||
'default_enabled': True,
|
||||
'formatter': '_format_system_startup',
|
||||
},
|
||||
'system_shutdown': {
|
||||
'title': '{hostname}: System shutting down',
|
||||
'body': 'The node is shutting down.\n{reason}',
|
||||
@@ -787,6 +960,20 @@ TEMPLATES = {
|
||||
),
|
||||
'label': 'AI model auto-updated',
|
||||
'group': 'system',
|
||||
'severity': 'info',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── ProxMenux updates ──
|
||||
'proxmenux_update': {
|
||||
'title': '{hostname}: ProxMenux {new_version} available',
|
||||
'body': (
|
||||
'A new version of ProxMenux is available.\n'
|
||||
'Current: {current_version}\n'
|
||||
'New: {new_version}'
|
||||
),
|
||||
'label': 'ProxMenux update available',
|
||||
'group': 'updates',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
@@ -938,7 +1125,19 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pve_message = data.get('pve_message', '')
|
||||
pve_title = data.get('pve_title', '')
|
||||
|
||||
if event_type in ('backup_complete', 'backup_fail') and pve_message:
|
||||
# Check for custom formatter function
|
||||
formatter_name = template.get('formatter')
|
||||
if formatter_name and formatter_name in globals():
|
||||
formatter_func = globals()[formatter_name]
|
||||
try:
|
||||
title, body_text = formatter_func(data)
|
||||
except Exception:
|
||||
# Fallback to standard formatting if formatter fails
|
||||
try:
|
||||
body_text = template['body'].format(**variables)
|
||||
except (KeyError, ValueError):
|
||||
body_text = template['body']
|
||||
elif event_type in ('backup_complete', 'backup_fail') and pve_message:
|
||||
parsed = _parse_vzdump_message(pve_message)
|
||||
if parsed:
|
||||
is_success = (event_type == 'backup_complete')
|
||||
@@ -1057,6 +1256,7 @@ CATEGORY_EMOJI = {
|
||||
'services': '\u2699\uFE0F', # gear
|
||||
'health': '\U0001FA7A', # stethoscope
|
||||
'updates': '\U0001F504', # counterclockwise arrows (update)
|
||||
'hardware': '\U0001F3AE', # video game controller (GPU/PCIe hardware)
|
||||
'other': '\U0001F4E8', # incoming envelope
|
||||
}
|
||||
|
||||
@@ -1064,23 +1264,27 @@ CATEGORY_EMOJI = {
|
||||
EVENT_EMOJI = {
|
||||
# VM / CT
|
||||
'vm_start': '\u25B6\uFE0F', # play button
|
||||
'vm_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
|
||||
'vm_stop': '\u23F9\uFE0F', # stop button
|
||||
'vm_shutdown': '\u23CF\uFE0F', # eject
|
||||
'vm_fail': '\U0001F4A5', # collision (crash)
|
||||
'vm_restart': '\U0001F504', # cycle
|
||||
'ct_start': '\u25B6\uFE0F',
|
||||
'ct_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
|
||||
'ct_stop': '\u23F9\uFE0F',
|
||||
'ct_shutdown': '\u23CF\uFE0F',
|
||||
'ct_restart': '\U0001F504',
|
||||
'ct_fail': '\U0001F4A5',
|
||||
'migration_start': '\U0001F69A', # moving truck
|
||||
'migration_complete': '\u2705', # check mark
|
||||
'migration_warning': '\U0001F69A\u26A0\uFE0F', # 🚚⚠️ truck + warning
|
||||
'migration_fail': '\u274C', # cross mark
|
||||
'replication_fail': '\u274C',
|
||||
'replication_complete': '\u2705',
|
||||
# Backups
|
||||
'backup_start': '\U0001F4BE\U0001F680', # 💾🚀 floppy + rocket
|
||||
'backup_complete': '\U0001F4BE\u2705', # 💾✅ floppy + check
|
||||
'backup_warning': '\U0001F4BE\u26A0\uFE0F', # 💾⚠️ floppy + warning
|
||||
'backup_fail': '\U0001F4BE\u274C', # 💾❌ floppy + cross
|
||||
'snapshot_complete': '\U0001F4F8', # camera with flash
|
||||
'snapshot_fail': '\u274C',
|
||||
@@ -1106,6 +1310,7 @@ EVENT_EMOJI = {
|
||||
'node_disconnect': '\U0001F50C',
|
||||
'node_reconnect': '\u2705',
|
||||
# Services
|
||||
'system_startup': '\U0001F680', # rocket (startup)
|
||||
'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode)
|
||||
'system_reboot': '\U0001F504',
|
||||
'system_problem': '\u26A0\uFE0F',
|
||||
@@ -1121,8 +1326,13 @@ EVENT_EMOJI = {
|
||||
'update_summary': '\U0001F4E6',
|
||||
'pve_update': '\U0001F195', # NEW
|
||||
'update_complete': '\u2705',
|
||||
'proxmenux_update': '\U0001F195', # NEW
|
||||
# AI
|
||||
'ai_model_migrated': '\U0001F916', # robot
|
||||
'ai_model_migrated': '\U0001F504', # arrows counterclockwise (refresh/update)
|
||||
# GPU / PCIe
|
||||
'gpu_mode_switch': '\U0001F3AE', # video game controller (represents GPU)
|
||||
'gpu_passthrough_blocked': '\U0001F6AB', # prohibited sign (blocked)
|
||||
'pci_passthrough_conflict': '\u26A0\uFE0F', # warning triangle (conflict)
|
||||
}
|
||||
|
||||
# Decorative field-level icons for body text enrichment
|
||||
@@ -1164,6 +1374,8 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
The function is idempotent: if the title already starts with an emoji,
|
||||
it is returned unchanged.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Pick the best title icon: event-specific > category > severity circle
|
||||
template = TEMPLATES.get(event_type, {})
|
||||
group = template.get('group', 'other')
|
||||
@@ -1184,8 +1396,55 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
enriched_title = title.replace(sev_icon, icon, 1)
|
||||
break
|
||||
|
||||
# ── Preprocess body: add line breaks before known patterns ──
|
||||
# This helps when everything comes concatenated
|
||||
preprocessed = body
|
||||
|
||||
# First, clean up duplicated device references like "/dev/sda: /dev/sda: /dev/sda [SAT]"
|
||||
# Convert to just "/dev/sda [SAT]" or "/dev/sda:"
|
||||
preprocessed = re.sub(r'(/dev/\w+):\s*\1:\s*\1', r'\1', preprocessed)
|
||||
preprocessed = re.sub(r'(/dev/\w+):\s*\1', r'\1', preprocessed)
|
||||
|
||||
# Patterns that should start on a new line
|
||||
line_break_patterns = [
|
||||
(r';\s*/dev/', '\n/dev/'), # ;/dev/sdb -> newline + /dev/sdb
|
||||
(r'(?<=[a-z])\s+/dev/', '\n/dev/'), # "sectors /dev/sdb" -> newline before /dev/
|
||||
(r'(?<=\))\s*/dev/', '\n/dev/'), # ") /dev/sdb" -> newline before /dev/
|
||||
(r'\bDevice:', '\nDevice:'), # Device: on new line
|
||||
(r'\bError:', '\nError:'), # Error: on new line
|
||||
(r'\bAction:', '\nAction:'), # Action: on new line
|
||||
(r'\bAffected:', '\nAffected:'), # Affected: on new line
|
||||
(r'Device not currently', '\nDevice not currently'), # Note about missing device
|
||||
(r'\bSMART:', '\nSMART:'), # SMART status
|
||||
]
|
||||
|
||||
for pattern, replacement in line_break_patterns:
|
||||
preprocessed = re.sub(pattern, replacement, preprocessed)
|
||||
|
||||
# Clean up multiple newlines and leading newlines
|
||||
preprocessed = re.sub(r'\n{3,}', '\n\n', preprocessed)
|
||||
preprocessed = re.sub(r'^\n+', '', preprocessed)
|
||||
preprocessed = preprocessed.strip()
|
||||
|
||||
# ── Extended emoji mappings for health/disk messages ──
|
||||
HEALTH_EMOJI_MAP = {
|
||||
# Disk patterns
|
||||
'/dev/': '\U0001F4BF', # DVD disk
|
||||
'Device:': '\U0001F4BF', # DVD disk
|
||||
'Error:': '\u274C', # Red X
|
||||
'Action:': '\U0001F4A1', # Light bulb (tip)
|
||||
'Affected:': '\U0001F3AF', # Target
|
||||
'SMART:': '\U0001F4CA', # Chart
|
||||
'Device not currently': '\U0001F4CC', # Pushpin (note)
|
||||
# Status patterns
|
||||
'unreadable': '\u26A0\uFE0F', # Warning
|
||||
'pending': '\u26A0\uFE0F', # Warning
|
||||
'FAILED': '\u274C', # Red X
|
||||
'PASSED': '\u2705', # Green check
|
||||
}
|
||||
|
||||
# Build enriched body: prepend field emojis to recognizable lines
|
||||
lines = body.split('\n')
|
||||
lines = preprocessed.split('\n')
|
||||
enriched_lines = []
|
||||
|
||||
for line in lines:
|
||||
@@ -1194,6 +1453,21 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
enriched_lines.append(line)
|
||||
continue
|
||||
|
||||
# First, check health-specific patterns
|
||||
health_enriched = False
|
||||
for pattern, emoji in HEALTH_EMOJI_MAP.items():
|
||||
if stripped.startswith(pattern):
|
||||
# Don't double-add emoji if already present
|
||||
if not stripped.startswith(emoji):
|
||||
enriched_lines.append(f'{emoji} {stripped}')
|
||||
else:
|
||||
enriched_lines.append(stripped)
|
||||
health_enriched = True
|
||||
break
|
||||
|
||||
if health_enriched:
|
||||
continue
|
||||
|
||||
# Try to match "FieldName: value" patterns
|
||||
enriched = False
|
||||
for field_key, field_icon in FIELD_EMOJI.items():
|
||||
@@ -1255,341 +1529,230 @@ AI_LANGUAGES = {
|
||||
|
||||
# Token limits for different detail levels
|
||||
# max_tokens is a LIMIT, not fixed consumption - you only pay for tokens actually generated
|
||||
# Note: Some providers (especially Gemini) may have lower default limits, so we use generous values
|
||||
AI_DETAIL_TOKENS = {
|
||||
'brief': 300, # Short messages, 2-3 lines
|
||||
'standard': 1000, # Standard messages, sufficient for 15-20 VMs
|
||||
'detailed': 2000, # Complete technical reports with all details
|
||||
'brief': 500, # Short messages, 2-3 lines
|
||||
'standard': 1500, # Standard messages, sufficient for 15-20 VMs
|
||||
'detailed': 3000, # Complete technical reports with all details
|
||||
}
|
||||
|
||||
# System prompt template - informative, no recommendations
|
||||
AI_SYSTEM_PROMPT = """You are a system notification formatter for ProxMenux Monitor, a Proxmox VE monitoring tool.
|
||||
# System prompt template - optimized hybrid version
|
||||
AI_SYSTEM_PROMPT = """You are a notification FORMATTER for ProxMenux Monitor (Proxmox VE).
|
||||
Your job: translate alerts into {language} and enrich them with context when provided.
|
||||
|
||||
Your task is to translate and reformat incoming server alert messages into {language}.
|
||||
═══ ABSOLUTE CONSTRAINTS (NO EXCEPTIONS) ═══
|
||||
- NO HALLUCINATIONS: Do not invent causes, solutions, or facts not present in the provided data
|
||||
- NO SPECULATION: If something is unclear, state what IS known, not what MIGHT be
|
||||
- NO CONVERSATIONAL TEXT: Never write "Here is...", "I've translated...", "Let me explain..."
|
||||
- ONLY use information from: the message, journal context, and known error database (if provided)
|
||||
|
||||
═══ ABSOLUTE RULES ═══
|
||||
1. Translate BOTH title and body to {language}. Every word, label, and unit must be in {language}.
|
||||
2. NO markdown: no **bold**, no *italic*, no `code`, no headers (#), no bullet lists (- or *)
|
||||
3. Plain text only — the output is sent to chat apps and email which handle their own formatting
|
||||
4. Tone: factual, concise, technical. No greetings, no closings, no apologies
|
||||
5. DO NOT add recommendations, action items, or suggestions ("you should…", "consider…")
|
||||
6. Present ONLY the facts already in the input — do not invent or assume information
|
||||
7. OUTPUT ONLY THE FINAL RESULT — never include both original and processed versions.
|
||||
Do NOT append "Original message:", "Original:", "Source:", or any before/after comparison.
|
||||
Return ONLY the single, final formatted message in {language}.
|
||||
8. PLAIN NARRATIVE LINES — if a line in the input is a complete sentence (not a "Label: value"
|
||||
pair), translate it as-is. Never prepend "Message:", "Note:", or any other label to a sentence.
|
||||
9. Detail level to apply: {detail_level}
|
||||
- brief → 2-3 lines, essential data only (status + key metric)
|
||||
- standard → short paragraph covering who/what/where and the key value
|
||||
- detailed → full technical breakdown of all available fields
|
||||
10. Keep the "hostname: " prefix in the title. Translate only the descriptive part.
|
||||
Example: "pve01: Updates available" → "pve01: Actualizaciones disponibles"
|
||||
11. EMPTY LIST VALUES — if a list field is empty, "none", or "0":
|
||||
Always write the translated word for "none" on the line after the label, never leave it blank.
|
||||
Example: 🗂️ Important packages:\\n• none
|
||||
Example (Spanish): 🗂️ Paquetes importantes:\\n• ninguno
|
||||
Example (Français): 🗂️ Paquets importants:\\n• aucun
|
||||
12. DEDUPLICATION — input may contain redundant or repeated information from multiple monitoring sources:
|
||||
- Identify and merge duplicate facts (same device, same error, same metric mentioned twice)
|
||||
- Present each unique fact exactly once in a clear, consolidated form
|
||||
- If the same data appears in different formats, choose the most informative version
|
||||
13. PROXMOX CONTEXT — silently translate Proxmox technical references into plain language.
|
||||
Never explain what the term means — just use the human-readable equivalent directly.
|
||||
═══ WHAT TO TRANSLATE ═══
|
||||
Translate: labels, descriptions, status words, units (GB→Go in French, etc.)
|
||||
DO NOT translate: hostnames, IPs, paths, VM/CT IDs, device names (/dev/sdX), technical identifiers
|
||||
|
||||
Service / process name mapping (replace the raw name with the friendly form):
|
||||
- "pve-container@XXXX.service" → "Container CT XXXX"
|
||||
- "qemu-server@XXXX.service" → "Virtual Machine VM XXXX"
|
||||
- "pvesr-XXXX" → "storage replication job for XXXX"
|
||||
- "vzdump" → "backup process"
|
||||
- "pveproxy" → "Proxmox web proxy"
|
||||
- "pvedaemon" → "Proxmox daemon"
|
||||
- "pvestatd" → "Proxmox statistics service"
|
||||
- "pvescheduler" → "Proxmox task scheduler"
|
||||
- "pve-cluster" → "Proxmox cluster service"
|
||||
- "corosync" → "cluster communication service"
|
||||
- "ceph-osd@N" → "Ceph storage disk N"
|
||||
- "ceph-mon" → "Ceph monitor service"
|
||||
═══ CORE RULES ═══
|
||||
1. Plain text only — NO markdown, no **bold**, no `code`, no bullet lists (use "• " for packages only)
|
||||
2. Preserve severity: "failed" stays "failed", "warning" stays "warning" — never soften errors
|
||||
3. Preserve structure: keep same fields and line order, only translate content
|
||||
4. Detail level "{detail_level}" - controls AMOUNT OF EVENT INFO (not tips/suggestions):
|
||||
- brief: 1-2 lines max. Only: what happened + where
|
||||
- standard: 3-6 lines. Include: what, where, cause, affected devices
|
||||
- detailed: Full report with ALL info: what, where, cause, affected, logs, SMART data, history
|
||||
5. DEDUPLICATION: merge duplicate facts from multiple sources into one clear statement
|
||||
6. EMPTY LISTS: write translated "none" after label, never leave blank
|
||||
7. Keep "hostname:" prefix in title — translate only the descriptive part
|
||||
8. DO NOT add recommendations or suggestions UNLESS AI Suggestions mode is enabled below
|
||||
9. ENRICHED CONTEXT: You may receive additional context data including:
|
||||
- "System uptime: X days (stable system)" → helps distinguish startup issues from runtime failures
|
||||
- "Event frequency: N occurrences, first seen X ago" → indicates recurring vs one-time issues
|
||||
- "SMART Health: PASSED/FAILED" with disk attributes → critical for disk errors
|
||||
- "KNOWN PROXMOX ERROR DETECTED" with cause/solution → YOU MUST USE this exact information
|
||||
|
||||
How to use enriched context:
|
||||
- If uptime is <10min and error is service-related → mention "occurred shortly after boot"
|
||||
- If frequency shows recurring pattern → mention "recurring issue (N times in X hours)"
|
||||
- If SMART shows FAILED → treat as CRITICAL: "Disk failing - immediate attention required"
|
||||
- If KNOWN ERROR is provided → YOU MUST incorporate its Cause and Solution (translate, don't copy verbatim)
|
||||
|
||||
systemd message patterns (rewrite the whole phrase, not just the service name):
|
||||
- "systemd[1]: pve-container@9000.service: Failed"
|
||||
→ "Container CT 9000 service failed"
|
||||
- "systemd[1]: qemu-server@100.service: Failed with result 'exit-code'"
|
||||
→ "Virtual Machine VM 100 failed to start"
|
||||
- "systemd[1]: Started pve-container@9000.service"
|
||||
→ "Container CT 9000 started"
|
||||
|
||||
ATA / SMART / kernel error patterns (replace raw kernel log with plain description):
|
||||
- "ata8.00: exception Emask 0x1 SAct 0x4ce0 SErr 0x40000 action 0x0"
|
||||
→ "ATA controller error on port 8"
|
||||
- "blk_update_request: I/O error, dev sdX, sector NNNN"
|
||||
→ "I/O error on disk /dev/sdX at sector NNNN"
|
||||
- "SCSI error: return code = 0x08000002"
|
||||
→ "SCSI communication error"
|
||||
|
||||
Apply these mappings everywhere: in the body narrative, in field values, and when
|
||||
the raw technical string appears inside a longer sentence.
|
||||
10. JOURNAL CONTEXT EXTRACTION: When journal logs are provided:
|
||||
- Extract specific IDs (VM/CT numbers, disk devices, service names)
|
||||
- Include relevant timestamps if they help explain the timeline
|
||||
- Identify root cause when logs clearly show it (e.g., "exit-code 255" -> "process crashed")
|
||||
- Translate technical terms: "Emask 0x10" -> "ATA bus error", "DRDY ERR" -> "drive not ready"
|
||||
- If logs show the same error repeating, state frequency: "occurred 15 times in 10 minutes"
|
||||
- IGNORE journal entries unrelated to the main event
|
||||
11. OUTPUT ONLY the final result — no "Original:", no before/after comparisons
|
||||
12. Unknown input: preserve as closely as possible, translate what you can
|
||||
13. REDUNDANCY: Never repeat the same information twice. If title says "CT 103 failed", body should not start with "Container 103 failed"
|
||||
{suggestions_addon}
|
||||
═══ PROXMOX MAPPINGS (use directly, never explain) ═══
|
||||
pve-container@XXXX → "CT XXXX" | qemu-server@XXXX → "VM XXXX" | vzdump → "backup"
|
||||
pveproxy/pvedaemon/pvestatd → "Proxmox service" | corosync → "cluster service"
|
||||
"ata8.00: exception Emask..." → "ATA error on port 8"
|
||||
"blk_update_request: I/O error, dev sdX" → "I/O error on /dev/sdX"
|
||||
{emoji_instructions}
|
||||
═══ MESSAGE FORMATS ═══
|
||||
|
||||
═══ MESSAGE TYPES — FORMAT RULES ═══
|
||||
BACKUP: List each VM/CT with status/size/duration/storage. End with summary.
|
||||
- Partial failure (some OK, some failed) = "Backup partially failed", not "failed"
|
||||
- NEVER collapse multi-VM backup into one line — show each VM separately
|
||||
- ALWAYS include storage path and summary line
|
||||
|
||||
BACKUP (backup_complete / backup_fail / backup_start):
|
||||
Input contains: VM/CT names, IDs, size, duration, storage location, status per VM
|
||||
Output body: first line is plain text (no emoji) describing the event briefly.
|
||||
Then list each VM/CT with its fields. End with a summary line.
|
||||
PARTIAL FAILURE RULE: if some VMs succeeded and at least one failed, use a combined title
|
||||
like "Backup partially failed" / "Copia de seguridad parcialmente fallida" — never say
|
||||
"backup failed" when there are also successful VMs in the same job.
|
||||
NEVER omit the storage/archive line or the summary line — always include them even for long jobs.
|
||||
UPDATES: Counts on own lines. Packages use "• " under header. No redundant summary.
|
||||
|
||||
UPDATES (update_summary):
|
||||
- Each count on its own line with its label.
|
||||
- Package list uses "• " (bullet + space) per package, NOT the 🗂️ emoji on each line.
|
||||
- The 🗂️ emoji goes only on the "Important packages:" header line.
|
||||
- NEVER add a redundant summary line repeating the total count.
|
||||
|
||||
PVE UPDATE (pve_update):
|
||||
- First line: plain sentence announcing the new version (no emoji on this line).
|
||||
- Blank line after intro.
|
||||
- Current version: 🔹 prefix | New version: 🟢 prefix
|
||||
- Blank line before packages block.
|
||||
- Packages header: 🗂️ | Package lines: 📌 prefix with version arrow v{{old}} ➜ v{{new}}
|
||||
DISK/SMART: Device + specific error. Deduplicate repeated info.
|
||||
|
||||
DISK / SMART ERRORS (disk_io_error / storage_unavailable):
|
||||
Input contains: device name, error type, SMART values or I/O error codes
|
||||
Output body: device, then the specific error or failing attribute
|
||||
DEDUPLICATION: Input may contain repeated or similar information from multiple sources.
|
||||
If you see the same device, error count, or technical details mentioned multiple times,
|
||||
consolidate them into a single, clear statement. Never repeat the same information twice.
|
||||
HEALTH: Category + severity + what changed. Duration if resolved.
|
||||
|
||||
RESOURCES (cpu_high / ram_high / temp_high / load_high):
|
||||
Input contains: current value, threshold, core count
|
||||
Output: current value vs threshold, context if available
|
||||
VM/CT LIFECYCLE: Confirm event with key facts (1-2 lines).
|
||||
|
||||
SECURITY (auth_fail / ip_block):
|
||||
Input contains: source IP, user, service, jail, failure count
|
||||
Output: list each field on its own line
|
||||
═══ OUTPUT FORMAT (CRITICAL - MUST FOLLOW EXACTLY) ═══
|
||||
|
||||
VM/CT LIFECYCLE (vm_start, vm_stop, vm_fail, ct_*, migration_*, replication_*):
|
||||
Input contains: VM name, ID, target node (migrations), reason (failures)
|
||||
Output: one or two lines confirming the event with key facts
|
||||
|
||||
CLUSTER (split_brain / node_disconnect / node_reconnect):
|
||||
Input: node name, quorum status
|
||||
Output: state change + quorum value
|
||||
|
||||
HEALTH (new_error / error_resolved / health_persistent / health_degraded):
|
||||
Input: category, severity, duration, reason
|
||||
Output: what changed, in which category, for how long (if resolved)
|
||||
|
||||
═══ OUTPUT FORMAT (follow exactly — parsers rely on these markers) ═══
|
||||
Your response MUST have EXACTLY this structure:
|
||||
[TITLE]
|
||||
translated title here
|
||||
your translated title text
|
||||
[BODY]
|
||||
translated body here
|
||||
your translated body text
|
||||
|
||||
CRITICAL:
|
||||
- [TITLE] on its own line, title text on the very next line — no blank line between them
|
||||
- [BODY] on its own line, body text starting on the very next line — no blank line between them
|
||||
- Do NOT write "Title:", "Body:", or any label substituting the markers
|
||||
- Do NOT include the literal words TITLE or BODY anywhere in the translated content"""
|
||||
ABSOLUTE RULES (violations break the parser):
|
||||
1. [TITLE] and [BODY] are INVISIBLE PARSING MARKERS — they separate title from body
|
||||
2. Your actual title/body content must NEVER contain the words "[TITLE]" or "[BODY]"
|
||||
3. Your actual title/body content must NEVER contain "Title:" or "Body:" prefixes
|
||||
4. Line 1: write exactly [TITLE]
|
||||
5. Line 2: write your title text (emoji + hostname: description)
|
||||
6. Line 3: write exactly [BODY]
|
||||
7. Line 4+: write your body text
|
||||
|
||||
WRONG (markers appear in content):
|
||||
[TITLE]
|
||||
🔵 server: [TITLE] Updates available
|
||||
[BODY]
|
||||
[BODY] 153 updates available
|
||||
|
||||
CORRECT (markers are separators only):
|
||||
[TITLE]
|
||||
🔵 server: Updates available
|
||||
[BODY]
|
||||
153 updates available
|
||||
|
||||
- Output ONLY the formatted result — no explanations, no "Original:", no commentary"""
|
||||
|
||||
# Addon for experimental suggestions mode
|
||||
AI_SUGGESTIONS_ADDON = """
|
||||
═══ AI SUGGESTIONS MODE (ENABLED) ═══
|
||||
You MAY add ONE brief, actionable tip at the END of the body using this exact format:
|
||||
|
||||
💡 Tip: [your concise suggestion here]
|
||||
|
||||
Rules for the tip:
|
||||
- ONLY include if the log context or Known Error database clearly points to a specific fix
|
||||
- Keep under 100 characters
|
||||
- Be specific: "Run 'pvecm status' to check quorum" NOT "Check cluster status"
|
||||
- If Known Error provides a solution, YOU MUST USE IT (don't invent your own)
|
||||
- Never guess — skip the tip if the cause/solution is unclear
|
||||
"""
|
||||
|
||||
# Emoji instructions injected into AI_SYSTEM_PROMPT for rich channels (Telegram, Discord, Pushover)
|
||||
AI_EMOJI_INSTRUCTIONS = """
|
||||
═══ EMOJI RULES ═══
|
||||
Place ONE emoji at the START of every non-empty line (title and each body line).
|
||||
Never skip a line. Never put the emoji at the end.
|
||||
A blank line must be completely empty — no emoji, no spaces.
|
||||
|
||||
TITLE emoji — one per event type:
|
||||
✅ success / resolved / complete / reconnected
|
||||
❌ failed / FAILED / error
|
||||
💥 crash / I/O error / hardware fault
|
||||
🆘 new critical health issue
|
||||
📦 backup started / updates available (update_summary)
|
||||
🆕 new PVE version available (pve_update)
|
||||
🔺 escalated / severity increased
|
||||
📋 health digest / persistent issues
|
||||
🚚 migration started
|
||||
🔌 network down / node disconnected
|
||||
🚨 auth failure / security alert
|
||||
🚷 IP banned / blocked
|
||||
🔑 permission change
|
||||
💢 split-brain
|
||||
💣 OOM kill
|
||||
🚀 VM or CT started
|
||||
⏹️ VM or CT stopped
|
||||
🔽 VM or CT shutdown
|
||||
🔄 restarted / reboot / proxmox updates
|
||||
🔥 high CPU / firewall issue
|
||||
💧 high memory
|
||||
🌡️ high temperature
|
||||
⚠️ warning / degraded / high load / system problem
|
||||
📉 low disk space
|
||||
🚫 storage unavailable
|
||||
🐢 high latency
|
||||
📸 snapshot created
|
||||
⏻ system shutdown
|
||||
|
||||
BODY LINE emoji — one per line based on content:
|
||||
🏷️ VM name / CT name / ID line (first line of VM/CT lifecycle events)
|
||||
✔️ status ok / success / action confirmed
|
||||
❌ status error / failed
|
||||
💽 size (individual VM/CT backup)
|
||||
💾 total backup size (summary line only)
|
||||
⏱️ duration
|
||||
🗄️ storage location / PBS path
|
||||
📦 total updates count
|
||||
🔒 security updates / jail
|
||||
🔄 proxmox updates
|
||||
⚙️ kernel updates / service name
|
||||
🗂️ important packages header
|
||||
🌐 source IP
|
||||
👤 user
|
||||
📝 reason / details
|
||||
🌡️ temperature
|
||||
🔥 CPU usage
|
||||
💧 memory usage
|
||||
📊 summary line / statistics
|
||||
👥 quorum / cluster nodes
|
||||
💿 disk device
|
||||
📂 filesystem / mount point
|
||||
📌 category / package item (pve_update)
|
||||
🚦 severity
|
||||
🖥️ node name
|
||||
🎯 target node
|
||||
🔹 current version (pve_update)
|
||||
🟢 new version (pve_update)
|
||||
═══ EMOJI ENRICHMENT (VISUAL CLARITY) ═══
|
||||
Your goal is to maintain the original structure of the message while using emojis to add visual clarity,
|
||||
ESPECIALLY when adding new context, formatting technical data, or writing tips.
|
||||
|
||||
RULES:
|
||||
1. PRESERVE BASE STRUCTURE: Respect the original fields and layout provided in the input message.
|
||||
2. ENHANCE WITH ICONS: Place emojis at the START of a line to identify the data type.
|
||||
3. NEW CONTEXT: When adding journal info, SMART data, or known errors, use appropriate icons to make it readable.
|
||||
4. NO SPAM: Do not put emojis in the middle or end of sentences. Use 1-3 emojis at START of lines where they add clarity. Combine when meaningful (💾✅ backup ok).
|
||||
5. HIGHLIGHT ONLY: Use emojis to highlight, not as filler. Blank lines = completely empty.
|
||||
|
||||
BLANK LINES FOR READABILITY — insert ONE blank line between logical sections within the body.
|
||||
Blank lines go BETWEEN groups, not before the first line or after the last line.
|
||||
A blank line must be completely empty — no emoji, no spaces.
|
||||
TITLE EMOJIS:
|
||||
✅ success ❌ failed 💥 crash 🆘 critical 📦 updates 🆕 pve-update 🚚 migration
|
||||
⏹️ stop 🔽 shutdown ⚠️ warning 💢 split-brain 🔌 disconnect 🚨 auth-fail 🚷 banned 📋 digest
|
||||
🚀 = something STARTS (VM/CT start, backup start, server boot, task begin)
|
||||
Combine: 💾🚀 backup-start 🖥️🚀 system-boot 🚀 VM/CT-start
|
||||
|
||||
When to add a blank line:
|
||||
- Updates: after the last count line, before the packages block
|
||||
- Backup multi-VM: one blank line between each VM entry; one blank line before the summary line
|
||||
- Disk/SMART errors: after the device line, before the error description lines
|
||||
- VM events with a reason: after the main status line, before Reason / Node / Target lines
|
||||
- Health events: after the category/status line, before duration or detail lines
|
||||
BODY EMOJIS:
|
||||
🏷️ VM/CT name ✔️ ok ❌ error 💽 size 💾 total ⏱️ duration 🗄️ storage 📊 summary
|
||||
📦 updates 🔒 security 🔄 proxmox ⚙️ kernel 🗂️ packages 💿 disk 📝 reason/log
|
||||
🌐 IP 👤 user 🌡️ temp 🔥 CPU 💧 RAM 🎯 target 🔹 current 🟢 new 📌 item
|
||||
|
||||
EXAMPLE — CT shutdown:
|
||||
[TITLE]
|
||||
🔽 amd: CT alpine (101) shut down
|
||||
[BODY]
|
||||
🏷️ Container alpine (ID: 101)
|
||||
✔️ Cleanly shut down
|
||||
BLANK LINES: Insert between logical sections (VM entries, before summary, before packages block).
|
||||
|
||||
EXAMPLE — VM started:
|
||||
[TITLE]
|
||||
🚀 pve01: VM arch-linux (100) started
|
||||
[BODY]
|
||||
🏷️ Virtual machine arch-linux (ID: 100)
|
||||
✔️ Now running
|
||||
═══ HOSTNAME RULE (CRITICAL) ═══
|
||||
The Title field contains the real hostname before the colon e.g.:
|
||||
("constructor: VM started" → hostname is "constructor").
|
||||
("amd: VM started" → hostname is "amd").
|
||||
("pve01: VM started" → hostname is "pve01").
|
||||
("pve05: VM started" → hostname is "pve05").
|
||||
You MUST use this EXACT hostname in your output. NEVER use generic names like "server", "host", or "node".
|
||||
|
||||
EXAMPLE — migration complete:
|
||||
[TITLE]
|
||||
🚚 amd: Migration complete — web01 (100)
|
||||
[BODY]
|
||||
🏷️ Virtual machine web01 (ID: 100)
|
||||
✔️ Successfully migrated
|
||||
|
||||
🎯 Target: node02
|
||||
═══ EXAMPLES (follow these formats) ═══
|
||||
|
||||
EXAMPLE — updates message (no important packages):
|
||||
[TITLE]
|
||||
📦 amd: Updates available
|
||||
[BODY]
|
||||
📦 Total updates: 24
|
||||
🔒 Security updates: 6
|
||||
🔄 Proxmox updates: 0
|
||||
⚙️ Kernel updates: 0
|
||||
BACKUP START:
|
||||
[TITLE]
|
||||
💾🚀 constructor: Backup started
|
||||
[BODY]
|
||||
Backup job starting on storage PBS.
|
||||
🏷️ VMs: web01 (100)
|
||||
|
||||
🗂️ Important packages:
|
||||
• none
|
||||
🗄️ Storage: PBS | ⚙️ Mode: stop
|
||||
|
||||
EXAMPLE — updates message (with important packages):
|
||||
[TITLE]
|
||||
📦 amd: Updates available
|
||||
[BODY]
|
||||
📦 Total updates: 90
|
||||
🔒 Security updates: 6
|
||||
🔄 Proxmox updates: 14
|
||||
⚙️ Kernel updates: 1
|
||||
BACKUP COMPLETE:
|
||||
[TITLE]
|
||||
💾✅ amd: Backup complete
|
||||
[BODY]
|
||||
Backup job finished on storage local-bak.
|
||||
|
||||
🗂️ Important packages:
|
||||
• pve-manager (9.1.4 -> 9.1.6)
|
||||
• qemu-server (9.1.3 -> 9.1.4)
|
||||
• pve-container (6.0.18 -> 6.1.2)
|
||||
|
||||
EXAMPLE — pve_update (new Proxmox VE version):
|
||||
[TITLE]
|
||||
🆕 pve01: Proxmox VE 9.1.6 available
|
||||
[BODY]
|
||||
🚀 A new Proxmox VE release is available.
|
||||
🏷️ VM web01 (ID: 100)
|
||||
✔️ Status: ok
|
||||
💽 Size: 12.3 GiB
|
||||
⏱️ Duration: 00:04:21
|
||||
🗄️ Storage: vm/100/2026-03-17T22:00:08Z
|
||||
|
||||
🔹 Current: 9.1.4
|
||||
🟢 New: 9.1.6
|
||||
📊 Total: 1 backup | 💾 12.3 GiB | ⏱️ 00:04:21
|
||||
|
||||
🗂️ Important packages:
|
||||
📌 pve-manager (v9.1.4 ➜ v9.1.6)
|
||||
BACKUP PARTIAL FAIL:
|
||||
[TITLE]
|
||||
💾❌ pve05: Backup partially failed
|
||||
[BODY]
|
||||
Backup job finished with errors.
|
||||
|
||||
EXAMPLE — backup complete with multiple VMs:
|
||||
[TITLE]
|
||||
💾✅ pve01: Backup complete
|
||||
[BODY]
|
||||
Backup job finished on storage local-bak.
|
||||
🏷️ VM web01 (ID: 100)
|
||||
✔️ Status: ok
|
||||
💽 Size: 12.3 GiB
|
||||
|
||||
🏷️ VM web01 (ID: 100)
|
||||
✔️ Status: ok
|
||||
💽 Size: 12.3 GiB
|
||||
⏱️ Duration: 00:04:21
|
||||
🗄️ Storage: vm/100/2026-03-17T22:00:08Z
|
||||
🏷️ VM broken (ID: 102)
|
||||
❌ Status: error
|
||||
|
||||
🏷️ CT db (ID: 101)
|
||||
✔️ Status: ok
|
||||
💽 Size: 4.1 GiB
|
||||
⏱️ Duration: 00:01:10
|
||||
🗄️ Storage: ct/101/2026-03-17T22:04:29Z
|
||||
📊 Total: 2 backups | ❌ 1 failed
|
||||
|
||||
📊 Total: 2 backups | 💾 16.4 GiB | ⏱️ 00:05:31
|
||||
UPDATES:
|
||||
[TITLE]
|
||||
📦 amd: Updates available
|
||||
[BODY]
|
||||
📦 Total updates: 24
|
||||
🔒 Security updates: 6
|
||||
🔄 Proxmox updates: 0
|
||||
|
||||
EXAMPLE — backup partially failed (some ok, some failed):
|
||||
[TITLE]
|
||||
💾❌ pve01: Backup partially failed
|
||||
[BODY]
|
||||
Backup job finished with errors on storage PBS2.
|
||||
🗂️ Important packages:
|
||||
• none
|
||||
|
||||
🏷️ VM web01 (ID: 100)
|
||||
✔️ Status: ok
|
||||
💽 Size: 12.3 GiB
|
||||
⏱️ Duration: 00:04:21
|
||||
🗄️ Storage: vm/100/2026-03-17T22:00:08Z
|
||||
VM/CT START:
|
||||
[TITLE]
|
||||
🚀 pve01: VM arch-linux (100) started
|
||||
[BODY]
|
||||
🏷️ Virtual machine arch-linux (ID: 100)
|
||||
✔️ Now running
|
||||
|
||||
🏷️ VM broken (ID: 102)
|
||||
❌ Status: error
|
||||
💽 Size: 0 B
|
||||
⏱️ Duration: 00:00:37
|
||||
|
||||
📊 Total: 2 backups | ❌ 1 failed | 💾 12.3 GiB | ⏱️ 00:04:58
|
||||
|
||||
EXAMPLE — disk I/O health warning:
|
||||
[TITLE]
|
||||
💥 amd: Health warning — Disk I/O errors
|
||||
[BODY]
|
||||
💿 Device: /dev/sda
|
||||
|
||||
⚠️ 1 sector currently unreadable (pending)
|
||||
📝 Disk reports sectors in pending reallocation state
|
||||
|
||||
EXAMPLE — health degraded (multiple issues):
|
||||
[TITLE]
|
||||
⚠️ amd: 2 health checks degraded
|
||||
[BODY]
|
||||
💥 Disk I/O error on /dev/sda: 1 sector currently unreadable (pending)
|
||||
|
||||
🏷️ Container CT 9005: ❌ failed to start
|
||||
🏷️ Container CT 9004: ❌ failed to start
|
||||
🏷️ Container CT 9002: ❌ failed to start"""
|
||||
HEALTH DEGRADED:
|
||||
[TITLE]
|
||||
⚠️ constructor: Health warning — Disk I/O
|
||||
[BODY]
|
||||
💿 Device: /dev/sda
|
||||
⚠️ 1 sector unreadable (pending)
|
||||
📝 Log: process crashed (exit-code 255)
|
||||
⚠️ Recurring: 5 times in 24h
|
||||
💡 Tip: Run 'systemctl status pvedaemon'"""
|
||||
|
||||
|
||||
# No emoji instructions for email/plain text channels
|
||||
@@ -1682,18 +1845,31 @@ class AIEnhancer:
|
||||
language_code = self.config.get('ai_language', 'en')
|
||||
language_name = AI_LANGUAGES.get(language_code, 'English')
|
||||
|
||||
# Get token limit for detail level
|
||||
max_tokens = AI_DETAIL_TOKENS.get(detail_level, 200)
|
||||
# Check for custom prompt mode
|
||||
prompt_mode = self.config.get('ai_prompt_mode', 'default')
|
||||
custom_prompt = self.config.get('ai_custom_prompt', '')
|
||||
|
||||
# Select emoji instructions based on channel type
|
||||
emoji_instructions = AI_EMOJI_INSTRUCTIONS if use_emojis else AI_NO_EMOJI_INSTRUCTIONS
|
||||
|
||||
# Build system prompt with emoji instructions
|
||||
system_prompt = AI_SYSTEM_PROMPT.format(
|
||||
language=language_name,
|
||||
detail_level=detail_level,
|
||||
emoji_instructions=emoji_instructions
|
||||
)
|
||||
if prompt_mode == 'custom' and custom_prompt.strip():
|
||||
# Custom prompt: user controls everything, use higher token limit
|
||||
system_prompt = custom_prompt
|
||||
max_tokens = 500 # Allow more tokens for custom prompts
|
||||
else:
|
||||
# Default prompt: use detail level and emoji settings
|
||||
max_tokens = AI_DETAIL_TOKENS.get(detail_level, 200)
|
||||
emoji_instructions = AI_EMOJI_INSTRUCTIONS if use_emojis else AI_NO_EMOJI_INSTRUCTIONS
|
||||
|
||||
# Check if experimental suggestions mode is enabled
|
||||
allow_suggestions = self.config.get('ai_allow_suggestions', 'false')
|
||||
if isinstance(allow_suggestions, str):
|
||||
allow_suggestions = allow_suggestions.lower() == 'true'
|
||||
suggestions_addon = AI_SUGGESTIONS_ADDON if allow_suggestions else ''
|
||||
|
||||
system_prompt = AI_SYSTEM_PROMPT.format(
|
||||
language=language_name,
|
||||
detail_level=detail_level,
|
||||
emoji_instructions=emoji_instructions,
|
||||
suggestions_addon=suggestions_addon
|
||||
)
|
||||
|
||||
# Build user message
|
||||
user_msg = f"Severity: {severity}\nTitle: {title}\nMessage:\n{body}"
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database of known Proxmox/Linux errors with causes, solutions, and severity levels.
|
||||
|
||||
This provides the AI with accurate, pre-verified information about common errors,
|
||||
reducing hallucinations and ensuring consistent, helpful responses.
|
||||
|
||||
Each entry includes:
|
||||
- pattern: regex pattern to match against error messages/logs
|
||||
- cause: brief explanation of what causes this error
|
||||
- cause_detailed: more comprehensive explanation for detailed mode
|
||||
- severity: info, warning, critical
|
||||
- solution: brief actionable solution
|
||||
- solution_detailed: step-by-step solution for detailed mode
|
||||
- url: optional documentation link
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
# Known error patterns with causes and solutions
|
||||
PROXMOX_KNOWN_ERRORS: List[Dict[str, Any]] = [
|
||||
# ==================== SUBSCRIPTION/LICENSE ====================
|
||||
{
|
||||
"pattern": r"no valid subscription|subscription.*invalid|not subscribed",
|
||||
"cause": "Proxmox enterprise repository requires paid subscription",
|
||||
"cause_detailed": "Proxmox VE uses a subscription model for enterprise features. Without a valid subscription key, access to the enterprise repository is denied. This is normal for home/lab users.",
|
||||
"severity": "info",
|
||||
"solution": "Use no-subscription repository or purchase subscription",
|
||||
"solution_detailed": "For home/lab use: Switch to the no-subscription repository by editing /etc/apt/sources.list.d/pve-enterprise.list. For production: Purchase a subscription at proxmox.com/pricing",
|
||||
"url": "https://pve.proxmox.com/wiki/Package_Repositories",
|
||||
"category": "updates"
|
||||
},
|
||||
|
||||
# ==================== CLUSTER/COROSYNC ====================
|
||||
{
|
||||
"pattern": r"quorum.*lost|lost.*quorum|not.*quorate",
|
||||
"cause": "Cluster lost majority of voting nodes",
|
||||
"cause_detailed": "Corosync cluster requires more than 50% of configured votes to maintain quorum. When quorum is lost, the cluster becomes read-only to prevent split-brain scenarios.",
|
||||
"severity": "critical",
|
||||
"solution": "Check network connectivity between nodes; ensure majority of nodes are online",
|
||||
"solution_detailed": "1. Verify network connectivity: ping all cluster nodes\n2. Check corosync status: systemctl status corosync\n3. View cluster status: pvecm status\n4. If nodes are unreachable, check firewall rules (ports 5405-5412 UDP)\n5. For emergency single-node operation: pvecm expected 1",
|
||||
"url": "https://pve.proxmox.com/wiki/Cluster_Manager",
|
||||
"category": "cluster"
|
||||
},
|
||||
{
|
||||
"pattern": r"corosync.*qdevice.*error|qdevice.*connection.*failed|qdevice.*not.*connected",
|
||||
"cause": "QDevice helper node is unreachable",
|
||||
"cause_detailed": "The Corosync QDevice provides an additional vote for 2-node clusters. When it cannot connect, the cluster may lose quorum if one node fails.",
|
||||
"severity": "warning",
|
||||
"solution": "Check QDevice server connectivity and corosync-qnetd service",
|
||||
"solution_detailed": "1. Verify QDevice server is running: systemctl status corosync-qnetd (on QDevice host)\n2. Check connectivity: nc -zv <qdevice-ip> 5403\n3. Restart qdevice: systemctl restart corosync-qdevice\n4. Check certificates: corosync-qdevice-net-certutil -s",
|
||||
"url": "https://pve.proxmox.com/wiki/Cluster_Manager#_corosync_external_vote_support",
|
||||
"category": "cluster"
|
||||
},
|
||||
{
|
||||
"pattern": r"corosync.*retransmit|corosync.*token.*timeout|ring.*mark.*faulty",
|
||||
"cause": "Network latency or packet loss between cluster nodes",
|
||||
"cause_detailed": "Corosync uses multicast/unicast for cluster communication. High latency, packet loss, or network congestion causes token timeouts and retransmissions, potentially leading to node eviction.",
|
||||
"severity": "warning",
|
||||
"solution": "Check network quality between nodes; consider increasing token timeout",
|
||||
"solution_detailed": "1. Test network latency: ping -c 100 <other-node>\n2. Check for packet loss between nodes\n3. Verify MTU settings match on all interfaces\n4. Increase token timeout in /etc/pve/corosync.conf if needed (default 1000ms)\n5. Check switch/router for congestion",
|
||||
"category": "cluster"
|
||||
},
|
||||
|
||||
# ==================== DISK/STORAGE ====================
|
||||
{
|
||||
"pattern": r"SMART.*FAILED|smart.*failed.*health|Pre-fail|Old_age.*FAILING",
|
||||
"cause": "Disk SMART health check failed - disk is failing",
|
||||
"cause_detailed": "SMART (Self-Monitoring, Analysis and Reporting Technology) detected critical disk health issues. The disk is likely failing and data loss is imminent.",
|
||||
"severity": "critical",
|
||||
"solution": "IMMEDIATELY backup data and replace disk",
|
||||
"solution_detailed": "1. URGENT: Backup all data from this disk immediately\n2. Check SMART details: smartctl -a /dev/sdX\n3. Note the failing attributes (Reallocated_Sector_Ct, Current_Pending_Sector, etc.)\n4. Plan disk replacement\n5. If in RAID/ZFS: initiate disk replacement procedure",
|
||||
"category": "disks"
|
||||
},
|
||||
{
|
||||
"pattern": r"Reallocated_Sector_Ct.*threshold|reallocated.*sectors?.*exceeded",
|
||||
"cause": "Disk has excessive bad sectors being remapped",
|
||||
"cause_detailed": "The disk firmware has remapped multiple bad sectors to spare areas. While the disk is still functioning, this indicates physical degradation and eventual failure.",
|
||||
"severity": "warning",
|
||||
"solution": "Monitor closely and plan disk replacement",
|
||||
"solution_detailed": "1. Check current value: smartctl -A /dev/sdX | grep Reallocated\n2. If value is increasing, plan immediate replacement\n3. Backup important data\n4. Run extended SMART test: smartctl -t long /dev/sdX",
|
||||
"category": "disks"
|
||||
},
|
||||
{
|
||||
"pattern": r"ata.*error|ATA.*bus.*error|Emask.*0x|DRDY.*ERR|UNC.*error",
|
||||
"cause": "ATA communication error with disk",
|
||||
"cause_detailed": "The SATA/ATA controller encountered communication errors with the disk. This can indicate cable issues, controller problems, or disk failure.",
|
||||
"severity": "warning",
|
||||
"solution": "Check SATA cables and connections; verify disk health with smartctl",
|
||||
"solution_detailed": "1. Check SMART health: smartctl -H /dev/sdX\n2. Inspect and reseat SATA cables\n3. Try different SATA port\n4. Check dmesg for pattern of errors\n5. If errors persist, disk may be failing",
|
||||
"category": "disks"
|
||||
},
|
||||
{
|
||||
"pattern": r"I/O.*error|blk_update_request.*error|Buffer I/O error",
|
||||
"cause": "Disk I/O operation failed",
|
||||
"cause_detailed": "The kernel failed to read or write data to the disk. This can be caused by disk failure, cable issues, or filesystem corruption.",
|
||||
"severity": "critical",
|
||||
"solution": "Check disk health and connections immediately",
|
||||
"solution_detailed": "1. Check SMART status: smartctl -H /dev/sdX\n2. Check dmesg for related errors: dmesg | grep -i error\n3. Verify disk is still accessible: lsblk\n4. If ZFS: check pool status with zpool status\n5. Consider filesystem check if safe to unmount",
|
||||
"category": "disks"
|
||||
},
|
||||
{
|
||||
"pattern": r"zfs.*pool.*DEGRADED|pool.*is.*degraded",
|
||||
"cause": "ZFS pool has reduced redundancy",
|
||||
"cause_detailed": "One or more devices in the ZFS pool are unavailable or experiencing errors. The pool is still functional but without full redundancy.",
|
||||
"severity": "warning",
|
||||
"solution": "Identify failed device with 'zpool status' and replace",
|
||||
"solution_detailed": "1. Check pool status: zpool status <pool>\n2. Identify the DEGRADED or UNAVAIL device\n3. If device is present but erroring: zpool scrub <pool>\n4. To replace: zpool replace <pool> <old-device> <new-device>\n5. Monitor resilver progress: zpool status",
|
||||
"category": "storage"
|
||||
},
|
||||
{
|
||||
"pattern": r"zfs.*pool.*FAULTED|pool.*is.*faulted",
|
||||
"cause": "ZFS pool is inaccessible",
|
||||
"cause_detailed": "The ZFS pool has lost too many devices and cannot maintain data integrity. Data may be inaccessible.",
|
||||
"severity": "critical",
|
||||
"solution": "Check failed devices; may need data recovery",
|
||||
"solution_detailed": "1. Check status: zpool status <pool>\n2. Identify all failed devices\n3. Attempt to online devices: zpool online <pool> <device>\n4. If drives are physically present, try zpool clear <pool>\n5. May require data recovery if multiple drives failed",
|
||||
"category": "storage"
|
||||
},
|
||||
|
||||
# ==================== CEPH ====================
|
||||
{
|
||||
"pattern": r"ceph.*OSD.*down|osd\.\d+.*down|ceph.*osd.*failed",
|
||||
"cause": "Ceph OSD daemon is not running",
|
||||
"cause_detailed": "A Ceph Object Storage Daemon (OSD) has stopped or crashed. This reduces storage redundancy and may trigger data rebalancing.",
|
||||
"severity": "warning",
|
||||
"solution": "Check disk health and restart OSD service",
|
||||
"solution_detailed": "1. Check OSD status: ceph osd tree\n2. View OSD logs: journalctl -u ceph-osd@<id>\n3. Check underlying disk: smartctl -H /dev/sdX\n4. Restart OSD: systemctl start ceph-osd@<id>\n5. If OSD keeps crashing, check for disk failure",
|
||||
"category": "storage"
|
||||
},
|
||||
{
|
||||
"pattern": r"ceph.*health.*WARN|HEALTH_WARN",
|
||||
"cause": "Ceph cluster has warnings",
|
||||
"cause_detailed": "Ceph detected issues that don't prevent operation but should be addressed. Common causes: degraded PGs, clock skew, full OSDs.",
|
||||
"severity": "warning",
|
||||
"solution": "Run 'ceph health detail' for specific issues",
|
||||
"solution_detailed": "1. Get details: ceph health detail\n2. Common fixes:\n - Degraded PGs: wait for recovery or add capacity\n - Clock skew: sync NTP on all nodes\n - Full OSDs: add storage or delete data\n3. Check: ceph status",
|
||||
"category": "storage"
|
||||
},
|
||||
{
|
||||
"pattern": r"ceph.*health.*ERR|HEALTH_ERR",
|
||||
"cause": "Ceph cluster has critical errors",
|
||||
"cause_detailed": "Ceph has detected critical issues that may affect data availability or integrity. Immediate attention required.",
|
||||
"severity": "critical",
|
||||
"solution": "Run 'ceph health detail' and address errors immediately",
|
||||
"solution_detailed": "1. Get details: ceph health detail\n2. Check OSD status: ceph osd tree\n3. Check MON status: ceph mon stat\n4. View PG status: ceph pg stat\n5. Address each error shown in health detail",
|
||||
"category": "storage"
|
||||
},
|
||||
|
||||
# ==================== VM/CT ERRORS ====================
|
||||
{
|
||||
"pattern": r"TASK ERROR.*failed to get exclusive lock|lock.*timeout|couldn't acquire lock",
|
||||
"cause": "Resource is locked by another operation",
|
||||
"cause_detailed": "Another task is currently holding a lock on this VM/CT. This prevents concurrent modifications that could cause corruption.",
|
||||
"severity": "info",
|
||||
"solution": "Wait for other task to complete or check for stuck tasks",
|
||||
"solution_detailed": "1. Check running tasks: cat /var/log/pve/tasks/active\n2. Wait for task completion\n3. If task is stuck (>1h), check process: ps aux | grep <vmid>\n4. As last resort, remove lock file: rm /var/lock/qemu-server/lock-<vmid>.conf",
|
||||
"category": "vms"
|
||||
},
|
||||
{
|
||||
"pattern": r"kvm.*not.*available|kvm.*disabled|hardware.*virtualization.*disabled",
|
||||
"cause": "KVM/hardware virtualization not available",
|
||||
"cause_detailed": "The CPU's hardware virtualization extensions (Intel VT-x or AMD-V) are either not supported, not enabled in BIOS, or blocked by another hypervisor.",
|
||||
"severity": "warning",
|
||||
"solution": "Enable VT-x/AMD-V in BIOS settings",
|
||||
"solution_detailed": "1. Reboot into BIOS/UEFI\n2. Find Virtualization settings (often in CPU or Advanced section)\n3. Enable Intel VT-x or AMD-V/SVM\n4. Save and reboot\n5. Verify: grep -E 'vmx|svm' /proc/cpuinfo",
|
||||
"category": "vms"
|
||||
},
|
||||
{
|
||||
"pattern": r"out of memory|OOM.*kill|cannot allocate memory|memory.*exhausted",
|
||||
"cause": "System or VM ran out of memory",
|
||||
"cause_detailed": "The Linux OOM (Out Of Memory) killer terminated a process to free memory. This indicates memory pressure from overcommitment or memory leaks.",
|
||||
"severity": "critical",
|
||||
"solution": "Increase memory allocation or reduce VM memory usage",
|
||||
"solution_detailed": "1. Check what was killed: dmesg | grep -i oom\n2. Review memory usage: free -h\n3. Check balloon driver status for VMs\n4. Consider adding swap or RAM\n5. Review VM memory allocations for overcommitment",
|
||||
"category": "memory"
|
||||
},
|
||||
|
||||
# ==================== NETWORK ====================
|
||||
{
|
||||
"pattern": r"bond.*slave.*link.*down|bond.*no.*active.*slave",
|
||||
"cause": "Network bond lost a slave interface",
|
||||
"cause_detailed": "One or more physical interfaces in a network bond have lost link. Depending on bond mode, this may reduce bandwidth or affect failover.",
|
||||
"severity": "warning",
|
||||
"solution": "Check physical cable connections and switch ports",
|
||||
"solution_detailed": "1. Check bond status: cat /proc/net/bonding/bond0\n2. Identify down slave interface\n3. Check physical cable connection\n4. Check switch port status and errors\n5. Verify interface: ethtool <slave-iface>",
|
||||
"category": "network"
|
||||
},
|
||||
{
|
||||
"pattern": r"link.*not.*ready|carrier.*lost|link.*down|NIC.*Link.*Down",
|
||||
"cause": "Network interface lost link",
|
||||
"cause_detailed": "The physical or virtual network interface has lost its connection. This could be a cable issue, switch problem, or driver issue.",
|
||||
"severity": "warning",
|
||||
"solution": "Check cable, switch port, and interface status",
|
||||
"solution_detailed": "1. Check interface: ip link show <iface>\n2. Check cable connection\n3. Check switch port LEDs\n4. Try: ip link set <iface> down && ip link set <iface> up\n5. Check driver: ethtool -i <iface>",
|
||||
"category": "network"
|
||||
},
|
||||
{
|
||||
"pattern": r"bridge.*STP.*blocked|spanning.*tree.*blocked",
|
||||
"cause": "Spanning Tree Protocol blocked a port",
|
||||
"cause_detailed": "STP detected a potential network loop and blocked a bridge port to prevent broadcast storms. This is normal behavior but may indicate network topology issues.",
|
||||
"severity": "info",
|
||||
"solution": "Review network topology; this may be expected behavior",
|
||||
"solution_detailed": "1. Check bridge status: brctl show\n2. View STP state: brctl showstp <bridge>\n3. If unexpected, review network topology for loops\n4. Consider disabling STP if network is simple: brctl stp <bridge> off",
|
||||
"category": "network"
|
||||
},
|
||||
|
||||
# ==================== SERVICES ====================
|
||||
{
|
||||
"pattern": r"pvedaemon.*failed|pveproxy.*failed|pvestatd.*failed",
|
||||
"cause": "Critical Proxmox service failed",
|
||||
"cause_detailed": "One of the core Proxmox daemons has crashed or failed to start. This may affect web GUI access or API functionality.",
|
||||
"severity": "critical",
|
||||
"solution": "Restart the failed service; check logs for cause",
|
||||
"solution_detailed": "1. Check status: systemctl status <service>\n2. View logs: journalctl -u <service> -n 50\n3. Restart: systemctl restart <service>\n4. If persistent, check: /var/log/pveproxy/access.log",
|
||||
"category": "pve_services"
|
||||
},
|
||||
{
|
||||
"pattern": r"failed to start.*service|service.*start.*failed|service.*activation.*failed",
|
||||
"cause": "System service failed to start",
|
||||
"cause_detailed": "A systemd service unit failed during startup. This could be due to configuration errors, missing dependencies, or resource issues.",
|
||||
"severity": "warning",
|
||||
"solution": "Check service logs with journalctl -u <service>",
|
||||
"solution_detailed": "1. Check status: systemctl status <service>\n2. View logs: journalctl -xeu <service>\n3. Check config: systemctl cat <service>\n4. Verify dependencies: systemctl list-dependencies <service>\n5. Try restart: systemctl restart <service>",
|
||||
"category": "services"
|
||||
},
|
||||
|
||||
# ==================== BACKUP ====================
|
||||
{
|
||||
"pattern": r"backup.*failed|vzdump.*error|backup.*job.*failed",
|
||||
"cause": "Backup job failed",
|
||||
"cause_detailed": "A scheduled or manual backup operation failed. Common causes: storage full, VM locked, network issues for remote storage.",
|
||||
"severity": "warning",
|
||||
"solution": "Check backup storage space and VM status",
|
||||
"solution_detailed": "1. Check backup log in Datacenter > Backup\n2. Verify storage space: df -h\n3. Check if VM is locked: qm list or pct list\n4. Verify backup storage is accessible\n5. Try manual backup to identify specific error",
|
||||
"category": "backups"
|
||||
},
|
||||
|
||||
# ==================== CERTIFICATES ====================
|
||||
{
|
||||
"pattern": r"certificate.*expired|SSL.*certificate.*expired|cert.*expir",
|
||||
"cause": "SSL/TLS certificate has expired",
|
||||
"cause_detailed": "An SSL certificate used for secure communication has passed its expiration date. This may cause connection failures or security warnings.",
|
||||
"severity": "warning",
|
||||
"solution": "Renew the certificate using pvenode cert set or Let's Encrypt",
|
||||
"solution_detailed": "1. Check certificate: pvenode cert info\n2. For self-signed renewal: pvecm updatecerts\n3. For Let's Encrypt: pvenode acme cert order\n4. Restart pveproxy after renewal: systemctl restart pveproxy",
|
||||
"url": "https://pve.proxmox.com/wiki/Certificate_Management",
|
||||
"category": "security"
|
||||
},
|
||||
|
||||
# ==================== HARDWARE/TEMPERATURE ====================
|
||||
{
|
||||
"pattern": r"temperature.*critical|thermal.*critical|CPU.*overheating|temp.*above.*threshold",
|
||||
"cause": "Component temperature critical",
|
||||
"cause_detailed": "A hardware component (CPU, disk, etc.) has reached a dangerous temperature. Sustained high temperatures can cause hardware damage or system shutdowns.",
|
||||
"severity": "critical",
|
||||
"solution": "Check cooling system immediately; clean dust, verify fans",
|
||||
"solution_detailed": "1. Check current temps: sensors\n2. Verify all fans are running\n3. Clean dust from heatsinks and filters\n4. Ensure adequate airflow\n5. Consider reapplying thermal paste if CPU\n6. Check ambient room temperature",
|
||||
"category": "temperature"
|
||||
},
|
||||
|
||||
# ==================== AUTHENTICATION ====================
|
||||
{
|
||||
"pattern": r"authentication.*failed|login.*failed|invalid.*credentials|access.*denied",
|
||||
"cause": "Authentication failure",
|
||||
"cause_detailed": "A login attempt failed due to invalid credentials or permissions. Multiple failures may indicate a brute-force attack.",
|
||||
"severity": "info",
|
||||
"solution": "Verify credentials; check for unauthorized access attempts",
|
||||
"solution_detailed": "1. Review auth logs: journalctl -u pvedaemon | grep auth\n2. Check for multiple failures from same IP\n3. Verify user exists: pveum user list\n4. If attack suspected, consider fail2ban\n5. Reset password if needed: pveum passwd <user>",
|
||||
"category": "security"
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def find_matching_error(text: str, category: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Find a known error that matches the given text.
|
||||
|
||||
Args:
|
||||
text: Error message or log content to match against
|
||||
category: Optional category to filter by
|
||||
|
||||
Returns:
|
||||
Matching error dict or None
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
text_lower = text.lower()
|
||||
|
||||
for error in PROXMOX_KNOWN_ERRORS:
|
||||
# Filter by category if specified
|
||||
if category and error.get("category") != category:
|
||||
continue
|
||||
|
||||
try:
|
||||
if re.search(error["pattern"], text_lower, re.IGNORECASE):
|
||||
return error
|
||||
except re.error:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_error_context(text: str, category: Optional[str] = None, detail_level: str = "standard") -> Optional[str]:
|
||||
"""Get formatted context for a known error.
|
||||
|
||||
Args:
|
||||
text: Error message to match
|
||||
category: Optional category filter
|
||||
detail_level: "minimal", "standard", or "detailed"
|
||||
|
||||
Returns:
|
||||
Formatted context string or None
|
||||
"""
|
||||
error = find_matching_error(text, category)
|
||||
if not error:
|
||||
return None
|
||||
|
||||
if detail_level == "minimal":
|
||||
return f"Known issue: {error['cause']}"
|
||||
|
||||
elif detail_level == "standard":
|
||||
lines = [
|
||||
f"KNOWN PROXMOX ERROR DETECTED:",
|
||||
f" Cause: {error['cause']}",
|
||||
f" Severity: {error['severity'].upper()}",
|
||||
f" Solution: {error['solution']}"
|
||||
]
|
||||
if error.get("url"):
|
||||
lines.append(f" Docs: {error['url']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
else: # detailed
|
||||
lines = [
|
||||
f"KNOWN PROXMOX ERROR DETECTED:",
|
||||
f" Cause: {error.get('cause_detailed', error['cause'])}",
|
||||
f" Severity: {error['severity'].upper()}",
|
||||
f" Solution: {error.get('solution_detailed', error['solution'])}"
|
||||
]
|
||||
if error.get("url"):
|
||||
lines.append(f" Documentation: {error['url']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_all_patterns() -> List[str]:
|
||||
"""Get all error patterns for external use."""
|
||||
return [error["pattern"] for error in PROXMOX_KNOWN_ERRORS]
|
||||
@@ -8,18 +8,32 @@ Monitors configured Proxmox storages and tracks unavailable storages
|
||||
import json
|
||||
import subprocess
|
||||
import socket
|
||||
import time
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
class ProxmoxStorageMonitor:
|
||||
"""Monitor Proxmox storage configuration and status"""
|
||||
|
||||
# Cache TTL: 177 seconds (~3 min) - offset to avoid sync with other processes
|
||||
_CACHE_TTL = 177
|
||||
|
||||
def __init__(self):
|
||||
self.configured_storages: Dict[str, Dict[str, Any]] = {}
|
||||
self._node_name_cache = {'name': None, 'time': 0}
|
||||
self._storage_status_cache = {'data': None, 'time': 0}
|
||||
self._config_cache_time = 0 # Track when config was last loaded
|
||||
self._load_configured_storages()
|
||||
|
||||
def _get_node_name(self) -> str:
|
||||
"""Get current Proxmox node name"""
|
||||
"""Get current Proxmox node name (cached)"""
|
||||
current_time = time.time()
|
||||
cache = self._node_name_cache
|
||||
|
||||
# Return cached result if fresh
|
||||
if cache['name'] and (current_time - cache['time']) < self._CACHE_TTL:
|
||||
return cache['name']
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pvesh', 'get', '/nodes', '--output-format', 'json'],
|
||||
@@ -32,9 +46,14 @@ class ProxmoxStorageMonitor:
|
||||
hostname = socket.gethostname()
|
||||
for node in nodes:
|
||||
if node.get('node') == hostname:
|
||||
cache['name'] = hostname
|
||||
cache['time'] = current_time
|
||||
return hostname
|
||||
if nodes:
|
||||
return nodes[0].get('node', hostname)
|
||||
name = nodes[0].get('node', hostname)
|
||||
cache['name'] = name
|
||||
cache['time'] = current_time
|
||||
return name
|
||||
return socket.gethostname()
|
||||
except Exception:
|
||||
return socket.gethostname()
|
||||
@@ -84,7 +103,7 @@ class ProxmoxStorageMonitor:
|
||||
|
||||
def get_storage_status(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get storage status, including unavailable storages
|
||||
Get storage status, including unavailable storages (cached)
|
||||
|
||||
Returns:
|
||||
{
|
||||
@@ -92,6 +111,13 @@ class ProxmoxStorageMonitor:
|
||||
'unavailable': [...]
|
||||
}
|
||||
"""
|
||||
current_time = time.time()
|
||||
cache = self._storage_status_cache
|
||||
|
||||
# Return cached result if fresh
|
||||
if cache['data'] and (current_time - cache['time']) < self._CACHE_TTL:
|
||||
return cache['data']
|
||||
|
||||
try:
|
||||
local_node = self._get_node_name()
|
||||
|
||||
@@ -176,10 +202,16 @@ class ProxmoxStorageMonitor:
|
||||
'node': local_node
|
||||
})
|
||||
|
||||
return {
|
||||
result_data = {
|
||||
'available': available_storages,
|
||||
'unavailable': unavailable_storages
|
||||
}
|
||||
|
||||
# Cache the result
|
||||
cache['data'] = result_data
|
||||
cache['time'] = current_time
|
||||
|
||||
return result_data
|
||||
|
||||
except Exception:
|
||||
return {
|
||||
@@ -192,10 +224,21 @@ class ProxmoxStorageMonitor:
|
||||
status = self.get_storage_status()
|
||||
return len(status['unavailable'])
|
||||
|
||||
def reload_configuration(self) -> None:
|
||||
"""Reload storage configuration from Proxmox"""
|
||||
def reload_configuration(self, force: bool = False) -> None:
|
||||
"""Reload storage configuration from Proxmox (cached)
|
||||
|
||||
Args:
|
||||
force: If True, bypass cache and force reload
|
||||
"""
|
||||
current_time = time.time()
|
||||
|
||||
# Skip reload if cache is still fresh (unless forced)
|
||||
if not force and (current_time - self._config_cache_time) < self._CACHE_TTL:
|
||||
return
|
||||
|
||||
self.configured_storages.clear()
|
||||
self._load_configured_storages()
|
||||
self._config_cache_time = current_time
|
||||
|
||||
|
||||
# Global instance
|
||||
|
||||
@@ -1984,3 +1984,149 @@ def parse_lynis_report():
|
||||
report["proxmox_context_applied"] = True
|
||||
|
||||
return report
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Uninstall Functions
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def uninstall_fail2ban():
|
||||
"""
|
||||
Uninstall Fail2Ban and clean up all configuration.
|
||||
Returns (success, message).
|
||||
"""
|
||||
try:
|
||||
# Stop fail2ban service
|
||||
_run_cmd(["systemctl", "stop", "fail2ban"], timeout=30)
|
||||
_run_cmd(["systemctl", "disable", "fail2ban"], timeout=10)
|
||||
|
||||
# Stop and remove auth logger services
|
||||
_run_cmd(["systemctl", "stop", "proxmox-auth-logger.service"], timeout=10)
|
||||
_run_cmd(["systemctl", "disable", "proxmox-auth-logger.service"], timeout=10)
|
||||
_run_cmd(["systemctl", "stop", "ssh-auth-logger.service"], timeout=10)
|
||||
_run_cmd(["systemctl", "disable", "ssh-auth-logger.service"], timeout=10)
|
||||
|
||||
# Remove systemd service files
|
||||
for svc_file in [
|
||||
"/etc/systemd/system/proxmox-auth-logger.service",
|
||||
"/etc/systemd/system/ssh-auth-logger.service",
|
||||
]:
|
||||
if os.path.exists(svc_file):
|
||||
os.remove(svc_file)
|
||||
|
||||
_run_cmd(["systemctl", "daemon-reload"], timeout=10)
|
||||
|
||||
# Remove log files created by auth loggers
|
||||
for log_file in ["/var/log/proxmox-auth.log", "/var/log/ssh-auth.log"]:
|
||||
if os.path.exists(log_file):
|
||||
os.remove(log_file)
|
||||
|
||||
# Purge fail2ban package
|
||||
_run_cmd(["apt-get", "purge", "-y", "fail2ban"], timeout=120)
|
||||
|
||||
# Remove configuration files
|
||||
for cfg_file in [
|
||||
"/etc/fail2ban/jail.d/proxmox.conf",
|
||||
"/etc/fail2ban/jail.d/proxmenux.conf",
|
||||
"/etc/fail2ban/filter.d/proxmox.conf",
|
||||
"/etc/fail2ban/filter.d/proxmenux.conf",
|
||||
"/etc/fail2ban/jail.local",
|
||||
]:
|
||||
if os.path.exists(cfg_file):
|
||||
os.remove(cfg_file)
|
||||
|
||||
# Restore SSH MaxAuthTries if backup exists
|
||||
base_dir = "/usr/local/share/proxmenux"
|
||||
backup_file = os.path.join(base_dir, "sshd_maxauthtries_backup")
|
||||
sshd_config = "/etc/ssh/sshd_config"
|
||||
if os.path.exists(backup_file) and os.path.exists(sshd_config):
|
||||
try:
|
||||
with open(backup_file, 'r') as f:
|
||||
original_val = f.read().strip()
|
||||
if original_val:
|
||||
with open(sshd_config, 'r') as f:
|
||||
content = f.read()
|
||||
import re
|
||||
content = re.sub(
|
||||
r'^MaxAuthTries.*$',
|
||||
f'MaxAuthTries {original_val}',
|
||||
content,
|
||||
flags=re.MULTILINE
|
||||
)
|
||||
with open(sshd_config, 'w') as f:
|
||||
f.write(content)
|
||||
_run_cmd(["systemctl", "reload", "sshd"], timeout=10)
|
||||
os.remove(backup_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Remove journald drop-in
|
||||
journald_dropin = "/etc/systemd/journald.conf.d/proxmenux-loglevel.conf"
|
||||
if os.path.exists(journald_dropin):
|
||||
os.remove(journald_dropin)
|
||||
_run_cmd(["systemctl", "restart", "systemd-journald"], timeout=30)
|
||||
|
||||
# Update component status
|
||||
components_file = os.path.join(base_dir, "components_status.json")
|
||||
if os.path.exists(components_file):
|
||||
try:
|
||||
import json
|
||||
with open(components_file, 'r') as f:
|
||||
components = json.load(f)
|
||||
if "fail2ban" in components:
|
||||
components["fail2ban"]["status"] = "removed"
|
||||
components["fail2ban"]["version"] = ""
|
||||
with open(components_file, 'w') as f:
|
||||
json.dump(components, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True, "Fail2Ban has been uninstalled successfully"
|
||||
except Exception as e:
|
||||
return False, f"Error uninstalling Fail2Ban: {str(e)}"
|
||||
|
||||
|
||||
def uninstall_lynis():
|
||||
"""
|
||||
Uninstall Lynis and clean up all files.
|
||||
Returns (success, message).
|
||||
"""
|
||||
try:
|
||||
import shutil
|
||||
|
||||
# Remove installation directory
|
||||
if os.path.exists("/opt/lynis"):
|
||||
shutil.rmtree("/opt/lynis")
|
||||
|
||||
# Remove wrapper script
|
||||
if os.path.exists("/usr/local/bin/lynis"):
|
||||
os.remove("/usr/local/bin/lynis")
|
||||
|
||||
# Remove report files
|
||||
for report_file in [
|
||||
"/var/log/lynis-report.dat",
|
||||
"/var/log/lynis.log",
|
||||
"/var/log/lynis-output.log",
|
||||
]:
|
||||
if os.path.exists(report_file):
|
||||
os.remove(report_file)
|
||||
|
||||
# Update component status
|
||||
base_dir = "/usr/local/share/proxmenux"
|
||||
components_file = os.path.join(base_dir, "components_status.json")
|
||||
if os.path.exists(components_file):
|
||||
try:
|
||||
import json
|
||||
with open(components_file, 'r') as f:
|
||||
components = json.load(f)
|
||||
if "lynis" in components:
|
||||
components["lynis"]["status"] = "removed"
|
||||
components["lynis"]["version"] = ""
|
||||
with open(components_file, 'w') as f:
|
||||
json.dump(components, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True, "Lynis has been uninstalled successfully"
|
||||
except Exception as e:
|
||||
return False, f"Error uninstalling Lynis: {str(e)}"
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
Centralized Startup Grace Period Management
|
||||
|
||||
This module provides a single source of truth for startup grace period logic.
|
||||
During system boot, various transient issues occur (high latency, storage not ready,
|
||||
QMP timeouts, etc.) that shouldn't trigger notifications or critical alerts.
|
||||
|
||||
Grace Periods:
|
||||
- VM/CT aggregation: 3 minutes - Aggregate multiple VM/CT starts into one notification
|
||||
- Health suppression: 5 minutes - Suppress transient health warnings/errors
|
||||
- Shutdown suppression: 2 minutes - Suppress VM/CT stops during system shutdown
|
||||
|
||||
Categories suppressed during startup:
|
||||
- storage: NFS/CIFS mounts may take time to become available
|
||||
- vms: VMs may have QMP timeouts or startup delays
|
||||
- network: Latency spikes during boot are normal
|
||||
- services: PVE services may take time to fully initialize
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from typing import Set, List, Tuple, Optional
|
||||
|
||||
# ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
# Grace period durations (seconds)
|
||||
STARTUP_VM_GRACE_SECONDS = 180 # 3 minutes for VM/CT start aggregation
|
||||
STARTUP_HEALTH_GRACE_SECONDS = 300 # 5 minutes for health warning suppression
|
||||
SHUTDOWN_GRACE_SECONDS = 120 # 2 minutes for VM/CT stop suppression
|
||||
|
||||
# Maximum system uptime to consider this a real server boot (not just service restart)
|
||||
# If system uptime > this value when service starts, skip startup notification
|
||||
MAX_BOOT_UPTIME_SECONDS = 600 # 10 minutes - if system was up longer, it's a service restart
|
||||
|
||||
|
||||
def _get_system_uptime() -> float:
|
||||
"""
|
||||
Get actual system uptime in seconds from /proc/uptime.
|
||||
Returns 0 if unable to read (will default to treating as new boot).
|
||||
"""
|
||||
try:
|
||||
with open('/proc/uptime', 'r') as f:
|
||||
return float(f.readline().split()[0])
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
# Categories to suppress during startup grace period
|
||||
# These categories typically have transient issues during boot
|
||||
STARTUP_GRACE_CATEGORIES: Set[str] = {
|
||||
'storage', # NFS/CIFS mounts may take time
|
||||
'vms', # VMs may have QMP timeouts
|
||||
'network', # Latency spikes during boot
|
||||
'services', # PVE services initialization
|
||||
}
|
||||
|
||||
|
||||
# ─── Singleton State ─────────────────────────────────────────────────────────
|
||||
|
||||
class _StartupGraceState:
|
||||
"""
|
||||
Thread-safe singleton managing all startup/shutdown grace period state.
|
||||
|
||||
Initialized when the module loads (service start), which serves as the
|
||||
reference point for determining if we're still in the startup period.
|
||||
"""
|
||||
|
||||
_instance: Optional['_StartupGraceState'] = None
|
||||
_init_lock = threading.Lock()
|
||||
|
||||
def __new__(cls) -> '_StartupGraceState':
|
||||
if cls._instance is None:
|
||||
with cls._init_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Startup time = when service started (module load time)
|
||||
self._startup_time: float = time.time()
|
||||
|
||||
# Check if this is a REAL system boot or just a service restart
|
||||
# by comparing system uptime to our threshold
|
||||
system_uptime = _get_system_uptime()
|
||||
self._is_real_boot: bool = system_uptime < MAX_BOOT_UPTIME_SECONDS
|
||||
|
||||
# Shutdown tracking
|
||||
self._shutdown_time: float = 0
|
||||
|
||||
# VM/CT aggregation during startup
|
||||
self._startup_vms: List[Tuple[str, str, str]] = [] # [(vmid, vmname, 'vm'|'ct'), ...]
|
||||
self._startup_aggregated: bool = False
|
||||
|
||||
self._initialized = True
|
||||
|
||||
# ─── Startup Period Checks ───────────────────────────────────────────────
|
||||
|
||||
def is_startup_vm_period(self) -> bool:
|
||||
"""
|
||||
Check if we're within the VM/CT start aggregation period (3 min).
|
||||
|
||||
During this period, individual VM/CT start notifications are collected
|
||||
and later sent as a single aggregated notification.
|
||||
"""
|
||||
with self._lock:
|
||||
return (time.time() - self._startup_time) < STARTUP_VM_GRACE_SECONDS
|
||||
|
||||
def is_startup_health_grace(self) -> bool:
|
||||
"""
|
||||
Check if we're within the health suppression period (5 min).
|
||||
|
||||
During this period:
|
||||
- Transient health warnings (latency, storage, etc.) are suppressed
|
||||
- CRITICAL/WARNING may be downgraded to INFO for certain categories
|
||||
- Health degradation notifications are skipped for grace categories
|
||||
"""
|
||||
with self._lock:
|
||||
return (time.time() - self._startup_time) < STARTUP_HEALTH_GRACE_SECONDS
|
||||
|
||||
def should_suppress_category(self, category: str) -> bool:
|
||||
"""
|
||||
Check if notifications for a category should be suppressed.
|
||||
|
||||
Args:
|
||||
category: Health category name (e.g., 'network', 'storage', 'vms')
|
||||
|
||||
Returns:
|
||||
True if we're in grace period AND category is in STARTUP_GRACE_CATEGORIES
|
||||
"""
|
||||
if category.lower() in STARTUP_GRACE_CATEGORIES:
|
||||
return self.is_startup_health_grace()
|
||||
return False
|
||||
|
||||
def is_real_system_boot(self) -> bool:
|
||||
"""
|
||||
Check if the service started during a real system boot.
|
||||
|
||||
Returns False if the system was already running for more than 10 minutes
|
||||
when the service started (indicates a service restart, not a system boot).
|
||||
|
||||
This prevents sending "System startup completed" notifications when
|
||||
just restarting the ProxMenux Monitor service.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._is_real_boot
|
||||
|
||||
def get_startup_elapsed(self) -> float:
|
||||
"""Get seconds elapsed since service startup."""
|
||||
with self._lock:
|
||||
return time.time() - self._startup_time
|
||||
|
||||
# ─── Shutdown Tracking ───────────────────────────────────────────────────
|
||||
|
||||
def mark_shutdown(self):
|
||||
"""
|
||||
Called when system_shutdown or system_reboot is detected.
|
||||
|
||||
After this, VM/CT stop notifications will be suppressed for the
|
||||
shutdown grace period (expected stops during system shutdown).
|
||||
"""
|
||||
with self._lock:
|
||||
self._shutdown_time = time.time()
|
||||
|
||||
def is_host_shutting_down(self) -> bool:
|
||||
"""
|
||||
Check if we're within the shutdown grace period.
|
||||
|
||||
During this period, VM/CT stop events are expected and should not
|
||||
generate notifications.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._shutdown_time == 0:
|
||||
return False
|
||||
return (time.time() - self._shutdown_time) < SHUTDOWN_GRACE_SECONDS
|
||||
|
||||
# ─── VM/CT Start Aggregation ─────────────────────────────────────────────
|
||||
|
||||
def add_startup_vm(self, vmid: str, vmname: str, vm_type: str):
|
||||
"""
|
||||
Record a VM/CT start during startup period for later aggregation.
|
||||
|
||||
Args:
|
||||
vmid: VM/CT ID
|
||||
vmname: VM/CT name
|
||||
vm_type: 'vm' or 'ct'
|
||||
"""
|
||||
with self._lock:
|
||||
self._startup_vms.append((vmid, vmname, vm_type))
|
||||
|
||||
def get_and_clear_startup_vms(self) -> List[Tuple[str, str, str]]:
|
||||
"""
|
||||
Get all recorded startup VMs and clear the list.
|
||||
|
||||
Should be called once after the VM aggregation grace period ends
|
||||
to get all VMs that started during boot for a single notification.
|
||||
|
||||
Returns:
|
||||
List of (vmid, vmname, vm_type) tuples
|
||||
"""
|
||||
with self._lock:
|
||||
vms = self._startup_vms.copy()
|
||||
self._startup_vms = []
|
||||
self._startup_aggregated = True
|
||||
return vms
|
||||
|
||||
def has_startup_vms(self) -> bool:
|
||||
"""Check if there are any startup VMs recorded."""
|
||||
with self._lock:
|
||||
return len(self._startup_vms) > 0
|
||||
|
||||
def was_startup_aggregated(self) -> bool:
|
||||
"""Check if startup aggregation has already been processed."""
|
||||
with self._lock:
|
||||
return self._startup_aggregated
|
||||
|
||||
def mark_startup_aggregated(self) -> None:
|
||||
"""Mark startup aggregation as completed without returning VMs."""
|
||||
with self._lock:
|
||||
self._startup_aggregated = True
|
||||
|
||||
|
||||
# ─── Module-level convenience functions ──────────────────────────────────────
|
||||
|
||||
# Global singleton instance
|
||||
_state = _StartupGraceState()
|
||||
|
||||
def is_startup_vm_period() -> bool:
|
||||
"""Check if we're within the VM/CT start aggregation period (3 min)."""
|
||||
return _state.is_startup_vm_period()
|
||||
|
||||
def is_startup_health_grace() -> bool:
|
||||
"""Check if we're within the health suppression period (5 min)."""
|
||||
return _state.is_startup_health_grace()
|
||||
|
||||
def should_suppress_category(category: str) -> bool:
|
||||
"""Check if notifications for a category should be suppressed during startup."""
|
||||
return _state.should_suppress_category(category)
|
||||
|
||||
def get_startup_elapsed() -> float:
|
||||
"""Get seconds elapsed since service startup."""
|
||||
return _state.get_startup_elapsed()
|
||||
|
||||
def mark_shutdown():
|
||||
"""Mark that system shutdown/reboot has been detected."""
|
||||
_state.mark_shutdown()
|
||||
|
||||
def is_host_shutting_down() -> bool:
|
||||
"""Check if we're within the shutdown grace period."""
|
||||
return _state.is_host_shutting_down()
|
||||
|
||||
def add_startup_vm(vmid: str, vmname: str, vm_type: str):
|
||||
"""Record a VM/CT start during startup period for aggregation."""
|
||||
_state.add_startup_vm(vmid, vmname, vm_type)
|
||||
|
||||
def get_and_clear_startup_vms() -> List[Tuple[str, str, str]]:
|
||||
"""Get all recorded startup VMs and clear the list."""
|
||||
return _state.get_and_clear_startup_vms()
|
||||
|
||||
def has_startup_vms() -> bool:
|
||||
"""Check if there are any startup VMs recorded."""
|
||||
return _state.has_startup_vms()
|
||||
|
||||
def was_startup_aggregated() -> bool:
|
||||
"""Check if startup aggregation has already been processed."""
|
||||
return _state.was_startup_aggregated()
|
||||
|
||||
def mark_startup_aggregated() -> None:
|
||||
"""Mark startup aggregation as completed without processing VMs.
|
||||
|
||||
Use this when skipping startup notification (e.g., service restart
|
||||
instead of real system boot) to prevent future checks.
|
||||
"""
|
||||
_state.mark_startup_aggregated()
|
||||
|
||||
def is_real_system_boot() -> bool:
|
||||
"""
|
||||
Check if this is a real system boot (not just a service restart).
|
||||
|
||||
Returns True if the system uptime was less than 10 minutes when the
|
||||
service started. Returns False if the system was already running
|
||||
longer (indicates the service was restarted, not the whole system).
|
||||
|
||||
Use this to prevent sending "System startup completed" notifications
|
||||
when just restarting the ProxMenux Monitor service.
|
||||
"""
|
||||
return _state.is_real_system_boot()
|
||||
|
||||
|
||||
# ─── Startup Report Collection ───────────────────────────────────────────────
|
||||
|
||||
def collect_startup_report() -> dict:
|
||||
"""
|
||||
Collect comprehensive startup report data.
|
||||
|
||||
Called at the end of the grace period to generate a complete
|
||||
startup report including:
|
||||
- VMs/CTs that started successfully
|
||||
- VMs/CTs that failed to start
|
||||
- Service status
|
||||
- Storage status
|
||||
- Journal errors during boot (for AI enrichment)
|
||||
|
||||
Returns:
|
||||
Dictionary with startup report data
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
report = {
|
||||
# VMs/CTs
|
||||
'vms_started': [],
|
||||
'cts_started': [],
|
||||
'vms_failed': [],
|
||||
'cts_failed': [],
|
||||
|
||||
# System status
|
||||
'services_ok': True,
|
||||
'services_failed': [],
|
||||
'storage_ok': True,
|
||||
'storage_unavailable': [],
|
||||
|
||||
# Health summary
|
||||
'health_status': 'OK',
|
||||
'health_issues': [],
|
||||
|
||||
# For AI enrichment
|
||||
'_journal_context': '',
|
||||
'_startup_errors': [],
|
||||
|
||||
# Metadata
|
||||
'startup_duration_seconds': get_startup_elapsed(),
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
|
||||
# Get VMs/CTs that started during boot
|
||||
startup_vms = get_and_clear_startup_vms()
|
||||
for vmid, vmname, vm_type in startup_vms:
|
||||
if vm_type == 'vm':
|
||||
report['vms_started'].append({'vmid': vmid, 'name': vmname})
|
||||
else:
|
||||
report['cts_started'].append({'vmid': vmid, 'name': vmname})
|
||||
|
||||
# Try to get health status from health_monitor
|
||||
try:
|
||||
import health_monitor
|
||||
health_data = health_monitor.get_detailed_status()
|
||||
|
||||
if health_data:
|
||||
report['health_status'] = health_data.get('overall_status', 'UNKNOWN')
|
||||
|
||||
# Check storage
|
||||
storage_cat = health_data.get('categories', {}).get('storage', {})
|
||||
if storage_cat.get('status') in ['CRITICAL', 'WARNING']:
|
||||
report['storage_ok'] = False
|
||||
for check in storage_cat.get('checks', []):
|
||||
if check.get('status') in ['CRITICAL', 'WARNING', 'error']:
|
||||
report['storage_unavailable'].append({
|
||||
'name': check.get('name', 'unknown'),
|
||||
'reason': check.get('reason', check.get('message', ''))
|
||||
})
|
||||
|
||||
# Check services
|
||||
services_cat = health_data.get('categories', {}).get('services', {})
|
||||
if services_cat.get('status') in ['CRITICAL', 'WARNING']:
|
||||
report['services_ok'] = False
|
||||
for check in services_cat.get('checks', []):
|
||||
if check.get('status') in ['CRITICAL', 'WARNING', 'error']:
|
||||
report['services_failed'].append({
|
||||
'name': check.get('name', 'unknown'),
|
||||
'reason': check.get('reason', check.get('message', ''))
|
||||
})
|
||||
|
||||
# Check VMs category for failed VMs
|
||||
vms_cat = health_data.get('categories', {}).get('vms', {})
|
||||
for check in vms_cat.get('checks', []):
|
||||
if check.get('status') in ['CRITICAL', 'WARNING', 'error']:
|
||||
# Determine if VM or CT based on name/type
|
||||
check_name = check.get('name', '')
|
||||
check_reason = check.get('reason', check.get('message', ''))
|
||||
if 'error al iniciar' in check_reason.lower() or 'failed to start' in check_reason.lower():
|
||||
if 'CT' in check_name or 'Container' in check_name:
|
||||
report['cts_failed'].append({
|
||||
'name': check_name,
|
||||
'reason': check_reason
|
||||
})
|
||||
else:
|
||||
report['vms_failed'].append({
|
||||
'name': check_name,
|
||||
'reason': check_reason
|
||||
})
|
||||
|
||||
# Collect all health issues for summary
|
||||
for cat_name, cat_data in health_data.get('categories', {}).items():
|
||||
if cat_data.get('status') in ['CRITICAL', 'WARNING']:
|
||||
report['health_issues'].append({
|
||||
'category': cat_name,
|
||||
'status': cat_data.get('status'),
|
||||
'reason': cat_data.get('reason', '')
|
||||
})
|
||||
except Exception as e:
|
||||
report['_startup_errors'].append(f"Error getting health data: {e}")
|
||||
|
||||
# Get journal errors during startup (for AI enrichment)
|
||||
try:
|
||||
boot_time = int(_state._startup_time)
|
||||
result = subprocess.run(
|
||||
['journalctl', '-p', 'err', '--since', f'@{boot_time}', '--no-pager', '-n', '50'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
report['_journal_context'] = result.stdout.strip()
|
||||
except Exception as e:
|
||||
report['_startup_errors'].append(f"Error getting journal: {e}")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def format_startup_summary(report: dict) -> str:
|
||||
"""
|
||||
Format a human-readable startup summary from report data.
|
||||
|
||||
Args:
|
||||
report: Dictionary from collect_startup_report()
|
||||
|
||||
Returns:
|
||||
Formatted summary string
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Count totals
|
||||
vms_ok = len(report.get('vms_started', []))
|
||||
cts_ok = len(report.get('cts_started', []))
|
||||
vms_fail = len(report.get('vms_failed', []))
|
||||
cts_fail = len(report.get('cts_failed', []))
|
||||
|
||||
total_ok = vms_ok + cts_ok
|
||||
total_fail = vms_fail + cts_fail
|
||||
|
||||
# Determine overall status
|
||||
has_issues = (
|
||||
total_fail > 0 or
|
||||
not report.get('services_ok', True) or
|
||||
not report.get('storage_ok', True) or
|
||||
report.get('health_status') in ['CRITICAL', 'WARNING']
|
||||
)
|
||||
|
||||
# Header
|
||||
if has_issues:
|
||||
issue_count = total_fail + len(report.get('services_failed', [])) + len(report.get('storage_unavailable', []))
|
||||
lines.append(f"System startup - {issue_count} issue(s) detected")
|
||||
else:
|
||||
lines.append("System startup completed")
|
||||
lines.append("All systems operational.")
|
||||
|
||||
# VMs/CTs started
|
||||
if total_ok > 0:
|
||||
parts = []
|
||||
if vms_ok > 0:
|
||||
parts.append(f"{vms_ok} VM{'s' if vms_ok > 1 else ''}")
|
||||
if cts_ok > 0:
|
||||
parts.append(f"{cts_ok} CT{'s' if cts_ok > 1 else ''}")
|
||||
|
||||
# List names
|
||||
names = []
|
||||
for vm in report.get('vms_started', []):
|
||||
names.append(f"{vm['name']} ({vm['vmid']})")
|
||||
for ct in report.get('cts_started', []):
|
||||
names.append(f"{ct['name']} ({ct['vmid']})")
|
||||
|
||||
line = f"{' and '.join(parts)} started"
|
||||
if names and len(names) <= 5:
|
||||
line += f": {', '.join(names)}"
|
||||
elif names:
|
||||
line += f": {', '.join(names[:3])}... (+{len(names)-3} more)"
|
||||
lines.append(line)
|
||||
|
||||
# Failed VMs/CTs
|
||||
if total_fail > 0:
|
||||
for vm in report.get('vms_failed', []):
|
||||
lines.append(f"VM failed: {vm['name']} - {vm.get('reason', 'unknown error')}")
|
||||
for ct in report.get('cts_failed', []):
|
||||
lines.append(f"CT failed: {ct['name']} - {ct.get('reason', 'unknown error')}")
|
||||
|
||||
# Storage issues
|
||||
if not report.get('storage_ok', True):
|
||||
unavailable = report.get('storage_unavailable', [])
|
||||
if unavailable:
|
||||
names = [s['name'] for s in unavailable]
|
||||
lines.append(f"Storage: {len(unavailable)} unavailable ({', '.join(names[:3])})")
|
||||
|
||||
# Service issues
|
||||
if not report.get('services_ok', True):
|
||||
failed = report.get('services_failed', [])
|
||||
if failed:
|
||||
names = [s['name'] for s in failed]
|
||||
lines.append(f"Services: {len(failed)} failed ({', '.join(names[:3])})")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ─── For backwards compatibility ─────────────────────────────────────────────
|
||||
|
||||
# Expose constants for external use
|
||||
GRACE_CATEGORIES = STARTUP_GRACE_CATEGORIES
|
||||
@@ -112,6 +112,50 @@ export interface UPS {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface CoralTPU {
|
||||
type: "pcie" | "usb"
|
||||
name: string
|
||||
vendor: string
|
||||
vendor_id: string
|
||||
device_id: string
|
||||
slot?: string // PCIe only, e.g. "0000:0c:00.0"
|
||||
bus_device?: string // USB only, e.g. "002:007"
|
||||
form_factor?: string // "M.2 / Mini PCIe (x1)" | "USB Accelerator" | ...
|
||||
interface_speed?: string // "PCIe 2.5GT/s x1" | "USB 3.0" | ...
|
||||
kernel_driver?: string | null
|
||||
usb_driver?: string | null
|
||||
kernel_modules?: {
|
||||
gasket: boolean
|
||||
apex: boolean
|
||||
}
|
||||
device_nodes?: string[]
|
||||
edgetpu_runtime?: string
|
||||
programmed?: boolean // USB only: runtime has interacted with the device
|
||||
drivers_ready: boolean
|
||||
// Thermal data — PCIe/M.2 only (apex driver). Always null for USB Coral.
|
||||
temperature?: number | null // °C current die temperature
|
||||
temperature_trips?: number[] | null // trip_point0/1/2_temp, ordered warn→critical
|
||||
thermal_warnings?: Array<{
|
||||
name: string // e.g. "hw_temp_warn1"
|
||||
threshold_c: number | null
|
||||
enabled: boolean
|
||||
}> | null
|
||||
}
|
||||
|
||||
export interface UsbDevice {
|
||||
bus_device: string // "002:007"
|
||||
vendor_id: string // "18d1"
|
||||
product_id: string // "9302"
|
||||
vendor: string
|
||||
name: string
|
||||
class_code: string // "ff"
|
||||
class_label: string // "Vendor Specific", "HID", "Mass Storage", ...
|
||||
speed_mbps: number
|
||||
speed_label: string // "USB 3.0" | "USB 2.0" | ...
|
||||
serial?: string
|
||||
driver?: string
|
||||
}
|
||||
|
||||
export interface GPU {
|
||||
slot: string
|
||||
name: string
|
||||
@@ -208,6 +252,8 @@ export interface HardwareData {
|
||||
fans?: Fan[]
|
||||
power_supplies?: PowerSupply[]
|
||||
ups?: UPS | UPS[]
|
||||
coral_tpus?: CoralTPU[]
|
||||
usb_devices?: UsbDevice[]
|
||||
}
|
||||
|
||||
export const fetcher = async (url: string) => {
|
||||
|
||||
+285
@@ -1,3 +1,288 @@
|
||||
# <img src="https://raw.githubusercontent.com/MacRimi/ProxMenux/main/images/logo.png" alt="ProxMenux logo" width="40"/> ProxMenux v1.2.0 — *AI-Enhanced Monitoring*
|
||||
|
||||

|
||||
|
||||
This release is the culmination of the v1.1.9.1 → v1.1.9.6 beta cycle and introduces the biggest evolution of **ProxMenux Monitor** to date: AI-enhanced notifications, a redesigned multi-channel notification system, a fully reworked hardware and storage experience, and broad performance improvements across the monitoring stack. It also consolidates all recent work on the Storage, Hardware and GPU/TPU scripts.
|
||||
|
||||
---
|
||||
|
||||
## 🤖 ProxMenux Monitor — AI-Enhanced Notifications
|
||||
|
||||
Notifications can now be enhanced using AI to generate clear, contextual messages instead of raw technical output.
|
||||
|
||||
Example — instead of `backup completed exitcode=0 size=2.3GB`, AI produces: *"The web server backup completed successfully. Size: 2.3GB"*.
|
||||
|
||||
### What AI does
|
||||
- Transforms technical notifications into readable messages
|
||||
- Translates to your preferred language
|
||||
- Lets you choose detail level: minimal, standard, or detailed
|
||||
- Works with Telegram, Discord, Email, Pushover, and Webhooks
|
||||
|
||||
### What AI does NOT do
|
||||
- It is **not** a chatbot or assistant
|
||||
- It does **not** analyze your system or make decisions
|
||||
- It does **not** have access to data beyond the notification being processed
|
||||
- It does **not** execute commands or modify the server
|
||||
- It does **not** store history or learn from your data
|
||||
|
||||
### Multi-Provider Support
|
||||
Choose between 6 AI providers, each with its own API key stored independently:
|
||||
- **Groq** — fast inference, generous free tier
|
||||
- **Google Gemini** — excellent quality/price ratio, free tier available
|
||||
- **OpenAI** — industry standard
|
||||
- **Anthropic Claude** — excellent for writing and translation
|
||||
- **OpenRouter** — 300+ models with a single API key
|
||||
- **Ollama** — 100% local execution, no internet required
|
||||
|
||||
### Verified AI Models
|
||||
A curated list of models (`verified_ai_models.json`) tested specifically for notification enhancement.
|
||||
|
||||
- **Hybrid verification**: the system fetches provider-side models and filters to only show those tested to work correctly
|
||||
- **Per-Provider Model Memory**: selected model is saved per provider, so switching providers preserves each choice
|
||||
- **Daily verification**: background task checks model availability and auto-migrates to a verified alternative if the current model disappears
|
||||
- **Incompatible models excluded**: Whisper, TTS, image/video, embeddings, guard models, etc. are filtered out per provider
|
||||
|
||||
| Provider | Recommended | Also Verified |
|
||||
|----------|-------------|---------------|
|
||||
| Gemini | gemini-2.5-flash-lite | gemini-flash-lite-latest |
|
||||
| OpenAI | gpt-4o-mini | gpt-4.1-mini |
|
||||
| Groq | llama-3.3-70b-versatile | llama-3.1-70b-versatile, llama-3.1-8b-instant, llama3-70b-8192, llama3-8b-8192, mixtral-8x7b-32768, gemma2-9b-it |
|
||||
| Anthropic | claude-3-5-haiku-latest | claude-3-5-sonnet-latest, claude-3-opus-latest |
|
||||
| OpenRouter | meta-llama/llama-3.3-70b-instruct | meta-llama/llama-3.1-70b-instruct, anthropic/claude-3.5-haiku, google/gemini-flash-2.5-flash-lite, openai/gpt-4o-mini, mistralai/mixtral-8x7b-instruct |
|
||||
| Ollama | (all local models) | No filtering — shows all installed models |
|
||||
|
||||
### Custom AI Prompts
|
||||
Advanced users can define their own prompt for full control over formatting and translation.
|
||||
|
||||
- **Prompt Mode selector** — Default Prompt or Custom Prompt
|
||||
- **Export / Import** — save and share custom prompts across installations
|
||||
- **Example Template** — starting point to build your own prompt
|
||||
- **Community Prompts** — direct link to GitHub Discussions to share templates
|
||||
- Language selector is hidden in Custom Prompt mode (you define the output language in the prompt itself)
|
||||
|
||||
### Enriched Context
|
||||
- System **uptime** is included only for error/warning events (not informational ones) — helps distinguish startup vs runtime errors
|
||||
- **Event frequency** tracking — indicates recurring vs one-time issues
|
||||
- **SMART disk health** data is passed for disk-related errors
|
||||
- **Known Proxmox errors** database improves diagnosis accuracy
|
||||
- Clearer prompt instructions to prevent AI hallucinations
|
||||
|
||||
---
|
||||
|
||||
## 📨 Notification System Redesign
|
||||
|
||||
- **Multi-Channel Architecture** — Telegram, Discord, Pushover, Email, and Webhook channels running simultaneously
|
||||
- **Per-Event Configuration** — enable/disable specific event types per channel
|
||||
- **Channel Overrides** — customize notification behaviour per channel
|
||||
- **Secure Webhook Endpoint** — external systems can send authenticated notifications
|
||||
- **Encrypted Storage** — API keys and sensitive data stored encrypted
|
||||
- **Queue-Based Processing** — background worker with automatic retry for failed notifications
|
||||
- **SQLite-Based Config Storage** — replaces file-based config for reliability
|
||||
|
||||
### Telegram Topics Support
|
||||
Send notifications to a specific topic inside groups with Topics enabled.
|
||||
- New **Topic ID** field on the Telegram channel
|
||||
- Automatic detection of topic-enabled groups
|
||||
- Fully backwards compatible
|
||||
|
||||
### ProxMenux Update Notifications
|
||||
The Monitor now detects when a new ProxMenux version is released.
|
||||
- **Dual-channel** — monitors both stable (`version.txt`) and beta (`beta_version.txt`)
|
||||
- **GitHub integration** — compares local vs remote versions
|
||||
- **Dashboard Update Indicator** — the ProxMenux logo changes to an update variant when a new version is detected (non-intrusive, no popups)
|
||||
- **Persistent state** — status stored in `config.json`, reset by update scripts
|
||||
- Single toggle in Settings controls both channels (enabled by default)
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Hardware Panel — Expanded Detection
|
||||
|
||||
The Hardware page has been significantly expanded, with better detection and richer per-device detail.
|
||||
|
||||
- **SCSI / SAS / RAID Controllers** — model, driver and PCI slot shown in the storage controllers section
|
||||
- **PCIe Link Speed Detection** — NVMe drives show current link speed (PCIe generation and lane width), making it easy to spot drives underperforming due to limited slot bandwidth
|
||||
- **Enhanced Disk Detail Modal** — NVMe, SATA, SAS, and USB drives now expose their specific fields (PCIe link info, SAS version/speed, interface type) instead of a generic view
|
||||
- **Smarter Disk Type Recognition** — uniform labelling for NVMe SSDs, SATA SSDs, HDDs and removable disks
|
||||
- **Hardware Info Caching** (`lspci`, `lspci -vmm`) — 5 min cache avoids repeated scans for data that doesn't change
|
||||
|
||||
---
|
||||
|
||||
## 💽 Storage Overview — Health, Observations, Exclusions
|
||||
|
||||
The Storage Overview has been reworked around real-time state and user-controlled tracking.
|
||||
|
||||
### Disk Health Status Alignment
|
||||
- Badges now reflect the **current** SMART state reported by Proxmox, not a historical worst value
|
||||
- **Observations preserved** — historical findings remain accessible via the "X obs." badge
|
||||
- **Automatic recovery** — when SMART reports healthy again, the disk immediately shows **Healthy**
|
||||
- Removed the old `worst_health` tracking that required manual clearing
|
||||
|
||||
### Disk Registry Improvements
|
||||
- **Smart serial lookup** — when a serial is unknown the system checks for an existing entry with a serial before inserting a new one
|
||||
- **No more duplicates** — prevents separate entries for the same disk appearing with/without a serial
|
||||
- **USB disk support** — handles USB drives that may appear under different device names between reboots
|
||||
|
||||
### Storage and Network Interface Exclusions
|
||||
- **Storage Exclusions** section — exclude drives from health monitoring and notifications
|
||||
- **Network Interface Exclusions** — new section for excluding interfaces (bridges `vmbr`, bonds, physical NICs, VLANs) from health and notifications; ideal for intentionally disabled interfaces that would otherwise generate false alerts
|
||||
- **Separate toggles** per item for Health monitoring and Notifications
|
||||
|
||||
### Disk Detection Robustness
|
||||
- **Power-On-Hours validation** — detects and corrects absurdly large values (billions of hours) on drives with non-standard SMART encoding
|
||||
- **Intelligent bit masking** — extracts the correct value from drives that pack extra info into high bytes
|
||||
- **Graceful fallback** — shows "N/A" instead of impossible numbers when data cannot be parsed
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Health Monitor & Error Lifecycle
|
||||
|
||||
### Stale Error Cleanup
|
||||
Errors for resources that no longer exist are now resolved automatically.
|
||||
- **Deleted VMs / CTs** — related errors auto-resolve when the resource is removed
|
||||
- **Removed Disks** — errors for disconnected USB or hot-swap drives are cleaned up
|
||||
- **Cluster Changes** — cluster errors clear when a node leaves the cluster
|
||||
- **Log Patterns** — log-based errors auto-resolve after 48 hours without recurrence
|
||||
- **Security Updates** — update notifications auto-resolve after 7 days
|
||||
|
||||
### Database Migration System
|
||||
- **Automatic column detection** — missing columns are added on startup
|
||||
- **Schema compatibility** — works with both old and new column naming conventions
|
||||
- **Backwards compatible** — databases from older ProxMenux versions are supported
|
||||
- **Graceful migration** — no data loss during schema updates
|
||||
|
||||
---
|
||||
|
||||
## 🧩 VM / CT Detail Modal
|
||||
|
||||
The VM/CT detail modal has been completely redesigned for usability.
|
||||
|
||||
- **Tabbed Navigation** — *Overview* (general information, status, resource usage) and *Backups* (dedicated history)
|
||||
- **Visual Enhancements** — icons throughout, improved hierarchy and spacing, better VM vs CT distinction
|
||||
- **Mobile Responsiveness** — adapts correctly to mobile screens in both webapp and direct browser access, no more overflow on small devices
|
||||
- **Touch-Friendly Controls** — larger buttons and spacing
|
||||
|
||||
### Secure Gateway Modal
|
||||
- **Scrollable storage list** when many destinations are available
|
||||
- Mobile-adapted layout and improved visual hierarchy
|
||||
|
||||
### Terminal Connection
|
||||
- **Reconnection loop fix** that was affecting mobile devices
|
||||
- Improved WebSocket handling for mobile browsers
|
||||
- More graceful connection timeout recovery
|
||||
|
||||
### Fail2ban & Lynis Management
|
||||
- **Delete buttons** added in Settings for both tools
|
||||
- Clean removal of packages and configuration files
|
||||
- Confirmation dialog to prevent accidental deletion
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Performance Optimizations
|
||||
|
||||
Major reduction in CPU usage and elimination of spikes on the Monitor.
|
||||
|
||||
### Staggered Polling Intervals
|
||||
Collectors now run on offset schedules to prevent simultaneous execution:
|
||||
|
||||
| Collector | Schedule |
|
||||
|-----------|----------|
|
||||
| CPU sampling | Every 30s at offset 0 |
|
||||
| Temperature sampling | Every 15s at offset 7s |
|
||||
| Latency pings | Every 60s at offset 25s |
|
||||
| Temperature record | Every 60s at offset 40s |
|
||||
| Health collector | Starts at 55s offset |
|
||||
| Notification polling | Health=10s, Updates=30s, ProxMenux=45s, AI=50s |
|
||||
|
||||
### Cached System Information
|
||||
Expensive commands now cached to reduce repeated execution:
|
||||
|
||||
| Command | Cache TTL | Impact |
|
||||
|---------|-----------|--------|
|
||||
| `pveversion` | 6 hours | Eliminates 23%+ CPU spikes from Perl execution |
|
||||
| `apt list --upgradable` | 6 hours | Reduces package manager queries |
|
||||
| `pvesh get /cluster/resources` | 30 seconds | 6 API calls per request reduced to 1 |
|
||||
| `sensors` | 10 seconds | Temperature readings cached between polls |
|
||||
| `smartctl` (SMART health) | 30 minutes | Disk health checks reduced from every 5 min |
|
||||
| `lspci` / `lspci -vmm` | 5 minutes | Hardware info cached (doesn't change) |
|
||||
| `journalctl --since 24h` | 1 hour | Login attempts count cached (92% reduction) |
|
||||
|
||||
### Increased journalctl Timeouts
|
||||
Prevents timeout cascades under system load:
|
||||
|
||||
| Query Type | Before | After |
|
||||
|------------|--------|-------|
|
||||
| Short-term (3-10 min) | 3s | 10s |
|
||||
| Medium-term (1 hour) | 5s | 15s |
|
||||
| Long-term (24 hours) | 5s | 20s |
|
||||
|
||||
### Reduced Polling Frequency
|
||||
- `TaskWatcher` interval raised from **2s → 5s** (60% fewer checks)
|
||||
|
||||
### GitHub Actions
|
||||
- All workflow actions upgraded to **v6** for Node.js 24 compatibility
|
||||
- Deprecation warnings eliminated in CI/CD
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Scripts — Storage, Hardware and GPU/TPU Work
|
||||
|
||||
This release also consolidates significant work on the core ProxMenux scripts.
|
||||
|
||||
### Storage scripts
|
||||
- **SMART scheduled tests** and improved interactive SMART test workflow with clearer progress feedback
|
||||
- **Disk formatting** (`format-disk.sh`) rework with safer device selection and dialog flow
|
||||
- **Disk passthrough** for VMs and CTs — updated device enumeration, serial-based identification, and cleaner teardown
|
||||
- **NVMe controller addition for VMs** — improved controller type selection and slot detection
|
||||
- **Import disk image** — smoother path validation and progress reporting
|
||||
- **Disk & storage manual guide** refresh
|
||||
|
||||
### Hardware / GPU / TPU scripts
|
||||
- **Coral TPU installer** updated for current kernels and udev rules (Proxmox VE 8 & VE 9)
|
||||
- **NVIDIA installer** — cleaner driver installation, kernel header handling, and VM/LXC attachment flow
|
||||
- **GPU mode switch** (direct and interactive variants) — safer switching between iGPU modes
|
||||
- **Add GPU to VM / LXC** — unified selection dialogs and permission handling
|
||||
- **Intel / AMD GPU tools** kept in sync with the new shared patterns
|
||||
- **Hardware & graphics menu** restructured for consistency with the rest of ProxMenux
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 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*
|
||||
|
||||
@@ -160,7 +160,6 @@ Contributions, bug reports and feature suggestions are welcome!
|
||||
- 💡 [Suggest a feature](https://github.com/MacRimi/ProxMenux/discussions)
|
||||
- 🔀 [Submit a pull request](https://github.com/MacRimi/ProxMenux/pulls)
|
||||
|
||||
If you find ProxMenux useful, consider giving it a ⭐ on GitHub — it helps others discover the project!
|
||||
|
||||
---
|
||||
|
||||
@@ -171,6 +170,7 @@ If you find ProxMenux useful, consider giving it a ⭐ on GitHub — it helps ot
|
||||
[](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;"/>
|
||||
|
||||
@@ -1,720 +0,0 @@
|
||||
# base-packages.txt - Generated on 2025-05-15 21:15:29
|
||||
# Proxmox Version: pve-manager/8.4.1/ (running kernel: 6.8.12-9-pve)
|
||||
|
||||
adduser
|
||||
apparmor
|
||||
apt
|
||||
apt-listchanges
|
||||
apt-utils
|
||||
attr
|
||||
base-files
|
||||
base-passwd
|
||||
bash
|
||||
bash-completion
|
||||
bc
|
||||
bind9-dnsutils
|
||||
bind9-host
|
||||
bind9-libs
|
||||
binutils
|
||||
binutils-common
|
||||
binutils-x86-64-linux-gnu
|
||||
bridge-utils
|
||||
bsdextrautils
|
||||
bsd-mailx
|
||||
bsdutils
|
||||
btrfs-progs
|
||||
busybox
|
||||
bzip2
|
||||
ca-certificates
|
||||
ceph-common
|
||||
ceph-fuse
|
||||
chrony
|
||||
cifs-utils
|
||||
console-setup
|
||||
console-setup-linux
|
||||
coreutils
|
||||
corosync
|
||||
cpio
|
||||
criu
|
||||
cron
|
||||
cron-daemon-common
|
||||
cstream
|
||||
curl
|
||||
dash
|
||||
dbus
|
||||
dbus-bin
|
||||
dbus-daemon
|
||||
dbus-session-bus-common
|
||||
dbus-system-bus-common
|
||||
debconf
|
||||
debconf-i18n
|
||||
debian-archive-keyring
|
||||
debian-faq
|
||||
debianutils
|
||||
dialog
|
||||
diffutils
|
||||
dirmngr
|
||||
distro-info-data
|
||||
dmeventd
|
||||
dmidecode
|
||||
dmsetup
|
||||
doc-debian
|
||||
dosfstools
|
||||
dpkg
|
||||
dtach
|
||||
e2fsprogs
|
||||
ebtables
|
||||
efibootmgr
|
||||
eject
|
||||
ethtool
|
||||
faketime
|
||||
fdisk
|
||||
fdutils
|
||||
file
|
||||
findutils
|
||||
fontconfig
|
||||
fontconfig-config
|
||||
fonts-dejavu-core
|
||||
fonts-font-awesome
|
||||
fonts-font-logos
|
||||
fonts-glyphicons-halflings
|
||||
frr
|
||||
frr-pythontools
|
||||
fuse
|
||||
gcc-12-base
|
||||
gdisk
|
||||
genisoimage
|
||||
gettext-base
|
||||
glusterfs-client
|
||||
glusterfs-common
|
||||
gnupg
|
||||
gnupg-l10n
|
||||
gnupg-utils
|
||||
gnutls-bin
|
||||
gpg
|
||||
gpg-agent
|
||||
gpgconf
|
||||
gpgsm
|
||||
gpgv
|
||||
gpg-wks-client
|
||||
gpg-wks-server
|
||||
grep
|
||||
groff-base
|
||||
grub2-common
|
||||
grub-common
|
||||
grub-efi-amd64
|
||||
grub-efi-amd64-bin
|
||||
grub-efi-amd64-signed
|
||||
grub-pc-bin
|
||||
gzip
|
||||
hdparm
|
||||
hostname
|
||||
ifupdown2
|
||||
inetutils-telnet
|
||||
init
|
||||
initramfs-tools
|
||||
initramfs-tools-core
|
||||
init-system-helpers
|
||||
iproute2
|
||||
ipset
|
||||
iptables
|
||||
iputils-ping
|
||||
isc-dhcp-client
|
||||
isc-dhcp-common
|
||||
iso-codes
|
||||
jq
|
||||
kbd
|
||||
keyboard-configuration
|
||||
keyutils
|
||||
klibc-utils
|
||||
kmod
|
||||
krb5-locales
|
||||
ksm-control-daemon
|
||||
less
|
||||
libacl1
|
||||
libaio1
|
||||
libanyevent-http-perl
|
||||
libanyevent-perl
|
||||
libapparmor1
|
||||
libappconfig-perl
|
||||
libapt-pkg6.0
|
||||
libapt-pkg-perl
|
||||
libarchive13
|
||||
libargon2-1
|
||||
libasound2
|
||||
libasound2-data
|
||||
libassuan0
|
||||
libasyncns0
|
||||
libattr1
|
||||
libaudit1
|
||||
libaudit-common
|
||||
libauthen-pam-perl
|
||||
libavahi-client3
|
||||
libavahi-common3
|
||||
libavahi-common-data
|
||||
libbabeltrace1
|
||||
libbinutils
|
||||
libblas3
|
||||
libblkid1
|
||||
libbpf1
|
||||
libbrotli1
|
||||
libbsd0
|
||||
libbytes-random-secure-perl
|
||||
libbz2-1.0
|
||||
libc6
|
||||
libcairo2
|
||||
libcap2
|
||||
libcap2-bin
|
||||
libcap-ng0
|
||||
libc-ares2
|
||||
libc-bin
|
||||
libcbor0.8
|
||||
libcephfs2
|
||||
libcfg7
|
||||
libc-l10n
|
||||
libclone-perl
|
||||
libcmap4
|
||||
libcom-err2
|
||||
libcommon-sense-perl
|
||||
libconvert-asn1-perl
|
||||
libcorosync-common4
|
||||
libcpg4
|
||||
libcrypt1
|
||||
libcrypt-openssl-bignum-perl
|
||||
libcrypt-openssl-random-perl
|
||||
libcrypt-openssl-rsa-perl
|
||||
libcrypt-random-seed-perl
|
||||
libcryptsetup12
|
||||
libcrypt-ssleay-perl
|
||||
libctf0
|
||||
libctf-nobfd0
|
||||
libcurl3-gnutls
|
||||
libcurl4
|
||||
libdatrie1
|
||||
libdb5.3
|
||||
libdbi1
|
||||
libdbus-1-3
|
||||
libdebconfclient0
|
||||
libdevel-cycle-perl
|
||||
libdevmapper1.02.1
|
||||
libdevmapper-event1.02.1
|
||||
libdigest-hmac-perl
|
||||
libdouble-conversion3
|
||||
libdrm2
|
||||
libdrm-common
|
||||
libdw1
|
||||
libedit2
|
||||
libefiboot1
|
||||
libefivar1
|
||||
libelf1
|
||||
libencode-locale-perl
|
||||
libepoxy0
|
||||
libevent-2.1-7
|
||||
libevent-core-2.1-7
|
||||
libexpat1
|
||||
libext2fs2
|
||||
libfaketime
|
||||
libfdisk1
|
||||
libfdt1
|
||||
libffi8
|
||||
libfido2-1
|
||||
libfile-chdir-perl
|
||||
libfile-find-rule-perl
|
||||
libfile-listing-perl
|
||||
libfile-readbackwards-perl
|
||||
libfilesys-df-perl
|
||||
libflac12
|
||||
libfmt9
|
||||
libfontconfig1
|
||||
libfreetype6
|
||||
libfribidi0
|
||||
libfstrm0
|
||||
libfuse2
|
||||
libfuse3-3
|
||||
libgbm1
|
||||
libgcc-s1
|
||||
libgcrypt20
|
||||
libgdbm6
|
||||
libgdbm-compat4
|
||||
libgfapi0
|
||||
libgfchangelog0
|
||||
libgfrpc0
|
||||
libgfxdr0
|
||||
libglib2.0-0
|
||||
libglusterd0
|
||||
libglusterfs0
|
||||
libgmp10
|
||||
libgnutls30
|
||||
libgnutls-dane0
|
||||
libgnutlsxx30
|
||||
libgoogle-perftools4
|
||||
libgpg-error0
|
||||
libgprofng0
|
||||
libgraphite2-3
|
||||
libgssapi-krb5-2
|
||||
libgstreamer1.0-0
|
||||
libgstreamer-plugins-base1.0-0
|
||||
libharfbuzz0b
|
||||
libhogweed6
|
||||
libhtml-parser-perl
|
||||
libhtml-tagset-perl
|
||||
libhtml-tree-perl
|
||||
libhttp-cookies-perl
|
||||
libhttp-daemon-perl
|
||||
libhttp-date-perl
|
||||
libhttp-message-perl
|
||||
libhttp-negotiate-perl
|
||||
libibverbs1
|
||||
libicu72
|
||||
libidn2-0
|
||||
libinih1
|
||||
libio-html-perl
|
||||
libio-multiplex-perl
|
||||
libio-socket-ssl-perl
|
||||
libio-stringy-perl
|
||||
libip4tc2
|
||||
libip6tc2
|
||||
libipset13
|
||||
libiscsi7
|
||||
libisns0
|
||||
libjansson4
|
||||
libjemalloc2
|
||||
libjpeg62-turbo
|
||||
libjq1
|
||||
libjs-bootstrap
|
||||
libjs-extjs
|
||||
libjs-jquery
|
||||
libjson-c5
|
||||
libjson-glib-1.0-0
|
||||
libjson-glib-1.0-common
|
||||
libjson-perl
|
||||
libjson-xs-perl
|
||||
libjs-qrcodejs
|
||||
libjs-sencha-touch
|
||||
libk5crypto3
|
||||
libkeyutils1
|
||||
libklibc
|
||||
libkmod2
|
||||
libknet1
|
||||
libkrb5-3
|
||||
libkrb5support0
|
||||
libksba8
|
||||
libldap-2.5-0
|
||||
libldb2
|
||||
liblinear4
|
||||
liblinux-inotify2-perl
|
||||
liblmdb0
|
||||
liblocale-gettext-perl
|
||||
liblockfile1
|
||||
liblockfile-bin
|
||||
liblttng-ust1
|
||||
liblttng-ust-common1
|
||||
liblttng-ust-ctl5
|
||||
liblua5.3-0
|
||||
liblvm2cmd2.03
|
||||
liblwp-mediatypes-perl
|
||||
liblwp-protocol-https-perl
|
||||
liblz4-1
|
||||
liblzma5
|
||||
liblzo2-2
|
||||
libmagic1
|
||||
libmagic-mgc
|
||||
libmath-random-isaac-perl
|
||||
libmaxminddb0
|
||||
libmd0
|
||||
libmime-base32-perl
|
||||
libmnl0
|
||||
libmount1
|
||||
libmp3lame0
|
||||
libmpg123-0
|
||||
libncurses6
|
||||
libncursesw6
|
||||
libnet1
|
||||
libnetaddr-ip-perl
|
||||
libnet-dbus-perl
|
||||
libnet-dns-perl
|
||||
libnetfilter-conntrack3
|
||||
libnetfilter-log1
|
||||
libnet-http-perl
|
||||
libnet-ip-perl
|
||||
libnet-ldap-perl
|
||||
libnet-ssleay-perl
|
||||
libnet-subnet-perl
|
||||
libnettle8
|
||||
libnewt0.52
|
||||
libnfnetlink0
|
||||
libnfsidmap1
|
||||
libnftables1
|
||||
libnftnl11
|
||||
libnghttp2-14
|
||||
libnl-3-200
|
||||
libnl-route-3-200
|
||||
libnozzle1
|
||||
libnpth0
|
||||
libnsl2
|
||||
libnspr4
|
||||
libnss3
|
||||
libnss-systemd
|
||||
libnuma1
|
||||
libnumber-compare-perl
|
||||
libnvpair3linux
|
||||
liboath0
|
||||
libogg0
|
||||
libonig5
|
||||
libopeniscsiusr
|
||||
libopus0
|
||||
liborc-0.4-0
|
||||
libp11-kit0
|
||||
libpam0g
|
||||
libpam-modules
|
||||
libpam-modules-bin
|
||||
libpam-runtime
|
||||
libpam-systemd
|
||||
libpango-1.0-0
|
||||
libpangocairo-1.0-0
|
||||
libpangoft2-1.0-0
|
||||
libpcap0.8
|
||||
libpci3
|
||||
libpcre2-16-0
|
||||
libpcre2-8-0
|
||||
libpcre3
|
||||
libperl5.36
|
||||
libpipeline1
|
||||
libpixman-1-0
|
||||
libpng16-16
|
||||
libpopt0
|
||||
libposix-strptime-perl
|
||||
libproc2-0
|
||||
libprotobuf32
|
||||
libprotobuf-c1
|
||||
libproxmox-acme-perl
|
||||
libproxmox-acme-plugins
|
||||
libproxmox-backup-qemu0
|
||||
libproxmox-rs-perl
|
||||
libpsl5
|
||||
libpulse0
|
||||
libpve-access-control
|
||||
libpve-apiclient-perl
|
||||
libpve-cluster-api-perl
|
||||
libpve-cluster-perl
|
||||
libpve-common-perl
|
||||
libpve-guest-common-perl
|
||||
libpve-http-server-perl
|
||||
libpve-network-api-perl
|
||||
libpve-network-perl
|
||||
libpve-notify-perl
|
||||
libpve-rs-perl
|
||||
libpve-storage-perl
|
||||
libpve-u2f-server-perl
|
||||
libpython3.11-minimal
|
||||
libpython3.11-stdlib
|
||||
libpython3-stdlib
|
||||
libqb100
|
||||
libqrencode4
|
||||
libqt5core5a
|
||||
libqt5dbus5
|
||||
libqt5network5
|
||||
libquorum5
|
||||
librabbitmq4
|
||||
librados2
|
||||
librados2-perl
|
||||
libradosstriper1
|
||||
librbd1
|
||||
librdkafka1
|
||||
librdmacm1
|
||||
libreadline8
|
||||
libregexp-ipv6-perl
|
||||
librgw2
|
||||
librrd8
|
||||
librrds-perl
|
||||
librtmp1
|
||||
libsasl2-2
|
||||
libsasl2-modules-db
|
||||
libseccomp2
|
||||
libselinux1
|
||||
libsemanage2
|
||||
libsemanage-common
|
||||
libsepol2
|
||||
libslang2
|
||||
libslirp0
|
||||
libsmartcols1
|
||||
libsmbclient
|
||||
libsnappy1v5
|
||||
libsndfile1
|
||||
libsocket6-perl
|
||||
libspice-server1
|
||||
libsqlite3-0
|
||||
libss2
|
||||
libssh2-1
|
||||
libssl3
|
||||
libstatgrab10
|
||||
libstdc++6
|
||||
libstring-shellquote-perl
|
||||
libsubid4
|
||||
libsystemd0
|
||||
libsystemd-shared
|
||||
libtalloc2
|
||||
libtasn1-6
|
||||
libtcmalloc-minimal4
|
||||
libtdb1
|
||||
libtemplate-perl
|
||||
libterm-readline-gnu-perl
|
||||
libtevent0
|
||||
libtext-charwidth-perl
|
||||
libtext-glob-perl
|
||||
libtext-iconv-perl
|
||||
libtext-wrapi18n-perl
|
||||
libthai0
|
||||
libthai-data
|
||||
libthrift-0.17.0
|
||||
libtimedate-perl
|
||||
libtinfo6
|
||||
libtirpc3
|
||||
libtirpc-common
|
||||
libtpms0
|
||||
libtry-tiny-perl
|
||||
libtypes-serialiser-perl
|
||||
libu2f-server0
|
||||
libuchardet0
|
||||
libudev1
|
||||
libunbound8
|
||||
libunistring2
|
||||
libunwind8
|
||||
liburcu8
|
||||
liburing2
|
||||
liburi-perl
|
||||
libusb-1.0-0
|
||||
libusbredirparser1
|
||||
libuuid1
|
||||
libuuid-perl
|
||||
libuutil3linux
|
||||
libuv1
|
||||
libva2
|
||||
libva-drm2
|
||||
libvirglrenderer1
|
||||
libvorbis0a
|
||||
libvorbisenc2
|
||||
libvotequorum8
|
||||
libvulkan1
|
||||
libwayland-server0
|
||||
libwbclient0
|
||||
libwrap0
|
||||
libwww-perl
|
||||
libwww-robotrules-perl
|
||||
libx11-6
|
||||
libx11-data
|
||||
libx11-xcb1
|
||||
libxau6
|
||||
libxcb1
|
||||
libxcb-render0
|
||||
libxcb-shm0
|
||||
libxdmcp6
|
||||
libxext6
|
||||
libxml2
|
||||
libxml-libxml-perl
|
||||
libxml-namespacesupport-perl
|
||||
libxml-parser-perl
|
||||
libxml-sax-base-perl
|
||||
libxml-sax-perl
|
||||
libxml-twig-perl
|
||||
libxrender1
|
||||
libxslt1.1
|
||||
libxtables12
|
||||
libxxhash0
|
||||
libyaml-0-2
|
||||
libyaml-libyaml-perl
|
||||
libyang3
|
||||
libzfs4linux
|
||||
libzpool5linux
|
||||
libzstd1
|
||||
linux-base
|
||||
locales
|
||||
login
|
||||
logrotate
|
||||
logsave
|
||||
lsof
|
||||
lua-lpeg
|
||||
lvm2
|
||||
lxcfs
|
||||
lxc-pve
|
||||
lzop
|
||||
mailcap
|
||||
man-db
|
||||
manpages
|
||||
mawk
|
||||
media-types
|
||||
memtest86+
|
||||
mime-support
|
||||
mokutil
|
||||
mount
|
||||
nano
|
||||
ncurses-base
|
||||
ncurses-bin
|
||||
ncurses-term
|
||||
netbase
|
||||
netcat-traditional
|
||||
nfs-common
|
||||
nftables
|
||||
nmap
|
||||
nmap-common
|
||||
novnc-pve
|
||||
open-iscsi
|
||||
openssh-client
|
||||
openssh-server
|
||||
openssh-sftp-server
|
||||
openssl
|
||||
passwd
|
||||
pci.ids
|
||||
pciutils
|
||||
perl
|
||||
perl-base
|
||||
perl-modules-5.36
|
||||
perl-openssl-defaults
|
||||
pinentry-curses
|
||||
postfix
|
||||
procmail
|
||||
procps
|
||||
proxmox-archive-keyring
|
||||
proxmox-backup-client
|
||||
proxmox-backup-file-restore
|
||||
proxmox-backup-restore-image
|
||||
proxmox-default-kernel
|
||||
proxmox-firewall
|
||||
proxmox-grub
|
||||
proxmox-kernel-6.8
|
||||
proxmox-kernel-6.8.12-10-pve-signed
|
||||
proxmox-kernel-6.8.12-9-pve-signed
|
||||
proxmox-kernel-helper
|
||||
proxmox-mail-forward
|
||||
proxmox-mini-journalreader
|
||||
proxmox-offline-mirror-docs
|
||||
proxmox-offline-mirror-helper
|
||||
proxmox-termproxy
|
||||
proxmox-ve
|
||||
proxmox-websocket-tunnel
|
||||
proxmox-widget-toolkit
|
||||
psmisc
|
||||
pv
|
||||
pve-cluster
|
||||
pve-container
|
||||
pve-docs
|
||||
pve-edk2-firmware
|
||||
pve-edk2-firmware-legacy
|
||||
pve-edk2-firmware-ovmf
|
||||
pve-esxi-import-tools
|
||||
pve-firewall
|
||||
pve-firmware
|
||||
pve-ha-manager
|
||||
pve-i18n
|
||||
pve-lxc-syscalld
|
||||
pve-manager
|
||||
pve-qemu-kvm
|
||||
pve-xtermjs
|
||||
python3
|
||||
python3.11
|
||||
python3.11-minimal
|
||||
python3.11-venv
|
||||
python3-apt
|
||||
python3-ceph-argparse
|
||||
python3-ceph-common
|
||||
python3-cephfs
|
||||
python3-certifi
|
||||
python3-chardet
|
||||
python3-charset-normalizer
|
||||
python3-debconf
|
||||
python3-debian
|
||||
python3-debianbts
|
||||
python3-distutils
|
||||
python3-httplib2
|
||||
python3-idna
|
||||
python3-jwt
|
||||
python3-lib2to3
|
||||
python3-minimal
|
||||
python3-pip-whl
|
||||
python3-pkg-resources
|
||||
python3-prettytable
|
||||
python3-protobuf
|
||||
python3-pycurl
|
||||
python3-pyparsing
|
||||
python3-pysimplesoap
|
||||
python3-pyvmomi
|
||||
python3-rados
|
||||
python3-rbd
|
||||
python3-reportbug
|
||||
python3-requests
|
||||
python3-rgw
|
||||
python3-setuptools
|
||||
python3-setuptools-whl
|
||||
python3-six
|
||||
python3-systemd
|
||||
python3-urllib3
|
||||
python3-venv
|
||||
python3-wcwidth
|
||||
python3-yaml
|
||||
python-apt-common
|
||||
qemu-server
|
||||
qrencode
|
||||
readline-common
|
||||
reportbug
|
||||
rpcbind
|
||||
rrdcached
|
||||
rsync
|
||||
runit-helper
|
||||
samba-common
|
||||
samba-libs
|
||||
sed
|
||||
sensible-utils
|
||||
sgml-base
|
||||
shared-mime-info
|
||||
shim-helpers-amd64-signed
|
||||
shim-signed
|
||||
shim-signed-common
|
||||
shim-unsigned
|
||||
smartmontools
|
||||
smbclient
|
||||
socat
|
||||
spiceterm
|
||||
spl
|
||||
sqlite3
|
||||
ssh
|
||||
ssl-cert
|
||||
strace
|
||||
swtpm
|
||||
swtpm-libs
|
||||
swtpm-tools
|
||||
systemd
|
||||
systemd-boot
|
||||
systemd-boot-efi
|
||||
systemd-sysv
|
||||
sysvinit-utils
|
||||
tar
|
||||
tasksel
|
||||
tasksel-data
|
||||
tcpdump
|
||||
thin-provisioning-tools
|
||||
time
|
||||
traceroute
|
||||
tzdata
|
||||
ucf
|
||||
udev
|
||||
uidmap
|
||||
usbutils
|
||||
usrmerge
|
||||
util-linux
|
||||
util-linux-extra
|
||||
vim-common
|
||||
vim-tiny
|
||||
virtiofsd
|
||||
vncterm
|
||||
wamerican
|
||||
wget
|
||||
whiptail
|
||||
xfsprogs
|
||||
xkb-data
|
||||
xsltproc
|
||||
xz-utils
|
||||
zfs-initramfs
|
||||
zfsutils-linux
|
||||
zfs-zed
|
||||
zlib1g
|
||||
zstd
|
||||
@@ -1 +0,0 @@
|
||||
1.1.9.2
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
+10
-2
@@ -821,14 +821,22 @@ install_normal_version() {
|
||||
cp "./version.txt" "$LOCAL_VERSION_FILE"
|
||||
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
|
||||
|
||||
# Wipe the scripts tree before copying so any file removed upstream
|
||||
# (renamed, consolidated, deprecated) disappears from the user install.
|
||||
# Only $BASE_DIR/scripts/ is cleared; config.json, cache.json,
|
||||
# components_status.json, version.txt, beta_version.txt, monitor.db,
|
||||
# smart/, oci/ and the AppImage live outside this path and are preserved.
|
||||
rm -rf "$BASE_DIR/scripts"
|
||||
mkdir -p "$BASE_DIR/scripts"
|
||||
cp -r "./scripts/"* "$BASE_DIR/scripts/"
|
||||
chmod -R +x "$BASE_DIR/scripts/"
|
||||
# Only .sh files need the executable bit. Applying +x recursively would
|
||||
# also flag README.md, .json, .py etc. as executable for no reason.
|
||||
find "$BASE_DIR/scripts" -type f -name '*.sh' -exec chmod +x {} +
|
||||
chmod +x "$BASE_DIR/install_proxmenux.sh"
|
||||
msg_ok "Necessary files created."
|
||||
|
||||
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
|
||||
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Installing ProxMenux Monitor"
|
||||
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux Monitor - Beta Program Installer
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Subproject : ProxMenux Monitor Beta
|
||||
# Copyright : (c) 2024-2025 MacRimi
|
||||
# License : GPL-3.0 (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : Beta
|
||||
# Branch : develop
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script installs the BETA version of ProxMenux Monitor
|
||||
# from the develop branch on GitHub.
|
||||
#
|
||||
# Beta testers are expected to:
|
||||
# - Report bugs and unexpected behavior via GitHub Issues
|
||||
# - Provide feedback to help improve the final release
|
||||
#
|
||||
# Installs:
|
||||
# • dialog, curl, jq, git (system dependencies)
|
||||
# • ProxMenux core files (/usr/local/share/proxmenux)
|
||||
# • ProxMenux Monitor AppImage (Web dashboard on port 8008)
|
||||
# • Systemd service (auto-start on boot)
|
||||
#
|
||||
# Notes:
|
||||
# - Clones from the 'develop' branch
|
||||
# - Beta version file: beta_version.txt in the repository
|
||||
# - Transition to stable: re-run the official installer
|
||||
# ==========================================================
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────
|
||||
INSTALL_DIR="/usr/local/bin"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
CONFIG_FILE="$BASE_DIR/config.json"
|
||||
CACHE_FILE="$BASE_DIR/cache.json"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||
BETA_VERSION_FILE="$BASE_DIR/beta_version.txt"
|
||||
MENU_SCRIPT="menu"
|
||||
|
||||
MONITOR_INSTALL_DIR="$BASE_DIR"
|
||||
MONITOR_SERVICE_FILE="/etc/systemd/system/proxmenux-monitor.service"
|
||||
MONITOR_PORT=8008
|
||||
|
||||
REPO_URL="https://github.com/MacRimi/ProxMenux.git"
|
||||
REPO_BRANCH="develop"
|
||||
TEMP_DIR="/tmp/proxmenux-beta-install-$$"
|
||||
|
||||
# ── Colors ─────────────────────────────────────────────────
|
||||
RESET="\033[0m"
|
||||
BOLD="\033[1m"
|
||||
WHITE="\033[38;5;15m"
|
||||
NEON_PURPLE_BLUE="\033[38;5;99m"
|
||||
DARK_GRAY="\033[38;5;244m"
|
||||
ORANGE="\033[38;5;208m"
|
||||
GN="\033[1;92m"
|
||||
YW="\033[33m"
|
||||
YWB="\033[1;33m"
|
||||
RD="\033[01;31m"
|
||||
BL="\033[36m"
|
||||
CL="\033[m"
|
||||
BGN="\e[1;32m"
|
||||
TAB=" "
|
||||
BFR="\\r\\033[K"
|
||||
HOLD="-"
|
||||
BOR=" | "
|
||||
CM="${GN}✓ ${CL}"
|
||||
|
||||
SPINNER_PID=""
|
||||
|
||||
# ── Spinner ────────────────────────────────────────────────
|
||||
spinner() {
|
||||
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
|
||||
local spin_i=0
|
||||
printf "\e[?25l"
|
||||
while true; do
|
||||
printf "\r ${YW}%s${CL}" "${frames[spin_i]}"
|
||||
spin_i=$(( (spin_i + 1) % ${#frames[@]} ))
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
type_text() {
|
||||
local text="$1"
|
||||
local delay=0.04
|
||||
for ((i=0; i<${#text}; i++)); do
|
||||
echo -n "${text:$i:1}"
|
||||
sleep $delay
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
msg_info() {
|
||||
local msg="$1"
|
||||
echo -ne "${TAB}${YW}${HOLD}${msg}"
|
||||
spinner &
|
||||
SPINNER_PID=$!
|
||||
}
|
||||
|
||||
msg_ok() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
|
||||
kill $SPINNER_PID > /dev/null 2>&1
|
||||
SPINNER_PID=""
|
||||
fi
|
||||
printf "\e[?25h"
|
||||
echo -e "${BFR}${TAB}${CM}${GN}${1}${CL}"
|
||||
}
|
||||
|
||||
msg_error() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
|
||||
kill $SPINNER_PID > /dev/null 2>&1
|
||||
SPINNER_PID=""
|
||||
fi
|
||||
printf "\e[?25h"
|
||||
echo -e "${BFR}${TAB}${RD}[ERROR] ${1}${CL}"
|
||||
}
|
||||
|
||||
msg_warn() {
|
||||
if [ -n "$SPINNER_PID" ] && ps -p $SPINNER_PID > /dev/null 2>&1; then
|
||||
kill $SPINNER_PID > /dev/null 2>&1
|
||||
SPINNER_PID=""
|
||||
fi
|
||||
printf "\e[?25h"
|
||||
echo -e "${BFR}${TAB}${YWB}${1}${CL}"
|
||||
}
|
||||
|
||||
msg_title() {
|
||||
echo -e "\n"
|
||||
echo -e "${TAB}${BOLD}${HOLD}${BOR}${1}${BOR}${HOLD}${CL}"
|
||||
echo -e "\n"
|
||||
}
|
||||
|
||||
show_progress() {
|
||||
echo -e "\n${BOLD}${BL}${TAB}Installing ProxMenux Beta: Step ${1} of ${2}${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${YW}${HOLD}${3}${CL}"
|
||||
}
|
||||
|
||||
# ── Cleanup ────────────────────────────────────────────────
|
||||
cleanup() {
|
||||
if [ -d "$TEMP_DIR" ]; then
|
||||
rm -rf "$TEMP_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── Logo ───────────────────────────────────────────────────
|
||||
show_proxmenux_logo() {
|
||||
clear
|
||||
|
||||
if [[ -z "$SSH_TTY" && -z "$(who am i | awk '{print $NF}' | grep -E '([0-9]{1,3}\.){3}[0-9]{1,3}')" ]]; then
|
||||
|
||||
LOGO=$(cat << "EOF"
|
||||
\e[0m\e[38;2;61;61;61m▆\e[38;2;60;60;60m▄\e[38;2;54;54;54m▂\e[0m \e[38;2;0;0;0m \e[0m \e[38;2;54;54;54m▂\e[38;2;60;60;60m▄\e[38;2;61;61;61m▆\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[38;2;61;61;61;48;2;37;37;37m▇\e[0m\e[38;2;60;60;60m▅\e[38;2;56;56;56m▃\e[38;2;37;37;37m▁ \e[38;2;36;36;36m▁\e[38;2;56;56;56m▃\e[38;2;60;60;60m▅\e[38;2;61;61;61;48;2;37;37;37m▇\e[48;2;62;62;62m \e[0m\e[7m\e[38;2;60;60;60m▁\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[7m\e[38;2;61;61;61m▂\e[0m\e[38;2;62;62;62;48;2;61;61;61m┈\e[48;2;62;62;62m \e[48;2;61;61;61m┈\e[0m\e[38;2;60;60;60m▆\e[38;2;57;57;57m▄\e[38;2;48;48;48m▂\e[0m \e[38;2;47;47;47m▂\e[38;2;57;57;57m▄\e[38;2;60;60;60m▆\e[38;2;62;62;62;48;2;61;61;61m┈\e[48;2;62;62;62m \e[48;2;61;61;61m┈\e[0m\e[7m\e[38;2;60;60;60m▂\e[38;2;57;57;57m▄\e[38;2;47;47;47m▆\e[0m \e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[7m\e[38;2;39;39;39m▇\e[38;2;57;57;57m▅\e[38;2;60;60;60m▃\e[0m\e[38;2;40;40;40;48;2;61;61;61m▁\e[48;2;62;62;62m \e[38;2;54;54;54;48;2;61;61;61m┊\e[48;2;62;62;62m \e[38;2;39;39;39;48;2;61;61;61m▁\e[0m\e[7m\e[38;2;60;60;60m▃\e[38;2;57;57;57m▅\e[38;2;38;38;38m▇\e[0m \e[38;2;193;60;2m▃\e[38;2;217;67;2m▅\e[38;2;225;70;2m▇\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[0m \e[38;2;203;63;2m▄\e[38;2;147;45;1m▂\e[0m \e[7m\e[38;2;55;55;55m▆\e[38;2;60;60;60m▄\e[38;2;61;61;61m▂\e[38;2;60;60;60m▄\e[38;2;55;55;55m▆\e[0m \e[38;2;144;44;1m▂\e[38;2;202;62;2m▄\e[38;2;219;68;2m▆\e[38;2;231;72;3;48;2;226;70;2m┈\e[48;2;231;72;3m \e[48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[7m\e[38;2;121;37;1m▉\e[0m\e[38;2;0;0;0;48;2;231;72;3m \e[0m\e[38;2;221;68;2m▇\e[38;2;208;64;2m▅\e[38;2;212;66;2m▂\e[38;2;123;37;0m▁\e[38;2;211;65;2m▂\e[38;2;207;64;2m▅\e[38;2;220;68;2m▇\e[48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m┈\e[0m\e[7m\e[38;2;221;68;2m▂\e[0m\e[38;2;44;13;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏\e[0m \e[7m\e[38;2;190;59;2m▅\e[38;2;216;67;2m▃\e[38;2;225;70;2m▁\e[0m\e[38;2;95;29;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;230;71;2m┈\e[48;2;231;72;3m \e[0m\e[7m\e[38;2;225;70;2m▁\e[38;2;216;67;2m▃\e[38;2;191;59;2m▅\e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏ \e[0m \e[7m\e[38;2;172;53;1m▆\e[38;2;213;66;2m▄\e[38;2;219;68;2m▂\e[38;2;213;66;2m▄\e[38;2;174;54;2m▆\e[0m \e[38;2;0;0;0m \e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[38;2;59;59;59;48;2;62;62;62m▏ \e[0m\e[38;2;32;32;32m▏ \e[0m \e[38;2;0;0;0;48;2;231;72;3m \e[38;2;231;72;3;48;2;225;70;2m▉\e[0m
|
||||
\e[7m\e[38;2;52;52;52m▆\e[38;2;59;59;59m▄\e[38;2;61;61;61m▂\e[0m\e[38;2;31;31;31m▏ \e[0m \e[7m\e[38;2;228;71;2m▂\e[38;2;221;69;2m▄\e[38;2;196;60;2m▆\e[0m
|
||||
EOF
|
||||
)
|
||||
TEXT=(
|
||||
""
|
||||
""
|
||||
"${BOLD}ProxMenux${RESET}"
|
||||
""
|
||||
"${BOLD}${NEON_PURPLE_BLUE}An Interactive Menu for${RESET}"
|
||||
"${BOLD}${NEON_PURPLE_BLUE}Proxmox VE management${RESET}"
|
||||
""
|
||||
"${BOLD}${YW} ★ BETA PROGRAM ★${RESET}"
|
||||
""
|
||||
""
|
||||
)
|
||||
mapfile -t logo_lines <<< "$LOGO"
|
||||
for i in {0..9}; do
|
||||
echo -e "${TAB}${logo_lines[i]} ${WHITE}│${RESET} ${TEXT[i]}"
|
||||
done
|
||||
echo -e
|
||||
|
||||
else
|
||||
|
||||
TEXT=(
|
||||
"" "" "" ""
|
||||
"${BOLD}ProxMenux${RESET}"
|
||||
""
|
||||
"${BOLD}${NEON_PURPLE_BLUE}An Interactive Menu for${RESET}"
|
||||
"${BOLD}${NEON_PURPLE_BLUE}Proxmox VE management${RESET}"
|
||||
""
|
||||
"${BOLD}${YW} ★ BETA PROGRAM ★${RESET}"
|
||||
"" "" ""
|
||||
)
|
||||
LOGO=(
|
||||
"${DARK_GRAY}░░░░ ░░░░${RESET}"
|
||||
"${DARK_GRAY}░░░░░░░ ░░░░░░ ${RESET}"
|
||||
"${DARK_GRAY}░░░░░░░░░░░ ░░░░░░░ ${RESET}"
|
||||
"${DARK_GRAY}░░░░ ░░░░░░ ░░░░░░ ${ORANGE}░░${RESET}"
|
||||
"${DARK_GRAY}░░░░ ░░░░░░░ ${ORANGE}░░▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ░░░ ${ORANGE}░▒▒▒▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒░ ░▒▒▒▒▒▒▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}░▒▒▒▒▒ ▒▒▒▒▒░░ ▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}░░▒▒▒▒▒▒▒░░ ▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}░░░ ▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒▒${RESET}"
|
||||
"${DARK_GRAY}░░░░ ${ORANGE}▒▒▒░${RESET}"
|
||||
"${DARK_GRAY} ░░ ${ORANGE}░░ ${RESET}"
|
||||
)
|
||||
for i in {0..12}; do
|
||||
echo -e "${TAB}${LOGO[i]} │${RESET} ${TEXT[i]}"
|
||||
done
|
||||
echo -e
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Beta welcome message ───────────────────────────────────
|
||||
show_beta_welcome() {
|
||||
local width=62
|
||||
local line
|
||||
line=$(printf '─%.0s' $(seq 1 $width))
|
||||
|
||||
echo -e "${TAB}${BOLD}${YW}┌${line}┐${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL}${BOLD} Welcome to the ProxMenux Monitor Beta Program ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}└${line}┘${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${WHITE}You are about to install a ${BOLD}pre-release (beta)${RESET}${WHITE} version of${CL}"
|
||||
echo -e "${TAB}${WHITE}ProxMenux Monitor, built from the ${BOLD}develop${RESET}${WHITE} branch.${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${GN}What this means for you:${CL}"
|
||||
echo -e "${TAB} ${GN}•${CL} You'll get the latest features before the official release."
|
||||
echo -e "${TAB} ${GN}•${CL} Some things may not work perfectly — that's expected."
|
||||
echo -e "${TAB} ${GN}•${CL} Your feedback is what makes the final version better."
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${YW}How to report issues:${CL}"
|
||||
echo -e "${TAB} ${YW}→${CL} Open a GitHub Issue at:"
|
||||
echo -e "${TAB} ${BL}https://github.com/MacRimi/ProxMenux/issues${CL}"
|
||||
echo -e "${TAB} ${YW}→${CL} Describe what happened, what you expected, and any"
|
||||
echo -e "${TAB} error messages you saw. Logs help a lot:"
|
||||
echo -e "${TAB} ${DARK_GRAY}journalctl -u proxmenux-monitor -n 50${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${NEON_PURPLE_BLUE}Thank you for being part of the beta program!${CL}"
|
||||
echo -e "${TAB}${DARK_GRAY}Your help is essential to deliver a stable and polished release.${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${BOLD}${YW}┌${line}┐${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL} ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL} Press ${BOLD}${GN}[Enter]${CL} to continue with the beta installation, ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL} or ${BOLD}${RD}[Ctrl+C]${CL} to cancel and exit. ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}│${CL} ${YW}│${CL}"
|
||||
echo -e "${TAB}${BOLD}${YW}└${line}┘${CL}"
|
||||
echo
|
||||
|
||||
read -r -p ""
|
||||
echo
|
||||
}
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────
|
||||
get_server_ip() {
|
||||
local ip
|
||||
ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+')
|
||||
[ -z "$ip" ] && ip=$(hostname -I | awk '{print $1}')
|
||||
[ -z "$ip" ] && ip="localhost"
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
update_config() {
|
||||
local component="$1"
|
||||
local status="$2"
|
||||
local timestamp
|
||||
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||
[ ! -f "$CONFIG_FILE" ] || ! jq empty "$CONFIG_FILE" >/dev/null 2>&1 && echo '{}' > "$CONFIG_FILE"
|
||||
|
||||
local tmp_file
|
||||
tmp_file=$(mktemp)
|
||||
if jq --arg comp "$component" --arg stat "$status" --arg time "$timestamp" \
|
||||
'.[$comp] = {status: $stat, timestamp: $time}' "$CONFIG_FILE" > "$tmp_file" 2>/dev/null; then
|
||||
mv "$tmp_file" "$CONFIG_FILE"
|
||||
else
|
||||
echo '{}' > "$CONFIG_FILE"
|
||||
fi
|
||||
[ -f "$tmp_file" ] && rm -f "$tmp_file"
|
||||
}
|
||||
|
||||
cleanup_corrupted_files() {
|
||||
if [ -f "$CONFIG_FILE" ] && ! jq empty "$CONFIG_FILE" >/dev/null 2>&1; then
|
||||
rm -f "$CONFIG_FILE"
|
||||
fi
|
||||
if [ -f "$CACHE_FILE" ] && ! jq empty "$CACHE_FILE" >/dev/null 2>&1; then
|
||||
rm -f "$CACHE_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
detect_latest_appimage() {
|
||||
local appimage_dir="$TEMP_DIR/AppImage"
|
||||
[ ! -d "$appimage_dir" ] && return 1
|
||||
local latest
|
||||
latest=$(find "$appimage_dir" -name "ProxMenux-*.AppImage" -type f | sort -V | tail -1)
|
||||
[ -z "$latest" ] && return 1
|
||||
echo "$latest"
|
||||
}
|
||||
|
||||
get_appimage_version() {
|
||||
local filename
|
||||
filename=$(basename "$1")
|
||||
echo "$filename" | grep -oP 'ProxMenux-\K[0-9]+\.[0-9]+\.[0-9]+'
|
||||
}
|
||||
|
||||
# ── Monitor install ────────────────────────────────────────
|
||||
install_proxmenux_monitor() {
|
||||
local appimage_source
|
||||
appimage_source=$(detect_latest_appimage)
|
||||
|
||||
if [ -z "$appimage_source" ] || [ ! -f "$appimage_source" ]; then
|
||||
msg_error "ProxMenux Monitor AppImage not found in $TEMP_DIR/AppImage/"
|
||||
msg_warn "Make sure the AppImage directory exists in the develop branch."
|
||||
update_config "proxmenux_monitor" "appimage_not_found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local appimage_version
|
||||
appimage_version=$(get_appimage_version "$appimage_source")
|
||||
|
||||
systemctl is-active --quiet proxmenux-monitor.service 2>/dev/null && \
|
||||
systemctl stop proxmenux-monitor.service
|
||||
|
||||
local service_exists=false
|
||||
[ -f "$MONITOR_SERVICE_FILE" ] && service_exists=true
|
||||
|
||||
local sha256_file="$TEMP_DIR/AppImage/ProxMenux-Monitor.AppImage.sha256"
|
||||
if [ -f "$sha256_file" ]; then
|
||||
msg_info "Verifying AppImage integrity..."
|
||||
local expected_hash actual_hash
|
||||
expected_hash=$(grep -Eo '^[a-f0-9]+' "$sha256_file" | tr -d '\n')
|
||||
actual_hash=$(sha256sum "$appimage_source" | awk '{print $1}')
|
||||
if [ "$expected_hash" != "$actual_hash" ]; then
|
||||
msg_error "SHA256 verification failed! The AppImage may be corrupted."
|
||||
return 1
|
||||
fi
|
||||
msg_ok "SHA256 verification passed."
|
||||
else
|
||||
msg_warn "SHA256 checksum file not found. Skipping verification."
|
||||
fi
|
||||
|
||||
msg_info "Installing ProxMenux Monitor (beta)..."
|
||||
mkdir -p "$MONITOR_INSTALL_DIR"
|
||||
local target_path="$MONITOR_INSTALL_DIR/ProxMenux-Monitor.AppImage"
|
||||
cp "$appimage_source" "$target_path"
|
||||
chmod +x "$target_path"
|
||||
msg_ok "ProxMenux Monitor beta v${appimage_version} installed."
|
||||
|
||||
if [ "$service_exists" = false ]; then
|
||||
return 0
|
||||
else
|
||||
systemctl start proxmenux-monitor.service
|
||||
sleep 2
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
update_config "proxmenux_monitor" "beta_updated"
|
||||
return 2
|
||||
else
|
||||
msg_warn "Service failed to restart. Check: journalctl -u proxmenux-monitor"
|
||||
update_config "proxmenux_monitor" "failed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
create_monitor_service() {
|
||||
msg_info "Creating ProxMenux Monitor service..."
|
||||
local exec_path="$MONITOR_INSTALL_DIR/ProxMenux-Monitor.AppImage"
|
||||
|
||||
if [ -f "$TEMP_DIR/systemd/proxmenux-monitor.service" ]; then
|
||||
sed "s|ExecStart=.*|ExecStart=$exec_path|g" \
|
||||
"$TEMP_DIR/systemd/proxmenux-monitor.service" > "$MONITOR_SERVICE_FILE"
|
||||
msg_ok "Service file loaded from repository."
|
||||
else
|
||||
cat > "$MONITOR_SERVICE_FILE" << EOF
|
||||
[Unit]
|
||||
Description=ProxMenux Monitor - Web Dashboard (Beta)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=$MONITOR_INSTALL_DIR
|
||||
ExecStart=$exec_path
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
Environment="PORT=$MONITOR_PORT"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
msg_ok "Default service file created."
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable proxmenux-monitor.service > /dev/null 2>&1
|
||||
systemctl start proxmenux-monitor.service > /dev/null 2>&1
|
||||
sleep 3
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
msg_ok "ProxMenux Monitor service started successfully."
|
||||
update_config "proxmenux_monitor" "beta_installed"
|
||||
return 0
|
||||
else
|
||||
msg_warn "ProxMenux Monitor service failed to start."
|
||||
echo -e "${TAB}${DARK_GRAY}Check logs : journalctl -u proxmenux-monitor -n 20${CL}"
|
||||
echo -e "${TAB}${DARK_GRAY}Check status: systemctl status proxmenux-monitor${CL}"
|
||||
update_config "proxmenux_monitor" "failed"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main install ───────────────────────────────────────────
|
||||
install_beta() {
|
||||
local total_steps=4
|
||||
local current_step=1
|
||||
|
||||
# ── Step 1: Dependencies ──────────────────────────────
|
||||
show_progress $current_step $total_steps "Installing system dependencies"
|
||||
|
||||
if ! command -v jq > /dev/null 2>&1; then
|
||||
apt-get update > /dev/null 2>&1
|
||||
if apt-get install -y jq > /dev/null 2>&1 && command -v jq > /dev/null 2>&1; then
|
||||
update_config "jq" "installed"
|
||||
else
|
||||
local jq_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"
|
||||
if wget -q -O /usr/local/bin/jq "$jq_url" 2>/dev/null && chmod +x /usr/local/bin/jq \
|
||||
&& command -v jq > /dev/null 2>&1; then
|
||||
update_config "jq" "installed_from_github"
|
||||
else
|
||||
msg_error "Failed to install jq. Please install it manually and re-run."
|
||||
update_config "jq" "failed"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
update_config "jq" "already_installed"
|
||||
fi
|
||||
|
||||
local 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
|
||||
update_config "$pkg" "installed"
|
||||
else
|
||||
msg_error "Failed to install $pkg. Please install it manually."
|
||||
update_config "$pkg" "failed"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
update_config "$pkg" "already_installed"
|
||||
fi
|
||||
done
|
||||
|
||||
msg_ok "Dependencies installed: jq, dialog, curl, git."
|
||||
|
||||
# ── Step 2: Clone develop branch ─────────────────────
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Cloning ProxMenux develop branch"
|
||||
|
||||
msg_info "Cloning branch '${REPO_BRANCH}' from repository..."
|
||||
if ! git clone --depth 1 --branch "$REPO_BRANCH" "$REPO_URL" "$TEMP_DIR" 2>/dev/null; then
|
||||
msg_error "Failed to clone branch '$REPO_BRANCH' from $REPO_URL"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "Repository cloned successfully (branch: ${REPO_BRANCH})."
|
||||
|
||||
# Read beta version if available
|
||||
local beta_version="unknown"
|
||||
if [ -f "$TEMP_DIR/beta_version.txt" ]; then
|
||||
beta_version=$(cat "$TEMP_DIR/beta_version.txt" | tr -d '[:space:]')
|
||||
fi
|
||||
|
||||
cd "$TEMP_DIR"
|
||||
|
||||
# ── Step 3: Files ─────────────────────────────────────
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Creating directories and copying files"
|
||||
|
||||
mkdir -p "$BASE_DIR" "$INSTALL_DIR"
|
||||
[ ! -f "$CONFIG_FILE" ] && echo '{}' > "$CONFIG_FILE"
|
||||
|
||||
# Preserve user/runtime directories that must never be overwritten
|
||||
mkdir -p "$BASE_DIR/oci"
|
||||
|
||||
cp "./scripts/utils.sh" "$UTILS_FILE"
|
||||
cp "./menu" "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
cp "./version.txt" "$LOCAL_VERSION_FILE" 2>/dev/null || true
|
||||
|
||||
# Store beta version marker
|
||||
if [ -f "$TEMP_DIR/beta_version.txt" ]; then
|
||||
cp "$TEMP_DIR/beta_version.txt" "$BETA_VERSION_FILE"
|
||||
else
|
||||
echo "$beta_version" > "$BETA_VERSION_FILE"
|
||||
fi
|
||||
|
||||
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh" 2>/dev/null || true
|
||||
cp "./install_proxmenux_beta.sh" "$BASE_DIR/install_proxmenux_beta.sh" 2>/dev/null || true
|
||||
|
||||
mkdir -p "$BASE_DIR/scripts"
|
||||
cp -r "./scripts/"* "$BASE_DIR/scripts/"
|
||||
chmod -R +x "$BASE_DIR/scripts/"
|
||||
|
||||
if [ -d "./oci" ]; then
|
||||
mkdir -p "$BASE_DIR/oci"
|
||||
cp -r "./oci/"* "$BASE_DIR/oci/" 2>/dev/null || true
|
||||
fi
|
||||
chmod +x "$INSTALL_DIR/$MENU_SCRIPT"
|
||||
[ -f "$BASE_DIR/install_proxmenux.sh" ] && chmod +x "$BASE_DIR/install_proxmenux.sh"
|
||||
[ -f "$BASE_DIR/install_proxmenux_beta.sh" ] && chmod +x "$BASE_DIR/install_proxmenux_beta.sh"
|
||||
|
||||
# Store beta flag in config
|
||||
update_config "beta_program" "active"
|
||||
update_config "beta_version" "$beta_version"
|
||||
update_config "install_branch" "$REPO_BRANCH"
|
||||
|
||||
msg_ok "Files installed. Beta version: ${beta_version}."
|
||||
|
||||
# ── Step 4: Monitor ───────────────────────────────────
|
||||
((current_step++))
|
||||
show_progress $current_step $total_steps "Installing ProxMenux Monitor (beta)"
|
||||
|
||||
install_proxmenux_monitor
|
||||
local monitor_status=$?
|
||||
|
||||
if [ $monitor_status -eq 0 ]; then
|
||||
create_monitor_service
|
||||
elif [ $monitor_status -eq 2 ]; then
|
||||
msg_ok "ProxMenux Monitor beta updated successfully."
|
||||
fi
|
||||
|
||||
msg_ok "Beta installation completed."
|
||||
}
|
||||
|
||||
# ── Stable transition notice ───────────────────────────────
|
||||
check_stable_available() {
|
||||
# Called if a stable version is detected (future use by update logic)
|
||||
# When main's version.txt > beta_version.txt, the menu/updater can call this
|
||||
echo -e "\n${TAB}${BOLD}${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}"
|
||||
echo -e "${TAB}${BOLD}${GN} A stable release is now available!${CL}"
|
||||
echo -e "${TAB}${WHITE} To leave the beta program and switch to the stable version,${CL}"
|
||||
echo -e "${TAB}${WHITE} run the official installer:${CL}"
|
||||
echo -e ""
|
||||
echo -e "${TAB} ${YWB}bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/MacRimi/ProxMenux/main/install_proxmenux.sh)\"${CL}"
|
||||
echo -e ""
|
||||
echo -e "${TAB}${DARK_GRAY} This will cleanly replace your beta install with the stable release.${CL}"
|
||||
echo -e "${TAB}${BOLD}${GN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}\n"
|
||||
}
|
||||
|
||||
# ── Entry point ────────────────────────────────────────────
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo -e "${RD}[ERROR] This script must be run as root.${CL}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup_corrupted_files
|
||||
show_proxmenux_logo
|
||||
show_beta_welcome
|
||||
|
||||
msg_title "Installing ProxMenux Beta — branch: develop"
|
||||
install_beta
|
||||
|
||||
# Load utils if available
|
||||
[ -f "$UTILS_FILE" ] && source "$UTILS_FILE"
|
||||
|
||||
msg_title "ProxMenux Beta installed successfully"
|
||||
|
||||
if systemctl is-active --quiet proxmenux-monitor.service; then
|
||||
local_ip=$(get_server_ip)
|
||||
echo -e "${GN}🌐 ProxMenux Monitor (beta) is running${CL}: ${BL}http://${local_ip}:${MONITOR_PORT}${CL}"
|
||||
echo
|
||||
fi
|
||||
|
||||
echo -ne "${GN}"
|
||||
type_text "To run ProxMenux, execute this command in your terminal:"
|
||||
echo -e "${YWB} menu${CL}"
|
||||
echo
|
||||
echo -e "${TAB}${DARK_GRAY}Report issues at: https://github.com/MacRimi/ProxMenux/issues${CL}"
|
||||
echo
|
||||
exit 0
|
||||
+12907
-2662
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Apply Pending Restore On Boot
|
||||
# ==========================================================
|
||||
|
||||
PENDING_BASE="${PMX_RESTORE_PENDING_BASE:-/var/lib/proxmenux/restore-pending}"
|
||||
CURRENT_LINK="${PENDING_BASE}/current"
|
||||
LOG_DIR="${PMX_RESTORE_LOG_DIR:-/var/log/proxmenux}"
|
||||
DEST_PREFIX="${PMX_RESTORE_DEST_PREFIX:-/}"
|
||||
PRE_BACKUP_BASE="${PMX_RESTORE_PRE_BACKUP_BASE:-/root/proxmenux-pre-restore}"
|
||||
RECOVERY_BASE="${PMX_RESTORE_RECOVERY_BASE:-/root/proxmenux-recovery}"
|
||||
|
||||
mkdir -p "$LOG_DIR" "$PENDING_BASE/completed" >/dev/null 2>&1 || true
|
||||
LOG_FILE="${LOG_DIR}/proxmenux-restore-onboot-$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
exec >>"$LOG_FILE" 2>&1
|
||||
|
||||
echo "=== ProxMenux pending restore started at $(date -Iseconds) ==="
|
||||
|
||||
if [[ ! -e "$CURRENT_LINK" ]]; then
|
||||
echo "No pending restore link found. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
PENDING_DIR="$(readlink -f "$CURRENT_LINK" 2>/dev/null || echo "$CURRENT_LINK")"
|
||||
if [[ ! -d "$PENDING_DIR" ]]; then
|
||||
echo "Pending restore directory not found: $PENDING_DIR"
|
||||
rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
APPLY_LIST="${PENDING_DIR}/apply-on-boot.list"
|
||||
PLAN_ENV="${PENDING_DIR}/plan.env"
|
||||
STATE_FILE="${PENDING_DIR}/state"
|
||||
|
||||
if [[ -f "$PLAN_ENV" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$PLAN_ENV"
|
||||
fi
|
||||
|
||||
: "${HB_RESTORE_INCLUDE_ZFS:=0}"
|
||||
|
||||
if [[ ! -f "$APPLY_LIST" ]]; then
|
||||
echo "Apply list missing: $APPLY_LIST"
|
||||
echo "failed" >"$STATE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pending dir: $PENDING_DIR"
|
||||
echo "Apply list: $APPLY_LIST"
|
||||
echo "Include ZFS: $HB_RESTORE_INCLUDE_ZFS"
|
||||
echo "running" >"$STATE_FILE"
|
||||
|
||||
backup_root="${PRE_BACKUP_BASE}/$(date +%Y%m%d_%H%M%S)-onboot"
|
||||
mkdir -p "$backup_root" >/dev/null 2>&1 || true
|
||||
|
||||
cluster_recovery_root=""
|
||||
applied=0
|
||||
skipped=0
|
||||
failed=0
|
||||
|
||||
while IFS= read -r rel; do
|
||||
[[ -z "$rel" ]] && continue
|
||||
|
||||
src="${PENDING_DIR}/rootfs/${rel}"
|
||||
dst="${DEST_PREFIX%/}/${rel}"
|
||||
|
||||
if [[ ! -e "$src" ]]; then
|
||||
((skipped++))
|
||||
continue
|
||||
fi
|
||||
|
||||
# Never restore cluster virtual filesystem data live.
|
||||
if [[ "$rel" == etc/pve* ]] || [[ "$rel" == var/lib/pve-cluster* ]]; then
|
||||
if [[ -z "$cluster_recovery_root" ]]; then
|
||||
cluster_recovery_root="${RECOVERY_BASE}/$(date +%Y%m%d_%H%M%S)-onboot"
|
||||
mkdir -p "$cluster_recovery_root" >/dev/null 2>&1 || true
|
||||
fi
|
||||
mkdir -p "$cluster_recovery_root/$(dirname "$rel")" >/dev/null 2>&1 || true
|
||||
cp -a "$src" "$cluster_recovery_root/$rel" >/dev/null 2>&1 || true
|
||||
((skipped++))
|
||||
continue
|
||||
fi
|
||||
|
||||
# /etc/zfs is opt-in.
|
||||
if [[ "$rel" == etc/zfs || "$rel" == etc/zfs/* ]]; then
|
||||
if [[ "$HB_RESTORE_INCLUDE_ZFS" != "1" ]]; then
|
||||
((skipped++))
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -e "$dst" ]]; then
|
||||
mkdir -p "$backup_root/$(dirname "$rel")" >/dev/null 2>&1 || true
|
||||
cp -a "$dst" "$backup_root/$rel" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -d "$src" ]]; then
|
||||
mkdir -p "$dst" >/dev/null 2>&1 || true
|
||||
if rsync -aAXH --delete "$src/" "$dst/" >/dev/null 2>&1; then
|
||||
((applied++))
|
||||
else
|
||||
((failed++))
|
||||
fi
|
||||
else
|
||||
mkdir -p "$(dirname "$dst")" >/dev/null 2>&1 || true
|
||||
if cp -a "$src" "$dst" >/dev/null 2>&1; then
|
||||
((applied++))
|
||||
else
|
||||
((failed++))
|
||||
fi
|
||||
fi
|
||||
done <"$APPLY_LIST"
|
||||
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
command -v update-initramfs >/dev/null 2>&1 && update-initramfs -u -k all >/dev/null 2>&1 || true
|
||||
command -v update-grub >/dev/null 2>&1 && update-grub >/dev/null 2>&1 || true
|
||||
|
||||
echo "Applied: $applied"
|
||||
echo "Skipped: $skipped"
|
||||
echo "Failed: $failed"
|
||||
echo "Backup before restore: $backup_root"
|
||||
|
||||
if [[ -n "$cluster_recovery_root" ]]; then
|
||||
helper="${cluster_recovery_root}/apply-cluster-restore.sh"
|
||||
cat > "$helper" <<EOF
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
RECOVERY_ROOT="${cluster_recovery_root}"
|
||||
echo "Cluster recovery helper"
|
||||
echo "Source: \$RECOVERY_ROOT"
|
||||
echo
|
||||
echo "WARNING: run this only in a maintenance window."
|
||||
echo
|
||||
read -r -p "Type YES to continue: " ans
|
||||
[[ "\$ans" == "YES" ]] || { echo "Aborted."; exit 1; }
|
||||
|
||||
systemctl stop pve-cluster || true
|
||||
[[ -d "\$RECOVERY_ROOT/etc/pve" ]] && mkdir -p /etc/pve && cp -a "\$RECOVERY_ROOT/etc/pve/." /etc/pve/ || true
|
||||
[[ -d "\$RECOVERY_ROOT/var/lib/pve-cluster" ]] && mkdir -p /var/lib/pve-cluster && cp -a "\$RECOVERY_ROOT/var/lib/pve-cluster/." /var/lib/pve-cluster/ || true
|
||||
systemctl start pve-cluster || true
|
||||
echo "Cluster recovery finished."
|
||||
EOF
|
||||
chmod +x "$helper" >/dev/null 2>&1 || true
|
||||
|
||||
echo "Cluster paths extracted to: $cluster_recovery_root"
|
||||
echo "Cluster recovery helper: $helper"
|
||||
fi
|
||||
|
||||
if [[ "$failed" -eq 0 ]]; then
|
||||
echo "completed" >"$STATE_FILE"
|
||||
else
|
||||
echo "completed_with_errors" >"$STATE_FILE"
|
||||
fi
|
||||
|
||||
restore_id="$(basename "$PENDING_DIR")"
|
||||
mv "$PENDING_DIR" "${PENDING_BASE}/completed/${restore_id}" >/dev/null 2>&1 || true
|
||||
rm -f "$CURRENT_LINK" >/dev/null 2>&1 || true
|
||||
|
||||
systemctl disable proxmenux-restore-onboot.service >/dev/null 2>&1 || true
|
||||
|
||||
echo "=== ProxMenux pending restore finished at $(date -Iseconds) ==="
|
||||
echo "Log file: $LOG_FILE"
|
||||
|
||||
exit 0
|
||||
+1600
-828
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Scheduled Backup Jobs
|
||||
# ==========================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||
|
||||
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
|
||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||
elif [[ ! -f "$UTILS_FILE" ]]; then
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
fi
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$UTILS_FILE"
|
||||
else
|
||||
echo "ERROR: utils.sh not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LIB_FILE="$SCRIPT_DIR/lib_host_backup_common.sh"
|
||||
[[ ! -f "$LIB_FILE" ]] && LIB_FILE="$LOCAL_SCRIPTS_DEFAULT/backup_restore/lib_host_backup_common.sh"
|
||||
if [[ -f "$LIB_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$LIB_FILE"
|
||||
else
|
||||
msg_error "$(translate "Cannot load backup library: lib_host_backup_common.sh")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
JOBS_DIR="/var/lib/proxmenux/backup-jobs"
|
||||
LOG_DIR="/var/log/proxmenux/backup-jobs"
|
||||
mkdir -p "$JOBS_DIR" "$LOG_DIR" >/dev/null 2>&1 || true
|
||||
|
||||
_job_file() { echo "${JOBS_DIR}/$1.env"; }
|
||||
_job_paths_file() { echo "${JOBS_DIR}/$1.paths"; }
|
||||
_service_file() { echo "/etc/systemd/system/proxmenux-backup-$1.service"; }
|
||||
_timer_file() { echo "/etc/systemd/system/proxmenux-backup-$1.timer"; }
|
||||
|
||||
_normalize_uint() {
|
||||
local v="${1:-0}"
|
||||
[[ "$v" =~ ^[0-9]+$ ]] || v=0
|
||||
echo "$v"
|
||||
}
|
||||
|
||||
_write_job_env() {
|
||||
local file="$1"
|
||||
shift
|
||||
{
|
||||
echo "# ProxMenux scheduled backup job"
|
||||
local kv key val
|
||||
for kv in "$@"; do
|
||||
key="${kv%%=*}"
|
||||
val="${kv#*=}"
|
||||
printf '%s=%q\n' "$key" "$val"
|
||||
done
|
||||
} > "$file"
|
||||
}
|
||||
|
||||
_list_jobs() {
|
||||
local f
|
||||
for f in "$JOBS_DIR"/*.env; do
|
||||
[[ -f "$f" ]] || continue
|
||||
basename "$f" .env
|
||||
done | sort
|
||||
}
|
||||
|
||||
_show_job_status() {
|
||||
local id="$1"
|
||||
local timer_state="disabled"
|
||||
local service_state="unknown"
|
||||
systemctl is-enabled --quiet "proxmenux-backup-${id}.timer" >/dev/null 2>&1 && timer_state="enabled"
|
||||
service_state=$(systemctl is-active "proxmenux-backup-${id}.service" 2>/dev/null || echo "inactive")
|
||||
echo "${timer_state}/${service_state}"
|
||||
}
|
||||
|
||||
_write_job_units() {
|
||||
local id="$1"
|
||||
local on_calendar="$2"
|
||||
local runner="$LOCAL_SCRIPTS/backup_restore/run_scheduled_backup.sh"
|
||||
[[ ! -f "$runner" ]] && runner="$SCRIPT_DIR/run_scheduled_backup.sh"
|
||||
|
||||
cat > "$(_service_file "$id")" <<EOF
|
||||
[Unit]
|
||||
Description=ProxMenux Scheduled Backup Job (${id})
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=${runner} ${id}
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
EOF
|
||||
|
||||
cat > "$(_timer_file "$id")" <<EOF
|
||||
[Unit]
|
||||
Description=ProxMenux Scheduled Backup Timer (${id})
|
||||
|
||||
[Timer]
|
||||
OnCalendar=${on_calendar}
|
||||
Persistent=true
|
||||
RandomizedDelaySec=120
|
||||
Unit=proxmenux-backup-${id}.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
_prompt_retention() {
|
||||
local __out_var="$1"
|
||||
local last hourly daily weekly monthly yearly
|
||||
last=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
|
||||
--inputbox "$(translate "keep-last (0 disables)")" 9 60 "7" 3>&1 1>&2 2>&3) || return 1
|
||||
hourly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
|
||||
--inputbox "$(translate "keep-hourly (0 disables)")" 9 60 "0" 3>&1 1>&2 2>&3) || return 1
|
||||
daily=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
|
||||
--inputbox "$(translate "keep-daily (0 disables)")" 9 60 "7" 3>&1 1>&2 2>&3) || return 1
|
||||
weekly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
|
||||
--inputbox "$(translate "keep-weekly (0 disables)")" 9 60 "4" 3>&1 1>&2 2>&3) || return 1
|
||||
monthly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
|
||||
--inputbox "$(translate "keep-monthly (0 disables)")" 9 60 "3" 3>&1 1>&2 2>&3) || return 1
|
||||
yearly=$(dialog --backtitle "ProxMenux" --title "$(translate "Retention")" \
|
||||
--inputbox "$(translate "keep-yearly (0 disables)")" 9 60 "0" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
last=$(_normalize_uint "$last")
|
||||
hourly=$(_normalize_uint "$hourly")
|
||||
daily=$(_normalize_uint "$daily")
|
||||
weekly=$(_normalize_uint "$weekly")
|
||||
monthly=$(_normalize_uint "$monthly")
|
||||
yearly=$(_normalize_uint "$yearly")
|
||||
|
||||
local -n out="$__out_var"
|
||||
out=(
|
||||
"KEEP_LAST=$last"
|
||||
"KEEP_HOURLY=$hourly"
|
||||
"KEEP_DAILY=$daily"
|
||||
"KEEP_WEEKLY=$weekly"
|
||||
"KEEP_MONTHLY=$monthly"
|
||||
"KEEP_YEARLY=$yearly"
|
||||
)
|
||||
}
|
||||
|
||||
_create_job() {
|
||||
local id backend on_calendar profile_mode
|
||||
id=$(dialog --backtitle "ProxMenux" --title "$(translate "New backup job")" \
|
||||
--inputbox "$(translate "Job ID (letters, numbers, - _)")" 9 68 "hostcfg-daily" 3>&1 1>&2 2>&3) || return 1
|
||||
[[ -z "$id" ]] && return 1
|
||||
id=$(echo "$id" | tr -cs '[:alnum:]_-' '-' | sed 's/^-*//; s/-*$//')
|
||||
[[ -z "$id" ]] && return 1
|
||||
[[ -f "$(_job_file "$id")" ]] && {
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Error")" \
|
||||
--msgbox "$(translate "A job with this ID already exists.")" 8 62
|
||||
return 1
|
||||
}
|
||||
|
||||
backend=$(dialog --backtitle "ProxMenux" --title "$(translate "Backend")" \
|
||||
--menu "\n$(translate "Select backup backend:")" 14 70 6 \
|
||||
"local" "Local archive" \
|
||||
"borg" "Borg repository" \
|
||||
"pbs" "Proxmox Backup Server" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
on_calendar=$(dialog --backtitle "ProxMenux" --title "$(translate "Schedule")" \
|
||||
--inputbox "$(translate "systemd OnCalendar expression")"$'\n'"$(translate "Example: daily or Mon..Fri 03:00")" \
|
||||
11 72 "daily" 3>&1 1>&2 2>&3) || return 1
|
||||
[[ -z "$on_calendar" ]] && return 1
|
||||
|
||||
profile_mode=$(dialog --backtitle "ProxMenux" --title "$(translate "Profile")" \
|
||||
--menu "\n$(translate "Select backup profile:")" 12 68 4 \
|
||||
"default" "Default critical paths" \
|
||||
"custom" "Custom selected paths" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
local -a paths=()
|
||||
hb_select_profile_paths "$profile_mode" paths || return 1
|
||||
|
||||
local -a retention=()
|
||||
_prompt_retention retention || return 1
|
||||
|
||||
local -a lines=(
|
||||
"JOB_ID=$id"
|
||||
"BACKEND=$backend"
|
||||
"ON_CALENDAR=$on_calendar"
|
||||
"PROFILE_MODE=$profile_mode"
|
||||
"ENABLED=1"
|
||||
)
|
||||
lines+=("${retention[@]}")
|
||||
|
||||
case "$backend" in
|
||||
local)
|
||||
local dest_dir ext
|
||||
dest_dir=$(hb_prompt_dest_dir) || return 1
|
||||
ext=$(dialog --backtitle "ProxMenux" --title "$(translate "Archive format")" \
|
||||
--menu "\n$(translate "Select local archive format:")" 12 62 4 \
|
||||
"tar.zst" "tar + zstd (preferred)" \
|
||||
"tar.gz" "tar + gzip" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
lines+=("LOCAL_DEST_DIR=$dest_dir" "LOCAL_ARCHIVE_EXT=$ext")
|
||||
;;
|
||||
borg)
|
||||
local repo passphrase
|
||||
hb_select_borg_repo repo || return 1
|
||||
hb_prepare_borg_passphrase || return 1
|
||||
passphrase="${BORG_PASSPHRASE:-}"
|
||||
lines+=(
|
||||
"BORG_REPO=$repo"
|
||||
"BORG_PASSPHRASE=$passphrase"
|
||||
"BORG_ENCRYPT_MODE=${BORG_ENCRYPT_MODE:-none}"
|
||||
)
|
||||
;;
|
||||
pbs)
|
||||
hb_select_pbs_repository || return 1
|
||||
hb_ask_pbs_encryption
|
||||
local bid
|
||||
bid="hostcfg-$(hostname)"
|
||||
bid=$(dialog --backtitle "ProxMenux" --title "PBS" \
|
||||
--inputbox "$(translate "Backup ID for this job:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$bid" 3>&1 1>&2 2>&3) || return 1
|
||||
bid=$(echo "$bid" | tr -cs '[:alnum:]_-' '-' | sed 's/-*$//')
|
||||
lines+=(
|
||||
"PBS_REPOSITORY=${HB_PBS_REPOSITORY}"
|
||||
"PBS_PASSWORD=${HB_PBS_SECRET}"
|
||||
"PBS_BACKUP_ID=${bid}"
|
||||
"PBS_KEYFILE=${HB_PBS_KEYFILE:-}"
|
||||
"PBS_ENCRYPTION_PASSWORD=${HB_PBS_ENC_PASS:-}"
|
||||
)
|
||||
;;
|
||||
esac
|
||||
|
||||
_write_job_env "$(_job_file "$id")" "${lines[@]}"
|
||||
|
||||
: > "$(_job_paths_file "$id")"
|
||||
local p
|
||||
for p in "${paths[@]}"; do
|
||||
echo "$p" >> "$(_job_paths_file "$id")"
|
||||
done
|
||||
|
||||
_write_job_units "$id" "$on_calendar"
|
||||
systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Scheduled backup job created")"
|
||||
echo -e ""
|
||||
echo -e "${TAB}${BGN}$(translate "Job ID:")${CL} ${BL}${id}${CL}"
|
||||
echo -e "${TAB}${BGN}$(translate "Backend:")${CL} ${BL}${backend}${CL}"
|
||||
echo -e "${TAB}${BGN}$(translate "Schedule:")${CL} ${BL}${on_calendar}${CL}"
|
||||
echo -e "${TAB}${BGN}$(translate "Status:")${CL} ${BL}$(_show_job_status "$id")${CL}"
|
||||
echo -e ""
|
||||
msg_success "$(translate "Press Enter to continue...")"
|
||||
read -r
|
||||
return 0
|
||||
}
|
||||
|
||||
_pick_job() {
|
||||
local title="$1"
|
||||
local __out_var="$2"
|
||||
|
||||
local -a ids=()
|
||||
mapfile -t ids < <(_list_jobs)
|
||||
if [[ ${#ids[@]} -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "No jobs")" \
|
||||
--msgbox "$(translate "No scheduled backup jobs found.")" 8 62
|
||||
return 1
|
||||
fi
|
||||
|
||||
local -a menu=()
|
||||
local i=1 id
|
||||
for id in "${ids[@]}"; do
|
||||
menu+=("$i" "$id [$(_show_job_status "$id")]")
|
||||
((i++))
|
||||
done
|
||||
local sel
|
||||
sel=$(dialog --backtitle "ProxMenux" --title "$title" \
|
||||
--menu "\n$(translate "Select a job:")" "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||
"${menu[@]}" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
local picked="${ids[$((sel-1))]}"
|
||||
local -n out="$__out_var"
|
||||
out="$picked"
|
||||
return 0
|
||||
}
|
||||
|
||||
_job_run_now() {
|
||||
local id=""
|
||||
_pick_job "$(translate "Run job now")" id || return 1
|
||||
local runner="$LOCAL_SCRIPTS/backup_restore/run_scheduled_backup.sh"
|
||||
[[ ! -f "$runner" ]] && runner="$SCRIPT_DIR/run_scheduled_backup.sh"
|
||||
if "$runner" "$id"; then
|
||||
msg_ok "$(translate "Job executed successfully.")"
|
||||
else
|
||||
msg_warn "$(translate "Job execution finished with errors. Check logs.")"
|
||||
fi
|
||||
msg_success "$(translate "Press Enter to continue...")"
|
||||
read -r
|
||||
}
|
||||
|
||||
_job_toggle() {
|
||||
local id=""
|
||||
_pick_job "$(translate "Enable/Disable job")" id || return 1
|
||||
if systemctl is-enabled --quiet "proxmenux-backup-${id}.timer" >/dev/null 2>&1; then
|
||||
systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
|
||||
msg_warn "$(translate "Job timer disabled:") $id"
|
||||
else
|
||||
systemctl enable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Job timer enabled:") $id"
|
||||
fi
|
||||
msg_success "$(translate "Press Enter to continue...")"
|
||||
read -r
|
||||
}
|
||||
|
||||
_job_delete() {
|
||||
local id=""
|
||||
_pick_job "$(translate "Delete job")" id || return 1
|
||||
if ! whiptail --title "$(translate "Confirm delete")" \
|
||||
--yesno "$(translate "Delete scheduled backup job?")"$'\n\n'"ID: ${id}" 10 66; then
|
||||
return 1
|
||||
fi
|
||||
systemctl disable --now "proxmenux-backup-${id}.timer" >/dev/null 2>&1 || true
|
||||
rm -f "$(_service_file "$id")" "$(_timer_file "$id")" "$(_job_file "$id")" "$(_job_paths_file "$id")"
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Job deleted:") $id"
|
||||
msg_success "$(translate "Press Enter to continue...")"
|
||||
read -r
|
||||
}
|
||||
|
||||
_show_jobs() {
|
||||
local tmp
|
||||
tmp=$(mktemp) || return
|
||||
{
|
||||
echo "=== $(translate "Scheduled backup jobs") ==="
|
||||
echo ""
|
||||
local id
|
||||
while IFS= read -r id; do
|
||||
[[ -z "$id" ]] && continue
|
||||
echo "• $id [$(_show_job_status "$id")]"
|
||||
if [[ -f "${LOG_DIR}/${id}-last.status" ]]; then
|
||||
sed 's/^/ /' "${LOG_DIR}/${id}-last.status"
|
||||
fi
|
||||
echo ""
|
||||
done < <(_list_jobs)
|
||||
} > "$tmp"
|
||||
dialog --backtitle "ProxMenux" --title "$(translate "Scheduled backup jobs")" \
|
||||
--textbox "$tmp" 28 100 || true
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
main_menu() {
|
||||
while true; do
|
||||
local choice
|
||||
choice=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate "Backup scheduler and retention")" \
|
||||
--menu "\n$(translate "Choose action:")" "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||
1 "$(translate "Create scheduled backup job")" \
|
||||
2 "$(translate "Show jobs and last run status")" \
|
||||
3 "$(translate "Run a job now")" \
|
||||
4 "$(translate "Enable / disable job timer")" \
|
||||
5 "$(translate "Delete job")" \
|
||||
0 "$(translate "Return")" \
|
||||
3>&1 1>&2 2>&3) || return 0
|
||||
|
||||
case "$choice" in
|
||||
1) _create_job ;;
|
||||
2) _show_jobs ;;
|
||||
3) _job_run_now ;;
|
||||
4) _job_toggle ;;
|
||||
5) _job_delete ;;
|
||||
0) return 0 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
main_menu
|
||||
@@ -0,0 +1,770 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Host Config Backup/Restore - Shared Library
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.0
|
||||
# Last Updated: 08/04/2026
|
||||
# ==========================================================
|
||||
# Do not execute directly — source from backup_host.sh
|
||||
|
||||
# Library guard
|
||||
[[ "${BASH_SOURCE[0]}" == "$0" ]] && {
|
||||
echo "This file is a library. Source it, do not run it directly." >&2; exit 1
|
||||
}
|
||||
|
||||
HB_STATE_DIR="/usr/local/share/proxmenux"
|
||||
HB_BORG_VERSION="1.2.8"
|
||||
HB_BORG_LINUX64_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
|
||||
HB_BORG_LINUX64_URL="https://github.com/borgbackup/borg/releases/download/${HB_BORG_VERSION}/borg-linux64"
|
||||
|
||||
# Translation wrapper — safe fallback if translate not yet loaded
|
||||
hb_translate() {
|
||||
declare -f translate >/dev/null 2>&1 && translate "$1" || echo "$1"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# UI SIZE CONSTANTS
|
||||
# ==========================================================
|
||||
HB_UI_MENU_H=22
|
||||
HB_UI_MENU_W=84
|
||||
HB_UI_MENU_LIST=10
|
||||
HB_UI_INPUT_H=10
|
||||
HB_UI_INPUT_W=72
|
||||
HB_UI_PASS_H=10
|
||||
HB_UI_PASS_W=72
|
||||
HB_UI_YESNO_H=10
|
||||
HB_UI_YESNO_W=78
|
||||
|
||||
# ==========================================================
|
||||
# DEFAULT PROFILE PATHS
|
||||
# ==========================================================
|
||||
hb_default_profile_paths() {
|
||||
local paths=(
|
||||
"/etc/pve"
|
||||
"/etc/network"
|
||||
"/etc/hosts"
|
||||
"/etc/hostname"
|
||||
"/etc/ssh"
|
||||
"/etc/systemd/system"
|
||||
"/etc/modules"
|
||||
"/etc/modules-load.d"
|
||||
"/etc/modprobe.d"
|
||||
"/etc/udev/rules.d"
|
||||
"/etc/default/grub"
|
||||
"/etc/fstab"
|
||||
"/etc/kernel"
|
||||
"/etc/apt"
|
||||
"/etc/vzdump.conf"
|
||||
"/etc/postfix"
|
||||
"/etc/resolv.conf"
|
||||
"/etc/timezone"
|
||||
"/etc/iscsi"
|
||||
"/etc/multipath"
|
||||
"/usr/local/bin"
|
||||
"/usr/local/share/proxmenux"
|
||||
"/root"
|
||||
"/etc/cron.d"
|
||||
"/etc/cron.daily"
|
||||
"/etc/cron.hourly"
|
||||
"/etc/cron.weekly"
|
||||
"/etc/cron.monthly"
|
||||
"/etc/cron.allow"
|
||||
"/etc/cron.deny"
|
||||
"/var/spool/cron/crontabs"
|
||||
"/var/lib/pve-cluster"
|
||||
)
|
||||
if [[ -d /etc/zfs ]] || command -v zpool >/dev/null 2>&1; then
|
||||
paths+=("/etc/zfs")
|
||||
fi
|
||||
printf '%s\n' "${paths[@]}"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# PATH CLASSIFICATION (restore safety)
|
||||
# Returns: dangerous | reboot | hot
|
||||
# ==========================================================
|
||||
hb_classify_path() {
|
||||
local rel="$1" # without leading /
|
||||
case "$rel" in
|
||||
etc/pve|etc/pve/*|\
|
||||
var/lib/pve-cluster|var/lib/pve-cluster/*|\
|
||||
etc/network|etc/network/*)
|
||||
echo "dangerous" ;;
|
||||
etc/modules|etc/modules/*|\
|
||||
etc/modules-load.d|etc/modules-load.d/*|\
|
||||
etc/modprobe.d|etc/modprobe.d/*|\
|
||||
etc/udev/rules.d|etc/udev/rules.d/*|\
|
||||
etc/default/grub|\
|
||||
etc/fstab|\
|
||||
etc/kernel|etc/kernel/*|\
|
||||
etc/iscsi|etc/iscsi/*|\
|
||||
etc/multipath|etc/multipath/*|\
|
||||
etc/zfs|etc/zfs/*)
|
||||
echo "reboot" ;;
|
||||
*)
|
||||
echo "hot" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
hb_path_warning() {
|
||||
local rel="$1"
|
||||
case "$rel" in
|
||||
etc/pve|etc/pve/*)
|
||||
hb_translate "/etc/pve is managed by pmxcfs (cluster filesystem). Applying this on a running node can corrupt cluster state. Use 'Export to file' and apply it manually during a maintenance window." ;;
|
||||
var/lib/pve-cluster|var/lib/pve-cluster/*)
|
||||
hb_translate "/var/lib/pve-cluster is live cluster data. Never restore this while the node is running. Use 'Export to file' for manual recovery only." ;;
|
||||
etc/network|etc/network/*)
|
||||
hb_translate "/etc/network controls active interfaces. Applying may immediately change or drop network connectivity, including active SSH sessions." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# PROFILE PATH SELECTION
|
||||
# ==========================================================
|
||||
hb_select_profile_paths() {
|
||||
local mode="$1"
|
||||
local __out_var="$2"
|
||||
local -n __out_ref="$__out_var"
|
||||
|
||||
mapfile -t __defaults < <(hb_default_profile_paths)
|
||||
|
||||
if [[ "$mode" == "default" ]]; then
|
||||
__out_ref=("${__defaults[@]}")
|
||||
return 0
|
||||
fi
|
||||
|
||||
local options=() idx=1 path
|
||||
for path in "${__defaults[@]}"; do
|
||||
options+=("$idx" "$path" "off")
|
||||
((idx++))
|
||||
done
|
||||
|
||||
local selected
|
||||
selected=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(hb_translate "Custom backup profile")" \
|
||||
--separate-output --checklist \
|
||||
"$(hb_translate "Select paths to include:")" \
|
||||
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
__out_ref=()
|
||||
local choice
|
||||
while read -r choice; do
|
||||
[[ -z "$choice" ]] && continue
|
||||
__out_ref+=("${__defaults[$((choice-1))]}")
|
||||
done <<< "$selected"
|
||||
|
||||
if [[ ${#__out_ref[@]} -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
|
||||
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# STAGING OPERATIONS
|
||||
# ==========================================================
|
||||
hb_prepare_staging() {
|
||||
local staging_root="$1"; shift
|
||||
local paths=("$@")
|
||||
|
||||
rm -rf "$staging_root"
|
||||
mkdir -p "$staging_root/rootfs" "$staging_root/metadata"
|
||||
|
||||
local selected_file="$staging_root/metadata/selected_paths.txt"
|
||||
local missing_file="$staging_root/metadata/missing_paths.txt"
|
||||
: > "$selected_file"
|
||||
: > "$missing_file"
|
||||
|
||||
local p rel target
|
||||
for p in "${paths[@]}"; do
|
||||
rel="${p#/}"
|
||||
echo "$rel" >> "$selected_file"
|
||||
[[ -e "$p" ]] || { echo "$p" >> "$missing_file"; continue; }
|
||||
target="$staging_root/rootfs/$rel"
|
||||
if [[ -d "$p" ]]; then
|
||||
mkdir -p "$target"
|
||||
local -a rsync_opts=(
|
||||
-aAXH --numeric-ids
|
||||
--exclude "images/"
|
||||
--exclude "dump/"
|
||||
--exclude "tmp/"
|
||||
--exclude "*.log"
|
||||
)
|
||||
|
||||
# /root is included by default for easier recovery, but avoid volatile/sensitive noise.
|
||||
if [[ "$rel" == "root" || "$rel" == "root/"* ]]; then
|
||||
rsync_opts+=(
|
||||
--exclude ".bash_history"
|
||||
--exclude ".cache/"
|
||||
--exclude "tmp/"
|
||||
--exclude ".local/share/Trash/"
|
||||
)
|
||||
fi
|
||||
|
||||
# Runtime pending-restore data belongs in /var/lib/proxmenux, never in app code tree.
|
||||
if [[ "$rel" == "usr/local/share/proxmenux" || "$rel" == "usr/local/share/proxmenux/"* ]]; then
|
||||
rsync_opts+=(
|
||||
--exclude "restore-pending/"
|
||||
)
|
||||
fi
|
||||
|
||||
rsync "${rsync_opts[@]}" "$p/" "$target/" 2>/dev/null || true
|
||||
else
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp -a "$p" "$target" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Metadata snapshot
|
||||
local meta="$staging_root/metadata"
|
||||
{
|
||||
echo "generated_at=$(date -Iseconds)"
|
||||
echo "hostname=$(hostname)"
|
||||
echo "kernel=$(uname -r)"
|
||||
} > "$meta/run_info.env"
|
||||
command -v pveversion >/dev/null 2>&1 && pveversion -v > "$meta/pveversion.txt" 2>&1 || true
|
||||
command -v lsblk >/dev/null 2>&1 && lsblk -f > "$meta/lsblk.txt" 2>&1 || true
|
||||
command -v qm >/dev/null 2>&1 && qm list > "$meta/qm-list.txt" 2>&1 || true
|
||||
command -v pct >/dev/null 2>&1 && pct list > "$meta/pct-list.txt" 2>&1 || true
|
||||
command -v zpool >/dev/null 2>&1 && zpool status > "$meta/zpool.txt" 2>&1 || true
|
||||
|
||||
# Manifest + checksums
|
||||
(
|
||||
cd "$staging_root/rootfs" || return 1
|
||||
find . -mindepth 1 -print | sort > "$meta/manifest.txt"
|
||||
find . -type f -print0 | sort -z | xargs -0 sha256sum 2>/dev/null \
|
||||
> "$meta/checksums.sha256" || true
|
||||
)
|
||||
}
|
||||
|
||||
hb_load_restore_paths() {
|
||||
local restore_root="$1"
|
||||
local __out_var="$2"
|
||||
local -n __out="$__out_var"
|
||||
|
||||
__out=()
|
||||
local selected="$restore_root/metadata/selected_paths.txt"
|
||||
if [[ -f "$selected" ]]; then
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && __out+=("$line")
|
||||
done < "$selected"
|
||||
fi
|
||||
# Fallback: scan rootfs
|
||||
if [[ ${#__out[@]} -eq 0 ]]; then
|
||||
local p
|
||||
while IFS= read -r p; do
|
||||
[[ -n "$p" && -e "$restore_root/rootfs/${p#/}" ]] && __out+=("${p#/}")
|
||||
done < <(hb_default_profile_paths)
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# PBS CONFIG — auto-detect from storage.cfg + manual
|
||||
# ==========================================================
|
||||
hb_collect_pbs_configs() {
|
||||
HB_PBS_NAMES=()
|
||||
HB_PBS_REPOS=()
|
||||
HB_PBS_SECRETS=()
|
||||
HB_PBS_SOURCES=()
|
||||
|
||||
if [[ -f /etc/pve/storage.cfg ]]; then
|
||||
local current="" server="" datastore="" username="" pw_file pw_val
|
||||
while IFS= read -r line; do
|
||||
line="${line%%#*}"
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
[[ -z "$line" ]] && continue
|
||||
if [[ $line =~ ^pbs:[[:space:]]*(.+)$ ]]; then
|
||||
if [[ -n "$current" && -n "$server" && -n "$datastore" && -n "$username" ]]; then
|
||||
pw_file="/etc/pve/priv/storage/${current}.pw"
|
||||
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
|
||||
HB_PBS_NAMES+=("$current")
|
||||
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
|
||||
HB_PBS_SECRETS+=("$pw_val")
|
||||
HB_PBS_SOURCES+=("proxmox")
|
||||
fi
|
||||
current="${BASH_REMATCH[1]}"; server="" datastore="" username=""
|
||||
elif [[ -n "$current" ]]; then
|
||||
[[ $line =~ ^[[:space:]]+server[[:space:]]+(.+)$ ]] && server="${BASH_REMATCH[1]}"
|
||||
[[ $line =~ ^[[:space:]]+datastore[[:space:]]+(.+)$ ]] && datastore="${BASH_REMATCH[1]}"
|
||||
[[ $line =~ ^[[:space:]]+username[[:space:]]+(.+)$ ]] && username="${BASH_REMATCH[1]}"
|
||||
if [[ $line =~ ^[a-zA-Z]+:[[:space:]] &&
|
||||
-n "$server" && -n "$datastore" && -n "$username" ]]; then
|
||||
pw_file="/etc/pve/priv/storage/${current}.pw"
|
||||
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
|
||||
HB_PBS_NAMES+=("$current")
|
||||
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
|
||||
HB_PBS_SECRETS+=("$pw_val")
|
||||
HB_PBS_SOURCES+=("proxmox")
|
||||
current="" server="" datastore="" username=""
|
||||
fi
|
||||
fi
|
||||
done < /etc/pve/storage.cfg
|
||||
# Last stanza
|
||||
if [[ -n "$current" && -n "$server" && -n "$datastore" && -n "$username" ]]; then
|
||||
pw_file="/etc/pve/priv/storage/${current}.pw"
|
||||
pw_val="$([[ -f "$pw_file" ]] && cat "$pw_file" || echo "")"
|
||||
HB_PBS_NAMES+=("$current")
|
||||
HB_PBS_REPOS+=("${username}@${server}:${datastore}")
|
||||
HB_PBS_SECRETS+=("$pw_val")
|
||||
HB_PBS_SOURCES+=("proxmox")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Manual configs
|
||||
local manual_cfg="$HB_STATE_DIR/pbs-manual-configs.txt"
|
||||
if [[ -f "$manual_cfg" ]]; then
|
||||
local line name repo sf
|
||||
while IFS= read -r line; do
|
||||
line="${line%%#*}"
|
||||
line="${line#"${line%%[![:space:]]*}"}"
|
||||
line="${line%"${line##*[![:space:]]}"}"
|
||||
[[ -z "$line" ]] && continue
|
||||
name="${line%%|*}"; repo="${line##*|}"
|
||||
sf="$HB_STATE_DIR/pbs-pass-${name}.txt"
|
||||
HB_PBS_NAMES+=("$name"); HB_PBS_REPOS+=("$repo")
|
||||
HB_PBS_SECRETS+=("$([[ -f "$sf" ]] && cat "$sf" || echo "")")
|
||||
HB_PBS_SOURCES+=("manual")
|
||||
done < "$manual_cfg"
|
||||
fi
|
||||
}
|
||||
|
||||
hb_configure_pbs_manual() {
|
||||
local name user host datastore repo secret
|
||||
|
||||
name=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
|
||||
--inputbox "$(hb_translate "Configuration name:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "PBS-$(date +%m%d)" 3>&1 1>&2 2>&3) || return 1
|
||||
[[ -z "$name" ]] && return 1
|
||||
|
||||
user=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
|
||||
--inputbox "$(hb_translate "Username (e.g. root@pam or user@pbs!token):")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root@pam" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
host=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
|
||||
--inputbox "$(hb_translate "PBS host or IP address:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
|
||||
[[ -z "$host" ]] && return 1
|
||||
|
||||
datastore=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
|
||||
--inputbox "$(hb_translate "Datastore name:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
|
||||
[[ -z "$datastore" ]] && return 1
|
||||
|
||||
secret=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Add PBS")" \
|
||||
--insecure --passwordbox "$(hb_translate "Password or API token secret:")" \
|
||||
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
repo="${user}@${host}:${datastore}"
|
||||
mkdir -p "$HB_STATE_DIR"
|
||||
local cfg_line="${name}|${repo}"
|
||||
local manual_cfg="$HB_STATE_DIR/pbs-manual-configs.txt"
|
||||
touch "$manual_cfg"
|
||||
grep -Fxq "$cfg_line" "$manual_cfg" || echo "$cfg_line" >> "$manual_cfg"
|
||||
printf '%s' "$secret" > "$HB_STATE_DIR/pbs-pass-${name}.txt"
|
||||
chmod 600 "$HB_STATE_DIR/pbs-pass-${name}.txt"
|
||||
|
||||
HB_PBS_NAME="$name"; HB_PBS_REPOSITORY="$repo"; HB_PBS_SECRET="$secret"
|
||||
}
|
||||
|
||||
hb_select_pbs_repository() {
|
||||
hb_collect_pbs_configs
|
||||
|
||||
local menu=() i=1 idx
|
||||
for idx in "${!HB_PBS_NAMES[@]}"; do
|
||||
local src="${HB_PBS_SOURCES[$idx]}"
|
||||
local label="${HB_PBS_NAMES[$idx]} — ${HB_PBS_REPOS[$idx]} [$src]"
|
||||
[[ -z "${HB_PBS_SECRETS[$idx]}" ]] && label+=" ⚠ $(hb_translate "no password")"
|
||||
menu+=("$i" "$label"); ((i++))
|
||||
done
|
||||
menu+=("$i" "$(hb_translate "+ Add new PBS manually")")
|
||||
|
||||
local choice
|
||||
choice=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(hb_translate "Select PBS repository")" \
|
||||
--menu "\n$(hb_translate "Available PBS repositories:")" \
|
||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
if [[ "$choice" == "$i" ]]; then
|
||||
hb_configure_pbs_manual || return 1
|
||||
else
|
||||
local sel=$((choice-1))
|
||||
HB_PBS_NAME="${HB_PBS_NAMES[$sel]}"
|
||||
export HB_PBS_REPOSITORY="${HB_PBS_REPOS[$sel]}"
|
||||
HB_PBS_SECRET="${HB_PBS_SECRETS[$sel]}"
|
||||
if [[ -z "$HB_PBS_SECRET" ]]; then
|
||||
HB_PBS_SECRET=$(dialog --backtitle "ProxMenux" --title "PBS" \
|
||||
--insecure --passwordbox \
|
||||
"$(hb_translate "Password for:") $HB_PBS_NAME" \
|
||||
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
|
||||
mkdir -p "$HB_STATE_DIR"
|
||||
printf '%s' "$HB_PBS_SECRET" > "$HB_STATE_DIR/pbs-pass-${HB_PBS_NAME}.txt"
|
||||
chmod 600 "$HB_STATE_DIR/pbs-pass-${HB_PBS_NAME}.txt"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
hb_ask_pbs_encryption() {
|
||||
local key_file="$HB_STATE_DIR/pbs-key.conf"
|
||||
local enc_pass_file="$HB_STATE_DIR/pbs-encryption-pass.txt"
|
||||
export HB_PBS_KEYFILE_OPT=""
|
||||
export HB_PBS_ENC_PASS=""
|
||||
|
||||
dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption")" \
|
||||
--yesno "$(hb_translate "Encrypt this backup with a keyfile?")" \
|
||||
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
|
||||
|
||||
if [[ -f "$key_file" ]]; then
|
||||
export HB_PBS_KEYFILE_OPT="--keyfile $key_file"
|
||||
if [[ -f "$enc_pass_file" ]]; then
|
||||
HB_PBS_ENC_PASS="$(<"$enc_pass_file")"
|
||||
export HB_PBS_ENC_PASS
|
||||
fi
|
||||
msg_ok "$(hb_translate "Using existing encryption key:") $key_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# No key — offer to create one
|
||||
dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption")" \
|
||||
--yesno "$(hb_translate "No encryption key found. Create one now?")" \
|
||||
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
|
||||
|
||||
local pass1 pass2
|
||||
while true; do
|
||||
pass1=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
|
||||
"$(hb_translate "Encryption passphrase (separate from PBS password):")" \
|
||||
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 0
|
||||
pass2=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
|
||||
"$(hb_translate "Confirm encryption passphrase:")" \
|
||||
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 0
|
||||
[[ "$pass1" == "$pass2" ]] && break
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
|
||||
done
|
||||
|
||||
msg_info "$(hb_translate "Creating PBS encryption key...")"
|
||||
if PBS_ENCRYPTION_PASSWORD="$pass1" \
|
||||
proxmox-backup-client key create "$key_file" >/dev/null 2>&1; then
|
||||
printf '%s' "$pass1" > "$enc_pass_file"
|
||||
chmod 600 "$enc_pass_file"
|
||||
msg_ok "$(hb_translate "Encryption key created:") $key_file"
|
||||
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
|
||||
HB_PBS_ENC_PASS="$pass1"
|
||||
local key_warn_msg
|
||||
key_warn_msg="$(hb_translate "IMPORTANT: Back up this key file. Without it the backup cannot be restored.")"$'\n\n'"$(hb_translate "Key:") $key_file"
|
||||
dialog --backtitle "ProxMenux" --msgbox \
|
||||
"$key_warn_msg" \
|
||||
10 74
|
||||
else
|
||||
msg_error "$(hb_translate "Failed to create encryption key. Backup will proceed without encryption.")"
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# BORG
|
||||
# ==========================================================
|
||||
hb_ensure_borg() {
|
||||
command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; }
|
||||
local appimage="$HB_STATE_DIR/borg"
|
||||
local tmp_file
|
||||
[[ -x "$appimage" ]] && { echo "$appimage"; return 0; }
|
||||
command -v sha256sum >/dev/null 2>&1 || {
|
||||
msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")"
|
||||
return 1
|
||||
}
|
||||
msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..."
|
||||
mkdir -p "$HB_STATE_DIR"
|
||||
tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1
|
||||
if wget -qO "$tmp_file" "$HB_BORG_LINUX64_URL"; then
|
||||
if echo "${HB_BORG_LINUX64_SHA256} $tmp_file" | sha256sum -c - >/dev/null 2>&1; then
|
||||
mv -f "$tmp_file" "$appimage"
|
||||
else
|
||||
rm -f "$tmp_file"
|
||||
msg_error "$(hb_translate "Borg binary checksum verification failed.")"
|
||||
return 1
|
||||
fi
|
||||
chmod +x "$appimage"
|
||||
msg_ok "$(hb_translate "Borg ready.")"
|
||||
echo "$appimage"; return 0
|
||||
fi
|
||||
rm -f "$tmp_file"
|
||||
msg_error "$(hb_translate "Failed to download Borg.")"
|
||||
return 1
|
||||
}
|
||||
|
||||
hb_borg_init_if_needed() {
|
||||
local borg_bin="$1" repo="$2" encrypt_mode="$3"
|
||||
"$borg_bin" list "$repo" >/dev/null 2>&1 && return 0
|
||||
if "$borg_bin" help repo-create >/dev/null 2>&1; then
|
||||
"$borg_bin" repo-create -e "$encrypt_mode" "$repo"
|
||||
else
|
||||
"$borg_bin" init --encryption="$encrypt_mode" "$repo"
|
||||
fi
|
||||
}
|
||||
|
||||
hb_prepare_borg_passphrase() {
|
||||
local pass_file="$HB_STATE_DIR/borg-pass.txt"
|
||||
BORG_ENCRYPT_MODE="none"
|
||||
unset BORG_PASSPHRASE
|
||||
|
||||
if [[ -f "$pass_file" ]]; then
|
||||
export BORG_PASSPHRASE
|
||||
BORG_PASSPHRASE="$(<"$pass_file")"
|
||||
BORG_ENCRYPT_MODE="repokey"
|
||||
return 0
|
||||
fi
|
||||
|
||||
dialog --backtitle "ProxMenux" --title "$(hb_translate "Borg encryption")" \
|
||||
--yesno "$(hb_translate "Encrypt this Borg repository?")" \
|
||||
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
|
||||
|
||||
local pass1 pass2
|
||||
while true; do
|
||||
pass1=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
|
||||
"$(hb_translate "Borg passphrase:")" \
|
||||
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
|
||||
pass2=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
|
||||
"$(hb_translate "Confirm Borg passphrase:")" \
|
||||
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
|
||||
[[ "$pass1" == "$pass2" ]] && break
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--msgbox "$(hb_translate "Passphrases do not match.")" 8 50
|
||||
done
|
||||
|
||||
mkdir -p "$HB_STATE_DIR"
|
||||
printf '%s' "$pass1" > "$pass_file"
|
||||
chmod 600 "$pass_file"
|
||||
export BORG_PASSPHRASE="$pass1"
|
||||
export BORG_ENCRYPT_MODE="repokey"
|
||||
}
|
||||
|
||||
hb_select_borg_repo() {
|
||||
local _borg_repo_var="$1"
|
||||
local -n _borg_repo_ref="$_borg_repo_var"
|
||||
local type
|
||||
|
||||
type=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(hb_translate "Borg repository location")" \
|
||||
--menu "\n$(hb_translate "Select repository destination:")" \
|
||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||
"local" "$(hb_translate 'Local directory')" \
|
||||
"usb" "$(hb_translate 'Mounted external disk')" \
|
||||
"remote" "$(hb_translate 'Remote server via SSH')" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
unset BORG_RSH
|
||||
case "$type" in
|
||||
local)
|
||||
_borg_repo_ref=$(dialog --backtitle "ProxMenux" \
|
||||
--inputbox "$(hb_translate "Borg repository path:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
|
||||
;;
|
||||
usb)
|
||||
local mnt
|
||||
mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1
|
||||
_borg_repo_ref="$mnt/borgbackup"
|
||||
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
|
||||
;;
|
||||
remote)
|
||||
local user host rpath ssh_key
|
||||
user=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH user:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
|
||||
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
|
||||
rpath=$(dialog --backtitle "ProxMenux" \
|
||||
--inputbox "$(hb_translate "Remote repository path:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
if dialog --backtitle "ProxMenux" \
|
||||
--yesno "$(hb_translate "Use a custom SSH key?")" \
|
||||
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
|
||||
ssh_key=$(dialog --backtitle "ProxMenux" \
|
||||
--fselect "$HOME/.ssh/" 12 70 3>&1 1>&2 2>&3) || return 1
|
||||
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
|
||||
fi
|
||||
_borg_repo_ref="ssh://$user@$host/$rpath"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# COMMON PROMPTS
|
||||
# ==========================================================
|
||||
hb_trim_dialog_value() {
|
||||
local value="$1"
|
||||
value="${value//$'\r'/}"
|
||||
value="${value//$'\n'/}"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
hb_prompt_mounted_path() {
|
||||
local default_path="${1:-/mnt/backup}"
|
||||
local out
|
||||
|
||||
out=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(hb_translate "Mounted disk path")" \
|
||||
--inputbox "$(hb_translate "Path where the external disk is mounted:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
out=$(hb_trim_dialog_value "$out")
|
||||
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
|
||||
if ! mountpoint -q "$out" 2>/dev/null; then
|
||||
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \
|
||||
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \
|
||||
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
|
||||
fi
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
hb_prompt_dest_dir() {
|
||||
local selection out
|
||||
|
||||
selection=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(hb_translate "Select destination")" \
|
||||
--menu "\n$(hb_translate "Choose where to save the backup:")" \
|
||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||
"vzdump" "$(hb_translate '/var/lib/vz/dump (Proxmox default vzdump path)')" \
|
||||
"backup" "$(hb_translate '/backup')" \
|
||||
"local" "$(hb_translate 'Custom local directory')" \
|
||||
"usb" "$(hb_translate 'Mounted external disk')" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
case "$selection" in
|
||||
vzdump) out="/var/lib/vz/dump" ;;
|
||||
backup) out="/backup" ;;
|
||||
local)
|
||||
out=$(dialog --backtitle "ProxMenux" \
|
||||
--inputbox "$(hb_translate "Enter directory path:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup" 3>&1 1>&2 2>&3) || return 1
|
||||
;;
|
||||
usb) out=$(hb_prompt_mounted_path "/mnt/backup") || return 1 ;;
|
||||
esac
|
||||
|
||||
out=$(hb_trim_dialog_value "$out")
|
||||
[[ -n "$out" ]] || return 1
|
||||
mkdir -p "$out" || { msg_error "$(hb_translate "Cannot create:") $out"; return 1; }
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
hb_prompt_restore_source_dir() {
|
||||
local choice out
|
||||
|
||||
choice=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(hb_translate "Restore source location")" \
|
||||
--menu "\n$(hb_translate "Where are the backup archives stored?")" \
|
||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||
"vzdump" "$(hb_translate '/var/lib/vz/dump (Proxmox default)')" \
|
||||
"backup" "$(hb_translate '/backup')" \
|
||||
"usb" "$(hb_translate 'Mounted external disk')" \
|
||||
"custom" "$(hb_translate 'Custom path')" \
|
||||
3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
case "$choice" in
|
||||
vzdump) out="/var/lib/vz/dump" ;;
|
||||
backup) out="/backup" ;;
|
||||
usb) out=$(hb_prompt_mounted_path "/mnt/backup") || return 1 ;;
|
||||
custom)
|
||||
out=$(dialog --backtitle "ProxMenux" \
|
||||
--inputbox "$(hb_translate "Enter path:")" \
|
||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup" 3>&1 1>&2 2>&3) || return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
out=$(hb_trim_dialog_value "$out")
|
||||
[[ -n "$out" && -d "$out" ]] || {
|
||||
msg_error "$(hb_translate "Directory does not exist.")"
|
||||
return 1
|
||||
}
|
||||
echo "$out"
|
||||
}
|
||||
|
||||
hb_prompt_local_archive() {
|
||||
local base_dir="$1"
|
||||
local title="${2:-$(hb_translate "Select backup archive")}"
|
||||
local -a rows=() files=() menu=()
|
||||
|
||||
# Single find pass using -printf: no per-file stat subprocesses.
|
||||
# maxdepth 6 catches nested backup layouts commonly used in /var/lib/vz/dump.
|
||||
mapfile -t rows < <(
|
||||
find "$base_dir" -maxdepth 6 -type f \
|
||||
\( -name '*.tar.zst' -o -name '*.tar.gz' -o -name '*.tar' \) \
|
||||
-printf '%T@|%s|%p\n' 2>/dev/null \
|
||||
| sort -t'|' -k1,1nr \
|
||||
| head -200
|
||||
)
|
||||
|
||||
if [[ ${#rows[@]} -eq 0 ]]; then
|
||||
local no_backups_msg
|
||||
no_backups_msg="$(hb_translate "No backup archives were found in:") $base_dir"$'\n\n'"$(hb_translate "Select another source path and try again.")"
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(hb_translate "No backups found")" \
|
||||
--msgbox "$no_backups_msg" \
|
||||
10 78 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
local i=1 row epoch size path date_str size_str label
|
||||
for row in "${rows[@]}"; do
|
||||
epoch="${row%%|*}"; row="${row#*|}"
|
||||
size="${row%%|*}"; path="${row#*|}"
|
||||
epoch="${epoch%%.*}" # drop sub-second fraction from %T@
|
||||
date_str=$(date -d "@$epoch" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "-")
|
||||
size_str=$(numfmt --to=iec-i --suffix=B "$size" 2>/dev/null || echo "${size}B")
|
||||
label="${path#$base_dir/} $date_str $size_str"
|
||||
files+=("$path"); menu+=("$i" "$label"); ((i++))
|
||||
done
|
||||
|
||||
local choice
|
||||
choice=$(dialog --backtitle "ProxMenux" --title "$title" \
|
||||
--menu "\n$(hb_translate "Detected backups — newest first:")" \
|
||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
|
||||
|
||||
echo "${files[$((choice-1))]}"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# UTILITIES
|
||||
# ==========================================================
|
||||
hb_human_elapsed() {
|
||||
local secs="$1"
|
||||
if (( secs < 60 )); then printf '%ds' "$secs"
|
||||
elif (( secs < 3600 )); then printf '%dm %ds' "$((secs/60))" "$((secs%60))"
|
||||
else printf '%dh %dm' "$((secs/3600))" "$(( (secs%3600)/60 ))"
|
||||
fi
|
||||
}
|
||||
|
||||
hb_file_size() {
|
||||
local path="$1"
|
||||
if [[ -f "$path" ]]; then
|
||||
numfmt --to=iec-i --suffix=B "$(stat -c %s "$path" 2>/dev/null || echo 0)" 2>/dev/null \
|
||||
|| du -sh "$path" 2>/dev/null | awk '{print $1}'
|
||||
elif [[ -d "$path" ]]; then
|
||||
du -sh "$path" 2>/dev/null | awk '{print $1}'
|
||||
else
|
||||
echo "-"
|
||||
fi
|
||||
}
|
||||
|
||||
hb_show_log() {
|
||||
local logfile="$1" title="${2:-$(hb_translate "Operation log")}"
|
||||
[[ -f "$logfile" && -s "$logfile" ]] || return 0
|
||||
dialog --backtitle "ProxMenux" --exit-label "OK" \
|
||||
--title "$title" --textbox "$logfile" 26 110 || true
|
||||
}
|
||||
|
||||
hb_require_cmd() {
|
||||
local cmd="$1" pkg="${2:-$1}"
|
||||
command -v "$cmd" >/dev/null 2>&1 && return 0
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
msg_warn "$(hb_translate "Installing dependency:") $pkg"
|
||||
apt-get update -qq >/dev/null 2>&1 && apt-get install -y "$pkg" >/dev/null 2>&1
|
||||
fi
|
||||
command -v "$cmd" >/dev/null 2>&1
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Run Scheduled Host Backup Job
|
||||
# ==========================================================
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||
|
||||
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
|
||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||
elif [[ ! -f "$UTILS_FILE" ]]; then
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
fi
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$UTILS_FILE"
|
||||
else
|
||||
echo "ERROR: utils.sh not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LIB_FILE="$SCRIPT_DIR/lib_host_backup_common.sh"
|
||||
[[ ! -f "$LIB_FILE" ]] && LIB_FILE="$LOCAL_SCRIPTS_DEFAULT/backup_restore/lib_host_backup_common.sh"
|
||||
if [[ -f "$LIB_FILE" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$LIB_FILE"
|
||||
else
|
||||
echo "ERROR: lib_host_backup_common.sh not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
JOBS_DIR="${PMX_BACKUP_JOBS_DIR:-/var/lib/proxmenux/backup-jobs}"
|
||||
LOG_DIR="${PMX_BACKUP_LOG_DIR:-/var/log/proxmenux/backup-jobs}"
|
||||
LOCK_DIR="${PMX_BACKUP_LOCK_DIR:-/var/lock}"
|
||||
mkdir -p "$JOBS_DIR" "$LOG_DIR" >/dev/null 2>&1 || true
|
||||
|
||||
_sb_prune_local() {
|
||||
local job_id="$1"
|
||||
local dest_dir="$2"
|
||||
local ext="$3" # tar.zst or tar.gz
|
||||
local keep_last="${KEEP_LAST:-0}"
|
||||
|
||||
local -a files=()
|
||||
mapfile -t files < <(find "$dest_dir" -maxdepth 1 -type f -name "${job_id}-*.${ext}" | sort -r)
|
||||
[[ ${#files[@]} -eq 0 ]] && return 0
|
||||
|
||||
if [[ "$keep_last" =~ ^[0-9]+$ ]] && (( keep_last > 0 )); then
|
||||
local idx=0
|
||||
for f in "${files[@]}"; do
|
||||
idx=$((idx+1))
|
||||
(( idx <= keep_last )) && continue
|
||||
rm -f "$f" || true
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
_sb_run_local() {
|
||||
local stage_root="$1"
|
||||
local job_id="$2"
|
||||
local ts="$3"
|
||||
local dest_dir="$4"
|
||||
local archive_ext="${LOCAL_ARCHIVE_EXT:-tar.zst}"
|
||||
local archive="${dest_dir}/${job_id}-${ts}.${archive_ext}"
|
||||
|
||||
mkdir -p "$dest_dir" || return 1
|
||||
|
||||
if [[ "$archive_ext" == "tar.zst" ]] && command -v zstd >/dev/null 2>&1; then
|
||||
tar --zstd -cf "$archive" -C "$stage_root" . >/dev/null 2>&1 || return 1
|
||||
else
|
||||
archive="${dest_dir}/${job_id}-${ts}.tar.gz"
|
||||
tar -czf "$archive" -C "$stage_root" . >/dev/null 2>&1 || return 1
|
||||
archive_ext="tar.gz"
|
||||
fi
|
||||
|
||||
_sb_prune_local "$job_id" "$dest_dir" "$archive_ext"
|
||||
echo "LOCAL_ARCHIVE=$archive"
|
||||
return 0
|
||||
}
|
||||
|
||||
_sb_run_borg() {
|
||||
local stage_root="$1"
|
||||
local archive_name="$2"
|
||||
local borg_bin repo passphrase
|
||||
|
||||
borg_bin=$(hb_ensure_borg) || return 1
|
||||
repo="${BORG_REPO:-}"
|
||||
passphrase="${BORG_PASSPHRASE:-}"
|
||||
[[ -z "$repo" || -z "$passphrase" ]] && return 1
|
||||
|
||||
export BORG_PASSPHRASE="$passphrase"
|
||||
|
||||
if ! hb_borg_init_if_needed "$borg_bin" "$repo" "${BORG_ENCRYPT_MODE:-none}" >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
(cd "$stage_root" && "$borg_bin" create --stats \
|
||||
"${repo}::${archive_name}" rootfs metadata) >/dev/null 2>&1 || return 1
|
||||
|
||||
"$borg_bin" prune -v --list "$repo" \
|
||||
${KEEP_LAST:+--keep-last "$KEEP_LAST"} \
|
||||
${KEEP_HOURLY:+--keep-hourly "$KEEP_HOURLY"} \
|
||||
${KEEP_DAILY:+--keep-daily "$KEEP_DAILY"} \
|
||||
${KEEP_WEEKLY:+--keep-weekly "$KEEP_WEEKLY"} \
|
||||
${KEEP_MONTHLY:+--keep-monthly "$KEEP_MONTHLY"} \
|
||||
${KEEP_YEARLY:+--keep-yearly "$KEEP_YEARLY"} \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
echo "BORG_ARCHIVE=${archive_name}"
|
||||
return 0
|
||||
}
|
||||
|
||||
_sb_run_pbs() {
|
||||
local stage_root="$1"
|
||||
local backup_id="$2"
|
||||
local epoch="$3"
|
||||
local -a cmd=(
|
||||
proxmox-backup-client backup
|
||||
"hostcfg.pxar:${stage_root}/rootfs"
|
||||
--repository "$PBS_REPOSITORY"
|
||||
--backup-type host
|
||||
--backup-id "$backup_id"
|
||||
--backup-time "$epoch"
|
||||
)
|
||||
|
||||
[[ -z "${PBS_REPOSITORY:-}" || -z "${PBS_PASSWORD:-}" ]] && return 1
|
||||
if [[ -n "${PBS_KEYFILE:-}" ]]; then
|
||||
cmd+=(--keyfile "$PBS_KEYFILE")
|
||||
fi
|
||||
|
||||
env PBS_PASSWORD="$PBS_PASSWORD" PBS_ENCRYPTION_PASSWORD="${PBS_ENCRYPTION_PASSWORD:-}" \
|
||||
"${cmd[@]}" >/dev/null 2>&1 || return 1
|
||||
|
||||
# Best effort prune for PBS group.
|
||||
proxmox-backup-client prune "host/${backup_id}" --repository "$PBS_REPOSITORY" \
|
||||
${KEEP_LAST:+--keep-last "$KEEP_LAST"} \
|
||||
${KEEP_HOURLY:+--keep-hourly "$KEEP_HOURLY"} \
|
||||
${KEEP_DAILY:+--keep-daily "$KEEP_DAILY"} \
|
||||
${KEEP_WEEKLY:+--keep-weekly "$KEEP_WEEKLY"} \
|
||||
${KEEP_MONTHLY:+--keep-monthly "$KEEP_MONTHLY"} \
|
||||
${KEEP_YEARLY:+--keep-yearly "$KEEP_YEARLY"} \
|
||||
>/dev/null 2>&1 || true
|
||||
|
||||
echo "PBS_SNAPSHOT=host/${backup_id}/${epoch}"
|
||||
return 0
|
||||
}
|
||||
|
||||
main() {
|
||||
local job_id="${1:-}"
|
||||
[[ -z "$job_id" ]] && { echo "Usage: $0 <job_id>" >&2; exit 1; }
|
||||
|
||||
local job_file="${JOBS_DIR}/${job_id}.env"
|
||||
[[ -f "$job_file" ]] || { echo "Job not found: $job_id" >&2; exit 1; }
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$job_file"
|
||||
|
||||
local lock_file="${LOCK_DIR}/proxmenux-backup-${job_id}.lock"
|
||||
if command -v flock >/dev/null 2>&1; then
|
||||
exec 9>"$lock_file" || exit 1
|
||||
if ! flock -n 9; then
|
||||
echo "Another run is active for job ${job_id}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local ts log_file stage_root summary_file
|
||||
ts="$(date +%Y%m%d_%H%M%S)"
|
||||
log_file="${LOG_DIR}/${job_id}-${ts}.log"
|
||||
summary_file="${LOG_DIR}/${job_id}-last.status"
|
||||
stage_root="$(mktemp -d /tmp/proxmenux-sched-stage.XXXXXX)"
|
||||
|
||||
{
|
||||
echo "JOB_ID=${job_id}"
|
||||
echo "RUN_AT=$(date -Iseconds)"
|
||||
echo "BACKEND=${BACKEND:-}"
|
||||
echo "PROFILE_MODE=${PROFILE_MODE:-default}"
|
||||
} >"$summary_file"
|
||||
|
||||
{
|
||||
echo "=== Scheduled backup job ${job_id} started at $(date -Iseconds) ==="
|
||||
echo "Backend: ${BACKEND:-}"
|
||||
} >"$log_file"
|
||||
|
||||
local -a paths=()
|
||||
if [[ "${PROFILE_MODE:-default}" == "custom" && -f "${JOBS_DIR}/${job_id}.paths" ]]; then
|
||||
mapfile -t paths < "${JOBS_DIR}/${job_id}.paths"
|
||||
else
|
||||
mapfile -t paths < <(hb_default_profile_paths)
|
||||
fi
|
||||
|
||||
if [[ ${#paths[@]} -eq 0 ]]; then
|
||||
echo "No paths configured for job" >>"$log_file"
|
||||
echo "RESULT=failed" >>"$summary_file"
|
||||
rm -rf "$stage_root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hb_prepare_staging "$stage_root" "${paths[@]}" >>"$log_file" 2>&1
|
||||
|
||||
local rc=1
|
||||
case "${BACKEND:-}" in
|
||||
local)
|
||||
_sb_run_local "$stage_root" "$job_id" "$ts" "${LOCAL_DEST_DIR:-/var/lib/vz/dump}" >>"$log_file" 2>&1
|
||||
rc=$?
|
||||
;;
|
||||
borg)
|
||||
_sb_run_borg "$stage_root" "${job_id}-${ts}" >>"$log_file" 2>&1
|
||||
rc=$?
|
||||
;;
|
||||
pbs)
|
||||
_sb_run_pbs "$stage_root" "${PBS_BACKUP_ID:-hostcfg-$(hostname)}" "$(date +%s)" >>"$log_file" 2>&1
|
||||
rc=$?
|
||||
;;
|
||||
*)
|
||||
echo "Unknown backend: ${BACKEND:-}" >>"$log_file"
|
||||
rc=1
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -rf "$stage_root"
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
echo "RESULT=ok" >>"$summary_file"
|
||||
echo "LOG_FILE=${log_file}" >>"$summary_file"
|
||||
echo "=== Job finished OK at $(date -Iseconds) ===" >>"$log_file"
|
||||
exit 0
|
||||
else
|
||||
echo "RESULT=failed" >>"$summary_file"
|
||||
echo "LOG_FILE=${log_file}" >>"$summary_file"
|
||||
echo "=== Job finished with errors at $(date -Iseconds) ===" >>"$log_file"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Backup/Restore Test Matrix (non-destructive)
|
||||
# ==========================================================
|
||||
|
||||
set -u
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RUNNER="${SCRIPT_DIR}/run_scheduled_backup.sh"
|
||||
APPLY_ONBOOT="${SCRIPT_DIR}/apply_pending_restore.sh"
|
||||
HOST_SCRIPT="${SCRIPT_DIR}/backup_host.sh"
|
||||
LIB_SCRIPT="${SCRIPT_DIR}/lib_host_backup_common.sh"
|
||||
SCHED_SCRIPT="${SCRIPT_DIR}/backup_scheduler.sh"
|
||||
|
||||
KEEP_TMP=0
|
||||
if [[ "${1:-}" == "--keep-tmp" ]]; then
|
||||
KEEP_TMP=1
|
||||
fi
|
||||
|
||||
TMP_ROOT="$(mktemp -d /tmp/proxmenux-brtest.XXXXXX)"
|
||||
REPORT_FILE="/tmp/proxmenux-backup-restore-test-$(date +%Y%m%d_%H%M%S).log"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
log() {
|
||||
echo "$*" | tee -a "$REPORT_FILE"
|
||||
}
|
||||
|
||||
pass() {
|
||||
PASS=$((PASS + 1))
|
||||
log "[PASS] $*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAIL=$((FAIL + 1))
|
||||
log "[FAIL] $*"
|
||||
}
|
||||
|
||||
skip() {
|
||||
SKIP=$((SKIP + 1))
|
||||
log "[SKIP] $*"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ "$KEEP_TMP" -eq 0 ]]; then
|
||||
rm -rf "$TMP_ROOT"
|
||||
else
|
||||
log "[INFO] Temp root preserved: $TMP_ROOT"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
assert_file_contains() {
|
||||
local file="$1"
|
||||
local needle="$2"
|
||||
if [[ -f "$file" ]] && grep -q "$needle" "$file"; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
run_cmd_expect_ok() {
|
||||
local desc="$1"
|
||||
shift
|
||||
if "$@" >>"$REPORT_FILE" 2>&1; then
|
||||
pass "$desc"
|
||||
return 0
|
||||
fi
|
||||
fail "$desc"
|
||||
return 1
|
||||
}
|
||||
|
||||
run_cmd_expect_fail() {
|
||||
local desc="$1"
|
||||
shift
|
||||
if "$@" >>"$REPORT_FILE" 2>&1; then
|
||||
fail "$desc"
|
||||
return 1
|
||||
fi
|
||||
pass "$desc"
|
||||
return 0
|
||||
}
|
||||
|
||||
syntax_tests() {
|
||||
log "\n=== Syntax checks ==="
|
||||
run_cmd_expect_ok "bash -n backup_host.sh" bash -n "$HOST_SCRIPT"
|
||||
run_cmd_expect_ok "bash -n lib_host_backup_common.sh" bash -n "$LIB_SCRIPT"
|
||||
run_cmd_expect_ok "bash -n backup_scheduler.sh" bash -n "$SCHED_SCRIPT"
|
||||
run_cmd_expect_ok "bash -n run_scheduled_backup.sh" bash -n "$RUNNER"
|
||||
run_cmd_expect_ok "bash -n apply_pending_restore.sh" bash -n "$APPLY_ONBOOT"
|
||||
}
|
||||
|
||||
scheduler_e2e_tests() {
|
||||
log "\n=== Scheduler E2E (sandbox) ==="
|
||||
if ! help mapfile >/dev/null 2>&1; then
|
||||
skip "Scheduler E2E skipped: current bash does not provide mapfile (requires bash >= 4)."
|
||||
return
|
||||
fi
|
||||
|
||||
local jobs_dir="$TMP_ROOT/backup-jobs"
|
||||
local logs_dir="$TMP_ROOT/backup-jobs-logs"
|
||||
local lock_dir="$TMP_ROOT/locks"
|
||||
local archives_dir="$TMP_ROOT/archives"
|
||||
|
||||
mkdir -p "$jobs_dir" "$logs_dir" "$lock_dir" "$archives_dir"
|
||||
|
||||
cat > "$jobs_dir/t1.env" <<EOJ
|
||||
JOB_ID=t1
|
||||
BACKEND=local
|
||||
PROFILE_MODE=custom
|
||||
LOCAL_DEST_DIR=${archives_dir}
|
||||
LOCAL_ARCHIVE_EXT=tar.gz
|
||||
KEEP_LAST=2
|
||||
KEEP_HOURLY=0
|
||||
KEEP_DAILY=0
|
||||
KEEP_WEEKLY=0
|
||||
KEEP_MONTHLY=0
|
||||
KEEP_YEARLY=0
|
||||
EOJ
|
||||
|
||||
cat > "$jobs_dir/t1.paths" <<EOP
|
||||
/etc/hosts
|
||||
/etc/resolv.conf
|
||||
EOP
|
||||
|
||||
local i
|
||||
for i in 1 2 3; do
|
||||
if PMX_BACKUP_JOBS_DIR="$jobs_dir" PMX_BACKUP_LOG_DIR="$logs_dir" PMX_BACKUP_LOCK_DIR="$lock_dir" \
|
||||
bash "$RUNNER" t1 >>"$REPORT_FILE" 2>&1; then
|
||||
:
|
||||
else
|
||||
fail "Runner execution #$i for t1"
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
local archive_count
|
||||
archive_count="$(find "$archives_dir" -maxdepth 1 -type f -name 't1-*.tar.gz' | wc -l | tr -d ' ')"
|
||||
if [[ "$archive_count" == "2" ]]; then
|
||||
pass "Retention KEEP_LAST=2 keeps exactly 2 archives"
|
||||
else
|
||||
fail "Retention expected 2 archives, got $archive_count"
|
||||
fi
|
||||
|
||||
if assert_file_contains "$logs_dir/t1-last.status" "RESULT=ok"; then
|
||||
pass "t1-last.status reports RESULT=ok"
|
||||
else
|
||||
fail "t1-last.status does not report RESULT=ok"
|
||||
fi
|
||||
|
||||
cat > "$jobs_dir/tbad.env" <<EOJ
|
||||
JOB_ID=tbad
|
||||
BACKEND=invalid
|
||||
PROFILE_MODE=custom
|
||||
KEEP_LAST=1
|
||||
EOJ
|
||||
echo "/etc/hosts" > "$jobs_dir/tbad.paths"
|
||||
|
||||
run_cmd_expect_fail "Invalid backend fails" \
|
||||
env PMX_BACKUP_JOBS_DIR="$jobs_dir" PMX_BACKUP_LOG_DIR="$logs_dir" PMX_BACKUP_LOCK_DIR="$lock_dir" \
|
||||
bash "$RUNNER" tbad
|
||||
|
||||
if assert_file_contains "$logs_dir/tbad-last.status" "RESULT=failed"; then
|
||||
pass "tbad-last.status reports RESULT=failed"
|
||||
else
|
||||
fail "tbad-last.status does not report RESULT=failed"
|
||||
fi
|
||||
|
||||
cat > "$jobs_dir/tempty.env" <<EOJ
|
||||
JOB_ID=tempty
|
||||
BACKEND=local
|
||||
PROFILE_MODE=custom
|
||||
LOCAL_DEST_DIR=${archives_dir}
|
||||
LOCAL_ARCHIVE_EXT=tar.gz
|
||||
KEEP_LAST=1
|
||||
EOJ
|
||||
: > "$jobs_dir/tempty.paths"
|
||||
|
||||
run_cmd_expect_fail "Empty paths fails" \
|
||||
env PMX_BACKUP_JOBS_DIR="$jobs_dir" PMX_BACKUP_LOG_DIR="$logs_dir" PMX_BACKUP_LOCK_DIR="$lock_dir" \
|
||||
bash "$RUNNER" tempty
|
||||
|
||||
if assert_file_contains "$logs_dir/tempty-last.status" "RESULT=failed"; then
|
||||
pass "tempty-last.status reports RESULT=failed"
|
||||
else
|
||||
fail "tempty-last.status does not report RESULT=failed"
|
||||
fi
|
||||
}
|
||||
|
||||
pending_restore_tests() {
|
||||
log "\n=== Pending restore E2E (sandbox) ==="
|
||||
local pending_base="$TMP_ROOT/restore-pending"
|
||||
local logs_dir="$TMP_ROOT/restore-logs"
|
||||
local target_root="$TMP_ROOT/target"
|
||||
local pre_backup_base="$TMP_ROOT/pre-restore"
|
||||
local recovery_base="$TMP_ROOT/recovery"
|
||||
|
||||
mkdir -p "$pending_base/r1/rootfs/etc/pve" "$pending_base/r1/rootfs/etc/zfs" "$pending_base/r1/rootfs/etc" "$target_root/etc"
|
||||
|
||||
echo "new-value" > "$pending_base/r1/rootfs/etc/test.conf"
|
||||
echo "cluster-data" > "$pending_base/r1/rootfs/etc/pve/cluster.cfg"
|
||||
echo "zfs-data" > "$pending_base/r1/rootfs/etc/zfs/zpool.cache"
|
||||
echo "old-value" > "$target_root/etc/test.conf"
|
||||
|
||||
cat > "$pending_base/r1/apply-on-boot.list" <<EOL
|
||||
etc/test.conf
|
||||
etc/pve/cluster.cfg
|
||||
etc/zfs/zpool.cache
|
||||
EOL
|
||||
|
||||
cat > "$pending_base/r1/plan.env" <<EOP
|
||||
HB_RESTORE_INCLUDE_ZFS=0
|
||||
EOP
|
||||
|
||||
ln -sfn "$pending_base/r1" "$pending_base/current"
|
||||
|
||||
if PMX_RESTORE_PENDING_BASE="$pending_base" PMX_RESTORE_LOG_DIR="$logs_dir" \
|
||||
PMX_RESTORE_DEST_PREFIX="$target_root" PMX_RESTORE_PRE_BACKUP_BASE="$pre_backup_base" \
|
||||
PMX_RESTORE_RECOVERY_BASE="$recovery_base" \
|
||||
bash "$APPLY_ONBOOT" >>"$REPORT_FILE" 2>&1; then
|
||||
pass "apply_pending_restore completes"
|
||||
else
|
||||
fail "apply_pending_restore completes"
|
||||
return
|
||||
fi
|
||||
|
||||
if assert_file_contains "$target_root/etc/test.conf" "new-value"; then
|
||||
pass "Regular file restored into target prefix"
|
||||
else
|
||||
fail "Regular file was not restored"
|
||||
fi
|
||||
|
||||
if [[ -e "$target_root/etc/pve/cluster.cfg" ]]; then
|
||||
fail "Cluster file should not be restored live"
|
||||
else
|
||||
pass "Cluster file skipped from live restore"
|
||||
fi
|
||||
|
||||
if find "$recovery_base" -type f -name cluster.cfg 2>/dev/null | grep -q .; then
|
||||
pass "Cluster file extracted to recovery directory"
|
||||
else
|
||||
fail "Cluster file not found in recovery directory"
|
||||
fi
|
||||
|
||||
if assert_file_contains "$pending_base/completed/r1/state" "completed"; then
|
||||
pass "Pending restore state marked completed"
|
||||
else
|
||||
fail "Pending restore state not marked completed"
|
||||
fi
|
||||
|
||||
if [[ -e "$pending_base/current" ]]; then
|
||||
fail "current symlink should be removed"
|
||||
else
|
||||
pass "current symlink removed"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
log "ProxMenux backup/restore test matrix"
|
||||
log "Report: $REPORT_FILE"
|
||||
log "Temp root: $TMP_ROOT"
|
||||
|
||||
syntax_tests
|
||||
scheduler_e2e_tests
|
||||
pending_restore_tests
|
||||
|
||||
log "\n=== Summary ==="
|
||||
log "PASS=$PASS"
|
||||
log "FAIL=$FAIL"
|
||||
log "SKIP=$SKIP"
|
||||
|
||||
if [[ "$FAIL" -eq 0 ]]; then
|
||||
log "RESULT=OK"
|
||||
exit 0
|
||||
else
|
||||
log "RESULT=FAILED"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,204 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 17/08/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script automates the process of enabling and configuring Intel Integrated GPU (iGPU) support in Proxmox VE LXC containers.
|
||||
# Its goal is to simplify the configuration of hardware-accelerated graphical capabilities within containers, allowing for efficient
|
||||
# use of Intel iGPUs for tasks such as transcoding, rendering, and accelerating graphics-intensive applications.
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
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
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
|
||||
configure_lxc_for_igpu() {
|
||||
validate_container_id
|
||||
|
||||
CONFIG_FILE="/etc/pve/lxc/${CONTAINER_ID}.conf"
|
||||
[[ -f "$CONFIG_FILE" ]] || { msg_error "$(translate 'Configuration file for container') $CONTAINER_ID $(translate 'not found.')"; exit 1; }
|
||||
|
||||
|
||||
if [[ ! -d /dev/dri ]]; then
|
||||
modprobe i915 2>/dev/null || true
|
||||
for _ in {1..5}; do
|
||||
[[ -d /dev/dri ]] && break
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
CT_TYPE=$(pct config "$CONTAINER_ID" | awk '/^unprivileged:/ {print $2}')
|
||||
[[ -z "$CT_TYPE" ]] && CT_TYPE="0"
|
||||
|
||||
msg_info "$(translate 'Configuring Intel iGPU passthrough for container...')"
|
||||
|
||||
for rn in /dev/dri/renderD*; do
|
||||
[[ -e "$rn" ]] || continue
|
||||
chmod 660 "$rn" 2>/dev/null || true
|
||||
chgrp render "$rn" 2>/dev/null || true
|
||||
done
|
||||
|
||||
mapfile -t RENDER_NODES < <(find /dev/dri -maxdepth 1 -type c -name 'renderD*' 2>/dev/null || true)
|
||||
mapfile -t CARD_NODES < <(find /dev/dri -maxdepth 1 -type c -name 'card*' 2>/dev/null || true)
|
||||
FB_NODE=""
|
||||
[[ -e /dev/fb0 ]] && FB_NODE="/dev/fb0"
|
||||
|
||||
if [[ ${#RENDER_NODES[@]} -eq 0 && ${#CARD_NODES[@]} -eq 0 && -z "$FB_NODE" ]]; then
|
||||
msg_warn "$(translate 'No VA-API devices found on host (/dev/dri*, /dev/fb0). Is i915 loaded?')"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if grep -q '^features:' "$CONFIG_FILE"; then
|
||||
grep -Eq '^features:.*(^|,)\s*nesting=1(\s|,|$)' "$CONFIG_FILE" || sed -i 's/^features:\s*/&nesting=1, /' "$CONFIG_FILE"
|
||||
else
|
||||
echo "features: nesting=1" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if [[ "$CT_TYPE" == "0" ]]; then
|
||||
|
||||
sed -i '/^lxc\.cgroup2\.devices\.allow:\s*c\s*226:/d' "$CONFIG_FILE"
|
||||
sed -i '\|^lxc\.mount\.entry:\s*/dev/dri|d' "$CONFIG_FILE"
|
||||
sed -i '\|^lxc\.mount\.entry:\s*/dev/fb0|d' "$CONFIG_FILE"
|
||||
|
||||
echo "lxc.cgroup2.devices.allow: c 226:* rwm" >> "$CONFIG_FILE"
|
||||
echo "lxc.mount.entry: /dev/dri dev/dri none bind,optional,create=dir" >> "$CONFIG_FILE"
|
||||
[[ -n "$FB_NODE" ]] && echo "lxc.mount.entry: /dev/fb0 dev/fb0 none bind,optional,create=file" >> "$CONFIG_FILE"
|
||||
|
||||
|
||||
else
|
||||
sed -i '/^dev[0-9]\+:/d' "$CONFIG_FILE"
|
||||
|
||||
idx=0
|
||||
for c in "${CARD_NODES[@]}"; do
|
||||
echo "dev${idx}: $c,gid=44" >> "$CONFIG_FILE"
|
||||
idx=$((idx+1))
|
||||
done
|
||||
for r in "${RENDER_NODES[@]}"; do
|
||||
echo "dev${idx}: $r,gid=104" >> "$CONFIG_FILE"
|
||||
idx=$((idx+1))
|
||||
done
|
||||
|
||||
fi
|
||||
msg_ok "$(translate 'iGPU configuration added to container') $CONTAINER_ID."
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
install_igpu_in_container() {
|
||||
|
||||
msg_info2 "$(translate 'Installing iGPU drivers inside the container...')"
|
||||
tput sc
|
||||
LOG_FILE=$(mktemp)
|
||||
|
||||
|
||||
pct start "$CONTAINER_ID" >/dev/null 2>&1
|
||||
|
||||
script -q -c "pct exec \"$CONTAINER_ID\" -- bash -c '
|
||||
set -e
|
||||
getent group video >/dev/null || groupadd -g 44 video
|
||||
getent group render >/dev/null || groupadd -g 104 render
|
||||
usermod -aG video,render root || true
|
||||
|
||||
apt-get update >/dev/null 2>&1
|
||||
apt-get install -y va-driver-all ocl-icd-libopencl1 intel-opencl-icd vainfo intel-gpu-tools
|
||||
|
||||
chgrp video /dev/dri 2>/dev/null || true
|
||||
chmod 755 /dev/dri 2>/dev/null || true
|
||||
'" "$LOG_FILE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
tput rc
|
||||
tput ed
|
||||
rm -f "$LOG_FILE"
|
||||
msg_ok "$(translate 'iGPU drivers installed inside the container.')"
|
||||
else
|
||||
tput rc
|
||||
tput ed
|
||||
msg_error "$(translate 'Failed to install iGPU drivers inside the container.')"
|
||||
cat "$LOG_FILE"
|
||||
rm -f "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
select_container
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Add HW iGPU acceleration to an LXC")"
|
||||
configure_lxc_for_igpu
|
||||
install_igpu_in_container
|
||||
|
||||
|
||||
msg_success "$(translate 'iGPU configuration completed in container') $CONTAINER_ID."
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
@@ -1,368 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script allows users to assign physical disks to existing
|
||||
# Proxmox virtual machines (VMs) through an interactive menu.
|
||||
# - Detects the system disk and excludes it from selection.
|
||||
# - Lists all available VMs for the user to choose from.
|
||||
# - Identifies and displays unassigned physical disks.
|
||||
# - Allows the user to select multiple disks and attach them to a VM.
|
||||
# - Supports interface types: SATA, SCSI, VirtIO, and IDE.
|
||||
# - Ensures that disks are not already assigned to active VMs.
|
||||
# - Warns about disk sharing between multiple VMs to avoid data corruption.
|
||||
# - Configures the selected disks for the VM and verifies the assignment.
|
||||
#
|
||||
# The goal of this script is to simplify the process of assigning
|
||||
# physical disks to Proxmox VMs, reducing manual configurations
|
||||
# and preventing potential errors.
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
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
|
||||
show_proxmenux_logo
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
get_disk_info() {
|
||||
local disk=$1
|
||||
MODEL=$(lsblk -dn -o MODEL "$disk" | xargs)
|
||||
SIZE=$(lsblk -dn -o SIZE "$disk" | xargs)
|
||||
echo "$MODEL" "$SIZE"
|
||||
}
|
||||
|
||||
|
||||
VM_LIST=$(qm list | awk 'NR>1 {print $1, $2}')
|
||||
if [ -z "$VM_LIST" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No VMs available in the system.")" 8 40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
VMID=$(whiptail --title "$(translate "Select VM")" --menu "$(translate "Select the VM to which you want to add disks:")" 15 60 8 $VM_LIST 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$VMID" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No VM was selected.")" 8 40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VMID=$(echo "$VMID" | tr -d '"')
|
||||
|
||||
|
||||
msg_ok "$(translate "VM selected successfully.")"
|
||||
|
||||
|
||||
VM_STATUS=$(qm status "$VMID" | awk '{print $2}')
|
||||
if [ "$VM_STATUS" == "running" ]; then
|
||||
whiptail --title "$(translate "Warning")" --msgbox "$(translate "The VM is powered on. Turn it off before adding disks.")" 12 60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
##########################################
|
||||
|
||||
msg_info "$(translate "Detecting available disks...")"
|
||||
|
||||
USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}')
|
||||
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
|
||||
|
||||
ZFS_DISKS=""
|
||||
ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror')
|
||||
|
||||
for entry in $ZFS_RAW; do
|
||||
|
||||
path=""
|
||||
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then
|
||||
if [ -e "/dev/disk/by-id/$entry" ]; then
|
||||
path=$(readlink -f "/dev/disk/by-id/$entry")
|
||||
fi
|
||||
elif [[ "$entry" == /dev/* ]]; then
|
||||
path="$entry"
|
||||
fi
|
||||
|
||||
|
||||
if [ -n "$path" ]; then
|
||||
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
|
||||
if [ -n "$base_disk" ]; then
|
||||
ZFS_DISKS+="/dev/$base_disk"$'\n'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
|
||||
|
||||
|
||||
is_disk_in_use() {
|
||||
local disk="$1"
|
||||
|
||||
|
||||
while read -r part fstype; do
|
||||
case "$fstype" in
|
||||
zfs_member|linux_raid_member)
|
||||
return 0 ;;
|
||||
esac
|
||||
|
||||
if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then
|
||||
return 0
|
||||
fi
|
||||
done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2)
|
||||
|
||||
|
||||
if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
FREE_DISKS=()
|
||||
|
||||
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -n1 readlink -f | sort -u)
|
||||
RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u)
|
||||
|
||||
while read -r DISK; do
|
||||
|
||||
[[ "$DISK" =~ /dev/zd ]] && continue
|
||||
|
||||
INFO=($(get_disk_info "$DISK"))
|
||||
MODEL="${INFO[@]::${#INFO[@]}-1}"
|
||||
SIZE="${INFO[-1]}"
|
||||
LABEL=""
|
||||
SHOW_DISK=true
|
||||
|
||||
IS_MOUNTED=false
|
||||
IS_RAID=false
|
||||
IS_ZFS=false
|
||||
IS_LVM=false
|
||||
|
||||
while read -r part fstype; do
|
||||
[[ "$fstype" == "zfs_member" ]] && IS_ZFS=true
|
||||
[[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true
|
||||
[[ "$fstype" == "LVM2_member" ]] && IS_LVM=true
|
||||
if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2)
|
||||
|
||||
REAL_PATH=$(readlink -f "$DISK")
|
||||
if echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
|
||||
|
||||
|
||||
USED_BY=""
|
||||
REAL_PATH=$(readlink -f "$DISK")
|
||||
CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
|
||||
|
||||
if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then
|
||||
USED_BY="⚠ $(translate "In use")"
|
||||
else
|
||||
for SYMLINK in /dev/disk/by-id/*; do
|
||||
if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then
|
||||
if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then
|
||||
USED_BY="⚠ $(translate "In use")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then
|
||||
if grep -q "active raid" /proc/mdstat; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if $IS_ZFS; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
|
||||
if $IS_MOUNTED; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
|
||||
if qm config "$VMID" | grep -vE '^\s*#|^description:' | grep -q "$DISK"; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
if $SHOW_DISK; then
|
||||
[[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]"
|
||||
[[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID"
|
||||
[[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM"
|
||||
[[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS"
|
||||
|
||||
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
|
||||
FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF")
|
||||
fi
|
||||
done < <(lsblk -dn -e 7,11 -o PATH)
|
||||
|
||||
|
||||
|
||||
if [ "${#FREE_DISKS[@]}" -eq 0 ]; then
|
||||
cleanup
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks available for this VM.")" 8 40
|
||||
clear
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Available disks detected.")"
|
||||
|
||||
|
||||
|
||||
######################################################
|
||||
|
||||
|
||||
|
||||
|
||||
MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1)
|
||||
TOTAL_WIDTH=$((MAX_WIDTH + 20))
|
||||
|
||||
if [ $TOTAL_WIDTH -lt 50 ]; then
|
||||
TOTAL_WIDTH=50
|
||||
fi
|
||||
|
||||
|
||||
SELECTED=$(whiptail --title "$(translate "Select Disks")" --checklist \
|
||||
"$(translate "Select the disks you want to add:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$SELECTED" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks were selected.")" 10 64
|
||||
clear
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Disks selected successfully.")"
|
||||
|
||||
|
||||
INTERFACE=$(whiptail --title "$(translate "Interface Type")" --menu "$(translate "Select the interface type for all disks:")" 15 40 4 \
|
||||
"sata" "$(translate "Add as SATA")" \
|
||||
"scsi" "$(translate "Add as SCSI")" \
|
||||
"virtio" "$(translate "Add as VirtIO")" \
|
||||
"ide" "$(translate "Add as IDE")" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$INTERFACE" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No interface type was selected for the disks.")" 8 40
|
||||
clear
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Interface type selected: $INTERFACE")"
|
||||
|
||||
DISKS_ADDED=0
|
||||
ERROR_MESSAGES=""
|
||||
SUCCESS_MESSAGES=""
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate "Processing selected disks...")"
|
||||
|
||||
for DISK in $SELECTED; do
|
||||
DISK=$(echo "$DISK" | tr -d '"')
|
||||
DISK_INFO=$(get_disk_info "$DISK")
|
||||
|
||||
ASSIGNED_TO=""
|
||||
RUNNING_VMS=""
|
||||
RUNNING_CTS=""
|
||||
|
||||
|
||||
while read -r VM_ID VM_NAME; do
|
||||
if [[ "$VM_ID" =~ ^[0-9]+$ ]] && qm config "$VM_ID" | grep -q "$DISK"; then
|
||||
ASSIGNED_TO+="VM $VM_ID $VM_NAME\n"
|
||||
VM_STATUS=$(qm status "$VM_ID" | awk '{print $2}')
|
||||
if [ "$VM_STATUS" == "running" ]; then
|
||||
RUNNING_VMS+="VM $VM_ID $VM_NAME\n"
|
||||
fi
|
||||
fi
|
||||
done < <(qm list | awk 'NR>1 {print $1, $2}')
|
||||
|
||||
|
||||
while read -r CT_ID CT_NAME; do
|
||||
if [[ "$CT_ID" =~ ^[0-9]+$ ]] && pct config "$CT_ID" | grep -q "$DISK"; then
|
||||
ASSIGNED_TO+="CT $CT_ID $CT_NAME\n"
|
||||
CT_STATUS=$(pct status "$CT_ID" | awk '{print $2}')
|
||||
if [ "$CT_STATUS" == "running" ]; then
|
||||
RUNNING_CTS+="CT $CT_ID $CT_NAME\n"
|
||||
fi
|
||||
fi
|
||||
done < <(pct list | awk 'NR>1 {print $1, $2}')
|
||||
|
||||
if [ -n "$RUNNING_VMS" ] || [ -n "$RUNNING_CTS" ]; then
|
||||
ERROR_MESSAGES+="$(translate "The disk") $DISK_INFO $(translate "is currently in use by the following running VM(s) or CT(s):")\\n$RUNNING_VMS$RUNNING_CTS\\n\\n$(translate "You cannot add this disk while the VM or CT is running.")\\n$(translate "Please shut it down first and run this script again to add the disk.")\\n\\n"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -n "$ASSIGNED_TO" ]; then
|
||||
cleanup
|
||||
whiptail --title "$(translate "Disk Already Assigned")" --yesno "$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" 15 70
|
||||
if [ $? -ne 0 ]; then
|
||||
sleep 1
|
||||
exec "$0"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
INDEX=0
|
||||
while qm config "$VMID" | grep -q "${INTERFACE}${INDEX}"; do
|
||||
((INDEX++))
|
||||
done
|
||||
|
||||
RESULT=$(qm set "$VMID" -${INTERFACE}${INDEX} "$DISK" 2>&1)
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
MESSAGE="$(translate "The disk") $DISK_INFO $(translate "has been successfully added to VM") $VMID."
|
||||
if [ -n "$ASSIGNED_TO" ]; then
|
||||
MESSAGE+="\\n\\n$(translate "WARNING: This disk is also assigned to the following VM(s):")\\n$ASSIGNED_TO"
|
||||
MESSAGE+="\\n$(translate "Make sure not to start VMs that share this disk at the same time to avoid data corruption.")"
|
||||
fi
|
||||
SUCCESS_MESSAGES+="$MESSAGE\\n\\n"
|
||||
((DISKS_ADDED++))
|
||||
else
|
||||
ERROR_MESSAGES+="$(translate "Could not add disk") $DISK_INFO $(translate "to VM") $VMID.\\n$(translate "Error:") $RESULT\\n\\n"
|
||||
fi
|
||||
done
|
||||
|
||||
msg_ok "$(translate "Disk processing completed.")"
|
||||
|
||||
|
||||
|
||||
if [ -n "$SUCCESS_MESSAGES" ]; then
|
||||
MSG_LINES=$(echo "$SUCCESS_MESSAGES" | wc -l)
|
||||
whiptail --title "$(translate "Successful Operations")" --msgbox "$SUCCESS_MESSAGES" 16 70
|
||||
fi
|
||||
|
||||
if [ -n "$ERROR_MESSAGES" ]; then
|
||||
whiptail --title "$(translate "Warnings and Errors")" --msgbox "$ERROR_MESSAGES" 16 70
|
||||
fi
|
||||
|
||||
|
||||
|
||||
exit 0
|
||||
@@ -1,537 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script allows users to assign physical disks to existing
|
||||
# Proxmox containers (CTs) through an interactive menu.
|
||||
# - Detects the system disk and excludes it from selection.
|
||||
# - Lists all available CTs for the user to choose from.
|
||||
# - Identifies and displays unassigned physical disks.
|
||||
# - Allows the user to select multiple disks and attach them to a CT.
|
||||
# - Configures the selected disks for the CT and verifies the assignment.
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
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
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
|
||||
get_disk_info() {
|
||||
local disk=$1
|
||||
MODEL=$(lsblk -dn -o MODEL "$disk" | xargs)
|
||||
SIZE=$(lsblk -dn -o SIZE "$disk" | xargs)
|
||||
echo "$MODEL" "$SIZE"
|
||||
}
|
||||
|
||||
|
||||
|
||||
CT_LIST=$(pct list | awk 'NR>1 {print $1, $3}')
|
||||
if [ -z "$CT_LIST" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No CTs available in the system.")" 8 40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
CTID=$(whiptail --title "$(translate "Select CT")" --menu "$(translate "Select the CT to which you want to add disks:")" 15 60 8 $CT_LIST 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$CTID" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No CT was selected.")" 8 40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CTID=$(echo "$CTID" | tr -d '"')
|
||||
|
||||
msg_ok "$(translate "CT selected successfully.")"
|
||||
|
||||
|
||||
|
||||
|
||||
CT_STATUS=$(pct status "$CTID" | awk '{print $2}')
|
||||
if [ "$CT_STATUS" != "running" ]; then
|
||||
msg_info "$(translate "Starting CT") $CTID..."
|
||||
pct start "$CTID"
|
||||
sleep 2
|
||||
if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then
|
||||
msg_error "$(translate "Failed to start the CT.")"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "$(translate "CT started successfully.")"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
CONF_FILE="/etc/pve/lxc/$CTID.conf"
|
||||
|
||||
if grep -q '^unprivileged: 1' "$CONF_FILE"; then
|
||||
if whiptail --title "$(translate "Privileged Container")" \
|
||||
--yesno "$(translate "The selected container is unprivileged. A privileged container is required for direct device passthrough.")\\n\\n$(translate "Do you want to convert it to a privileged container now?")" 12 70; then
|
||||
|
||||
msg_info "$(translate "Stopping container") $CTID..."
|
||||
pct shutdown "$CTID" &
|
||||
for i in {1..10}; do
|
||||
sleep 1
|
||||
if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$(pct status "$CTID" | awk '{print $2}')" == "running" ]; then
|
||||
msg_error "$(translate "Failed to stop the container.")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Container stopped.")"
|
||||
|
||||
cp "$CONF_FILE" "$CONF_FILE.bak"
|
||||
sed -i '/^unprivileged: 1/d' "$CONF_FILE"
|
||||
echo "unprivileged: 0" >> "$CONF_FILE"
|
||||
|
||||
msg_ok "$(translate "Container successfully converted to privileged.")"
|
||||
|
||||
msg_info "$(translate "Starting container") $CTID..."
|
||||
pct start "$CTID"
|
||||
sleep 2
|
||||
if [ "$(pct status "$CTID" | awk '{print $2}')" != "running" ]; then
|
||||
msg_error "$(translate "Failed to start the container.")"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "$(translate "Container started successfully.")"
|
||||
|
||||
else
|
||||
whiptail --title "$(translate "Aborted")" \
|
||||
--msgbox "$(translate "Operation cancelled. Cannot continue with an unprivileged container.")" 10 60
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
##########################################
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate "Detecting available disks...")"
|
||||
|
||||
USED_DISKS=$(lsblk -n -o PKNAME,TYPE | grep 'lvm' | awk '{print "/dev/" $1}')
|
||||
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
|
||||
|
||||
ZFS_DISKS=""
|
||||
ZFS_RAW=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror')
|
||||
|
||||
for entry in $ZFS_RAW; do
|
||||
path=""
|
||||
if [[ "$entry" == wwn-* || "$entry" == ata-* ]]; then
|
||||
if [ -e "/dev/disk/by-id/$entry" ]; then
|
||||
path=$(readlink -f "/dev/disk/by-id/$entry")
|
||||
fi
|
||||
elif [[ "$entry" == /dev/* ]]; then
|
||||
path="$entry"
|
||||
fi
|
||||
|
||||
if [ -n "$path" ]; then
|
||||
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
|
||||
if [ -n "$base_disk" ]; then
|
||||
ZFS_DISKS+="/dev/$base_disk"$'\n'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
|
||||
|
||||
is_disk_in_use() {
|
||||
local disk="$1"
|
||||
|
||||
while read -r part fstype; do
|
||||
case "$fstype" in
|
||||
zfs_member|linux_raid_member)
|
||||
return 0 ;;
|
||||
esac
|
||||
|
||||
if echo "$MOUNTED_DISKS" | grep -q "/dev/$part"; then
|
||||
return 0
|
||||
fi
|
||||
done < <(lsblk -ln -o NAME,FSTYPE "$disk" | tail -n +2)
|
||||
|
||||
if echo "$USED_DISKS" | grep -q "$disk" || echo "$ZFS_DISKS" | grep -q "$disk"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
FREE_DISKS=()
|
||||
|
||||
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -r -n1 readlink -f | sort -u)
|
||||
|
||||
if [[ -n "$LVM_DEVICES" ]] && echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
|
||||
RAID_ACTIVE=$(grep -Po 'md\d+\s*:\s*active\s+raid[0-9]+' /proc/mdstat | awk '{print $1}' | sort -u)
|
||||
|
||||
while read -r DISK; do
|
||||
[[ "$DISK" =~ /dev/zd ]] && continue
|
||||
|
||||
INFO=($(get_disk_info "$DISK"))
|
||||
MODEL="${INFO[@]::${#INFO[@]}-1}"
|
||||
SIZE="${INFO[-1]}"
|
||||
LABEL=""
|
||||
SHOW_DISK=true
|
||||
|
||||
IS_MOUNTED=false
|
||||
IS_RAID=false
|
||||
IS_ZFS=false
|
||||
IS_LVM=false
|
||||
|
||||
while read -r part fstype; do
|
||||
[[ "$fstype" == "zfs_member" ]] && IS_ZFS=true
|
||||
[[ "$fstype" == "linux_raid_member" ]] && IS_RAID=true
|
||||
[[ "$fstype" == "LVM2_member" ]] && IS_LVM=true
|
||||
if grep -q "/dev/$part" <<< "$MOUNTED_DISKS"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
done < <(lsblk -ln -o NAME,FSTYPE "$DISK" | tail -n +2)
|
||||
|
||||
REAL_PATH=$(readlink -f "$DISK")
|
||||
if echo "$LVM_DEVICES" | grep -qFx "$REAL_PATH"; then
|
||||
IS_MOUNTED=true
|
||||
fi
|
||||
|
||||
|
||||
USED_BY=""
|
||||
REAL_PATH=$(readlink -f "$DISK")
|
||||
CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
|
||||
|
||||
if grep -Fq "$REAL_PATH" <<< "$CONFIG_DATA"; then
|
||||
USED_BY="⚠ $(translate "In use")"
|
||||
else
|
||||
for SYMLINK in /dev/disk/by-id/*; do
|
||||
if [[ "$(readlink -f "$SYMLINK")" == "$REAL_PATH" ]]; then
|
||||
if grep -Fq "$SYMLINK" <<< "$CONFIG_DATA"; then
|
||||
USED_BY="⚠ $(translate "In use")"
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if $IS_RAID && grep -q "$DISK" <<< "$(cat /proc/mdstat)"; then
|
||||
if grep -q "active raid" /proc/mdstat; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if $IS_ZFS; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
if $IS_MOUNTED; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
if pct config "$CTID" | grep -vE '^\s*#|^description:' | grep -q "$DISK"; then
|
||||
SHOW_DISK=false
|
||||
fi
|
||||
|
||||
if $SHOW_DISK; then
|
||||
[[ -n "$USED_BY" ]] && LABEL+=" [$USED_BY]"
|
||||
[[ "$IS_RAID" == true ]] && LABEL+=" ⚠ RAID"
|
||||
[[ "$IS_LVM" == true ]] && LABEL+=" ⚠ LVM"
|
||||
[[ "$IS_ZFS" == true ]] && LABEL+=" ⚠ ZFS"
|
||||
|
||||
DESCRIPTION=$(printf "%-30s %10s%s" "$MODEL" "$SIZE" "$LABEL")
|
||||
FREE_DISKS+=("$DISK" "$DESCRIPTION" "OFF")
|
||||
fi
|
||||
done < <(lsblk -dn -e 7,11 -o PATH)
|
||||
|
||||
if [ "${#FREE_DISKS[@]}" -eq 0 ]; then
|
||||
cleanup
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks available for this CT.")" 8 40
|
||||
clear
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Available disks detected.")"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
######################################################
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
MAX_WIDTH=$(printf "%s\n" "${FREE_DISKS[@]}" | awk '{print length}' | sort -nr | head -n1)
|
||||
TOTAL_WIDTH=$((MAX_WIDTH + 20))
|
||||
|
||||
if [ $TOTAL_WIDTH -lt 50 ]; then
|
||||
TOTAL_WIDTH=50
|
||||
fi
|
||||
|
||||
SELECTED=$(whiptail --title "$(translate "Select Disks")" --radiolist \
|
||||
"$(translate "Select the disks you want to add:")" 20 $TOTAL_WIDTH 10 "${FREE_DISKS[@]}" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$SELECTED" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No disks were selected.")" 10 64
|
||||
clear
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Disks selected successfully.")"
|
||||
|
||||
DISKS_ADDED=0
|
||||
ERROR_MESSAGES=""
|
||||
SUCCESS_MESSAGES=""
|
||||
|
||||
msg_info "$(translate "Processing selected disks...")"
|
||||
|
||||
for DISK in $SELECTED; do
|
||||
DISK=$(echo "$DISK" | tr -d '"')
|
||||
DISK_INFO=$(get_disk_info "$DISK")
|
||||
|
||||
ASSIGNED_TO=""
|
||||
RUNNING_CTS=""
|
||||
RUNNING_VMS=""
|
||||
|
||||
# Comprobar CTs
|
||||
while read -r CT_ID CT_NAME; do
|
||||
if [[ "$CT_ID" =~ ^[0-9]+$ ]] && pct config "$CT_ID" | grep -q "$DISK"; then
|
||||
ASSIGNED_TO+="CT $CT_ID $CT_NAME\n"
|
||||
CT_STATUS=$(pct status "$CT_ID" | awk '{print $2}')
|
||||
if [ "$CT_STATUS" == "running" ]; then
|
||||
RUNNING_CTS+="CT $CT_ID $CT_NAME\n"
|
||||
fi
|
||||
fi
|
||||
done < <(pct list | awk 'NR>1 {print $1, $3}')
|
||||
|
||||
# Comprobar VMs
|
||||
while read -r VM_ID VM_NAME; do
|
||||
if [[ "$VM_ID" =~ ^[0-9]+$ ]] && qm config "$VM_ID" | grep -q "$DISK"; then
|
||||
ASSIGNED_TO+="VM $VM_ID $VM_NAME\n"
|
||||
VM_STATUS=$(qm status "$VM_ID" | awk '{print $2}')
|
||||
if [ "$VM_STATUS" == "running" ]; then
|
||||
RUNNING_VMS+="VM $VM_ID $VM_NAME\n"
|
||||
fi
|
||||
fi
|
||||
done < <(qm list | awk 'NR>1 {print $1, $2}')
|
||||
|
||||
if [ -n "$RUNNING_CTS" ] || [ -n "$RUNNING_VMS" ]; then
|
||||
ERROR_MESSAGES+="$(translate "The disk") $DISK_INFO $(translate "is in use by the following running VM(s) or CT(s):")\\n$RUNNING_CTS$RUNNING_VMS\\n\\n"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -n "$ASSIGNED_TO" ]; then
|
||||
cleanup
|
||||
whiptail --title "$(translate "Disk Already Assigned")" --yesno "$(translate "The disk") $DISK_INFO $(translate "is already assigned to the following VM(s) or CT(s):")\\n$ASSIGNED_TO\\n\\n$(translate "Do you want to continue anyway?")" 15 70
|
||||
if [ $? -ne 0 ]; then
|
||||
sleep 1
|
||||
exec "$0"
|
||||
fi
|
||||
fi
|
||||
|
||||
cleanup
|
||||
|
||||
|
||||
|
||||
|
||||
if lsblk "$DISK" | grep -q "raid" || grep -q "${DISK##*/}" /proc/mdstat; then
|
||||
whiptail --title "$(translate "RAID Detected")" --msgbox "$(translate "The disk") $DISK_INFO $(translate "appears to be part of a") RAID. $(translate "For security reasons, the system cannot format it.")\\n\\n$(translate "If you are sure you want to use it, please remove the") RAID metadata $(translate "or format it manually using external tools.")\\n\\n$(translate "After that, run this script again to add it.")" 18 70
|
||||
exit
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
MOUNT_POINT=$(whiptail --title "$(translate "Mount Point")" --inputbox "$(translate "Enter the mount point for the disk (e.g., /mnt/disk_passthrough):")" 10 60 "/mnt/disk_passthrough" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$MOUNT_POINT" ]; then
|
||||
whiptail --title "$(translate "Error")" --msgbox "$(translate "No mount point was specified.")" 8 40
|
||||
continue
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "Mount point specified: $MOUNT_POINT")"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
PARTITION=$(lsblk -rno NAME "$DISK" | awk -v disk="$(basename "$DISK")" '$1 != disk {print $1; exit}')
|
||||
SKIP_FORMAT=false
|
||||
|
||||
if [ -n "$PARTITION" ]; then
|
||||
PARTITION="/dev/$PARTITION"
|
||||
CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs)
|
||||
|
||||
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
|
||||
SKIP_FORMAT=true
|
||||
msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION."
|
||||
else
|
||||
whiptail --title "$(translate "Unsupported Filesystem")" --yesno "$(translate "The partition") $PARTITION $(translate "has an unsupported filesystem ($CURRENT_FS).\\nDo you want to format it?")" 10 70
|
||||
if [ $? -ne 0 ]; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
else
|
||||
|
||||
CURRENT_FS=$(lsblk -no FSTYPE "$DISK" | xargs)
|
||||
|
||||
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
|
||||
SKIP_FORMAT=true
|
||||
PARTITION="$DISK"
|
||||
msg_ok "$(translate "Detected filesystem") $CURRENT_FS $(translate "directly on disk") $DISK.)"
|
||||
else
|
||||
|
||||
whiptail --title "$(translate "No Valid Partitions")" --yesno "$(translate "The disk has no partitions and no valid filesystem. Do you want to create a new partition and format it?")" 10 70
|
||||
if [ $? -ne 0 ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -e "$(translate "Creating partition table and partition...")"
|
||||
parted -s "$DISK" mklabel gpt
|
||||
parted -s "$DISK" mkpart primary 0% 100%
|
||||
sleep 2
|
||||
partprobe "$DISK"
|
||||
sleep 2
|
||||
|
||||
PARTITION=$(lsblk -rno NAME "$DISK" | awk -v disk="$(basename "$DISK")" '$1 != disk {print $1; exit}')
|
||||
if [ -n "$PARTITION" ]; then
|
||||
PARTITION="/dev/$PARTITION"
|
||||
else
|
||||
whiptail --title "$(translate "Partition Error")" --msgbox "$(translate "Failed to create partition on disk") $DISK_INFO." 8 70
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if [ "$SKIP_FORMAT" != true ]; then
|
||||
CURRENT_FS=$(lsblk -no FSTYPE "$PARTITION" | xargs)
|
||||
if [[ "$CURRENT_FS" == "ext4" || "$CURRENT_FS" == "xfs" || "$CURRENT_FS" == "btrfs" ]]; then
|
||||
SKIP_FORMAT=true
|
||||
msg_ok "$(translate "Detected existing filesystem") $CURRENT_FS $(translate "on") $PARTITION. $(translate "Skipping format.")"
|
||||
else
|
||||
|
||||
FORMAT_TYPE=$(whiptail --title "$(translate "Select Format Type")" --menu "$(translate "Select the filesystem type for") $DISK_INFO:" 15 60 6 \
|
||||
"ext4" "$(translate "Extended Filesystem 4 (recommended)")" \
|
||||
"xfs" "$(translate "XFS Filesystem")" \
|
||||
"btrfs" "$(translate "Btrfs Filesystem")" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$FORMAT_TYPE" ]; then
|
||||
whiptail --title "$(translate "Format Cancelled")" --msgbox "$(translate "Format operation cancelled. The disk will not be added.")" 8 60
|
||||
continue
|
||||
fi
|
||||
|
||||
whiptail --title "$(translate "WARNING")" --yesno "$(translate "WARNING: This operation will FORMAT the disk") $DISK_INFO $(translate "with") $FORMAT_TYPE.\\n\\n$(translate "ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!")\\n\\n$(translate "Are you sure you want to continue")" 15 70
|
||||
if [ $? -ne 0 ]; then
|
||||
whiptail --title "$(translate "Format Cancelled")" --msgbox "$(translate "Format operation cancelled. The disk will not be added.")" 8 60
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if [ "$SKIP_FORMAT" != true ]; then
|
||||
echo -e "$(translate "Formatting partition") $PARTITION $(translate "with") $FORMAT_TYPE..."
|
||||
|
||||
case "$FORMAT_TYPE" in
|
||||
"ext4") mkfs.ext4 -F "$PARTITION" ;;
|
||||
"xfs") mkfs.xfs -f "$PARTITION" ;;
|
||||
"btrfs") mkfs.btrfs -f "$PARTITION" ;;
|
||||
esac
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
whiptail --title "$(translate "Format Failed")" --msgbox "$(translate "Failed to format partition") $PARTITION $(translate "with") $FORMAT_TYPE.\\n\\n$(translate "The disk may be in use by the system or have hardware issues.")" 12 70
|
||||
continue
|
||||
else
|
||||
msg_ok "$(translate "Partition") $PARTITION $(translate "successfully formatted with") $FORMAT_TYPE."
|
||||
partprobe "$DISK"
|
||||
sleep 2
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
INDEX=0
|
||||
while pct config "$CTID" | grep -q "mp${INDEX}:"; do
|
||||
((INDEX++))
|
||||
done
|
||||
|
||||
|
||||
|
||||
|
||||
##############################################################################
|
||||
|
||||
RESULT=$(pct set "$CTID" -mp${INDEX} "$PARTITION,mp=$MOUNT_POINT,backup=0,ro=0,acl=1" 2>&1)
|
||||
|
||||
pct exec "$CTID" -- chmod -R 775 "$MOUNT_POINT"
|
||||
|
||||
##############################################################################
|
||||
|
||||
|
||||
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
MESSAGE="$(translate "The disk") $DISK_INFO $(translate "has been successfully added to CT") $CTID $(translate "as a mount point at") $MOUNT_POINT."
|
||||
if [ -n "$ASSIGNED_TO" ]; then
|
||||
MESSAGE+="\\n\\n$(translate "WARNING: This disk is also assigned to the following CT(s):")\\n$ASSIGNED_TO"
|
||||
MESSAGE+="\\n$(translate "Make sure not to start CTs that share this disk at the same time to avoid data corruption.")"
|
||||
fi
|
||||
SUCCESS_MESSAGES+="$MESSAGE\\n\\n"
|
||||
((DISKS_ADDED++))
|
||||
else
|
||||
ERROR_MESSAGES+="$(translate "Could not add disk") $DISK_INFO $(translate "to CT") $CTID.\\n$(translate "Error:") $RESULT\\n\\n"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
|
||||
msg_ok "$(translate "Disk processing completed.")"
|
||||
|
||||
if [ -n "$SUCCESS_MESSAGES" ]; then
|
||||
MSG_LINES=$(echo "$SUCCESS_MESSAGES" | wc -l)
|
||||
whiptail --title "$(translate "Successful Operations")" --msgbox "$SUCCESS_MESSAGES" 16 70
|
||||
fi
|
||||
|
||||
if [ -n "$ERROR_MESSAGES" ]; then
|
||||
whiptail --title "$(translate "Warnings and Errors")" --msgbox "$ERROR_MESSAGES" 16 70
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,385 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - Disk Operations Helpers
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.0
|
||||
# Last Updated: 11/04/2026
|
||||
# ==========================================================
|
||||
# Shared low-level disk operations: wipe, partition, format.
|
||||
# Consumed by format-disk.sh, disk_host.sh and future scripts.
|
||||
#
|
||||
# Output variables (set by helpers, read by callers):
|
||||
# DOH_CREATED_PARTITION — partition path set by doh_create_partition()
|
||||
# DOH_PARTITION_ERROR_DETAIL — error detail set by doh_create_partition()
|
||||
# ==========================================================
|
||||
|
||||
if [[ -n "${__PROXMENUX_DISK_OPS_HELPERS__}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
__PROXMENUX_DISK_OPS_HELPERS__=1
|
||||
|
||||
# shellcheck disable=SC2034 # these are output variables read by callers (format-disk.sh, disk_host.sh)
|
||||
DOH_CREATED_PARTITION=""
|
||||
DOH_PARTITION_ERROR_DETAIL=""
|
||||
DOH_FORMAT_ERROR_DETAIL=""
|
||||
DOH_WIPE_ERROR_DETAIL=""
|
||||
|
||||
# Internal: print progress lines only when explicitly enabled by caller.
|
||||
# Enabled with: export DOH_SHOW_PROGRESS=1
|
||||
_doh_progress() {
|
||||
[[ "${DOH_SHOW_PROGRESS:-0}" == "1" ]] || return 0
|
||||
echo -e "${TAB}${YW}${HOLD}$*${CL}"
|
||||
}
|
||||
|
||||
# Internal: collect command stdout with timeout protection (best-effort).
|
||||
# Usage: _doh_collect_cmd <seconds> <cmd> [args...]
|
||||
_doh_collect_cmd() {
|
||||
local seconds="$1"
|
||||
shift
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout --kill-after=2 "${seconds}s" "$@" 2>/dev/null || true
|
||||
else
|
||||
"$@" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Internal: run a command with a timeout, suppressing all output including
|
||||
# the bash "Killed" job notification that leaks when --kill-after re-raises
|
||||
# SIGKILL. Plain SIGTERM is not enough for processes stuck in kernel D-state
|
||||
# (uninterruptible I/O wait on a busy ZFS/LVM disk), so --kill-after=2 is
|
||||
# needed. The notification is suppressed by temporarily redirecting the
|
||||
# current shell's stderr with exec before the call and restoring it after.
|
||||
# Usage: _doh_run_quick_cmd <seconds> <cmd> [args...]
|
||||
_doh_run_quick_cmd() {
|
||||
local seconds="$1"
|
||||
shift
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
local _saved_stderr
|
||||
exec {_saved_stderr}>&2 2>/dev/null
|
||||
timeout --kill-after=2 "${seconds}s" "$@" >/dev/null 2>&1
|
||||
local rc=$?
|
||||
exec 2>&"${_saved_stderr}" {_saved_stderr}>&-
|
||||
return $rc
|
||||
fi
|
||||
"$@" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Internal: unmount all ZFS datasets then export (or destroy) any ZFS pools
|
||||
# whose vdevs live on <disk>. Called at the very start of doh_wipe_disk so
|
||||
# ZFS fully releases the device before wipefs/sgdisk/partprobe touch it.
|
||||
# If the pool is still held after export, processes on it will be in D-state
|
||||
# and --kill-after in _doh_run_quick_cmd handles the force-kill.
|
||||
_doh_release_zfs_pools() {
|
||||
local disk="$1"
|
||||
command -v zpool >/dev/null 2>&1 || return 0
|
||||
|
||||
local pool_name dev resolved base parent
|
||||
while read -r pool_name; do
|
||||
[[ -z "$pool_name" ]] && continue
|
||||
local found=false
|
||||
while read -r dev; do
|
||||
[[ -z "$dev" ]] && continue
|
||||
if [[ "$dev" == /dev/* ]]; then
|
||||
resolved=$(readlink -f "$dev" 2>/dev/null)
|
||||
elif [[ -e "/dev/disk/by-id/$dev" ]]; then
|
||||
resolved=$(readlink -f "/dev/disk/by-id/$dev" 2>/dev/null)
|
||||
elif [[ -e "/dev/$dev" ]]; then
|
||||
resolved=$(readlink -f "/dev/$dev" 2>/dev/null)
|
||||
else
|
||||
continue
|
||||
fi
|
||||
[[ -z "$resolved" ]] && continue
|
||||
base=$(lsblk -no PKNAME "$resolved" 2>/dev/null)
|
||||
parent="${base:+/dev/$base}"
|
||||
[[ -z "$parent" ]] && parent="$resolved"
|
||||
if [[ "$parent" == "$disk" || "$resolved" == "$disk" ]]; then
|
||||
found=true; break
|
||||
fi
|
||||
done < <(_doh_collect_cmd 12 zpool list -v -H "$pool_name" | awk '{print $1}' | \
|
||||
grep -v '^-' | grep -v '^mirror' | grep -v '^raidz' | \
|
||||
grep -v "^${pool_name}$")
|
||||
if $found; then
|
||||
_doh_progress "- Releasing active ZFS pool: $pool_name"
|
||||
# Unmount all datasets (reverse order: deepest first)
|
||||
if command -v zfs >/dev/null 2>&1; then
|
||||
while read -r ds; do
|
||||
[[ -z "$ds" ]] && continue
|
||||
timeout 10s zfs unmount -f "$ds" >/dev/null 2>&1 || true
|
||||
done < <(_doh_collect_cmd 10 zfs list -H -o name -r "$pool_name" | sort -r)
|
||||
fi
|
||||
# Export the pool so the kernel releases the block device
|
||||
timeout 30s zpool export -f "$pool_name" >/dev/null 2>&1 || true
|
||||
# Wait for udev to finish processing the device release
|
||||
udevadm settle --timeout=5 >/dev/null 2>&1 || true
|
||||
sleep 1
|
||||
fi
|
||||
done < <(_doh_collect_cmd 8 zpool list -H -o name)
|
||||
}
|
||||
|
||||
# Internal: run a partitioning command with timeout, appending combined output to a file.
|
||||
# Usage: _doh_part_cmd <seconds> <outfile> <cmd> [args...]
|
||||
_doh_part_cmd() {
|
||||
local secs="$1" outfile="$2"
|
||||
shift 2
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
timeout --kill-after=3 "${secs}s" "$@" >>"$outfile" 2>&1
|
||||
else
|
||||
"$@" >>"$outfile" 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
# doh_wipe_disk <disk>
|
||||
# Unmounts all partitions, deactivates swap, wipes all filesystem metadata
|
||||
# and partition tables (wipefs + sgdisk + dd first/last 16 MiB).
|
||||
# Never fails — all sub-commands run with "|| true".
|
||||
doh_wipe_disk() {
|
||||
local disk="$1"
|
||||
local node mountpoint total_sectors seek_sectors discard_max base
|
||||
|
||||
DOH_WIPE_ERROR_DETAIL=""
|
||||
_doh_progress "[1/8] Preparing disk $disk"
|
||||
|
||||
# Optional heavy release flow (disabled by default to avoid hangs in busy hosts).
|
||||
if [[ "${DOH_ENABLE_STACK_RELEASE:-0}" == "1" ]]; then
|
||||
# Release any ZFS pools using this disk so the kernel lets go of it
|
||||
_doh_release_zfs_pools "$disk"
|
||||
|
||||
# Deactivate any LVM VGs backed by this disk
|
||||
if command -v vgchange >/dev/null 2>&1; then
|
||||
local pv rp vg
|
||||
while read -r pv; do
|
||||
rp=$(readlink -f "$pv" 2>/dev/null)
|
||||
base=$(lsblk -no PKNAME "${rp:-$pv}" 2>/dev/null)
|
||||
if [[ "/dev/${base}" == "$disk" || "$rp" == "$disk" ]]; then
|
||||
vg=$(_doh_collect_cmd 8 pvs --noheadings -o vg_name "${rp:-$pv}" | xargs)
|
||||
[[ -n "$vg" ]] && _doh_run_quick_cmd 8 vgchange -an "$vg" || true
|
||||
fi
|
||||
done < <(_doh_collect_cmd 8 pvs --noheadings -o pv_name | xargs -r -n1)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Unmount all partitions
|
||||
_doh_progress "[2/8] Unmounting partitions"
|
||||
while read -r node mountpoint; do
|
||||
[[ -z "$node" || -z "$mountpoint" ]] && continue
|
||||
_doh_run_quick_cmd 8 umount -f "$node" || true
|
||||
done < <(lsblk -lnpo NAME,MOUNTPOINT "$disk" 2>/dev/null | awk 'NR>1 && $2!="" {print $1" "$2}')
|
||||
|
||||
# Deactivate swap
|
||||
_doh_progress "[3/8] Disabling swap signatures"
|
||||
while read -r node; do
|
||||
[[ -z "$node" ]] && continue
|
||||
_doh_run_quick_cmd 8 swapoff "$node" || true
|
||||
done < <(lsblk -lnpo NAME "$disk" 2>/dev/null | awk 'NR>1 {print $1}')
|
||||
|
||||
# Wipe filesystem signatures and RAID superblocks on every node
|
||||
_doh_progress "[4/8] Removing filesystem/RAID signatures"
|
||||
while read -r node; do
|
||||
[[ -z "$node" ]] && continue
|
||||
_doh_run_quick_cmd 10 wipefs -a -f "$node" || true
|
||||
if command -v mdadm >/dev/null 2>&1; then
|
||||
_doh_run_quick_cmd 8 mdadm --zero-superblock --force "$node" || true
|
||||
fi
|
||||
done < <(lsblk -lnpo NAME "$disk" 2>/dev/null)
|
||||
|
||||
# Zap partition table
|
||||
_doh_progress "[5/8] Resetting partition table"
|
||||
_doh_run_quick_cmd 12 sgdisk --zap-all "$disk" || true
|
||||
|
||||
# TRIM/discard if device supports it
|
||||
_doh_progress "[6/8] Attempting discard/TRIM when supported"
|
||||
discard_max=$(lsblk -dn -o DISC-MAX "$disk" 2>/dev/null | xargs)
|
||||
if [[ -n "$discard_max" && "$discard_max" != "0B" && "$discard_max" != "0" ]]; then
|
||||
_doh_run_quick_cmd 15 blkdiscard -f "$disk" || true
|
||||
fi
|
||||
|
||||
# Zero first 16 MiB (destroys partition table / filesystem headers)
|
||||
_doh_progress "[7/8] Zeroing first metadata region"
|
||||
_doh_run_quick_cmd 20 dd if=/dev/zero of="$disk" bs=1M count=16 conv=fsync status=none || true
|
||||
|
||||
# Zero last 16 MiB (destroys backup GPT header)
|
||||
_doh_progress "[8/8] Zeroing backup GPT region"
|
||||
total_sectors=$(blockdev --getsz "$disk" 2>/dev/null || echo 0)
|
||||
if [[ "$total_sectors" =~ ^[0-9]+$ ]] && (( total_sectors > 32768 )); then
|
||||
seek_sectors=$(( total_sectors - 32768 ))
|
||||
_doh_run_quick_cmd 20 dd if=/dev/zero of="$disk" bs=512 seek="$seek_sectors" count=32768 conv=fsync status=none || true
|
||||
fi
|
||||
|
||||
udevadm settle --timeout=10 >/dev/null 2>&1 || true
|
||||
_doh_run_quick_cmd 8 partprobe "$disk" || true
|
||||
sleep 1
|
||||
}
|
||||
|
||||
# doh_create_partition <disk>
|
||||
# Creates a single GPT partition spanning the whole disk.
|
||||
# Tries parted → sgdisk → sfdisk in order; stops at first success.
|
||||
#
|
||||
# On success: sets DOH_CREATED_PARTITION to the new partition path, returns 0.
|
||||
# On failure: sets DOH_PARTITION_ERROR_DETAIL with tool diagnostics, returns 1.
|
||||
doh_create_partition() {
|
||||
local disk="$1"
|
||||
local created=false tmp_out err_snippet
|
||||
|
||||
DOH_CREATED_PARTITION=""
|
||||
DOH_PARTITION_ERROR_DETAIL=""
|
||||
|
||||
_doh_run_quick_cmd 5 blockdev --setrw "$disk" || true
|
||||
|
||||
# --- attempt 1: parted ---
|
||||
if command -v parted >/dev/null 2>&1; then
|
||||
tmp_out=$(mktemp)
|
||||
if _doh_part_cmd 15 "$tmp_out" parted -s -f "$disk" mklabel gpt; then
|
||||
if _doh_part_cmd 20 "$tmp_out" parted -s -f "$disk" mkpart primary 1MiB 100%; then
|
||||
created=true
|
||||
else
|
||||
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
|
||||
DOH_PARTITION_ERROR_DETAIL+="parted mkpart: ${err_snippet:-no details}"$'\n'
|
||||
fi
|
||||
else
|
||||
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
|
||||
DOH_PARTITION_ERROR_DETAIL+="parted mklabel: ${err_snippet:-no details}"$'\n'
|
||||
fi
|
||||
rm -f "$tmp_out"
|
||||
else
|
||||
DOH_PARTITION_ERROR_DETAIL+="parted command not found"$'\n'
|
||||
fi
|
||||
|
||||
# --- attempt 2: sgdisk ---
|
||||
if [[ "$created" != "true" ]] && command -v sgdisk >/dev/null 2>&1; then
|
||||
tmp_out=$(mktemp)
|
||||
_doh_run_quick_cmd 10 sgdisk --zap-all "$disk" || true
|
||||
# sgdisk does not accept "1MiB" notation — use sector 2048 (= 1 MiB at 512 B/sector)
|
||||
if _doh_part_cmd 20 "$tmp_out" sgdisk -o -n 1:2048:0 -t 1:8300 "$disk"; then
|
||||
created=true
|
||||
else
|
||||
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
|
||||
DOH_PARTITION_ERROR_DETAIL+="sgdisk create: ${err_snippet:-no details}"$'\n'
|
||||
fi
|
||||
rm -f "$tmp_out"
|
||||
elif [[ "$created" != "true" ]]; then
|
||||
DOH_PARTITION_ERROR_DETAIL+="sgdisk command not found"$'\n'
|
||||
fi
|
||||
|
||||
# --- attempt 3: sfdisk ---
|
||||
if [[ "$created" != "true" ]] && command -v sfdisk >/dev/null 2>&1; then
|
||||
tmp_out=$(mktemp)
|
||||
local sfdisk_ok=1
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
printf 'label: gpt\n,;\n' | timeout --kill-after=3 20s sfdisk --wipe always "$disk" >>"$tmp_out" 2>&1
|
||||
sfdisk_ok=$?
|
||||
else
|
||||
printf 'label: gpt\n,;\n' | sfdisk --wipe always "$disk" >>"$tmp_out" 2>&1
|
||||
sfdisk_ok=$?
|
||||
fi
|
||||
if [[ $sfdisk_ok -eq 0 ]]; then
|
||||
created=true
|
||||
else
|
||||
err_snippet=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
|
||||
DOH_PARTITION_ERROR_DETAIL+="sfdisk create: ${err_snippet:-no details}"$'\n'
|
||||
fi
|
||||
rm -f "$tmp_out"
|
||||
elif [[ "$created" != "true" ]]; then
|
||||
DOH_PARTITION_ERROR_DETAIL+="sfdisk command not found"$'\n'
|
||||
fi
|
||||
|
||||
[[ "$created" == "true" ]] || return 1
|
||||
|
||||
# Wait for the kernel to expose the new partition node
|
||||
udevadm settle --timeout=10 >/dev/null 2>&1 || true
|
||||
_doh_run_quick_cmd 8 partprobe "$disk" || true
|
||||
|
||||
local part
|
||||
for _ in {1..15}; do
|
||||
sleep 0.3
|
||||
part=$(lsblk -lnpo NAME "$disk" 2>/dev/null | awk 'NR==2{print; exit}')
|
||||
if [[ -n "$part" && -b "$part" ]]; then
|
||||
DOH_CREATED_PARTITION="$part"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Fallback: derive partition name from disk path (handles NVMe p-suffix)
|
||||
local fallback
|
||||
if [[ "$disk" =~ [0-9]$ ]]; then
|
||||
fallback="${disk}p1"
|
||||
else
|
||||
fallback="${disk}1"
|
||||
fi
|
||||
if [[ -b "$fallback" ]]; then
|
||||
DOH_CREATED_PARTITION="$fallback"
|
||||
return 0
|
||||
fi
|
||||
|
||||
DOH_PARTITION_ERROR_DETAIL+="partition node not detected after table refresh"$'\n'
|
||||
return 1
|
||||
}
|
||||
|
||||
# doh_format_partition <partition> <filesystem> [label] [zfs_pool_name] [zfs_mountpoint]
|
||||
#
|
||||
# Formats <partition> with <filesystem>.
|
||||
# label : optional FS label for ext4/xfs/btrfs (ignored for ZFS)
|
||||
# zfs_pool_name : required when filesystem=zfs; defaults to label if empty
|
||||
# zfs_mountpoint : ZFS pool mountpoint (default: "none" — no automatic mount)
|
||||
#
|
||||
# On failure: sets DOH_FORMAT_ERROR_DETAIL with tool diagnostics.
|
||||
# Returns 0 on success, 1 on failure.
|
||||
doh_format_partition() {
|
||||
local partition="$1"
|
||||
local filesystem="$2"
|
||||
local label="${3:-}"
|
||||
local zfs_pool="${4:-}"
|
||||
local zfs_mountpoint="${5:-none}"
|
||||
local tmp_out rc=1
|
||||
|
||||
DOH_FORMAT_ERROR_DETAIL=""
|
||||
tmp_out=$(mktemp)
|
||||
|
||||
case "$filesystem" in
|
||||
ext4)
|
||||
if [[ -n "$label" ]]; then
|
||||
mkfs.ext4 -F -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$?
|
||||
else
|
||||
mkfs.ext4 -F "$partition" >"$tmp_out" 2>&1; rc=$?
|
||||
fi
|
||||
;;
|
||||
xfs)
|
||||
if [[ -n "$label" ]]; then
|
||||
mkfs.xfs -f -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$?
|
||||
else
|
||||
mkfs.xfs -f "$partition" >"$tmp_out" 2>&1; rc=$?
|
||||
fi
|
||||
;;
|
||||
exfat)
|
||||
mkfs.exfat "$partition" >"$tmp_out" 2>&1; rc=$?
|
||||
;;
|
||||
btrfs)
|
||||
if [[ -n "$label" ]]; then
|
||||
mkfs.btrfs -f -L "$label" "$partition" >"$tmp_out" 2>&1; rc=$?
|
||||
else
|
||||
mkfs.btrfs -f "$partition" >"$tmp_out" 2>&1; rc=$?
|
||||
fi
|
||||
;;
|
||||
zfs)
|
||||
[[ -z "$zfs_pool" ]] && zfs_pool="${label:-pool}"
|
||||
zpool labelclear -f "$partition" >/dev/null 2>&1 || true
|
||||
zpool create -f -o ashift=12 \
|
||||
-O compression=lz4 -O atime=off -O xattr=sa -O acltype=posixacl \
|
||||
-m "$zfs_mountpoint" "$zfs_pool" "$partition" >"$tmp_out" 2>&1
|
||||
rc=$?
|
||||
;;
|
||||
*)
|
||||
echo "Unknown filesystem: $filesystem" >"$tmp_out"
|
||||
rc=1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
DOH_FORMAT_ERROR_DETAIL=$(tr '\n' ' ' <"$tmp_out" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')
|
||||
fi
|
||||
rm -f "$tmp_out"
|
||||
return $rc
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${__PROXMENUX_GPU_HOOK_GUARD_HELPERS__}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
__PROXMENUX_GPU_HOOK_GUARD_HELPERS__=1
|
||||
|
||||
PROXMENUX_GPU_HOOK_STORAGE_REF="local:snippets/proxmenux-gpu-guard.sh"
|
||||
PROXMENUX_GPU_HOOK_ABS_PATH="/var/lib/vz/snippets/proxmenux-gpu-guard.sh"
|
||||
|
||||
_gpu_guard_msg_warn() {
|
||||
if declare -F msg_warn >/dev/null 2>&1; then
|
||||
msg_warn "$1"
|
||||
else
|
||||
echo "[WARN] $1" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
_gpu_guard_msg_ok() {
|
||||
if declare -F msg_ok >/dev/null 2>&1; then
|
||||
msg_ok "$1"
|
||||
else
|
||||
echo "[OK] $1"
|
||||
fi
|
||||
}
|
||||
|
||||
_gpu_guard_has_vm_gpu() {
|
||||
local vmid="$1"
|
||||
qm config "$vmid" 2>/dev/null | grep -qE '^hostpci[0-9]+:'
|
||||
}
|
||||
|
||||
_gpu_guard_has_lxc_gpu() {
|
||||
local ctid="$1"
|
||||
local conf="/etc/pve/lxc/${ctid}.conf"
|
||||
[[ -f "$conf" ]] || return 1
|
||||
grep -qE 'dev[0-9]+:.*(/dev/dri|/dev/nvidia|/dev/kfd)|lxc\.mount\.entry:.*dev/dri' "$conf" 2>/dev/null
|
||||
}
|
||||
|
||||
ensure_proxmenux_gpu_guard_hookscript() {
|
||||
mkdir -p /var/lib/vz/snippets 2>/dev/null || true
|
||||
|
||||
cat >"$PROXMENUX_GPU_HOOK_ABS_PATH" <<'HOOKEOF'
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
|
||||
arg1="${1:-}"
|
||||
arg2="${2:-}"
|
||||
case "$arg1" in
|
||||
pre-start|post-start|pre-stop|post-stop)
|
||||
phase="$arg1"
|
||||
guest_id="$arg2"
|
||||
;;
|
||||
*)
|
||||
guest_id="$arg1"
|
||||
phase="$arg2"
|
||||
;;
|
||||
esac
|
||||
[[ "$phase" == "pre-start" ]] || exit 0
|
||||
|
||||
vm_conf="/etc/pve/qemu-server/${guest_id}.conf"
|
||||
ct_conf="/etc/pve/lxc/${guest_id}.conf"
|
||||
|
||||
if [[ -f "$vm_conf" ]]; then
|
||||
mapfile -t hostpci_lines < <(grep -E '^hostpci[0-9]+:' "$vm_conf" 2>/dev/null || true)
|
||||
[[ ${#hostpci_lines[@]} -eq 0 ]] && exit 0
|
||||
|
||||
# Build slot list used by this VM and block if any running VM already uses same slot.
|
||||
slot_keys=()
|
||||
for line in "${hostpci_lines[@]}"; do
|
||||
val="${line#*: }"
|
||||
[[ "$val" == *"mapping="* ]] && continue
|
||||
first_field="${val%%,*}"
|
||||
IFS=';' read -r -a ids <<< "$first_field"
|
||||
for id in "${ids[@]}"; do
|
||||
id="${id#host=}"
|
||||
id="${id// /}"
|
||||
[[ -z "$id" ]] && continue
|
||||
if [[ "$id" =~ ^[0-9a-fA-F]{2}:[0-9a-fA-F]{2}$ ]]; then
|
||||
key="${id,,}"
|
||||
else
|
||||
[[ "$id" =~ ^0000: ]] || id="0000:${id}"
|
||||
key="${id#0000:}"
|
||||
key="${key%.*}"
|
||||
key="${key,,}"
|
||||
fi
|
||||
dup=0
|
||||
for existing in "${slot_keys[@]}"; do
|
||||
[[ "$existing" == "$key" ]] && dup=1 && break
|
||||
done
|
||||
[[ "$dup" -eq 0 ]] && slot_keys+=("$key")
|
||||
done
|
||||
done
|
||||
|
||||
if [[ ${#slot_keys[@]} -gt 0 ]]; then
|
||||
conflict_details=""
|
||||
for other_conf in /etc/pve/qemu-server/*.conf; do
|
||||
[[ -f "$other_conf" ]] || continue
|
||||
other_vmid="$(basename "$other_conf" .conf)"
|
||||
[[ "$other_vmid" == "$guest_id" ]] && continue
|
||||
qm status "$other_vmid" 2>/dev/null | grep -q "status: running" || continue
|
||||
|
||||
for key in "${slot_keys[@]}"; do
|
||||
if grep -qE "^hostpci[0-9]+:.*(0000:)?${key}(\\.[0-7])?([,[:space:]]|$)" "$other_conf" 2>/dev/null; then
|
||||
other_name="$(awk '/^name:/ {print $2}' "$other_conf" 2>/dev/null)"
|
||||
[[ -z "$other_name" ]] && other_name="VM-${other_vmid}"
|
||||
conflict_details+=$'\n'"- ${key} in use by VM ${other_vmid} (${other_name})"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [[ -n "$conflict_details" ]]; then
|
||||
echo "ProxMenux GPU Guard: VM ${guest_id} blocked at pre-start." >&2
|
||||
echo "A hostpci device slot is already in use by another running VM." >&2
|
||||
printf '%s\n' "$conflict_details" >&2
|
||||
echo "Stop the source VM or remove/move the shared hostpci assignment." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
failed=0
|
||||
details=""
|
||||
for line in "${hostpci_lines[@]}"; do
|
||||
val="${line#*: }"
|
||||
[[ "$val" == *"mapping="* ]] && continue
|
||||
|
||||
first_field="${val%%,*}"
|
||||
IFS=';' read -r -a ids <<< "$first_field"
|
||||
for id in "${ids[@]}"; do
|
||||
id="${id#host=}"
|
||||
id="${id// /}"
|
||||
[[ -z "$id" ]] && continue
|
||||
|
||||
# Slot-only syntax (e.g. 01:00 or 0000:01:00) is accepted by Proxmox.
|
||||
if [[ "$id" =~ ^([0-9a-fA-F]{4}:)?[0-9a-fA-F]{2}:[0-9a-fA-F]{2}$ ]]; then
|
||||
slot="${id,,}"
|
||||
slot="${slot#0000:}"
|
||||
slot_has_gpu=false
|
||||
for dev in /sys/bus/pci/devices/0000:${slot}.*; do
|
||||
[[ -e "$dev" ]] || continue
|
||||
class_hex="$(cat "$dev/class" 2>/dev/null | sed 's/^0x//')"
|
||||
[[ "${class_hex:0:2}" != "03" ]] && continue
|
||||
slot_has_gpu=true
|
||||
drv="$(basename "$(readlink "$dev/driver" 2>/dev/null)" 2>/dev/null)"
|
||||
if [[ "$drv" != "vfio-pci" ]]; then
|
||||
failed=1
|
||||
details+=$'\n'"- ${dev##*/}: driver=${drv:-none}"
|
||||
fi
|
||||
done
|
||||
# If this slot does not include a display/3D controller, it is not GPU-guarded.
|
||||
[[ "$slot_has_gpu" == "true" ]] || true
|
||||
continue
|
||||
fi
|
||||
|
||||
[[ "$id" =~ ^0000: ]] || id="0000:${id}"
|
||||
dev_path="/sys/bus/pci/devices/${id}"
|
||||
if [[ ! -d "$dev_path" ]]; then
|
||||
failed=1
|
||||
details+=$'\n'"- ${id}: PCI device not found"
|
||||
continue
|
||||
fi
|
||||
class_hex="$(cat "$dev_path/class" 2>/dev/null | sed 's/^0x//')"
|
||||
# Enforce vfio only for display/3D devices (PCI class 03xx).
|
||||
[[ "${class_hex:0:2}" == "03" ]] || continue
|
||||
drv="$(basename "$(readlink "$dev_path/driver" 2>/dev/null)" 2>/dev/null)"
|
||||
if [[ "$drv" != "vfio-pci" ]]; then
|
||||
failed=1
|
||||
details+=$'\n'"- ${id}: driver=${drv:-none}"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [[ "$failed" -eq 1 ]]; then
|
||||
echo "ProxMenux GPU Guard: VM ${guest_id} blocked at pre-start." >&2
|
||||
echo "GPU passthrough device is not ready for VM mode (vfio-pci required)." >&2
|
||||
printf '%s\n' "$details" >&2
|
||||
echo "Switch mode to GPU -> VM from ProxMenux: GPUs and Coral-TPU Menu." >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -f "$ct_conf" ]]; then
|
||||
mapfile -t gpu_dev_paths < <(
|
||||
{
|
||||
grep -E '^dev[0-9]+:' "$ct_conf" 2>/dev/null | sed -E 's/^dev[0-9]+:[[:space:]]*([^,[:space:]]+).*/\1/'
|
||||
grep -E '^lxc\.mount\.entry:' "$ct_conf" 2>/dev/null | sed -E 's/^lxc\.mount\.entry:[[:space:]]*([^[:space:]]+).*/\1/'
|
||||
} | grep -E '^/dev/(dri|nvidia|kfd)' | sort -u
|
||||
)
|
||||
|
||||
[[ ${#gpu_dev_paths[@]} -eq 0 ]] && exit 0
|
||||
|
||||
missing=""
|
||||
for dev in "${gpu_dev_paths[@]}"; do
|
||||
[[ -e "$dev" ]] || missing+=$'\n'"- ${dev} unavailable"
|
||||
done
|
||||
|
||||
if [[ -n "$missing" ]]; then
|
||||
echo "ProxMenux GPU Guard: LXC ${guest_id} blocked at pre-start." >&2
|
||||
echo "Configured GPU devices are unavailable in host device nodes." >&2
|
||||
printf '%s\n' "$missing" >&2
|
||||
echo "Switch mode to GPU -> LXC from ProxMenux: GPUs and Coral-TPU Menu." >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 0
|
||||
HOOKEOF
|
||||
|
||||
chmod 755 "$PROXMENUX_GPU_HOOK_ABS_PATH" 2>/dev/null || true
|
||||
}
|
||||
|
||||
attach_proxmenux_gpu_guard_to_vm() {
|
||||
local vmid="$1"
|
||||
_gpu_guard_has_vm_gpu "$vmid" || return 0
|
||||
|
||||
local current
|
||||
current=$(qm config "$vmid" 2>/dev/null | awk '/^hookscript:/ {print $2}')
|
||||
if [[ "$current" == "$PROXMENUX_GPU_HOOK_STORAGE_REF" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if qm set "$vmid" --hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then
|
||||
_gpu_guard_msg_ok "PCIe passthrough guard attached to VM ${vmid}"
|
||||
else
|
||||
_gpu_guard_msg_warn "Could not attach PCIe passthrough guard to VM ${vmid}. Ensure 'local' storage supports snippets."
|
||||
fi
|
||||
}
|
||||
|
||||
attach_proxmenux_gpu_guard_to_lxc() {
|
||||
local ctid="$1"
|
||||
_gpu_guard_has_lxc_gpu "$ctid" || return 0
|
||||
|
||||
local current
|
||||
current=$(pct config "$ctid" 2>/dev/null | awk '/^hookscript:/ {print $2}')
|
||||
if [[ "$current" == "$PROXMENUX_GPU_HOOK_STORAGE_REF" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if pct set "$ctid" -hookscript "$PROXMENUX_GPU_HOOK_STORAGE_REF" >/dev/null 2>&1; then
|
||||
_gpu_guard_msg_ok "PCIe passthrough guard attached to LXC ${ctid}"
|
||||
else
|
||||
_gpu_guard_msg_warn "Could not attach PCIe passthrough guard to LXC ${ctid}. Ensure 'local' storage supports snippets."
|
||||
fi
|
||||
}
|
||||
|
||||
sync_proxmenux_gpu_guard_hooks() {
|
||||
ensure_proxmenux_gpu_guard_hookscript
|
||||
|
||||
local vmid ctid
|
||||
for conf in /etc/pve/qemu-server/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
vmid=$(basename "$conf" .conf)
|
||||
_gpu_guard_has_vm_gpu "$vmid" && attach_proxmenux_gpu_guard_to_vm "$vmid"
|
||||
done
|
||||
|
||||
for conf in /etc/pve/lxc/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
ctid=$(basename "$conf" .conf)
|
||||
_gpu_guard_has_lxc_gpu "$ctid" && attach_proxmenux_gpu_guard_to_lxc "$ctid"
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${__PROXMENUX_PCI_PASSTHROUGH_HELPERS__}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
__PROXMENUX_PCI_PASSTHROUGH_HELPERS__=1
|
||||
|
||||
function _pci_is_iommu_active() {
|
||||
grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null || return 1
|
||||
[[ -d /sys/kernel/iommu_groups ]] || return 1
|
||||
find /sys/kernel/iommu_groups -mindepth 1 -maxdepth 1 -type d -print -quit 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
function _pci_next_hostpci_index() {
|
||||
local vmid="$1"
|
||||
local idx=0
|
||||
local hostpci_existing
|
||||
|
||||
hostpci_existing=$(qm config "$vmid" 2>/dev/null) || return 1
|
||||
while grep -q "^hostpci${idx}:" <<< "$hostpci_existing"; do
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
echo "$idx"
|
||||
}
|
||||
|
||||
function _pci_slot_assigned_to_vm() {
|
||||
local pci_full="$1"
|
||||
local vmid="$2"
|
||||
local slot_base
|
||||
slot_base="${pci_full#0000:}"
|
||||
slot_base="${slot_base%.*}"
|
||||
|
||||
qm config "$vmid" 2>/dev/null \
|
||||
| grep -qE "^hostpci[0-9]+:.*(0000:)?${slot_base}(\\.[0-7])?([,[:space:]]|$)"
|
||||
}
|
||||
|
||||
function _pci_function_assigned_to_vm() {
|
||||
local pci_full="$1"
|
||||
local vmid="$2"
|
||||
local bdf slot func pattern
|
||||
bdf="${pci_full#0000:}"
|
||||
slot="${bdf%.*}"
|
||||
func="${bdf##*.}"
|
||||
|
||||
if [[ "$func" == "0" ]]; then
|
||||
pattern="^hostpci[0-9]+:.*(0000:)?(${bdf}|${slot})([,:[:space:]]|$)"
|
||||
else
|
||||
pattern="^hostpci[0-9]+:.*(0000:)?${bdf}([,[:space:]]|$)"
|
||||
fi
|
||||
|
||||
qm config "$vmid" 2>/dev/null | grep -qE "$pattern"
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE (v3 - Minimal Intrusive)
|
||||
# ==========================================================
|
||||
# This version makes a surgical change to the checked_command function
|
||||
# by changing the condition to 'if (false)' and commenting out the banner logic.
|
||||
# Also patches the mobile UI to remove the subscription dialog.
|
||||
# ==========================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Source utilities if available
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# File paths
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
MOBILE_UI_FILE="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
BACKUP_DIR="$BASE_DIR/backups"
|
||||
APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
PATCH_BIN="/usr/local/bin/pve-remove-nag-v3.sh"
|
||||
MARK="/* PROXMENUX_NAG_PATCH_V3 */"
|
||||
MOBILE_MARK="<!-- PROXMENUX_MOBILE_NAG_PATCH -->"
|
||||
|
||||
# Ensure tools JSON exists
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
# Register tool in JSON
|
||||
register_tool() {
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
local tool="$1" state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" \
|
||||
> "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
# Verify JS file integrity
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] || return 1
|
||||
[ -s "$file" ] || return 1
|
||||
grep -Eq 'Ext|function|var|const|let' "$file" || return 1
|
||||
if LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Create timestamped backup
|
||||
create_backup() {
|
||||
local file="$1"
|
||||
local timestamp
|
||||
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_file="$BACKUP_DIR/$(basename "$file").backup.$timestamp"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
if [ -f "$file" ]; then
|
||||
rm -f "$BACKUP_DIR"/"$(basename "$file")".backup.* 2>/dev/null || true
|
||||
|
||||
cp -a "$file" "$backup_file"
|
||||
echo "$backup_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Create the patch script that will be called by APT hook
|
||||
create_patch_script() {
|
||||
cat > "$PATCH_BIN" <<'EOFPATCH'
|
||||
#!/usr/bin/env bash
|
||||
# ==========================================================
|
||||
# Proxmox Subscription Banner Patch (v3 - Minimal)
|
||||
# ==========================================================
|
||||
set -euo pipefail
|
||||
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
MOBILE_UI_FILE="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
BACKUP_DIR="/usr/local/share/proxmenux/backups"
|
||||
MARK="/* PROXMENUX_NAG_PATCH_V3 */"
|
||||
MOBILE_MARK="<!-- PROXMENUX_MOBILE_NAG_PATCH -->"
|
||||
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] && [ -s "$file" ] && grep -Eq 'Ext|function' "$file" && ! LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null
|
||||
}
|
||||
|
||||
patch_checked_command() {
|
||||
[ -f "$JS_FILE" ] || return 0
|
||||
|
||||
# Check if already patched
|
||||
grep -q "$MARK" "$JS_FILE" && return 0
|
||||
|
||||
# Create backup
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local backup="$BACKUP_DIR/$(basename "$JS_FILE").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp -a "$JS_FILE" "$backup"
|
||||
|
||||
# Set trap to restore on error
|
||||
trap "cp -a '$backup' '$JS_FILE' 2>/dev/null || true" ERR
|
||||
|
||||
# Add patch marker at the beginning
|
||||
sed -i "1s|^|$MARK\n|" "$JS_FILE"
|
||||
|
||||
# Surgical patch: Change the condition in checked_command function
|
||||
# This changes the if condition to 'if (false)' making the banner never show
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
|
||||
# Pattern for newer versions (8.4.5+)
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
elif grep -q "res\.data\.status !== 'Active'" "$JS_FILE"; then
|
||||
# Pattern for older versions
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status !== 'Active'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
# Also handle the NoMoreNagging pattern if present
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'NoMoreNagging'" "$JS_FILE"; then
|
||||
sed -i "/checked_command: function/,/},$/s/res === null || res === undefined || !res || res\.data\.status\.toLowerCase() !== 'NoMoreNagging'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
# Verify integrity after patch
|
||||
if ! verify_js_integrity "$JS_FILE"; then
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Clean up generated files
|
||||
rm -f "$MIN_JS_FILE" "$GZ_FILE" 2>/dev/null || true
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
|
||||
|
||||
trap - ERR
|
||||
return 0
|
||||
}
|
||||
|
||||
patch_mobile_ui() {
|
||||
[ -f "$MOBILE_UI_FILE" ] || return 0
|
||||
|
||||
# Check if already patched
|
||||
grep -q "$MOBILE_MARK" "$MOBILE_UI_FILE" && return 0
|
||||
|
||||
# Create backup
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local backup="$BACKUP_DIR/$(basename "$MOBILE_UI_FILE").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp -a "$MOBILE_UI_FILE" "$backup"
|
||||
|
||||
# Set trap to restore on error
|
||||
trap "cp -a '$backup' '$MOBILE_UI_FILE' 2>/dev/null || true" ERR
|
||||
|
||||
# Insert the script before </head> tag
|
||||
sed -i "/<\/head>/i\\
|
||||
$MOBILE_MARK\\
|
||||
<!-- Script to remove subscription banner from mobile UI -->\\
|
||||
<script>\\
|
||||
function removeNoSubDialog() {\\
|
||||
const observer = new MutationObserver(() => {\\
|
||||
const diag = document.querySelector('dialog[aria-label=\"No valid subscription\"]');\\
|
||||
if (diag) {\\
|
||||
diag.remove();\\
|
||||
}\\
|
||||
});\\
|
||||
observer.observe(document.body, { childList: true, subtree: true });\\
|
||||
}\\
|
||||
window.addEventListener('load', () => {\\
|
||||
setTimeout(removeNoSubDialog, 200);\\
|
||||
});\\
|
||||
</script>" "$MOBILE_UI_FILE"
|
||||
|
||||
trap - ERR
|
||||
return 0
|
||||
}
|
||||
|
||||
reload_services() {
|
||||
systemctl is-active --quiet pveproxy 2>/dev/null && {
|
||||
systemctl reload pveproxy 2>/dev/null || systemctl restart pveproxy 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet nginx 2>/dev/null && {
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet pvedaemon 2>/dev/null && {
|
||||
systemctl reload pvedaemon 2>/dev/null || true
|
||||
}
|
||||
}
|
||||
|
||||
main() {
|
||||
patch_checked_command || return 1
|
||||
patch_mobile_ui || true
|
||||
reload_services
|
||||
}
|
||||
|
||||
main
|
||||
EOFPATCH
|
||||
|
||||
chmod 755 "$PATCH_BIN"
|
||||
}
|
||||
|
||||
# Create APT hook to reapply patch after updates
|
||||
create_apt_hook() {
|
||||
cat > "$APT_HOOK" <<'EOFAPT'
|
||||
/* ProxMenux: reapply minimal nag patch after upgrades */
|
||||
DPkg::Post-Invoke { "/usr/local/bin/pve-remove-nag-v3.sh || true"; };
|
||||
EOFAPT
|
||||
|
||||
chmod 644 "$APT_HOOK"
|
||||
|
||||
# Verify APT hook syntax
|
||||
apt-config dump >/dev/null 2>&1 || {
|
||||
rm -f "$APT_HOOK"
|
||||
}
|
||||
}
|
||||
|
||||
# Main function to remove subscription banner
|
||||
remove_subscription_banner_v3() {
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown")
|
||||
|
||||
msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying banner patch")"
|
||||
|
||||
|
||||
|
||||
# Remove old APT hooks
|
||||
for f in /etc/apt/apt.conf.d/*nag*; do
|
||||
[[ -e "$f" ]] && rm -f "$f"
|
||||
done
|
||||
|
||||
# Create backup for desktop UI
|
||||
local backup_file
|
||||
backup_file=$(create_backup "$JS_FILE")
|
||||
if [ -n "$backup_file" ]; then
|
||||
# msg_ok "$(translate "Desktop UI backup created"): $backup_file"
|
||||
:
|
||||
fi
|
||||
|
||||
if [ -f "$MOBILE_UI_FILE" ]; then
|
||||
local mobile_backup
|
||||
mobile_backup=$(create_backup "$MOBILE_UI_FILE")
|
||||
if [ -n "$mobile_backup" ]; then
|
||||
# msg_ok "$(translate "Mobile UI backup created"): $mobile_backup"
|
||||
:
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create patch script and APT hook
|
||||
create_patch_script
|
||||
create_apt_hook
|
||||
|
||||
# Apply the patch
|
||||
if ! "$PATCH_BIN"; then
|
||||
msg_error "$(translate "Error applying patch. Backups preserved at"): $BACKUP_DIR"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Register tool as applied
|
||||
register_tool "subscription_banner" true
|
||||
|
||||
msg_ok "$(translate "Subscription banner removed successfully")"
|
||||
msg_ok "$(translate "Desktop and Mobile UI patched")"
|
||||
msg_ok "$(translate "Refresh your browser (Ctrl+Shift+R) to see changes")"
|
||||
|
||||
}
|
||||
|
||||
# Run if executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
remove_subscription_banner_v3
|
||||
fi
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE 9.x
|
||||
# ==========================================================
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
local tool="$1"
|
||||
local state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
remove_subscription_banner_pve9() {
|
||||
local JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
local MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
|
||||
|
||||
local pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1)
|
||||
local pve_major=$(echo "$pve_version" | cut -d. -f1)
|
||||
|
||||
if [ "$pve_major" -lt 9 ] 2>/dev/null; then
|
||||
msg_error "This script is for PVE 9.x only. Detected PVE $pve_version"
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_info "Detected Proxmox VE $pve_version - Applying PVE 9.x patches"
|
||||
|
||||
|
||||
if [ ! -f "$JS_FILE" ]; then
|
||||
msg_error "JavaScript file not found: $JS_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
|
||||
|
||||
local backup_file="${JS_FILE}.backup.pve9.$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$JS_FILE" "$backup_file"
|
||||
|
||||
|
||||
for f in /etc/apt/apt.conf.d/*nag*; do
|
||||
[[ -e "$f" ]] && rm -f "$f"
|
||||
done
|
||||
|
||||
[[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE"
|
||||
[[ -f "$MIN_JS_FILE" ]] && rm -f "$MIN_JS_FILE"
|
||||
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
|
||||
|
||||
|
||||
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
|
||||
sed -i "s/title: gettext('No valid subscription')/title: gettext('Community Edition')/g" "$JS_FILE"
|
||||
|
||||
|
||||
sed -i "s/You do not have a valid subscription for this server/Community Edition - No subscription required/g" "$JS_FILE"
|
||||
sed -i "s/Enterprise repository needs valid subscription/Enterprise repository configured/g" "$JS_FILE"
|
||||
sed -i "s/icon: Ext\.Msg\.WARNING/icon: Ext.Msg.INFO/g" "$JS_FILE"
|
||||
|
||||
|
||||
sed -i "s/subscription = !(/subscription = false \&\& (/g" "$JS_FILE"
|
||||
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
|
||||
msg_warn "Some patches may not have applied correctly, retrying..."
|
||||
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
fi
|
||||
|
||||
|
||||
[[ -f "$APT_HOOK" ]] && rm -f "$APT_HOOK"
|
||||
cat > "$APT_HOOK" << 'EOF'
|
||||
DPkg::Post-Invoke {
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/res\\.data\\.status\\.toLowerCase() !== '\''active'\''/false/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscriptionActive: '\'\'\''/subscriptionActive: true/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/title: gettext('\''No valid subscription'\'')/title: gettext('\''Community Edition'\'')/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscription = !(/subscription = false \\&\\& (/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"rm -f /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz || true";
|
||||
};
|
||||
EOF
|
||||
|
||||
chmod 644 "$APT_HOOK"
|
||||
|
||||
|
||||
if ! apt-config dump >/dev/null 2>&1; then
|
||||
msg_warn "APT hook has syntax issues, removing..."
|
||||
rm -f "$APT_HOOK"
|
||||
else
|
||||
msg_ok "APT hook created successfully"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
|
||||
msg_ok "Subscription banner removed successfully for Proxmox VE $pve_version"
|
||||
msg_ok "Banner removal process completed - refresh your browser to see changes"
|
||||
|
||||
register_tool "subscription_banner" true
|
||||
}
|
||||
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
remove_subscription_banner_pve9
|
||||
fi
|
||||
@@ -1,119 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE 9.x ONLY
|
||||
# ==========================================================
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# Tool registration system
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
local tool="$1"
|
||||
local state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
remove_subscription_banner_pve9() {
|
||||
local JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
local MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
local GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
local APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
|
||||
# Verify PVE 9.x
|
||||
local pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1)
|
||||
local pve_major=$(echo "$pve_version" | cut -d. -f1)
|
||||
|
||||
if [ "$pve_major" -lt 9 ] 2>/dev/null; then
|
||||
msg_error "This script is for PVE 9.x only. Detected PVE $pve_version"
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_info "Detected Proxmox VE $pve_version - Applying PVE 9.x patches"
|
||||
|
||||
# Verify that the file exists
|
||||
if [ ! -f "$JS_FILE" ]; then
|
||||
msg_error "JavaScript file not found: $JS_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
# Create backup of original file
|
||||
local backup_file="${JS_FILE}.backup.pve9.$(date +%Y%m%d_%H%M%S)"
|
||||
cp "$JS_FILE" "$backup_file"
|
||||
|
||||
# Clean any existing problematic APT hooks
|
||||
for f in /etc/apt/apt.conf.d/*nag*; do
|
||||
[[ -e "$f" ]] && rm -f "$f"
|
||||
done
|
||||
|
||||
|
||||
# Main subscription check patches for PVE 9
|
||||
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
|
||||
sed -i "s/title: gettext('No valid subscription')/title: gettext('Community Edition')/g" "$JS_FILE"
|
||||
|
||||
# Additional UX improvements for PVE 9
|
||||
sed -i "s/You do not have a valid subscription for this server/Community Edition - No subscription required/g" "$JS_FILE"
|
||||
sed -i "s/Enterprise repository needs valid subscription/Enterprise repository configured/g" "$JS_FILE"
|
||||
sed -i "s/icon: Ext\.Msg\.WARNING/icon: Ext.Msg.INFO/g" "$JS_FILE"
|
||||
|
||||
# Additional subscription patterns that may exist in PVE 9
|
||||
sed -i "s/subscription = !(/subscription = false \&\& (/g" "$JS_FILE"
|
||||
|
||||
# Remove compressed/minified files to force regeneration
|
||||
[[ -f "$GZ_FILE" ]] && rm -f "$GZ_FILE"
|
||||
[[ -f "$MIN_JS_FILE" ]] && rm -f "$MIN_JS_FILE"
|
||||
|
||||
# Clear various caches
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
|
||||
# Create PVE 9.x specific APT hook
|
||||
[[ -f "$APT_HOOK" ]] && rm -f "$APT_HOOK"
|
||||
cat > "$APT_HOOK" << 'EOF'
|
||||
DPkg::Post-Invoke {
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/res\\.data\\.status\\.toLowerCase() !== '\''active'\''/false/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscriptionActive: '\'\'\''/subscriptionActive: true/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/title: gettext('\''No valid subscription'\'')/title: gettext('\''Community Edition'\'')/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"test -e /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js && sed -i 's/subscription = !(/subscription = false \\&\\& (/g' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js || true";
|
||||
"rm -f /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz || true";
|
||||
};
|
||||
EOF
|
||||
|
||||
chmod 644 "$APT_HOOK"
|
||||
|
||||
# Verify APT hook syntax
|
||||
if ! apt-config dump >/dev/null 2>&1; then
|
||||
msg_warn "APT hook has syntax issues, removing..."
|
||||
rm -f "$APT_HOOK"
|
||||
else
|
||||
msg_ok "APT hook created successfully"
|
||||
fi
|
||||
|
||||
|
||||
msg_ok "Subscription banner removed successfully for Proxmox VE $pve_version"
|
||||
msg_ok "Banner removal process completed"
|
||||
|
||||
|
||||
register_tool "subscription_banner" true
|
||||
}
|
||||
|
||||
|
||||
# Execute function if called directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
remove_subscription_banner_pve9
|
||||
fi
|
||||
@@ -1,257 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Remove Subscription Banner - Proxmox VE 9.x (Clean Version)
|
||||
# ==========================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
local tool="$1" state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" \
|
||||
> "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MOBILE_TPL="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
APT_HOOK="/etc/apt/apt.conf.d/no-nag-script"
|
||||
PATCH_BIN="/usr/local/bin/pve-remove-nag.sh"
|
||||
|
||||
MARK_JS="PROXMENUX_NAG_REMOVED_v2"
|
||||
MARK_MOBILE="<!-- PROXMENUX: MOBILE NAG PATCH v2 -->"
|
||||
|
||||
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] || return 1
|
||||
[ -s "$file" ] || return 1
|
||||
grep -Eq 'Ext|function|var|const|let' "$file" || return 1
|
||||
if LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
create_backup() {
|
||||
local file="$1"
|
||||
local backup_dir="$BASE_DIR/backups"
|
||||
local timestamp
|
||||
timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_file="$backup_dir/$(basename "$file").backup.$timestamp"
|
||||
mkdir -p "$backup_dir"
|
||||
if [ -f "$file" ]; then
|
||||
cp -a "$file" "$backup_file"
|
||||
ls -t "$backup_dir"/"$(basename "$file")".backup.* 2>/dev/null | tail -n +6 | xargs -r rm -f 2>/dev/null || true
|
||||
echo "$backup_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------------------------------
|
||||
|
||||
create_patch_script() {
|
||||
cat > "$PATCH_BIN" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js"
|
||||
MIN_JS_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js"
|
||||
GZ_FILE="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js.gz"
|
||||
MOBILE_TPL="/usr/share/pve-yew-mobile-gui/index.html.tpl"
|
||||
MARK_JS="PROXMENUX_NAG_REMOVED_v2"
|
||||
MARK_MOBILE="<!-- PROXMENUX: MOBILE NAG PATCH v2 -->"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
|
||||
verify_js_integrity() {
|
||||
local file="$1"
|
||||
[ -f "$file" ] && [ -s "$file" ] && grep -Eq 'Ext|function' "$file" && ! LC_ALL=C grep -qP '\x00' "$file" 2>/dev/null
|
||||
}
|
||||
|
||||
patch_web() {
|
||||
[ -f "$JS_FILE" ] || return 0
|
||||
grep -q "$MARK_JS" "$JS_FILE" && return 0
|
||||
|
||||
local backup_dir="$BASE_DIR/backups"
|
||||
mkdir -p "$backup_dir"
|
||||
local backup="$backup_dir/$(basename "$JS_FILE").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
cp -a "$JS_FILE" "$backup"
|
||||
trap "cp -a '$backup' '$JS_FILE' 2>/dev/null || true" ERR
|
||||
|
||||
sed -i '1s|^|/* '"$MARK_JS"' */\n|' "$JS_FILE"
|
||||
|
||||
local patterns_found=0
|
||||
|
||||
if grep -q "res\.data\.status\.toLowerCase() !== 'active'" "$JS_FILE"; then
|
||||
sed -i "s/res\.data\.status\.toLowerCase() !== 'active'/false/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
if grep -q "subscriptionActive: ''" "$JS_FILE"; then
|
||||
sed -i "s/subscriptionActive: ''/subscriptionActive: true/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
if grep -q "title: gettext('No valid subscription')" "$JS_FILE"; then
|
||||
sed -i "s/title: gettext('No valid subscription')/title: gettext('Community Edition')/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
if grep -q "icon: Ext\.Msg\.WARNING" "$JS_FILE"; then
|
||||
sed -i "s/icon: Ext\.Msg\.WARNING/icon: Ext.Msg.INFO/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
if grep -q "subscription = !(" "$JS_FILE"; then
|
||||
sed -i "s/subscription = !(/subscription = false \&\& (/g" "$JS_FILE"
|
||||
patterns_found=$((patterns_found + 1))
|
||||
fi
|
||||
|
||||
# Si nada coincidió (cambio upstream), restaura y sal limpio
|
||||
if [ "${patterns_found:-0}" -eq 0 ]; then
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Verificación final
|
||||
if ! verify_js_integrity "$JS_FILE"; then
|
||||
cp -a "$backup" "$JS_FILE"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Limpiar artefactos/cachés
|
||||
rm -f "$MIN_JS_FILE" "$GZ_FILE" 2>/dev/null || true
|
||||
find /var/cache/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -name "*.js*" -delete 2>/dev/null || true
|
||||
find /var/cache/nginx/ -type f -delete 2>/dev/null || true
|
||||
|
||||
trap - ERR
|
||||
}
|
||||
|
||||
patch_mobile() {
|
||||
[ -f "$MOBILE_TPL" ] || return 0
|
||||
grep -q "$MARK_MOBILE" "$MOBILE_TPL" && return 0
|
||||
|
||||
local backup_dir="$BASE_DIR/backups"
|
||||
mkdir -p "$backup_dir"
|
||||
cp -a "$MOBILE_TPL" "$backup_dir/$(basename "$MOBILE_TPL").backup.$(date +%Y%m%d_%H%M%S)"
|
||||
|
||||
cat >> "$MOBILE_TPL" <<EOM
|
||||
$MARK_MOBILE
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
function removeSubscriptionElements() {
|
||||
try {
|
||||
const dialogs = document.querySelectorAll('dialog.pwt-outer-dialog');
|
||||
dialogs.forEach(d => {
|
||||
const text = (d.textContent || '').toLowerCase();
|
||||
if (text.includes('subscription') || text.includes('no valid')) { d.remove(); }
|
||||
});
|
||||
const cards = document.querySelectorAll('.pwt-card.pwt-p-2.pwt-d-flex.pwt-interactive.pwt-justify-content-center');
|
||||
cards.forEach(c => {
|
||||
const text = (c.textContent || '').toLowerCase();
|
||||
const hasButton = c.querySelector('button');
|
||||
if (!hasButton && (text.includes('subscription') || text.includes('no valid'))) { c.remove(); }
|
||||
});
|
||||
const alerts = document.querySelectorAll('[class*="alert"], [class*="warning"], [class*="notice"]');
|
||||
alerts.forEach(a => {
|
||||
const text = (a.textContent || '').toLowerCase();
|
||||
if (text.includes('subscription') || text.includes('no valid')) { a.remove(); }
|
||||
});
|
||||
} catch (e) { console.warn('Error removing subscription elements:', e); }
|
||||
}
|
||||
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', removeSubscriptionElements); }
|
||||
else { removeSubscriptionElements(); }
|
||||
const observer = new MutationObserver(removeSubscriptionElements);
|
||||
if (document.body) {
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
const interval = setInterval(removeSubscriptionElements, 500);
|
||||
setTimeout(() => { try { observer.disconnect(); clearInterval(interval); } catch(e){} }, 30000);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
EOM
|
||||
}
|
||||
|
||||
reload_services() {
|
||||
systemctl is-active --quiet pveproxy 2>/dev/null && {
|
||||
systemctl reload pveproxy 2>/dev/null || systemctl restart pveproxy 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet nginx 2>/dev/null && {
|
||||
systemctl reload nginx 2>/dev/null || true
|
||||
}
|
||||
systemctl is-active --quiet pvedaemon 2>/dev/null && {
|
||||
systemctl reload pvedaemon 2>/dev/null || true
|
||||
}
|
||||
find /var/cache/pve-manager/ -type f -delete 2>/dev/null || true
|
||||
find /var/lib/pve-manager/ -type f -delete 2>/dev/null || true
|
||||
}
|
||||
|
||||
main() {
|
||||
patch_web || return 1
|
||||
patch_mobile
|
||||
reload_services
|
||||
}
|
||||
|
||||
main
|
||||
EOF
|
||||
chmod 755 "$PATCH_BIN"
|
||||
}
|
||||
# ----------------------------------------------------
|
||||
|
||||
|
||||
create_apt_hook() {
|
||||
cat > "$APT_HOOK" <<'EOF'
|
||||
/* ProxMenux: reapply nag patch after upgrades */
|
||||
DPkg::Post-Invoke { "/usr/local/bin/pve-remove-nag.sh || true"; };
|
||||
EOF
|
||||
chmod 644 "$APT_HOOK"
|
||||
apt-config dump >/dev/null 2>&1 || { msg_warn "APT hook syntax issue"; rm -f "$APT_HOOK"; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
remove_subscription_banner_pve9() {
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || true)
|
||||
local pve_major="${pve_version%%.*}"
|
||||
|
||||
msg_info "$(translate "Detected Proxmox VE ${pve_version:-9.x} – removing subscription banner")"
|
||||
|
||||
create_patch_script
|
||||
create_apt_hook
|
||||
|
||||
if ! "$PATCH_BIN"; then
|
||||
msg_error "$(translate "Error applying patches")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
register_tool "subscription_banner" true
|
||||
msg_ok "$(translate "Subscription banner removed successfully.")"
|
||||
msg_ok "$(translate "Refresh your browser to see changes.")"
|
||||
}
|
||||
|
||||
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
remove_subscription_banner_pve9
|
||||
fi
|
||||
@@ -1,339 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Proxmox VE Update Script
|
||||
# ==========================================================
|
||||
|
||||
# Configuration
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
local tool="$1"
|
||||
local state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
download_common_functions() {
|
||||
if ! source "$LOCAL_SCRIPTS/global/common-functions.sh"; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
update_pve9() {
|
||||
local pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||
local start_time=$(date +%s)
|
||||
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
||||
local changes_made=false
|
||||
local OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
local TARGET_CODENAME="trixie"
|
||||
|
||||
if [ -z "$OS_CODENAME" ]; then
|
||||
OS_CODENAME=$(lsb_release -cs 2>/dev/null || echo "trixie")
|
||||
fi
|
||||
|
||||
download_common_functions
|
||||
|
||||
|
||||
msg_info2 "$(translate "Detected: Proxmox VE $pve_version (Current: $OS_CODENAME, Target: $TARGET_CODENAME)")"
|
||||
echo -e
|
||||
|
||||
local available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! ping -c 1 download.proxmox.com >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Cannot reach Proxmox repositories")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
disable_sources_repo() {
|
||||
local file="$1"
|
||||
if [[ -f "$file" ]]; then
|
||||
sed -i ':a;/^\n*$/{$d;N;ba}' "$file"
|
||||
|
||||
if grep -q "^Enabled:" "$file"; then
|
||||
sed -i 's/^Enabled:.*$/Enabled: false/' "$file"
|
||||
else
|
||||
echo "Enabled: false" >> "$file"
|
||||
fi
|
||||
|
||||
if ! grep -q "^Types: " "$file"; then
|
||||
msg_warn "$(translate "Malformed .sources file detected, removing: $(basename "$file")")"
|
||||
rm -f "$file"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if disable_sources_repo "/etc/apt/sources.list.d/pve-enterprise.sources"; then
|
||||
msg_ok "$(translate "Enterprise Proxmox repository disabled")"
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if disable_sources_repo "/etc/apt/sources.list.d/ceph.sources"; then
|
||||
msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")"
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
for legacy_file in /etc/apt/sources.list.d/pve-public-repo.list \
|
||||
/etc/apt/sources.list.d/pve-install-repo.list \
|
||||
/etc/apt/sources.list.d/debian.list; do
|
||||
if [[ -f "$legacy_file" ]]; then
|
||||
rm -f "$legacy_file"
|
||||
msg_ok "$(translate "Removed legacy repository: $(basename "$legacy_file")")"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then
|
||||
rm -f /etc/apt/sources.list.d/debian.sources
|
||||
msg_ok "$(translate "Old debian.sources file removed to prevent duplication")"
|
||||
fi
|
||||
|
||||
|
||||
msg_info "$(translate "Creating Proxmox VE 9.x no-subscription repository...")"
|
||||
cat > /etc/apt/sources.list.d/proxmox.sources << EOF
|
||||
Enabled: true
|
||||
Types: deb
|
||||
URIs: http://download.proxmox.com/debian/pve
|
||||
Suites: ${TARGET_CODENAME}
|
||||
Components: pve-no-subscription
|
||||
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
EOF
|
||||
msg_ok "$(translate "Proxmox VE 9.x no-subscription repository created")"
|
||||
changes_made=true
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate "Creating Debian ${TARGET_CODENAME} sources file...")"
|
||||
cat > /etc/apt/sources.list.d/debian.sources << EOF
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian/
|
||||
Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: http://security.debian.org/debian-security/
|
||||
Suites: ${TARGET_CODENAME}-security
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
|
||||
|
||||
|
||||
msg_ok "$(translate "Debian repositories configured for $TARGET_CODENAME")"
|
||||
|
||||
local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf"
|
||||
if [ ! -f "$firmware_conf" ]; then
|
||||
msg_info "$(translate "Disabling non-free firmware warnings...")"
|
||||
echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > "$firmware_conf"
|
||||
msg_ok "$(translate "Non-free firmware warnings disabled")"
|
||||
fi
|
||||
|
||||
update_output=$(apt-get update 2>&1)
|
||||
update_exit_code=$?
|
||||
|
||||
if [ $update_exit_code -eq 0 ]; then
|
||||
msg_ok "$(translate "Package lists updated successfully")"
|
||||
else
|
||||
if echo "$update_output" | grep -q "NO_PUBKEY\|GPG error"; then
|
||||
msg_info "$(translate "Fixing GPG key issues...")"
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $(echo "$update_output" | grep "NO_PUBKEY" | sed 's/.*NO_PUBKEY //' | head -1) 2>/dev/null
|
||||
if apt-get update > "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Package lists updated after GPG fix")"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
return 1
|
||||
fi
|
||||
elif echo "$update_output" | grep -q "404\|Failed to fetch"; then
|
||||
msg_warn "$(translate "Some repositories are not available, continuing with available ones...")"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
echo "Error details: $update_output"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
|
||||
if apt policy 2>/dev/null | grep -q "${TARGET_CODENAME}.*pve-no-subscription"; then
|
||||
msg_ok "$(translate "Proxmox VE 9.x repositories verified")"
|
||||
else
|
||||
msg_warn "$(translate "Proxmox VE 9.x repositories verification inconclusive, continuing...")"
|
||||
fi
|
||||
|
||||
local current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local upgradable=$(apt list --upgradable 2>/dev/null | grep -c "upgradable")
|
||||
local security_updates=$(apt list --upgradable 2>/dev/null | grep -c "security")
|
||||
|
||||
show_update_menu() {
|
||||
local current_version="$1"
|
||||
local target_version="$2"
|
||||
local upgradable_count="$3"
|
||||
local security_count="$4"
|
||||
|
||||
local menu_text="$(translate "System Update Information")\n\n"
|
||||
menu_text+="$(translate "Current PVE Version"): $current_version\n"
|
||||
if [ -n "$target_version" ] && [ "$target_version" != "$current_version" ]; then
|
||||
menu_text+="$(translate "Available PVE Version"): $target_version\n"
|
||||
fi
|
||||
menu_text+="\n$(translate "Package Updates Available"): $upgradable_count\n"
|
||||
menu_text+="$(translate "Security Updates"): $security_count\n\n"
|
||||
|
||||
if [ "$upgradable_count" -eq 0 ]; then
|
||||
menu_text+="$(translate "System is already up to date")"
|
||||
whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70
|
||||
return 2
|
||||
else
|
||||
menu_text+="$(translate "Do you want to proceed with the system update?")"
|
||||
if whiptail --title "$(translate "Proxmox Update")" --yesno "$menu_text" 18 70; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
show_update_menu "$current_pve_version" "$available_pve_version" "$upgradable" "$security_updates"
|
||||
MENU_RESULT=$?
|
||||
|
||||
if [[ $MENU_RESULT -eq 1 ]]; then
|
||||
msg_info2 "$(translate "Update cancelled by user")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
return 0
|
||||
elif [[ $MENU_RESULT -eq 2 ]]; then
|
||||
msg_ok "$(translate "System is already up to date. No update needed.")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Cleaning up unused time synchronization services...")"
|
||||
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' purge ntp openntpd systemd-timesyncd > /dev/null 2>&1; then
|
||||
msg_ok "$(translate "Old time services removed successfully")"
|
||||
else
|
||||
msg_warn "$(translate "Some old time services could not be removed (not installed)")"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate "Updating packages...")"
|
||||
apt-get install pv -y > /dev/null 2>&1
|
||||
msg_ok "$(translate "Packages updated successfully")"
|
||||
|
||||
tput sc
|
||||
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y \
|
||||
-o Dpkg::Options::='--force-confdef' \
|
||||
-o Dpkg::Options::='--force-confold' \
|
||||
dist-upgrade 2>&1 | while IFS= read -r line; do
|
||||
|
||||
echo "$line" >> "$log_file"
|
||||
|
||||
if [[ "$line" =~ \[[#=\-]+\]\ *[0-9]{1,3}% ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$line" =~ ^(Setting\ up|Unpacking|Preparing\ to\ unpack|Processing\ triggers\ for) ]]; then
|
||||
package_name=$(echo "$line" | sed -E 's/.*(Setting up|Unpacking|Preparing to unpack|Processing triggers for) ([^ :]+).*/\2/')
|
||||
[ -z "$package_name" ] && package_name="$(translate "Unknown")"
|
||||
|
||||
row=$(( $(tput lines) - 6 ))
|
||||
tput cup $row 0; printf "%s\n" "$(translate "Installing packages...")"
|
||||
tput cup $((row + 1)) 0; printf "%s\n" "──────────────────────────────────────────────"
|
||||
tput cup $((row + 2)) 0; printf "%s %s\n" "$(translate "Package:")" "$package_name"
|
||||
tput cup $((row + 3)) 0; printf "%s\n" "Progress: [ ] 0%"
|
||||
tput cup $((row + 4)) 0; printf "%s\n" "──────────────────────────────────────────────"
|
||||
|
||||
for i in $(seq 1 10); do
|
||||
sleep 0.1
|
||||
progress=$((i * 10))
|
||||
tput cup $((row + 3)) 9
|
||||
printf "[%-50s] %3d%%" "$(printf "#%.0s" $(seq 1 $((progress/2))))" "$progress"
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
tput rc
|
||||
tput ed
|
||||
|
||||
upgrade_exit_code=${PIPESTATUS[0]}
|
||||
|
||||
|
||||
|
||||
|
||||
if [ $upgrade_exit_code -eq 0 ]; then
|
||||
msg_ok "$(translate "System upgrade completed successfully")"
|
||||
else
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
msg_info "$(translate "Installing essential Proxmox packages...")"
|
||||
local additional_packages="zfsutils-linux proxmox-backup-restore-image chrony"
|
||||
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install $additional_packages >> "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Essential Proxmox packages installed")"
|
||||
else
|
||||
msg_warn "$(translate "Some essential Proxmox packages may not have been installed")"
|
||||
fi
|
||||
|
||||
lvm_repair_check
|
||||
cleanup_duplicate_repos
|
||||
|
||||
#msg_info "$(translate "Performing system cleanup...")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
local minutes=$((duration / 60))
|
||||
local seconds=$((duration % 60))
|
||||
|
||||
echo -e "${TAB}${BGN}$(translate "====== PVE UPDATE COMPLETED ======")${CL}"
|
||||
echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}"
|
||||
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}"
|
||||
echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}"
|
||||
echo -e "${TAB}${GN}🖥️ $(translate "Proxmox VE")${CL}: ${BL}$target_version (Debian $OS_CODENAME)${CL}"
|
||||
|
||||
msg_ok "$(translate "Proxmox VE 9.x configuration completed.")"
|
||||
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
update_pve9
|
||||
fi
|
||||
@@ -1,304 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Proxmox VE Update Script - Improved Version
|
||||
# ==========================================================
|
||||
|
||||
# Configuration
|
||||
REPO_URL="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
ensure_tools_json() {
|
||||
[ -f "$TOOLS_JSON" ] || echo "{}" > "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
register_tool() {
|
||||
local tool="$1"
|
||||
local state="$2"
|
||||
ensure_tools_json
|
||||
jq --arg t "$tool" --argjson v "$state" '.[$t]=$v' "$TOOLS_JSON" > "$TOOLS_JSON.tmp" && mv "$TOOLS_JSON.tmp" "$TOOLS_JSON"
|
||||
}
|
||||
|
||||
download_common_functions() {
|
||||
if ! source <(curl -s "$REPO_URL/scripts/global/common-functions.sh"); then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
update_pve9() {
|
||||
local pve_version=$(pveversion | awk -F'/' '{print $2}' | cut -d'-' -f1)
|
||||
local start_time=$(date +%s)
|
||||
local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log"
|
||||
local changes_made=false
|
||||
local OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
|
||||
local TARGET_CODENAME="trixie"
|
||||
|
||||
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
|
||||
|
||||
if [ -z "$OS_CODENAME" ]; then
|
||||
OS_CODENAME=$(lsb_release -cs 2>/dev/null || echo "trixie")
|
||||
fi
|
||||
|
||||
download_common_functions
|
||||
|
||||
{
|
||||
msg_info2 "$(translate "Detected: Proxmox VE $pve_version (Current: $OS_CODENAME, Target: $TARGET_CODENAME)")"
|
||||
} | tee -a "$screen_capture"
|
||||
|
||||
|
||||
local available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! ping -c 1 download.proxmox.com >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Cannot reach Proxmox repositories")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
return 1
|
||||
fi
|
||||
|
||||
disable_sources_repo() {
|
||||
local file="$1"
|
||||
if [[ -f "$file" ]]; then
|
||||
sed -i ':a;/^\n*$/{$d;N;ba}' "$file"
|
||||
|
||||
if grep -q "^Enabled:" "$file"; then
|
||||
sed -i 's/^Enabled:.*$/Enabled: false/' "$file"
|
||||
else
|
||||
echo "Enabled: false" >> "$file"
|
||||
fi
|
||||
|
||||
if ! grep -q "^Types: " "$file"; then
|
||||
msg_warn "$(translate "Malformed .sources file detected, removing: $(basename "$file")")"
|
||||
rm -f "$file"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if disable_sources_repo "/etc/apt/sources.list.d/pve-enterprise.sources"; then
|
||||
msg_ok "$(translate "Enterprise Proxmox repository disabled")" | tee -a "$screen_capture"
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
if disable_sources_repo "/etc/apt/sources.list.d/ceph.sources"; then
|
||||
msg_ok "$(translate "Enterprise Proxmox Ceph repository disabled")" | tee -a "$screen_capture"
|
||||
changes_made=true
|
||||
fi
|
||||
|
||||
for legacy_file in /etc/apt/sources.list.d/pve-public-repo.list \
|
||||
/etc/apt/sources.list.d/pve-install-repo.list \
|
||||
/etc/apt/sources.list.d/debian.list; do
|
||||
if [[ -f "$legacy_file" ]]; then
|
||||
rm -f "$legacy_file"
|
||||
msg_ok "$(translate "Removed legacy repository: $(basename "$legacy_file")")" | tee -a "$screen_capture"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -f /etc/apt/sources.list.d/debian.sources ]]; then
|
||||
rm -f /etc/apt/sources.list.d/debian.sources
|
||||
msg_ok "$(translate "Old debian.sources file removed to prevent duplication")" | tee -a "$screen_capture"
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Creating Proxmox VE 9.x no-subscription repository...")"
|
||||
cat > /etc/apt/sources.list.d/proxmox.sources << EOF
|
||||
Enabled: true
|
||||
Types: deb
|
||||
URIs: http://download.proxmox.com/debian/pve
|
||||
Suites: ${TARGET_CODENAME}
|
||||
Components: pve-no-subscription
|
||||
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
EOF
|
||||
msg_ok "$(translate "Proxmox VE 9.x no-subscription repository created")" | tee -a "$screen_capture"
|
||||
changes_made=true
|
||||
|
||||
msg_info "$(translate "Creating Debian ${TARGET_CODENAME} sources file...")"
|
||||
cat > /etc/apt/sources.list.d/debian.sources << EOF
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian/
|
||||
Suites: ${TARGET_CODENAME} ${TARGET_CODENAME}-updates
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: http://security.debian.org/debian-security/
|
||||
Suites: ${TARGET_CODENAME}-security
|
||||
Components: main contrib non-free non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
|
||||
msg_ok "$(translate "Debian repositories configured for $TARGET_CODENAME")"
|
||||
|
||||
local firmware_conf="/etc/apt/apt.conf.d/no-firmware-warnings.conf"
|
||||
if [ ! -f "$firmware_conf" ]; then
|
||||
msg_info "$(translate "Disabling non-free firmware warnings...")"
|
||||
echo 'APT::Get::Update::SourceListWarnings::NonFreeFirmware "false";' > "$firmware_conf"
|
||||
msg_ok "$(translate "Non-free firmware warnings disabled")"
|
||||
fi
|
||||
|
||||
#update_output=$(apt-get update 2>&1)
|
||||
update_output=$(apt-get -o Dpkg::Progress-Fancy=1 update 2>&1)
|
||||
update_exit_code=$?
|
||||
|
||||
if [ $update_exit_code -eq 0 ]; then
|
||||
msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture"
|
||||
else
|
||||
if echo "$update_output" | grep -q "NO_PUBKEY\|GPG error"; then
|
||||
msg_info "$(translate "Fixing GPG key issues...")"
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $(echo "$update_output" | grep "NO_PUBKEY" | sed 's/.*NO_PUBKEY //' | head -1) 2>/dev/null
|
||||
if apt-get update > "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Package lists updated after GPG fix")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
return 1
|
||||
fi
|
||||
elif echo "$update_output" | grep -q "404\|Failed to fetch"; then
|
||||
msg_warn "$(translate "Some repositories are not available, continuing with available ones...")"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
echo "Error details: $update_output"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if apt policy 2>/dev/null | grep -q "${TARGET_CODENAME}.*pve-no-subscription"; then
|
||||
msg_ok "$(translate "Proxmox VE 9.x repositories verified")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_warn "$(translate "Proxmox VE 9.x repositories verification inconclusive, continuing...")"
|
||||
fi
|
||||
|
||||
local current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
local upgradable=$(apt list --upgradable 2>/dev/null | grep -c "upgradable")
|
||||
local security_updates=$(apt list --upgradable 2>/dev/null | grep -c "security")
|
||||
|
||||
show_update_menu() {
|
||||
local current_version="$1"
|
||||
local target_version="$2"
|
||||
local upgradable_count="$3"
|
||||
local security_count="$4"
|
||||
|
||||
local menu_text="$(translate "System Update Information")\n\n"
|
||||
menu_text+="$(translate "Current PVE Version"): $current_version\n"
|
||||
if [ -n "$target_version" ] && [ "$target_version" != "$current_version" ]; then
|
||||
menu_text+="$(translate "Available PVE Version"): $target_version\n"
|
||||
fi
|
||||
menu_text+="\n$(translate "Package Updates Available"): $upgradable_count\n"
|
||||
menu_text+="$(translate "Security Updates"): $security_count\n\n"
|
||||
|
||||
if [ "$upgradable_count" -eq 0 ]; then
|
||||
menu_text+="$(translate "System is already up to date")"
|
||||
whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70
|
||||
return 2
|
||||
else
|
||||
menu_text+="$(translate "Do you want to proceed with the system update?")"
|
||||
if whiptail --title "$(translate "Proxmox Update")" --yesno "$menu_text" 18 70; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
show_update_menu "$current_pve_version" "$available_pve_version" "$upgradable" "$security_updates"
|
||||
MENU_RESULT=$?
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [[ $MENU_RESULT -eq 1 ]]; then
|
||||
msg_info2 "$(translate "Update cancelled by user")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
elif [[ $MENU_RESULT -eq 2 ]]; then
|
||||
msg_ok "$(translate "System is already up to date. No update needed.")"
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Cleaning up unused time synchronization services...")"
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' purge ntp openntpd systemd-timesyncd > /dev/null 2>&1; then
|
||||
msg_ok "$(translate "Old time services removed successfully")"
|
||||
else
|
||||
msg_warn "$(translate "Some old time services could not be removed (not installed)")"
|
||||
fi
|
||||
|
||||
echo -e
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y \
|
||||
-o Dpkg::Options::='--force-confdef' \
|
||||
-o Dpkg::Options::='--force-confold' \
|
||||
dist-upgrade 2>&1 | tee -a "$log_file"
|
||||
|
||||
upgrade_exit_code=${PIPESTATUS[0]}
|
||||
echo -e
|
||||
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
|
||||
if [ $upgrade_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
rm -f "$screen_capture"
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_info "$(translate "Installing essential Proxmox packages...")"
|
||||
local additional_packages="zfsutils-linux proxmox-backup-restore-image chrony"
|
||||
|
||||
if /usr/bin/env DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::='--force-confdef' install $additional_packages >> "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Essential Proxmox packages installed")"
|
||||
else
|
||||
msg_warn "$(translate "Some essential Proxmox packages may not have been installed")"
|
||||
fi
|
||||
|
||||
lvm_repair_check
|
||||
cleanup_duplicate_repos
|
||||
|
||||
apt-get -y autoremove > /dev/null 2>&1 || true
|
||||
apt-get -y autoclean > /dev/null 2>&1 || true
|
||||
msg_ok "$(translate "Cleanup finished")"
|
||||
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
local minutes=$((duration / 60))
|
||||
local seconds=$((duration % 60))
|
||||
|
||||
echo -e "${TAB}${BGN}$(translate "====== PVE UPDATE COMPLETED ======")${CL}"
|
||||
echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}"
|
||||
echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}"
|
||||
echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}"
|
||||
echo -e "${TAB}${GN}🖥️ $(translate "Proxmox VE")${CL}: ${BL}$available_pve_version (Debian $OS_CODENAME)${CL}"
|
||||
|
||||
msg_ok "$(translate "Proxmox VE 9.x configuration completed.")"
|
||||
|
||||
rm -f "$screen_capture"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
update_pve9
|
||||
fi
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenux - Shared utility installation functions
|
||||
# ==========================================================
|
||||
# Source this file in scripts that need to install system utilities.
|
||||
# Provides: PROXMENUX_UTILS array, ensure_repositories(), install_single_package()
|
||||
#
|
||||
# Usage:
|
||||
# source "$LOCAL_SCRIPTS/global/utils-install-functions.sh"
|
||||
# ==========================================================
|
||||
|
||||
# All available utilities — format: "package:verify_command:description"
|
||||
PROXMENUX_UTILS=(
|
||||
"axel:axel:Download accelerator"
|
||||
"dos2unix:dos2unix:Convert DOS/Unix text files"
|
||||
"grc:grc:Generic log colorizer"
|
||||
"htop:htop:Interactive process viewer"
|
||||
"btop:btop:Modern resource monitor"
|
||||
"iftop:iftop:Real-time network usage"
|
||||
"iotop:iotop:Monitor disk I/O usage"
|
||||
"iperf3:iperf3:Network bandwidth testing"
|
||||
"intel-gpu-tools:intel_gpu_top:Intel GPU tools"
|
||||
"s-tui:s-tui:Stress-Terminal UI"
|
||||
"ipset:ipset:Manage IP sets"
|
||||
"iptraf-ng:iptraf-ng:Network monitoring tool"
|
||||
"plocate:locate:Locate files quickly"
|
||||
"msr-tools:rdmsr:Access CPU MSRs"
|
||||
"net-tools:netstat:Legacy networking tools"
|
||||
"sshpass:sshpass:Non-interactive SSH login"
|
||||
"tmux:tmux:Terminal multiplexer"
|
||||
"unzip:unzip:Extract ZIP files"
|
||||
"zip:zip:Create ZIP files"
|
||||
"libguestfs-tools:virt-filesystems:VM disk utilities"
|
||||
"aria2:aria2c:Multi-source downloader"
|
||||
"cabextract:cabextract:Extract CAB files"
|
||||
"wimtools:wimlib-imagex:Manage WIM images"
|
||||
"genisoimage:genisoimage:Create ISO images"
|
||||
"chntpw:chntpw:Edit Windows registry/passwords"
|
||||
)
|
||||
|
||||
|
||||
# Ensure APT repositories are configured for the current PVE version.
|
||||
# Creates missing no-subscription repo entries for PVE8 (bookworm) or PVE9 (trixie).
|
||||
ensure_repositories() {
|
||||
local pve_version need_update=false
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
|
||||
if [[ -z "$pve_version" ]]; then
|
||||
msg_error "Unable to detect Proxmox version."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if (( pve_version >= 9 )); then
|
||||
# ===== PVE 9 (Debian 13 - trixie) =====
|
||||
if [[ ! -f /etc/apt/sources.list.d/proxmox.sources ]]; then
|
||||
cat > /etc/apt/sources.list.d/proxmox.sources <<'EOF'
|
||||
Enabled: true
|
||||
Types: deb
|
||||
URIs: http://download.proxmox.com/debian/pve
|
||||
Suites: trixie
|
||||
Components: pve-no-subscription
|
||||
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
|
||||
EOF
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
if [[ ! -f /etc/apt/sources.list.d/debian.sources ]]; then
|
||||
cat > /etc/apt/sources.list.d/debian.sources <<'EOF'
|
||||
Types: deb
|
||||
URIs: http://deb.debian.org/debian/
|
||||
Suites: trixie trixie-updates
|
||||
Components: main contrib non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
|
||||
Types: deb
|
||||
URIs: http://security.debian.org/debian-security/
|
||||
Suites: trixie-security
|
||||
Components: main contrib non-free-firmware
|
||||
Signed-By: /usr/share/keyrings/debian-archive-keyring.gpg
|
||||
EOF
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
else
|
||||
# ===== PVE 8 (Debian 12 - bookworm) =====
|
||||
local sources_file="/etc/apt/sources.list"
|
||||
|
||||
if ! grep -qE 'deb .* bookworm .* main' "$sources_file" 2>/dev/null; then
|
||||
{
|
||||
echo "deb http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware"
|
||||
echo "deb http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware"
|
||||
echo "deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware"
|
||||
} >> "$sources_file"
|
||||
need_update=true
|
||||
fi
|
||||
|
||||
if [[ ! -f /etc/apt/sources.list.d/pve-no-subscription.list ]]; then
|
||||
echo "deb http://download.proxmox.com/debian/pve bookworm pve-no-subscription" \
|
||||
> /etc/apt/sources.list.d/pve-no-subscription.list
|
||||
need_update=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$need_update" == true ]] || [[ ! -d /var/lib/apt/lists || -z "$(ls -A /var/lib/apt/lists 2>/dev/null)" ]]; then
|
||||
msg_info "$(translate "Updating APT package lists...")"
|
||||
apt-get update >/dev/null 2>&1 || apt-get update
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
# Install a single package and verify the resulting command is available.
|
||||
# Args: package_name verify_command description
|
||||
# Returns: 0=ok 1=install_failed 2=installed_but_command_not_found
|
||||
install_single_package() {
|
||||
local package="$1"
|
||||
local command_name="${2:-$package}"
|
||||
local description="${3:-$package}"
|
||||
|
||||
msg_info "$(translate "Installing") $package${description:+ ($description)}..."
|
||||
local install_success=false
|
||||
|
||||
if DEBIAN_FRONTEND=noninteractive apt-get install -y "$package" >/dev/null 2>&1; then
|
||||
install_success=true
|
||||
fi
|
||||
cleanup 2>/dev/null || true
|
||||
|
||||
if [[ "$install_success" == true ]]; then
|
||||
hash -r 2>/dev/null
|
||||
sleep 1
|
||||
if command -v "$command_name" >/dev/null 2>&1; then
|
||||
msg_ok "$package $(translate "installed correctly and available")"
|
||||
return 0
|
||||
else
|
||||
msg_warn "$package $(translate "installed but command not immediately available")"
|
||||
msg_info2 "$(translate "May need to restart terminal")"
|
||||
return 2
|
||||
fi
|
||||
else
|
||||
msg_error "$(translate "Error installing") $package"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ -n "${__PROXMENUX_VM_STORAGE_HELPERS__}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
__PROXMENUX_VM_STORAGE_HELPERS__=1
|
||||
|
||||
function _array_contains() {
|
||||
local needle="$1"
|
||||
shift
|
||||
local item
|
||||
for item in "$@"; do
|
||||
[[ "$item" == "$needle" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
function _vm_boot_order_add_unique() {
|
||||
local arr_name="$1"
|
||||
shift
|
||||
local -n arr_ref="$arr_name"
|
||||
local entry
|
||||
for entry in "$@"; do
|
||||
[[ -z "$entry" ]] && continue
|
||||
_array_contains "$entry" "${arr_ref[@]}" || arr_ref+=("$entry")
|
||||
done
|
||||
}
|
||||
|
||||
function _vm_boot_order_join() {
|
||||
local -a unique_entries=()
|
||||
local entry
|
||||
for entry in "$@"; do
|
||||
[[ -z "$entry" ]] && continue
|
||||
_array_contains "$entry" "${unique_entries[@]}" || unique_entries+=("$entry")
|
||||
done
|
||||
[[ ${#unique_entries[@]} -gt 0 ]] || return 0
|
||||
local joined
|
||||
joined=$(IFS=';'; echo "${unique_entries[*]}")
|
||||
echo "$joined"
|
||||
}
|
||||
|
||||
function _vm_boot_order_hostpci_entries_for_pcis() {
|
||||
local vmid="$1"
|
||||
shift
|
||||
|
||||
local cfg
|
||||
cfg=$(qm config "$vmid" 2>/dev/null || true)
|
||||
[[ -n "$cfg" ]] || return 0
|
||||
|
||||
local -a hostpci_entries=()
|
||||
local pci bdf bdf_re slot_base slot_re line entry
|
||||
|
||||
for pci in "$@"; do
|
||||
[[ -n "$pci" ]] || continue
|
||||
bdf="${pci#0000:}"
|
||||
bdf_re="${bdf//./\\.}"
|
||||
|
||||
line=$(grep -E "^hostpci[0-9]+:.*(0000:)?${bdf_re}([,[:space:]]|$)" <<< "$cfg" | head -n1)
|
||||
if [[ -z "$line" ]]; then
|
||||
slot_base="${bdf%.*}"
|
||||
slot_re="${slot_base//./\\.}"
|
||||
line=$(grep -E "^hostpci[0-9]+:.*(0000:)?${slot_re}(\\.[0-7])?([,[:space:]]|$)" <<< "$cfg" | head -n1)
|
||||
fi
|
||||
|
||||
[[ -n "$line" ]] || continue
|
||||
entry="${line%%:*}"
|
||||
_array_contains "$entry" "${hostpci_entries[@]}" || hostpci_entries+=("$entry")
|
||||
done
|
||||
|
||||
printf '%s\n' "${hostpci_entries[@]}"
|
||||
}
|
||||
|
||||
function _vmids_scope_key() {
|
||||
[[ "$#" -eq 0 ]] && { echo ""; return 0; }
|
||||
printf '%s\n' "$@" | awk 'NF' | sort -u | paste -sd',' -
|
||||
}
|
||||
|
||||
function _refresh_host_storage_cache() {
|
||||
MOUNTED_DISKS=$(lsblk -ln -o NAME,MOUNTPOINT | awk '$2!="" {print "/dev/" $1}')
|
||||
SWAP_DISKS=$(swapon --noheadings --raw --show=NAME 2>/dev/null)
|
||||
LVM_DEVICES=$(pvs --noheadings -o pv_name 2> >(grep -v 'File descriptor .* leaked') | xargs -r -n1 readlink -f | sort -u)
|
||||
CONFIG_DATA=$(grep -vE '^\s*#' /etc/pve/qemu-server/*.conf /etc/pve/lxc/*.conf 2>/dev/null)
|
||||
|
||||
ZFS_DISKS=""
|
||||
local zfs_raw entry path base_disk
|
||||
zfs_raw=$(zpool list -v -H 2>/dev/null | awk '{print $1}' | grep -v '^NAME$' | grep -v '^-' | grep -v '^mirror' | grep -v '^raidz')
|
||||
for entry in $zfs_raw; do
|
||||
path=""
|
||||
if [[ "$entry" == /dev/* ]]; then
|
||||
path=$(readlink -f "$entry" 2>/dev/null)
|
||||
elif [[ -e "/dev/disk/by-id/$entry" ]]; then
|
||||
path=$(readlink -f "/dev/disk/by-id/$entry" 2>/dev/null)
|
||||
elif [[ -e "/dev/$entry" ]]; then
|
||||
path=$(readlink -f "/dev/$entry" 2>/dev/null)
|
||||
fi
|
||||
if [[ -n "$path" ]]; then
|
||||
base_disk=$(lsblk -no PKNAME "$path" 2>/dev/null)
|
||||
if [[ -n "$base_disk" ]]; then
|
||||
ZFS_DISKS+="/dev/$base_disk"$'\n'
|
||||
else
|
||||
# Whole-disk vdev — path is already the resolved disk itself
|
||||
ZFS_DISKS+="$path"$'\n'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
ZFS_DISKS=$(echo "$ZFS_DISKS" | sort -u)
|
||||
}
|
||||
|
||||
function _disk_is_host_system_used() {
|
||||
local disk="$1"
|
||||
local disk_real part fstype part_path
|
||||
DISK_USAGE_REASON=""
|
||||
|
||||
while read -r part fstype; do
|
||||
[[ -z "$part" ]] && continue
|
||||
part_path="/dev/$part"
|
||||
|
||||
if grep -qFx "$part_path" <<< "$MOUNTED_DISKS"; then
|
||||
DISK_USAGE_REASON="$(translate "Mounted filesystem detected") ($part_path)"
|
||||
return 0
|
||||
fi
|
||||
if grep -qFx "$part_path" <<< "$SWAP_DISKS"; then
|
||||
DISK_USAGE_REASON="$(translate "Swap partition detected") ($part_path)"
|
||||
return 0
|
||||
fi
|
||||
case "$fstype" in
|
||||
zfs_member)
|
||||
DISK_USAGE_REASON="$(translate "ZFS member detected") ($part_path)"
|
||||
return 0
|
||||
;;
|
||||
linux_raid_member)
|
||||
DISK_USAGE_REASON="$(translate "RAID member detected") ($part_path)"
|
||||
return 0
|
||||
;;
|
||||
LVM2_member)
|
||||
DISK_USAGE_REASON="$(translate "LVM physical volume detected") ($part_path)"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done < <(lsblk -ln -o NAME,FSTYPE "$disk" 2>/dev/null)
|
||||
|
||||
disk_real=$(readlink -f "$disk" 2>/dev/null)
|
||||
if [[ -n "$disk_real" && -n "$LVM_DEVICES" ]] && grep -qFx "$disk_real" <<< "$LVM_DEVICES"; then
|
||||
DISK_USAGE_REASON="$(translate "Disk is part of host LVM")"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$ZFS_DISKS" ]] && grep -qFx "$disk" <<< "$ZFS_DISKS"; then
|
||||
DISK_USAGE_REASON="$(translate "Disk is part of a host ZFS pool")"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
function _disk_used_in_guest_configs() {
|
||||
local disk="$1"
|
||||
local real_path escaped
|
||||
real_path=$(readlink -f "$disk" 2>/dev/null)
|
||||
|
||||
# Use boundary matching: path must be followed by comma, whitespace, or EOL
|
||||
# This prevents /dev/sdb from falsely matching /dev/sdb1 or /dev/sdb2
|
||||
if [[ -n "$real_path" ]]; then
|
||||
escaped="${real_path//./\\.}"
|
||||
if grep -qE "${escaped}(,|[[:space:]]|$)" <<< "$CONFIG_DATA"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local symlink symlink_escaped
|
||||
for symlink in /dev/disk/by-id/*; do
|
||||
[[ -e "$symlink" ]] || continue
|
||||
[[ "$(readlink -f "$symlink")" == "$real_path" ]] || continue
|
||||
symlink_escaped="${symlink//./\\.}"
|
||||
if grep -qE "${symlink_escaped}(,|[[:space:]]|$)" <<< "$CONFIG_DATA"; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Returns 0 if the disk is referenced in a RUNNING VM or CT config.
|
||||
# Mirrors _disk_used_in_guest_configs but checks guest status per-file.
|
||||
function _disk_used_in_running_guest() {
|
||||
local disk="$1"
|
||||
local real_path
|
||||
real_path=$(readlink -f "$disk" 2>/dev/null)
|
||||
|
||||
local -a aliases=()
|
||||
[[ -n "$disk" ]] && aliases+=("$disk")
|
||||
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
|
||||
local symlink
|
||||
for symlink in /dev/disk/by-id/*; do
|
||||
[[ -e "$symlink" ]] || continue
|
||||
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
|
||||
done
|
||||
|
||||
local conf vmid alias escaped
|
||||
for conf in /etc/pve/qemu-server/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
vmid=$(basename "$conf" .conf)
|
||||
for alias in "${aliases[@]}"; do
|
||||
escaped="${alias//./\\.}"
|
||||
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
|
||||
if qm status "$vmid" 2>/dev/null | grep -q "status: running"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
local ctid
|
||||
for conf in /etc/pve/lxc/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
ctid=$(basename "$conf" .conf)
|
||||
for alias in "${aliases[@]}"; do
|
||||
escaped="${alias//./\\.}"
|
||||
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
|
||||
if pct status "$ctid" 2>/dev/null | grep -q "status: running"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Prints "VM:VMID" or "CT:CTID" for each stopped guest that references the disk.
|
||||
function _disk_guest_ids() {
|
||||
local disk="$1"
|
||||
local real_path
|
||||
real_path=$(readlink -f "$disk" 2>/dev/null)
|
||||
|
||||
local -a aliases=()
|
||||
[[ -n "$disk" ]] && aliases+=("$disk")
|
||||
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
|
||||
local symlink
|
||||
for symlink in /dev/disk/by-id/*; do
|
||||
[[ -e "$symlink" ]] || continue
|
||||
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
|
||||
done
|
||||
|
||||
local conf vmid alias escaped
|
||||
for conf in /etc/pve/qemu-server/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
vmid=$(basename "$conf" .conf)
|
||||
for alias in "${aliases[@]}"; do
|
||||
escaped="${alias//./\\.}"
|
||||
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
|
||||
echo "VM:$vmid"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
local ctid
|
||||
for conf in /etc/pve/lxc/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
ctid=$(basename "$conf" .conf)
|
||||
for alias in "${aliases[@]}"; do
|
||||
escaped="${alias//./\\.}"
|
||||
if grep -qE "${escaped}(,|[[:space:]]|$)" "$conf" 2>/dev/null; then
|
||||
echo "CT:$ctid"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# Print the slot names (e.g. sata0, scsi1) in a VM config that reference the disk.
|
||||
function _find_disk_slots_in_vm() {
|
||||
local vmid="$1"
|
||||
local disk="$2"
|
||||
local real_path conf
|
||||
real_path=$(readlink -f "$disk" 2>/dev/null)
|
||||
conf="/etc/pve/qemu-server/${vmid}.conf"
|
||||
[[ -f "$conf" ]] || return
|
||||
|
||||
local -a aliases=("$disk")
|
||||
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
|
||||
local symlink
|
||||
for symlink in /dev/disk/by-id/*; do
|
||||
[[ -e "$symlink" ]] || continue
|
||||
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
|
||||
done
|
||||
|
||||
local key rest alias escaped
|
||||
while IFS=: read -r key rest; do
|
||||
key=$(echo "$key" | xargs)
|
||||
[[ "$key" =~ ^(scsi|sata|ide|virtio)[0-9]+$ ]] || continue
|
||||
for alias in "${aliases[@]}"; do
|
||||
escaped="${alias//./\\.}"
|
||||
if echo "$rest" | grep -qE "${escaped}(,|[[:space:]]|$)"; then
|
||||
echo "$key"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done < "$conf"
|
||||
}
|
||||
|
||||
# Print the mp names (e.g. mp0, mp1) in a CT config that reference the disk.
|
||||
function _find_disk_slots_in_ct() {
|
||||
local ctid="$1"
|
||||
local disk="$2"
|
||||
local real_path conf
|
||||
real_path=$(readlink -f "$disk" 2>/dev/null)
|
||||
conf="/etc/pve/lxc/${ctid}.conf"
|
||||
[[ -f "$conf" ]] || return
|
||||
|
||||
local -a aliases=("$disk")
|
||||
[[ -n "$real_path" && "$real_path" != "$disk" ]] && aliases+=("$real_path")
|
||||
local symlink
|
||||
for symlink in /dev/disk/by-id/*; do
|
||||
[[ -e "$symlink" ]] || continue
|
||||
[[ "$(readlink -f "$symlink" 2>/dev/null)" == "$real_path" ]] && aliases+=("$symlink")
|
||||
done
|
||||
|
||||
local key rest alias escaped
|
||||
while IFS=: read -r key rest; do
|
||||
key=$(echo "$key" | xargs)
|
||||
[[ "$key" =~ ^mp[0-9]+$ ]] || continue
|
||||
for alias in "${aliases[@]}"; do
|
||||
escaped="${alias//./\\.}"
|
||||
if echo "$rest" | grep -qE "${escaped}(,|[[:space:]]|$)"; then
|
||||
echo "$key"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done < "$conf"
|
||||
}
|
||||
|
||||
function _controller_block_devices() {
|
||||
local pci_full="$1"
|
||||
local pci_root="/sys/bus/pci/devices/$pci_full"
|
||||
[[ -d "$pci_root" ]] || return 0
|
||||
|
||||
local sys_block dev_name cur base
|
||||
# Walk /sys/block and resolve each block device back to its ancestor PCI device.
|
||||
# This avoids unbounded recursive scans while still handling NVMe/SATA paths.
|
||||
for sys_block in /sys/block/*; do
|
||||
[[ -e "$sys_block/device" ]] || continue
|
||||
dev_name=$(basename "$sys_block")
|
||||
[[ -b "/dev/$dev_name" ]] || continue
|
||||
|
||||
cur=$(readlink -f "$sys_block/device" 2>/dev/null)
|
||||
[[ -n "$cur" ]] || continue
|
||||
|
||||
while [[ "$cur" != "/" ]]; do
|
||||
base=$(basename "$cur")
|
||||
if [[ "$base" == "$pci_full" ]]; then
|
||||
echo "/dev/$dev_name"
|
||||
break
|
||||
fi
|
||||
cur=$(dirname "$cur")
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
function _vm_is_q35() {
|
||||
local vmid="$1"
|
||||
local machine_line
|
||||
machine_line=$(qm config "$vmid" 2>/dev/null | awk -F': ' '/^machine:/ {print $2}')
|
||||
[[ "$machine_line" == *q35* ]]
|
||||
}
|
||||
|
||||
function _vm_storage_register_vfio_iommu_tool() {
|
||||
local tools_json="${BASE_DIR:-/usr/local/share/proxmenux}/installed_tools.json"
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
[[ -f "$tools_json" ]] || echo "{}" > "$tools_json"
|
||||
jq '.vfio_iommu=true' "$tools_json" > "$tools_json.tmp" \
|
||||
&& mv "$tools_json.tmp" "$tools_json" || true
|
||||
}
|
||||
|
||||
function _vm_storage_enable_iommu_cmdline() {
|
||||
local cpu_vendor iommu_param
|
||||
cpu_vendor=$(grep -m1 "vendor_id" /proc/cpuinfo 2>/dev/null | awk '{print $3}')
|
||||
|
||||
if [[ "$cpu_vendor" == "GenuineIntel" ]]; then
|
||||
iommu_param="intel_iommu=on"
|
||||
elif [[ "$cpu_vendor" == "AuthenticAMD" ]]; then
|
||||
iommu_param="amd_iommu=on"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
|
||||
local cmdline_file="/etc/kernel/cmdline"
|
||||
local grub_file="/etc/default/grub"
|
||||
|
||||
if [[ -f "$cmdline_file" ]] && grep -qE 'root=ZFS=|root=ZFS/' "$cmdline_file" 2>/dev/null; then
|
||||
if ! grep -q "$iommu_param" "$cmdline_file"; then
|
||||
cp "$cmdline_file" "${cmdline_file}.bak.$(date +%Y%m%d_%H%M%S)"
|
||||
sed -i "s|\\s*$| ${iommu_param} iommu=pt|" "$cmdline_file"
|
||||
proxmox-boot-tool refresh >/dev/null 2>&1 || true
|
||||
fi
|
||||
elif [[ -f "$grub_file" ]]; then
|
||||
if ! grep -q "$iommu_param" "$grub_file"; then
|
||||
cp "$grub_file" "${grub_file}.bak.$(date +%Y%m%d_%H%M%S)"
|
||||
sed -i "/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param} iommu=pt\"|" "$grub_file"
|
||||
update-grub >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function _vm_storage_ensure_iommu_or_offer() {
|
||||
local reboot_policy="${VM_STORAGE_IOMMU_REBOOT_POLICY:-ask_now}"
|
||||
|
||||
if declare -F _pci_is_iommu_active >/dev/null 2>&1 && _pci_is_iommu_active; then
|
||||
_vm_storage_register_vfio_iommu_tool
|
||||
return 0
|
||||
fi
|
||||
|
||||
if grep -qE 'intel_iommu=on|amd_iommu=on' /proc/cmdline 2>/dev/null && \
|
||||
[[ -d /sys/kernel/iommu_groups ]] && \
|
||||
[[ -n "$(ls /sys/kernel/iommu_groups/ 2>/dev/null)" ]]; then
|
||||
_vm_storage_register_vfio_iommu_tool
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Dedup: if IOMMU was already configured/announced in this wizard run, skip prompt
|
||||
if [[ "${VM_STORAGE_IOMMU_PENDING_REBOOT:-0}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Detect if another script already wrote IOMMU params (e.g. GPU script ran first)
|
||||
if grep -qE 'intel_iommu=on|amd_iommu=on' /etc/kernel/cmdline 2>/dev/null || \
|
||||
grep -qE 'intel_iommu=on|amd_iommu=on' /etc/default/grub 2>/dev/null; then
|
||||
_vm_storage_register_vfio_iommu_tool
|
||||
VM_STORAGE_IOMMU_PENDING_REBOOT=1
|
||||
export VM_STORAGE_IOMMU_PENDING_REBOOT
|
||||
return 0
|
||||
fi
|
||||
|
||||
local prompt
|
||||
prompt="$(translate "IOMMU is not active on this system.")\n\n"
|
||||
prompt+="$(translate "Controller/NVMe passthrough to VMs requires IOMMU enabled in BIOS/UEFI and kernel.")\n\n"
|
||||
prompt+="$(translate "Do you want to enable IOMMU now?")\n\n"
|
||||
prompt+="$(translate "A host reboot is required after this change.")"
|
||||
|
||||
whiptail --title "IOMMU Required" --yesno "$prompt" 14 78
|
||||
[[ $? -ne 0 ]] && return 1
|
||||
|
||||
if ! _vm_storage_enable_iommu_cmdline; then
|
||||
whiptail --title "IOMMU" --msgbox \
|
||||
"$(translate "Failed to configure IOMMU automatically.")\n\n$(translate "Please configure it manually and reboot.")" \
|
||||
10 72
|
||||
return 1
|
||||
fi
|
||||
|
||||
_vm_storage_register_vfio_iommu_tool
|
||||
|
||||
if [[ "$reboot_policy" == "defer" ]]; then
|
||||
VM_STORAGE_IOMMU_PENDING_REBOOT=1
|
||||
export VM_STORAGE_IOMMU_PENDING_REBOOT
|
||||
whiptail --title "Reboot Required" --msgbox \
|
||||
"$(translate "IOMMU configured successfully.")\n\n$(translate "Continue the VM wizard and reboot the host at the end.")\n\n$(translate "You can now select Controller/NVMe devices in Storage Plan.")\n$(translate "Device assignments will be written now and become active after reboot.")" \
|
||||
12 78
|
||||
return 0
|
||||
fi
|
||||
|
||||
if whiptail --title "Reboot Required" --yesno \
|
||||
"$(translate "IOMMU configured successfully.")\n\n$(translate "Do you want to reboot now?")" 10 68; then
|
||||
reboot
|
||||
else
|
||||
whiptail --title "Reboot Required" --msgbox \
|
||||
"$(translate "Please reboot manually and run the passthrough step again.")" 9 68
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
function _vm_storage_confirm_controller_passthrough_risk() {
|
||||
local vmid="${1:-}"
|
||||
local vm_name="${2:-}"
|
||||
local title="${3:-Controller + NVMe}"
|
||||
local ui_mode="${4:-auto}" # wizard | standalone | auto
|
||||
local vm_label=""
|
||||
if [[ -n "$vmid" ]]; then
|
||||
vm_label="$vmid"
|
||||
[[ -n "$vm_name" ]] && vm_label="${vm_label} (${vm_name})"
|
||||
fi
|
||||
|
||||
local reinforce_limited_firmware="no"
|
||||
local bios_date bios_year current_year bios_age cpu_model risk_detail=""
|
||||
bios_date=$(cat /sys/class/dmi/id/bios_date 2>/dev/null)
|
||||
bios_year=$(echo "$bios_date" | grep -oE '[0-9]{4}' | tail -n1)
|
||||
current_year=$(date +%Y 2>/dev/null)
|
||||
if [[ -n "$bios_year" && -n "$current_year" ]]; then
|
||||
bios_age=$(( current_year - bios_year ))
|
||||
if (( bios_age >= 7 )); then
|
||||
reinforce_limited_firmware="yes"
|
||||
risk_detail="$(translate "BIOS from") ${bios_year} (${bios_age} $(translate "years old")) — $(translate "older firmware may increase passthrough instability")"
|
||||
fi
|
||||
fi
|
||||
cpu_model=$(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | xargs)
|
||||
if echo "$cpu_model" | grep -qiE 'J4[0-9]{3}|J3[0-9]{3}|N4[0-9]{3}|N3[0-9]{3}|Apollo Lake'; then
|
||||
reinforce_limited_firmware="yes"
|
||||
[[ -z "$risk_detail" ]] && risk_detail="$(translate "Low-power CPU platform"): ${cpu_model}"
|
||||
fi
|
||||
|
||||
if [[ "$ui_mode" == "auto" ]]; then
|
||||
if [[ "${PROXMENUX_UI_MODE:-}" == "wizard" || "${WIZARD_CALL:-false}" == "true" ]]; then
|
||||
ui_mode="wizard"
|
||||
else
|
||||
ui_mode="standalone"
|
||||
fi
|
||||
fi
|
||||
|
||||
local height=20
|
||||
[[ "$reinforce_limited_firmware" == "yes" ]] && height=23
|
||||
|
||||
if [[ "$ui_mode" == "wizard" ]]; then
|
||||
# whiptail: plain text (no color codes)
|
||||
local msg
|
||||
[[ -n "$vm_label" ]] && msg+="$(translate "Target VM"): ${vm_label}\n\n"
|
||||
msg+="⚠ $(translate "Controller/NVMe passthrough — compatibility notice")\n\n"
|
||||
msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n"
|
||||
msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n"
|
||||
if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then
|
||||
msg+="\n$(translate "Detected risk factor"): ${risk_detail}\n"
|
||||
fi
|
||||
msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${vmid:-<VMID>}.conf\n"
|
||||
msg+="\n$(translate "Do you want to continue?")"
|
||||
whiptail --title "$title" --yesno "$msg" $height 96
|
||||
else
|
||||
# dialog: colored format matching add_controller_nvme_vm.sh
|
||||
local msg
|
||||
[[ -n "$vm_label" ]] && msg+="\n\Zb$(translate "Target VM"): ${vm_label}\Zn\n"
|
||||
msg+="\n\Zb\Z4⚠ $(translate "Controller/NVMe passthrough — compatibility notice")\Zn\n\n"
|
||||
msg+="$(translate "Not all platforms support Controller/NVMe passthrough reliably.")\n"
|
||||
msg+="$(translate "On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.")\n"
|
||||
if [[ "$reinforce_limited_firmware" == "yes" && -n "$risk_detail" ]]; then
|
||||
msg+="\n\Z1$(translate "Detected risk factor"): ${risk_detail}\Zn\n"
|
||||
fi
|
||||
msg+="\n$(translate "If the host freezes, remove hostpci entries from") /etc/pve/qemu-server/${vmid:-<VMID>}.conf\n"
|
||||
msg+="\n\Zb$(translate "Do you want to continue?")\Zn"
|
||||
dialog --backtitle "ProxMenux" --colors \
|
||||
--title "$title" \
|
||||
--yesno "$msg" $height 96
|
||||
fi
|
||||
}
|
||||
|
||||
function _shorten_text() {
|
||||
local text="$1"
|
||||
local max_len="${2:-42}"
|
||||
[[ -z "$text" ]] && { echo ""; return; }
|
||||
if (( ${#text} > max_len )); then
|
||||
echo "${text:0:$((max_len-3))}..."
|
||||
else
|
||||
echo "$text"
|
||||
fi
|
||||
}
|
||||
|
||||
function _pci_storage_display_name() {
|
||||
local pci_full="$1"
|
||||
local raw_line name_part
|
||||
|
||||
raw_line=$(lspci -nn -s "${pci_full#0000:}" 2>/dev/null | sed 's/^[^ ]* //')
|
||||
if [[ -z "$raw_line" ]]; then
|
||||
translate "Unknown storage controller"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Prefer the right side after class prefix (e.g. "...: Vendor Model ...").
|
||||
name_part="${raw_line#*: }"
|
||||
[[ "$name_part" == "$raw_line" ]] && name_part="$raw_line"
|
||||
|
||||
# Remove noisy suffixes while keeping the meaningful model name.
|
||||
name_part="${name_part%% (rev *}"
|
||||
name_part=$(echo "$name_part" | sed -E 's/\[[0-9a-fA-F]{4}:[0-9a-fA-F]{4}\]//g')
|
||||
name_part=$(echo "$name_part" | sed -E 's/ Technology Inc\.?//g; s/ Corporation//g; s/ Co\., Ltd\.?//g')
|
||||
name_part=$(echo "$name_part" | sed -E 's/[[:space:]]+/ /g; s/^ +| +$//g')
|
||||
|
||||
[[ -z "$name_part" ]] && name_part="$raw_line"
|
||||
echo "$name_part"
|
||||
}
|
||||
|
||||
function _pci_slot_base() {
|
||||
local pci_full="$1"
|
||||
local slot
|
||||
slot="${pci_full#0000:}"
|
||||
slot="${slot%.*}"
|
||||
echo "$slot"
|
||||
}
|
||||
|
||||
function _vm_status_is_running() {
|
||||
local vmid="$1"
|
||||
qm status "$vmid" 2>/dev/null | grep -q "status: running"
|
||||
}
|
||||
|
||||
function _vm_onboot_is_enabled() {
|
||||
local vmid="$1"
|
||||
qm config "$vmid" 2>/dev/null | grep -qE '^onboot:\s*1'
|
||||
}
|
||||
|
||||
function _vm_name_by_id() {
|
||||
local vmid="$1"
|
||||
local conf="/etc/pve/qemu-server/${vmid}.conf"
|
||||
local vm_name
|
||||
vm_name=$(awk '/^name:/ {print $2}' "$conf" 2>/dev/null)
|
||||
[[ -z "$vm_name" ]] && vm_name="VM-${vmid}"
|
||||
echo "$vm_name"
|
||||
}
|
||||
|
||||
function _vm_has_pci_slot() {
|
||||
local vmid="$1"
|
||||
local slot_base="$2"
|
||||
local conf="/etc/pve/qemu-server/${vmid}.conf"
|
||||
[[ -f "$conf" ]] || return 1
|
||||
grep -qE "^hostpci[0-9]+:.*(0000:)?${slot_base}(\\.[0-7])?([,[:space:]]|$)" "$conf"
|
||||
}
|
||||
|
||||
function _pci_assigned_vm_ids() {
|
||||
local pci_full="$1"
|
||||
local exclude_vmid="${2:-}"
|
||||
local slot_base conf vmid
|
||||
slot_base=$(_pci_slot_base "$pci_full")
|
||||
|
||||
for conf in /etc/pve/qemu-server/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
vmid=$(basename "$conf" .conf)
|
||||
[[ -n "$exclude_vmid" && "$vmid" == "$exclude_vmid" ]] && continue
|
||||
if grep -qE "^hostpci[0-9]+:.*(0000:)?${slot_base}(\\.[0-7])?([,[:space:]]|$)" "$conf"; then
|
||||
echo "$vmid"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
function _remove_pci_slot_from_vm_config() {
|
||||
local vmid="$1"
|
||||
local slot_base="$2"
|
||||
local conf="/etc/pve/qemu-server/${vmid}.conf"
|
||||
[[ -f "$conf" ]] || return 1
|
||||
local tmpf
|
||||
tmpf=$(mktemp)
|
||||
awk -v slot="$slot_base" '
|
||||
$0 ~ "^hostpci[0-9]+:.*(0000:)?" slot "(\\.[0-7])?([,[:space:]]|$)" {next}
|
||||
{print}
|
||||
' "$conf" > "$tmpf" && cat "$tmpf" > "$conf"
|
||||
rm -f "$tmpf"
|
||||
}
|
||||
|
||||
function _pci_assigned_vm_summary() {
|
||||
local pci_full="$1"
|
||||
local slot_base conf vmid vm_name running onboot
|
||||
local -a refs=()
|
||||
local running_count=0 onboot_count=0
|
||||
|
||||
slot_base="${pci_full#0000:}"
|
||||
slot_base="${slot_base%.*}"
|
||||
|
||||
for conf in /etc/pve/qemu-server/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
|
||||
if ! grep -qE "^hostpci[0-9]+:.*(0000:)?${slot_base}(\\.[0-7])?([,[:space:]]|$)" "$conf"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
vmid=$(basename "$conf" .conf)
|
||||
vm_name=$(awk '/^name:/ {print $2}' "$conf" 2>/dev/null)
|
||||
[[ -z "$vm_name" ]] && vm_name="VM-${vmid}"
|
||||
|
||||
if qm status "$vmid" 2>/dev/null | grep -q "status: running"; then
|
||||
running="running"
|
||||
running_count=$((running_count + 1))
|
||||
else
|
||||
running="stopped"
|
||||
fi
|
||||
|
||||
if grep -qE "^onboot:\s*1" "$conf" 2>/dev/null; then
|
||||
onboot="1"
|
||||
onboot_count=$((onboot_count + 1))
|
||||
else
|
||||
onboot="0"
|
||||
fi
|
||||
|
||||
refs+=("${vmid}[${running},onboot=${onboot}]")
|
||||
done
|
||||
|
||||
[[ ${#refs[@]} -eq 0 ]] && return 1
|
||||
|
||||
local joined summary
|
||||
joined=$(IFS=', '; echo "${refs[*]}")
|
||||
summary="$(translate "Assigned to VM(s)"): ${joined}"
|
||||
if [[ "$running_count" -gt 0 ]]; then
|
||||
summary+=" ($(translate "running"): ${running_count})"
|
||||
fi
|
||||
if [[ "$onboot_count" -gt 0 ]]; then
|
||||
summary+=", onboot=1: ${onboot_count}"
|
||||
fi
|
||||
echo "$summary"
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - Universal GPU/iGPU Passthrough to LXC
|
||||
# ==================================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.0
|
||||
# Last Updated: 01/04/2026
|
||||
# ==================================================
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
LOG_FILE="/tmp/add_gpu_lxc.log"
|
||||
NVIDIA_WORKDIR="/opt/nvidia"
|
||||
INSTALL_ABORTED=false
|
||||
NVIDIA_INSTALL_SUCCESS=false
|
||||
NVIDIA_SMI_OUTPUT=""
|
||||
screen_capture="/tmp/proxmenux_add_gpu_screen_capture_$$.txt"
|
||||
LXC_SWITCH_MODE=false
|
||||
|
||||
INTEL_PCI=""
|
||||
INTEL_VID_DID=""
|
||||
AMD_PCI=""
|
||||
AMD_VID_DID=""
|
||||
NVIDIA_PCI=""
|
||||
NVIDIA_VID_DID=""
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
if [[ -f "$LOCAL_SCRIPTS/global/gpu_hook_guard_helpers.sh" ]]; then
|
||||
source "$LOCAL_SCRIPTS/global/gpu_hook_guard_helpers.sh"
|
||||
elif [[ -f "$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)/global/gpu_hook_guard_helpers.sh" ]]; then
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")"/.. && pwd)/global/gpu_hook_guard_helpers.sh"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helper: next available devN index in LXC config
|
||||
# ============================================================
|
||||
get_next_dev_index() {
|
||||
local config="$1"
|
||||
local idx=0
|
||||
while grep -q "^dev${idx}:" "$config" 2>/dev/null; do
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
echo "$idx"
|
||||
}
|
||||
|
||||
_get_lxc_run_title() {
|
||||
if [[ "$LXC_SWITCH_MODE" == "true" ]]; then
|
||||
echo "GPU Switch Mode (VM → LXC)"
|
||||
else
|
||||
echo "$(translate 'Add GPU to LXC')"
|
||||
fi
|
||||
}
|
||||
|
||||
_gpu_type_label() {
|
||||
case "$1" in
|
||||
intel) echo "${INTEL_NAME:-Intel iGPU}" ;;
|
||||
amd) echo "${AMD_NAME:-AMD GPU}" ;;
|
||||
nvidia) echo "${NVIDIA_NAME:-NVIDIA GPU}" ;;
|
||||
*) echo "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
_config_has_dev_entry() {
|
||||
local cfg="$1"
|
||||
local dev="$2"
|
||||
local dev_escaped
|
||||
dev_escaped=$(printf '%s' "$dev" | sed 's/[][(){}.^$*+?|\\]/\\&/g')
|
||||
grep -qE "^dev[0-9]+:.*${dev_escaped}([,[:space:]]|$)" "$cfg" 2>/dev/null
|
||||
}
|
||||
|
||||
_is_lxc_gpu_already_configured() {
|
||||
local cfg="$1"
|
||||
local gpu_type="$2"
|
||||
local dev
|
||||
|
||||
case "$gpu_type" in
|
||||
intel)
|
||||
local have_dri=0
|
||||
for dev in /dev/dri/card0 /dev/dri/card1 /dev/dri/renderD128 /dev/dri/renderD129; do
|
||||
[[ -c "$dev" ]] || continue
|
||||
have_dri=1
|
||||
_config_has_dev_entry "$cfg" "$dev" || return 1
|
||||
done
|
||||
[[ $have_dri -eq 1 ]] || return 1
|
||||
return 0
|
||||
;;
|
||||
amd)
|
||||
local have_dri=0
|
||||
for dev in /dev/dri/card0 /dev/dri/card1 /dev/dri/renderD128 /dev/dri/renderD129; do
|
||||
[[ -c "$dev" ]] || continue
|
||||
have_dri=1
|
||||
_config_has_dev_entry "$cfg" "$dev" || return 1
|
||||
done
|
||||
[[ $have_dri -eq 1 ]] || return 1
|
||||
if [[ -c "/dev/kfd" ]]; then
|
||||
_config_has_dev_entry "$cfg" "/dev/kfd" || return 1
|
||||
fi
|
||||
return 0
|
||||
;;
|
||||
nvidia)
|
||||
local -a nv_devs=()
|
||||
for dev in /dev/nvidia[0-9]* /dev/nvidiactl /dev/nvidia-uvm /dev/nvidia-uvm-tools /dev/nvidia-modeset; do
|
||||
[[ -c "$dev" ]] && nv_devs+=("$dev")
|
||||
done
|
||||
if [[ -d /dev/nvidia-caps ]]; then
|
||||
for dev in /dev/nvidia-caps/nvidia-cap[0-9]*; do
|
||||
[[ -c "$dev" ]] && nv_devs+=("$dev")
|
||||
done
|
||||
fi
|
||||
[[ ${#nv_devs[@]} -gt 0 ]] || return 1
|
||||
for dev in "${nv_devs[@]}"; do
|
||||
_config_has_dev_entry "$cfg" "$dev" || return 1
|
||||
done
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
precheck_existing_lxc_gpu_config() {
|
||||
local cfg="/etc/pve/lxc/${CONTAINER_ID}.conf"
|
||||
[[ -f "$cfg" ]] || return 0
|
||||
|
||||
local -a already_present=() missing=()
|
||||
local gpu_type
|
||||
for gpu_type in "${SELECTED_GPUS[@]}"; do
|
||||
if _is_lxc_gpu_already_configured "$cfg" "$gpu_type"; then
|
||||
already_present+=("$gpu_type")
|
||||
else
|
||||
missing+=("$gpu_type")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#missing[@]} -eq 0 ]]; then
|
||||
local msg labels=""
|
||||
for gpu_type in "${already_present[@]}"; do
|
||||
labels+=" • $(_gpu_type_label "$gpu_type")\n"
|
||||
done
|
||||
msg="\n$(translate 'The selected GPU configuration already exists in this container.')\n\n"
|
||||
msg+="$(translate 'No changes are required for') ${CONTAINER_ID}:\n\n${labels}"
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(_get_lxc_run_title)" \
|
||||
--msgbox "$msg" 14 74
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ${#already_present[@]} -gt 0 ]]; then
|
||||
local msg already_labels="" missing_labels=""
|
||||
for gpu_type in "${already_present[@]}"; do
|
||||
already_labels+=" • $(_gpu_type_label "$gpu_type")\n"
|
||||
done
|
||||
for gpu_type in "${missing[@]}"; do
|
||||
missing_labels+=" • $(_gpu_type_label "$gpu_type")\n"
|
||||
done
|
||||
msg="\n$(translate 'Some selected GPUs are already configured in this container.')\n\n"
|
||||
msg+="$(translate 'Already configured'):\n${already_labels}\n"
|
||||
msg+="$(translate 'Will be configured now'):\n${missing_labels}"
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(_get_lxc_run_title)" \
|
||||
--msgbox "$msg" 18 78
|
||||
fi
|
||||
|
||||
SELECTED_GPUS=("${missing[@]}")
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GPU detection on host
|
||||
# ============================================================
|
||||
detect_host_gpus() {
|
||||
HAS_INTEL=false
|
||||
HAS_AMD=false
|
||||
HAS_NVIDIA=false
|
||||
NVIDIA_READY=false
|
||||
NVIDIA_HOST_VERSION=""
|
||||
INTEL_NAME=""
|
||||
AMD_NAME=""
|
||||
NVIDIA_NAME=""
|
||||
INTEL_PCI=""
|
||||
INTEL_VID_DID=""
|
||||
AMD_PCI=""
|
||||
AMD_VID_DID=""
|
||||
NVIDIA_PCI=""
|
||||
NVIDIA_VID_DID=""
|
||||
|
||||
local intel_line amd_line nvidia_line
|
||||
intel_line=$(lspci -nn | grep -iE "VGA compatible|3D controller|Display controller" \
|
||||
| grep -i "Intel" | grep -iv "Ethernet\|Audio\|Network" | head -1)
|
||||
amd_line=$(lspci -nn | grep -iE "VGA compatible|3D controller|Display controller" \
|
||||
| grep -iE "AMD|Advanced Micro|Radeon" | head -1)
|
||||
nvidia_line=$(lspci -nn | grep -iE "VGA compatible|3D controller|Display controller" \
|
||||
| grep -i "NVIDIA" | head -1)
|
||||
|
||||
if [[ -n "$intel_line" ]]; then
|
||||
HAS_INTEL=true
|
||||
INTEL_NAME=$(echo "$intel_line" | sed 's/^[^:]*[^:]: //' | sed 's/ \[.*//' | cut -c1-58)
|
||||
INTEL_PCI="0000:$(echo "$intel_line" | awk '{print $1}')"
|
||||
INTEL_VID_DID=$(echo "$intel_line" | grep -oE '\[[0-9a-f]{4}:[0-9a-f]{4}\]' | tr -d '[]')
|
||||
fi
|
||||
if [[ -n "$amd_line" ]]; then
|
||||
HAS_AMD=true
|
||||
AMD_NAME=$(echo "$amd_line" | sed 's/^[^:]*[^:]: //' | sed 's/ \[.*//' | cut -c1-58)
|
||||
AMD_PCI="0000:$(echo "$amd_line" | awk '{print $1}')"
|
||||
AMD_VID_DID=$(echo "$amd_line" | grep -oE '\[[0-9a-f]{4}:[0-9a-f]{4}\]' | tr -d '[]')
|
||||
fi
|
||||
if [[ -n "$nvidia_line" ]]; then
|
||||
HAS_NVIDIA=true
|
||||
NVIDIA_NAME=$(echo "$nvidia_line" | sed 's/^[^:]*[^:]: //' | sed 's/ \[.*//' | cut -c1-58)
|
||||
NVIDIA_PCI="0000:$(echo "$nvidia_line" | awk '{print $1}')"
|
||||
NVIDIA_VID_DID=$(echo "$nvidia_line" | grep -oE '\[[0-9a-f]{4}:[0-9a-f]{4}\]' | tr -d '[]')
|
||||
if lsmod | grep -q "^nvidia " && command -v nvidia-smi >/dev/null 2>&1; then
|
||||
NVIDIA_HOST_VERSION=$(nvidia-smi --query-gpu=driver_version \
|
||||
--format=csv,noheader 2>/dev/null | head -n1 | tr -d '[:space:]')
|
||||
[[ -n "$NVIDIA_HOST_VERSION" ]] && NVIDIA_READY=true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Container selection
|
||||
# ============================================================
|
||||
select_container() {
|
||||
local menu_items=()
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^VMID ]] && continue
|
||||
local ctid status name
|
||||
ctid=$(echo "$line" | awk '{print $1}')
|
||||
status=$(echo "$line" | awk '{print $2}')
|
||||
name=$(echo "$line" | awk '{print $3}')
|
||||
[[ -z "$ctid" ]] && continue
|
||||
menu_items+=("$ctid" "${name:-CT-${ctid}} (${status})")
|
||||
done < <(pct list 2>/dev/null)
|
||||
|
||||
if [[ ${#menu_items[@]} -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'Add GPU to LXC')" \
|
||||
--msgbox "\n$(translate 'No LXC containers found on this system.')" 8 60
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CONTAINER_ID=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'Add GPU to LXC')" \
|
||||
--menu "\n$(translate 'Select the LXC container:')" 20 72 12 \
|
||||
"${menu_items[@]}" \
|
||||
2>&1 >/dev/tty) || exit 0
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GPU checklist selection
|
||||
# ============================================================
|
||||
select_gpus() {
|
||||
local gpu_items=()
|
||||
$HAS_INTEL && gpu_items+=("intel" "${INTEL_NAME:-Intel iGPU}" "off")
|
||||
$HAS_AMD && gpu_items+=("amd" "${AMD_NAME:-AMD GPU}" "off")
|
||||
$HAS_NVIDIA && gpu_items+=("nvidia" "${NVIDIA_NAME:-NVIDIA GPU}" "off")
|
||||
|
||||
local count=$(( ${#gpu_items[@]} / 3 ))
|
||||
|
||||
if [[ $count -eq 0 ]]; then
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'Add GPU to LXC')" \
|
||||
--msgbox "\n$(translate 'No compatible GPUs detected on this host.')" 8 60
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only one GPU — auto-select without menu
|
||||
if [[ $count -eq 1 ]]; then
|
||||
SELECTED_GPUS=("${gpu_items[0]}")
|
||||
return
|
||||
fi
|
||||
|
||||
# Multiple GPUs — checklist with loop on empty selection
|
||||
while true; do
|
||||
local raw_selection
|
||||
raw_selection=$(dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'Add GPU to LXC')" \
|
||||
--checklist "\n$(translate 'Select the GPU(s) to add to LXC') ${CONTAINER_ID}:" \
|
||||
18 80 10 \
|
||||
"${gpu_items[@]}" \
|
||||
2>&1 >/dev/tty) || exit 0
|
||||
|
||||
local selection
|
||||
selection=$(echo "$raw_selection" | tr -d '"')
|
||||
|
||||
if [[ -z "$selection" ]]; then
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'Add GPU to LXC')" \
|
||||
--msgbox "\n$(translate 'No GPU selected. Please select at least one GPU to continue.')" 8 68
|
||||
continue
|
||||
fi
|
||||
|
||||
read -ra SELECTED_GPUS <<< "$selection"
|
||||
break
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# NVIDIA host driver readiness check
|
||||
# ============================================================
|
||||
check_nvidia_ready() {
|
||||
if ! $NVIDIA_READY; then
|
||||
dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$(translate 'NVIDIA Drivers Not Found')" \
|
||||
--msgbox "\n\Z1\Zb$(translate 'NVIDIA drivers are not installed or not loaded on this host.')\Zn\n\n$(translate 'Please install the NVIDIA drivers first using the option:')\n\n \Z1\Zb$(translate 'Install NVIDIA Drivers on Host')\Zn\n\n$(translate 'available in this same GPU and TPU menu.')" \
|
||||
14 72
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# LXC config: DRI device passthrough (Intel / AMD shared)
|
||||
# ============================================================
|
||||
_configure_dri_devices() {
|
||||
local cfg="$1"
|
||||
local video_gid render_gid idx gid
|
||||
|
||||
video_gid=$(getent group video 2>/dev/null | cut -d: -f3); [[ -z "$video_gid" ]] && video_gid="44"
|
||||
render_gid=$(getent group render 2>/dev/null | cut -d: -f3); [[ -z "$render_gid" ]] && render_gid="104"
|
||||
|
||||
# Remove any pre-existing lxc.mount.entry for /dev/dri — it conflicts with devN: entries
|
||||
sed -i '/lxc\.mount\.entry:.*dev\/dri.*bind/d' "$cfg" 2>/dev/null || true
|
||||
|
||||
for dri_dev in /dev/dri/card0 /dev/dri/card1 /dev/dri/renderD128 /dev/dri/renderD129; do
|
||||
[[ ! -c "$dri_dev" ]] && continue
|
||||
if ! grep -qE "dev[0-9]+:.*${dri_dev}[^0-9/]" "$cfg" 2>/dev/null; then
|
||||
idx=$(get_next_dev_index "$cfg")
|
||||
case "$dri_dev" in
|
||||
/dev/dri/renderD*) gid="$render_gid" ;;
|
||||
*) gid="$video_gid" ;;
|
||||
esac
|
||||
echo "dev${idx}: ${dri_dev},gid=${gid}" >> "$cfg"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
_configure_intel() {
|
||||
local cfg="$1"
|
||||
_configure_dri_devices "$cfg"
|
||||
}
|
||||
|
||||
_configure_amd() {
|
||||
local cfg="$1"
|
||||
local render_gid idx
|
||||
|
||||
_configure_dri_devices "$cfg"
|
||||
|
||||
# /dev/kfd for ROCm / compute workloads
|
||||
if [[ -c "/dev/kfd" ]]; then
|
||||
render_gid=$(getent group render 2>/dev/null | cut -d: -f3)
|
||||
[[ -z "$render_gid" ]] && render_gid="104"
|
||||
if ! grep -q "dev.*/dev/kfd" "$cfg" 2>/dev/null; then
|
||||
idx=$(get_next_dev_index "$cfg")
|
||||
echo "dev${idx}: /dev/kfd,gid=${render_gid}" >> "$cfg"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
_configure_nvidia() {
|
||||
local cfg="$1"
|
||||
local idx dev video_gid
|
||||
|
||||
video_gid=$(getent group video 2>/dev/null | cut -d: -f3)
|
||||
[[ -z "$video_gid" ]] && video_gid="44"
|
||||
|
||||
local -a nv_devs=()
|
||||
for dev in /dev/nvidia[0-9]* /dev/nvidiactl /dev/nvidia-uvm /dev/nvidia-uvm-tools /dev/nvidia-modeset; do
|
||||
[[ -c "$dev" ]] && nv_devs+=("$dev")
|
||||
done
|
||||
if [[ -d /dev/nvidia-caps ]]; then
|
||||
for dev in /dev/nvidia-caps/nvidia-cap[0-9]*; do
|
||||
[[ -c "$dev" ]] && nv_devs+=("$dev")
|
||||
done
|
||||
fi
|
||||
|
||||
for dev in "${nv_devs[@]}"; do
|
||||
if ! grep -q "dev.*${dev}" "$cfg" 2>/dev/null; then
|
||||
idx=$(get_next_dev_index "$cfg")
|
||||
echo "dev${idx}: ${dev},gid=${video_gid}" >> "$cfg"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
configure_passthrough() {
|
||||
local ctid="$1"
|
||||
local cfg="/etc/pve/lxc/${ctid}.conf"
|
||||
CT_WAS_RUNNING=false
|
||||
|
||||
if pct status "$ctid" 2>/dev/null | grep -q "running"; then
|
||||
CT_WAS_RUNNING=true
|
||||
msg_info "$(translate 'Stopping container') ${ctid}..."
|
||||
pct stop "$ctid" >>"$LOG_FILE" 2>&1
|
||||
msg_ok "$(translate 'Container stopped.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
|
||||
for gpu_type in "${SELECTED_GPUS[@]}"; do
|
||||
case "$gpu_type" in
|
||||
intel)
|
||||
msg_info "$(translate 'Configuring Intel iGPU passthrough...')"
|
||||
_configure_intel "$cfg"
|
||||
msg_ok "$(translate 'Intel iGPU passthrough configured.')" | tee -a "$screen_capture"
|
||||
;;
|
||||
amd)
|
||||
msg_info "$(translate 'Configuring AMD GPU passthrough...')"
|
||||
_configure_amd "$cfg"
|
||||
msg_ok "$(translate 'AMD GPU passthrough configured.')" | tee -a "$screen_capture"
|
||||
;;
|
||||
nvidia)
|
||||
msg_info "$(translate 'Configuring NVIDIA GPU passthrough...')"
|
||||
_configure_nvidia "$cfg"
|
||||
msg_ok "$(translate 'NVIDIA GPU passthrough configured.')" | tee -a "$screen_capture"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Driver / userspace library installation inside container
|
||||
# ============================================================
|
||||
# ============================================================
|
||||
# Detect distro inside container (POSIX sh — works on Alpine too)
|
||||
# ============================================================
|
||||
_detect_container_distro() {
|
||||
local distro
|
||||
distro=$(pct exec "$1" -- grep "^ID=" /etc/os-release 2>/dev/null \
|
||||
| cut -d= -f2 | tr -d '[:space:]"')
|
||||
echo "${distro:-unknown}"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# GID sync helper — POSIX sh, works on all distros
|
||||
# ============================================================
|
||||
_sync_gids_in_container() {
|
||||
local ctid="$1"
|
||||
local hvid hrid
|
||||
hvid=$(getent group video 2>/dev/null | cut -d: -f3); [[ -z "$hvid" ]] && hvid="44"
|
||||
hrid=$(getent group render 2>/dev/null | cut -d: -f3); [[ -z "$hrid" ]] && hrid="104"
|
||||
|
||||
pct exec "$ctid" -- sh -c "
|
||||
sed -i 's/^video:x:[0-9]*:/video:x:${hvid}:/' /etc/group 2>/dev/null || true
|
||||
sed -i 's/^render:x:[0-9]*:/render:x:${hrid}:/' /etc/group 2>/dev/null || true
|
||||
grep -q '^video:' /etc/group 2>/dev/null || echo 'video:x:${hvid}:' >> /etc/group
|
||||
grep -q '^render:' /etc/group 2>/dev/null || echo 'render:x:${hrid}:' >> /etc/group
|
||||
" >>"$LOG_FILE" 2>&1 || true
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
_install_intel_drivers() {
|
||||
local ctid="$1"
|
||||
local distro="$2"
|
||||
|
||||
_sync_gids_in_container "$ctid"
|
||||
|
||||
case "$distro" in
|
||||
alpine)
|
||||
pct exec "$ctid" -- sh -c \
|
||||
"echo '@community https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories 2>/dev/null || true
|
||||
apk update && apk add --no-cache mesa-va-gallium intel-media-driver@community libva libva-utils" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
;;
|
||||
arch|manjaro|endeavouros)
|
||||
pct exec "$ctid" -- bash -c \
|
||||
"pacman -Sy --noconfirm intel-media-driver libva-utils mesa" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
;;
|
||||
*)
|
||||
pct exec "$ctid" -- bash -s >>"$LOG_FILE" 2>&1 << EOF
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y va-driver-all vainfo libva2 ocl-icd-libopencl1 intel-opencl-icd intel-gpu-tools 2>/dev/null || \
|
||||
apt-get install -y va-driver-all vainfo libva2 2>/dev/null || true
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
_install_amd_drivers() {
|
||||
local ctid="$1"
|
||||
local distro="$2"
|
||||
|
||||
_sync_gids_in_container "$ctid"
|
||||
|
||||
case "$distro" in
|
||||
alpine)
|
||||
pct exec "$ctid" -- sh -c \
|
||||
"apk update && apk add --no-cache mesa-va-gallium mesa-dri-gallium libva-utils" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
;;
|
||||
arch|manjaro|endeavouros)
|
||||
pct exec "$ctid" -- bash -c \
|
||||
"pacman -Sy --noconfirm mesa libva-mesa-driver libva-utils" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
;;
|
||||
*)
|
||||
pct exec "$ctid" -- bash -s >>"$LOG_FILE" 2>&1 << EOF
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update -qq
|
||||
apt-get install -y mesa-va-drivers libdrm-amdgpu1 vainfo libva2 2>/dev/null || \
|
||||
apt-get install -y mesa-va-drivers vainfo libva2 2>/dev/null || true
|
||||
EOF
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Memory management helpers (for NVIDIA .run installer)
|
||||
# ============================================================
|
||||
CT_ORIG_MEM=""
|
||||
NVIDIA_INSTALL_MIN_MB=2048
|
||||
|
||||
_ensure_container_memory() {
|
||||
local ctid="$1"
|
||||
local cur_mem
|
||||
cur_mem=$(pct config "$ctid" 2>/dev/null | awk '/^memory:/{print $2}')
|
||||
[[ -z "$cur_mem" ]] && cur_mem=512
|
||||
|
||||
if [[ "$cur_mem" -lt "$NVIDIA_INSTALL_MIN_MB" ]]; then
|
||||
if whiptail --title "$(translate 'Low Container Memory')" --yesno \
|
||||
"$(translate 'Container') ${ctid} $(translate 'has') ${cur_mem}MB RAM.\n\n$(translate 'The NVIDIA installer needs at least') ${NVIDIA_INSTALL_MIN_MB}MB $(translate 'to run without being killed by the OOM killer.')\n\n$(translate 'Increase container RAM temporarily to') ${NVIDIA_INSTALL_MIN_MB}MB?" \
|
||||
13 72; then
|
||||
CT_ORIG_MEM="$cur_mem"
|
||||
pct set "$ctid" -memory "$NVIDIA_INSTALL_MIN_MB" >>"$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
INSTALL_ABORTED=true
|
||||
msg_warn "$(translate 'Insufficient memory. NVIDIA install aborted.')"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
_restore_container_memory() {
|
||||
local ctid="$1"
|
||||
if [[ -n "$CT_ORIG_MEM" ]]; then
|
||||
msg_info "$(translate 'Restoring container memory to') ${CT_ORIG_MEM}MB..."
|
||||
pct set "$ctid" -memory "$CT_ORIG_MEM" >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'Memory restored.')"
|
||||
CT_ORIG_MEM=""
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
_install_nvidia_drivers() {
|
||||
local ctid="$1"
|
||||
local version="$NVIDIA_HOST_VERSION"
|
||||
local distro="$2"
|
||||
|
||||
case "$distro" in
|
||||
alpine)
|
||||
# Alpine uses apk — musl-compatible nvidia-utils from Alpine repos
|
||||
msg_info2 "$(translate 'Installing NVIDIA utils (Alpine)...')"
|
||||
pct exec "$ctid" -- sh -c \
|
||||
"apk update && apk add --no-cache nvidia-utils" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
;;
|
||||
|
||||
arch|manjaro|endeavouros)
|
||||
# Arch uses pacman — nvidia-utils provides nvidia-smi
|
||||
msg_info2 "$(translate 'Installing NVIDIA utils (Arch)...')"
|
||||
pct exec "$ctid" -- bash -c \
|
||||
"pacman -Sy --noconfirm nvidia-utils" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
;;
|
||||
|
||||
*)
|
||||
# Debian / Ubuntu / generic glibc: use the .run binary
|
||||
local run_file="${NVIDIA_WORKDIR}/NVIDIA-Linux-x86_64-${version}.run"
|
||||
|
||||
if [[ ! -f "$run_file" ]]; then
|
||||
msg_warn "$(translate 'NVIDIA installer not found at') ${run_file}."
|
||||
msg_warn "$(translate 'Run \"Install NVIDIA Drivers on Host\" first so the installer is cached.')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Memory check — nvidia-installer needs ~2GB during install
|
||||
_ensure_container_memory "$ctid" || return 1
|
||||
|
||||
# Disk space check — NVIDIA libs need ~1.5 GB free in the container
|
||||
local free_mb
|
||||
free_mb=$(pct exec "$ctid" -- df -m / 2>/dev/null | awk 'NR==2{print $4}' || echo 0)
|
||||
if [[ "$free_mb" -lt 1500 ]]; then
|
||||
_restore_container_memory "$ctid"
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'Insufficient Disk Space')" \
|
||||
--msgbox "\n$(translate 'Container') ${ctid} $(translate 'has only') ${free_mb}MB $(translate 'of free disk space.')\n\n$(translate 'NVIDIA libs require approximately 1.5GB of free space.')\n\n$(translate 'Please expand the container disk and run this option again.')" \
|
||||
12 72
|
||||
INSTALL_ABORTED=true
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract .run on the host — avoids decompression OOM inside container
|
||||
# Use msg_info2 (no spinner) so tee output is not mixed with spinner animation
|
||||
local extract_dir="${NVIDIA_WORKDIR}/extracted_${version}"
|
||||
local archive="/tmp/nvidia_lxc_${version}.tar.gz"
|
||||
|
||||
msg_info2 "$(translate 'Extracting NVIDIA installer on host...')"
|
||||
rm -rf "$extract_dir"
|
||||
sh "$run_file" --extract-only --target "$extract_dir" 2>&1 | tee -a "$LOG_FILE"
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
msg_warn "$(translate 'Extraction failed. Check log:') ${LOG_FILE}"
|
||||
_restore_container_memory "$ctid"
|
||||
return 1
|
||||
fi
|
||||
msg_ok "$(translate 'NVIDIA installer extracted.')" | tee -a "$screen_capture"
|
||||
|
||||
msg_info2 "$(translate 'Packing installer archive...')"
|
||||
tar --checkpoint=5000 --checkpoint-action=dot \
|
||||
-czf "$archive" -C "$extract_dir" . 2>&1 | tee -a "$LOG_FILE"
|
||||
echo ""
|
||||
local archive_size
|
||||
archive_size=$(du -sh "$archive" 2>/dev/null | cut -f1)
|
||||
msg_ok "$(translate 'Archive ready') (${archive_size})." | tee -a "$screen_capture"
|
||||
|
||||
msg_info "$(translate 'Copying installer to container') ${ctid}..."
|
||||
if ! pct push "$ctid" "$archive" /tmp/nvidia_lxc.tar.gz >>"$LOG_FILE" 2>&1; then
|
||||
msg_warn "$(translate 'pct push failed. Check log:') ${LOG_FILE}"
|
||||
rm -f "$archive"
|
||||
_restore_container_memory "$ctid"
|
||||
return 1
|
||||
fi
|
||||
rm -f "$archive"
|
||||
msg_ok "$(translate 'Installer copied to container.')" | tee -a "$screen_capture"
|
||||
|
||||
msg_info2 "$(translate 'Installing NVIDIA drivers in container. This may take several minutes...')"
|
||||
echo "" >>"$LOG_FILE"
|
||||
pct exec "$ctid" -- bash -c "
|
||||
mkdir -p /tmp/nvidia_lxc_install
|
||||
tar -xzf /tmp/nvidia_lxc.tar.gz -C /tmp/nvidia_lxc_install 2>&1
|
||||
/tmp/nvidia_lxc_install/nvidia-installer \
|
||||
--no-kernel-modules \
|
||||
--no-questions \
|
||||
--ui=none \
|
||||
--no-nouveau-check \
|
||||
--no-dkms \
|
||||
--no-install-compat32-libs
|
||||
EXIT=\$?
|
||||
rm -rf /tmp/nvidia_lxc_install /tmp/nvidia_lxc.tar.gz
|
||||
exit \$EXIT
|
||||
" 2>&1 | tee -a "$LOG_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
|
||||
rm -rf "$extract_dir"
|
||||
_restore_container_memory "$ctid"
|
||||
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
msg_warn "$(translate 'NVIDIA installer returned error') ${rc}. $(translate 'Check log:') ${LOG_FILE}"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if pct exec "$ctid" -- sh -c "which nvidia-smi" >/dev/null 2>&1; then
|
||||
return 0
|
||||
else
|
||||
msg_warn "$(translate 'nvidia-smi not found after install. Check log:') ${LOG_FILE}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
start_container_and_wait() {
|
||||
local ctid="$1"
|
||||
msg_info "$(translate 'Starting container') ${ctid}..."
|
||||
pct start "$ctid" >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
local ready=false
|
||||
for _ in {1..15}; do
|
||||
sleep 2
|
||||
if pct exec "$ctid" -- true >/dev/null 2>&1; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $ready; then
|
||||
msg_warn "$(translate 'Container did not become ready in time. Skipping driver installation.')"
|
||||
return 1
|
||||
fi
|
||||
msg_ok "$(translate 'Container started.')" | tee -a "$screen_capture"
|
||||
return 0
|
||||
}
|
||||
|
||||
install_drivers() {
|
||||
local ctid="$1"
|
||||
|
||||
# Detect distro once — passed to each install function
|
||||
msg_info "$(translate 'Detecting container OS...')"
|
||||
local ct_distro
|
||||
ct_distro=$(_detect_container_distro "$ctid")
|
||||
msg_ok "$(translate 'Container OS:') ${ct_distro}" | tee -a "$screen_capture"
|
||||
|
||||
for gpu_type in "${SELECTED_GPUS[@]}"; do
|
||||
case "$gpu_type" in
|
||||
intel)
|
||||
#msg_info "$(translate 'Installing Intel VA-API drivers in container...')"
|
||||
_install_intel_drivers "$ctid" "$ct_distro"
|
||||
msg_ok "$(translate 'Intel VA-API drivers installed.')" | tee -a "$screen_capture"
|
||||
;;
|
||||
amd)
|
||||
#msg_info "$(translate 'Installing AMD mesa drivers in container...')"
|
||||
_install_amd_drivers "$ctid" "$ct_distro"
|
||||
msg_ok "$(translate 'AMD mesa drivers installed.')" | tee -a "$screen_capture"
|
||||
;;
|
||||
nvidia)
|
||||
# No outer msg_info here — _install_nvidia_drivers manages its own messages
|
||||
# to avoid a dangling spinner before the whiptail memory dialog
|
||||
if _install_nvidia_drivers "$ctid" "$ct_distro"; then
|
||||
msg_ok "$(translate 'NVIDIA userspace libraries installed.')" | tee -a "$screen_capture"
|
||||
NVIDIA_INSTALL_SUCCESS=true
|
||||
elif [[ "$INSTALL_ABORTED" == "false" ]]; then
|
||||
msg_warn "$(translate 'NVIDIA install incomplete. Check log:') ${LOG_FILE}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Switch mode: GPU → VM → GPU → LXC
|
||||
# ============================================================
|
||||
|
||||
# Returns all vendor:device IDs in the same IOMMU group as a PCI device.
|
||||
# Skips PCI bridges (class 0x0604 / 0x0600).
|
||||
_get_iommu_group_ids() {
|
||||
local pci_full="$1"
|
||||
local group_link="/sys/bus/pci/devices/${pci_full}/iommu_group"
|
||||
[[ ! -L "$group_link" ]] && return
|
||||
|
||||
local group_dir
|
||||
group_dir="/sys/kernel/iommu_groups/$(basename "$(readlink "$group_link")")/devices"
|
||||
|
||||
for dev_path in "${group_dir}/"*; do
|
||||
[[ -e "$dev_path" ]] || continue
|
||||
local dev dev_class
|
||||
dev=$(basename "$dev_path")
|
||||
dev_class=$(cat "/sys/bus/pci/devices/${dev}/class" 2>/dev/null)
|
||||
[[ "$dev_class" == "0x0604" || "$dev_class" == "0x0600" ]] && continue
|
||||
local vid did
|
||||
vid=$(cat "/sys/bus/pci/devices/${dev}/vendor" 2>/dev/null | sed 's/0x//')
|
||||
did=$(cat "/sys/bus/pci/devices/${dev}/device" 2>/dev/null | sed 's/0x//')
|
||||
[[ -n "$vid" && -n "$did" ]] && echo "${vid}:${did}"
|
||||
done
|
||||
}
|
||||
|
||||
# Removes the given vendor:device IDs from the vfio-pci ids= line in vfio.conf.
|
||||
# If no IDs remain after removal, the line is deleted entirely.
|
||||
# Prints the number of remaining IDs to stdout (captured by caller).
|
||||
_remove_vfio_ids() {
|
||||
local vfio_conf="/etc/modprobe.d/vfio.conf"
|
||||
local -a ids_to_remove=("$@")
|
||||
[[ ! -f "$vfio_conf" ]] && echo "0" && return
|
||||
|
||||
local ids_line ids_part
|
||||
ids_line=$(grep "^options vfio-pci ids=" "$vfio_conf" 2>/dev/null | head -1)
|
||||
if [[ -z "$ids_line" ]]; then echo "0"; return; fi
|
||||
ids_part=$(echo "$ids_line" | grep -oE 'ids=[^[:space:]]+' | sed 's/ids=//')
|
||||
|
||||
local -a remaining=()
|
||||
IFS=',' read -ra current_ids <<< "$ids_part"
|
||||
for id in "${current_ids[@]}"; do
|
||||
local remove=false
|
||||
for r in "${ids_to_remove[@]}"; do
|
||||
[[ "$id" == "$r" ]] && remove=true && break
|
||||
done
|
||||
$remove || remaining+=("$id")
|
||||
done
|
||||
|
||||
sed -i '/^options vfio-pci ids=/d' "$vfio_conf"
|
||||
if [[ ${#remaining[@]} -gt 0 ]]; then
|
||||
local new_ids
|
||||
new_ids=$(IFS=','; echo "${remaining[*]}")
|
||||
echo "options vfio-pci ids=${new_ids} disable_vga=1" >> "$vfio_conf"
|
||||
fi
|
||||
|
||||
echo "${#remaining[@]}"
|
||||
}
|
||||
|
||||
# Removes blacklist entries for the given GPU driver type.
|
||||
_remove_gpu_blacklist() {
|
||||
local gpu_type="$1"
|
||||
local blacklist_file="/etc/modprobe.d/blacklist.conf"
|
||||
[[ ! -f "$blacklist_file" ]] && return
|
||||
case "$gpu_type" in
|
||||
nvidia)
|
||||
sed -i '/^blacklist nouveau$/d' "$blacklist_file"
|
||||
sed -i '/^blacklist nvidia$/d' "$blacklist_file"
|
||||
sed -i '/^blacklist nvidiafb$/d' "$blacklist_file"
|
||||
sed -i '/^blacklist nvidia_drm$/d' "$blacklist_file"
|
||||
sed -i '/^blacklist nvidia_modeset$/d' "$blacklist_file"
|
||||
sed -i '/^blacklist nvidia_uvm$/d' "$blacklist_file"
|
||||
sed -i '/^blacklist lbm-nouveau$/d' "$blacklist_file"
|
||||
sed -i '/^options nouveau modeset=0$/d' "$blacklist_file"
|
||||
|
||||
# Remove hard blacklist file created for VFIO mode
|
||||
local nvidia_blacklist="/etc/modprobe.d/nvidia-blacklist.conf"
|
||||
if [[ -f "$nvidia_blacklist" ]]; then
|
||||
rm -f "$nvidia_blacklist"
|
||||
fi
|
||||
|
||||
# Restore NVIDIA udev rules if they were disabled for VFIO mode
|
||||
local udev_disabled="/etc/udev/rules.d/70-nvidia.rules.proxmenux-disabled"
|
||||
local udev_rules="/etc/udev/rules.d/70-nvidia.rules"
|
||||
if [[ -f "$udev_disabled" ]]; then
|
||||
mv "$udev_disabled" "$udev_rules"
|
||||
udevadm control --reload-rules >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
amd)
|
||||
sed -i '/^blacklist radeon$/d' "$blacklist_file"
|
||||
sed -i '/^blacklist amdgpu$/d' "$blacklist_file"
|
||||
;;
|
||||
intel)
|
||||
sed -i '/^blacklist i915$/d' "$blacklist_file"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Removes AMD softdep entries from vfio.conf.
|
||||
_remove_amd_softdep() {
|
||||
local vfio_conf="/etc/modprobe.d/vfio.conf"
|
||||
[[ ! -f "$vfio_conf" ]] && return
|
||||
sed -i '/^softdep radeon pre: vfio-pci$/d' "$vfio_conf"
|
||||
sed -i '/^softdep amdgpu pre: vfio-pci$/d' "$vfio_conf"
|
||||
sed -i '/^softdep snd_hda_intel pre: vfio-pci$/d' "$vfio_conf"
|
||||
}
|
||||
|
||||
# Removes VFIO modules from /etc/modules (called when no IDs remain in vfio.conf).
|
||||
_remove_vfio_modules() {
|
||||
local modules_file="/etc/modules"
|
||||
[[ ! -f "$modules_file" ]] && return
|
||||
sed -i '/^vfio$/d' "$modules_file"
|
||||
sed -i '/^vfio_iommu_type1$/d' "$modules_file"
|
||||
sed -i '/^vfio_pci$/d' "$modules_file"
|
||||
sed -i '/^vfio_virqfd$/d' "$modules_file"
|
||||
}
|
||||
|
||||
# Detects if any selected GPU is currently in GPU → VM mode (VFIO binding).
|
||||
# If so, delegates switch handling to switch_gpu_mode.sh and exits.
|
||||
check_vfio_switch_mode() {
|
||||
local vfio_conf="/etc/modprobe.d/vfio.conf"
|
||||
[[ ! -f "$vfio_conf" ]] && return 0
|
||||
|
||||
local ids_line ids_part
|
||||
ids_line=$(grep "^options vfio-pci ids=" "$vfio_conf" 2>/dev/null | head -1)
|
||||
[[ -z "$ids_line" ]] && return 0
|
||||
ids_part=$(echo "$ids_line" | grep -oE 'ids=[^[:space:]]+' | sed 's/ids=//')
|
||||
[[ -z "$ids_part" ]] && return 0
|
||||
|
||||
# Detect which selected GPUs are in VFIO mode
|
||||
local -a vfio_types=() vfio_pcis=() vfio_names=()
|
||||
for gpu_type in "${SELECTED_GPUS[@]}"; do
|
||||
local pci="" vid_did="" gpu_name=""
|
||||
case "$gpu_type" in
|
||||
intel) pci="$INTEL_PCI"; vid_did="$INTEL_VID_DID"; gpu_name="$INTEL_NAME" ;;
|
||||
amd) pci="$AMD_PCI"; vid_did="$AMD_VID_DID"; gpu_name="$AMD_NAME" ;;
|
||||
nvidia) pci="$NVIDIA_PCI"; vid_did="$NVIDIA_VID_DID"; gpu_name="$NVIDIA_NAME" ;;
|
||||
esac
|
||||
[[ -z "$vid_did" ]] && continue
|
||||
if echo "$ids_part" | grep -q "$vid_did"; then
|
||||
vfio_types+=("$gpu_type")
|
||||
vfio_pcis+=("$pci")
|
||||
vfio_names+=("$gpu_name")
|
||||
fi
|
||||
done
|
||||
|
||||
[[ ${#vfio_types[@]} -eq 0 ]] && return 0
|
||||
|
||||
local msg
|
||||
msg="\n$(translate 'The following selected GPU(s) are currently in GPU -> VM mode (vfio-pci):')\n\n"
|
||||
for i in "${!vfio_types[@]}"; do
|
||||
msg+=" • ${vfio_names[$i]} (${vfio_pcis[$i]})\n"
|
||||
done
|
||||
msg+="\n\Z1\Zb$(translate 'To continue with Add GPU to LXC, first switch the host to GPU -> LXC mode and reboot.')\Zn\n"
|
||||
msg+="\Z1\Zb$(translate 'Do you want to open Switch GPU Mode now?')\Zn"
|
||||
|
||||
dialog --backtitle "ProxMenux" --colors \
|
||||
--title "$(translate 'GPU -> VM Mode Detected')" \
|
||||
--yesno "$msg" 18 84
|
||||
[[ $? -ne 0 ]] && exit 0
|
||||
|
||||
local switch_script="$LOCAL_SCRIPTS/gpu_tpu/switch_gpu_mode.sh"
|
||||
local local_switch_script
|
||||
local_switch_script="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/switch_gpu_mode.sh"
|
||||
if [[ ! -f "$switch_script" && -f "$local_switch_script" ]]; then
|
||||
switch_script="$local_switch_script"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$switch_script" ]]; then
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'Switch Script Not Found')" \
|
||||
--msgbox "\n$(translate 'switch_gpu_mode.sh was not found.')\n\n$(translate 'Expected path:')\n${LOCAL_SCRIPTS}/gpu_tpu/switch_gpu_mode.sh" 10 84
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bash "$switch_script"
|
||||
|
||||
dialog --backtitle "ProxMenux" --colors \
|
||||
--title "$(translate 'Next Step Required')" \
|
||||
--msgbox "\n\Z1\Zb$(translate 'After switching mode, reboot the host if requested.')\Zn\n\n$(translate 'Then run this option again:')\n\n \Z1\ZbAdd GPU to LXC\Zn\n\n$(translate 'This guarantees that device nodes are available before applying LXC GPU config.')" \
|
||||
12 84
|
||||
exit 0
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Main
|
||||
# ============================================================
|
||||
main() {
|
||||
: >"$LOG_FILE"
|
||||
: >"$screen_capture"
|
||||
|
||||
# ---- Phase 1: all dialogs (no terminal output yet) ----
|
||||
detect_host_gpus
|
||||
select_container
|
||||
select_gpus
|
||||
check_vfio_switch_mode
|
||||
precheck_existing_lxc_gpu_config
|
||||
|
||||
# NVIDIA check runs only if NVIDIA was selected
|
||||
for gpu_type in "${SELECTED_GPUS[@]}"; do
|
||||
[[ "$gpu_type" == "nvidia" ]] && check_nvidia_ready
|
||||
done
|
||||
|
||||
# ---- Phase 2: processing ----
|
||||
show_proxmenux_logo
|
||||
msg_title "$(_get_lxc_run_title)"
|
||||
|
||||
configure_passthrough "$CONTAINER_ID"
|
||||
if declare -F attach_proxmenux_gpu_guard_to_lxc >/dev/null 2>&1; then
|
||||
ensure_proxmenux_gpu_guard_hookscript
|
||||
attach_proxmenux_gpu_guard_to_lxc "$CONTAINER_ID"
|
||||
sync_proxmenux_gpu_guard_hooks
|
||||
fi
|
||||
|
||||
if start_container_and_wait "$CONTAINER_ID"; then
|
||||
install_drivers "$CONTAINER_ID"
|
||||
|
||||
# Capture nvidia-smi output while container is still running
|
||||
if $NVIDIA_INSTALL_SUCCESS; then
|
||||
NVIDIA_SMI_OUTPUT=$(pct exec "$CONTAINER_ID" -- nvidia-smi 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
if [[ "$CT_WAS_RUNNING" == "false" ]]; then
|
||||
pct stop "$CONTAINER_ID" >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_ABORTED" == "true" ]]; then
|
||||
rm -f "$screen_capture"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(_get_lxc_run_title)"
|
||||
cat "$screen_capture"
|
||||
echo -e "${TAB}${GN}📄 $(translate 'Log')${CL}: ${BL}${LOG_FILE}${CL}"
|
||||
if [[ -n "$NVIDIA_SMI_OUTPUT" ]]; then
|
||||
msg_info2 "$(translate 'NVIDIA driver verification in container:')"
|
||||
echo "$NVIDIA_SMI_OUTPUT"
|
||||
fi
|
||||
msg_success "$(translate 'GPU passthrough configured for LXC') ${CONTAINER_ID}."
|
||||
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
|
||||
read -r
|
||||
rm -f "$screen_capture"
|
||||
}
|
||||
|
||||
main
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# ProxMenux - GPU/TPU Manual CLI Guide
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : GPL-3.0
|
||||
# Version : 1.0
|
||||
# Last Updated: 07/04/2026
|
||||
# ==========================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||
if [[ -f "$LOCAL_SCRIPTS_LOCAL/utils.sh" ]]; then
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
|
||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||
elif [[ ! -f "$UTILS_FILE" ]]; then
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
fi
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
GREEN=$'\033[0;32m'
|
||||
NC=$'\033[0m'
|
||||
|
||||
_cl() {
|
||||
# _cl <num> <display_cmd> <description>
|
||||
# Prints a numbered command line with fixed-column alignment (separator at col 52).
|
||||
local num="$1" disp="$2" desc="$3"
|
||||
local pad=$((47 - ${#disp}))
|
||||
[[ $pad -lt 1 ]] && pad=1
|
||||
local spaces
|
||||
spaces=$(printf '%*s' "$pad" '')
|
||||
printf " %2d) %s%s%s%s - %s\n" "$num" "$GREEN" "$disp" "$NC" "$spaces" "$desc"
|
||||
}
|
||||
|
||||
while true; do
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "GPU/TPU - Manual CLI Guide")"
|
||||
echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}"
|
||||
echo
|
||||
|
||||
_cl 1 "lspci -nn | grep -iE 'VGA|3D|Display'" "$(translate 'Detect GPUs in host')"
|
||||
_cl 2 "lspci -nnk | grep -A3 -Ei 'VGA|3D'" "$(translate 'Show GPU kernel driver in use')"
|
||||
_cl 3 "cat /proc/cmdline" "$(translate 'Check kernel params (IOMMU flags)')"
|
||||
_cl 4 "dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie'" "$(translate 'Inspect passthrough/kernel events')"
|
||||
_cl 5 "find /sys/kernel/iommu_groups -type l" "$(translate 'List IOMMU group mapping')"
|
||||
_cl 6 "lsmod | grep -E 'vfio|nvidia|amdgpu|apex'" "$(translate 'Check loaded GPU/TPU modules')"
|
||||
_cl 7 "grep -R \"vfio-pci|blacklist\" /etc/modprobe.d" "$(translate 'Review passthrough config files')"
|
||||
_cl 8 "nvidia-smi" "$(translate 'Check NVIDIA driver and devices')"
|
||||
_cl 9 "qm config <vmid> | grep 'hostpci|bios'" "$(translate 'Check VM passthrough settings')"
|
||||
_cl 10 "pct config <ctid> | grep 'dev|lxc.cgroup2'" "$(translate 'Check LXC GPU/TPU mapping')"
|
||||
_cl 11 "ls -l /dev/dri /dev/kfd /dev/nvidia*" "$(translate 'Inspect host device nodes')"
|
||||
_cl 12 "qm set <vmid> --hostpci<slot> <BDF>,pcie=1" "[T] $(translate 'Assign GPU PCI function to VM')"
|
||||
_cl 13 "qm set <vmid> -delete hostpci<slot>" "[T] $(translate 'Remove passthrough device from VM')"
|
||||
_cl 14 "qm set <vmid> -onboot 0" "[T] $(translate 'Disable autostart on conflicting VM')"
|
||||
_cl 15 "sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s|...|'" "[T] $(translate 'Enable IOMMU in GRUB or ZFS boot')"
|
||||
_cl 16 "update-initramfs -u && proxmox-boot-tool" "[T] $(translate 'Apply boot/initramfs changes')"
|
||||
_cl 17 "lsusb | grep Coral ; lspci | grep Unichip" "$(translate 'Check Coral USB/M.2 detection')"
|
||||
echo -e " ${DEF} 0) $(translate 'Back to previous menu or Esc + Enter')${CL}"
|
||||
echo
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}"
|
||||
read -r user_input
|
||||
|
||||
if [[ "$user_input" == $'\x1b' ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
mode="exec"
|
||||
case "$user_input" in
|
||||
1) cmd="lspci -nn | grep -iE 'VGA compatible|3D controller|Display controller'" ;;
|
||||
2) cmd="lspci -nnk | grep -A3 -Ei 'VGA compatible|3D controller|Display controller'" ;;
|
||||
3) cmd="cat /proc/cmdline" ;;
|
||||
4) cmd="dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie|AER|reset'" ;;
|
||||
5) cmd="find /sys/kernel/iommu_groups -type l" ;;
|
||||
6) cmd="lsmod | grep -E 'vfio|nvidia|amdgpu|i915|apex|gasket'" ;;
|
||||
7) cmd="grep -R \"vfio-pci\\|blacklist .*nvidia\\|blacklist .*amdgpu\\|blacklist .*radeon\" /etc/modprobe.d /etc/modules /etc/default/grub /etc/kernel/cmdline 2>/dev/null" ;;
|
||||
8) cmd="nvidia-smi" ;;
|
||||
9)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"
|
||||
read -r vmid
|
||||
cmd="qm config $vmid | grep -E '^(hostpci|cpu:|machine:|bios:|args:|boot:)'"
|
||||
;;
|
||||
10)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}"
|
||||
read -r ctid
|
||||
cmd="pct config $ctid | grep -E '^(dev[0-9]+:|lxc\\.cgroup2\\.devices\\.allow:|lxc\\.mount\\.entry:|features:)'"
|
||||
;;
|
||||
11) cmd="ls -l /dev/dri /dev/kfd /dev/nvidia* /dev/apex* 2>/dev/null" ;;
|
||||
12)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:01:00.0): ')${CL}"; read -r bdf
|
||||
cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1"
|
||||
mode="template"
|
||||
;;
|
||||
13)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
|
||||
cmd="qm set $vmid -delete hostpci${slot}"
|
||||
mode="template"
|
||||
;;
|
||||
14)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
|
||||
cmd="qm set $vmid -onboot 0"
|
||||
mode="template"
|
||||
;;
|
||||
15)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Boot type (grub/zfs): ')${CL}"; read -r boot_type
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'CPU vendor (intel/amd): ')${CL}"; read -r cpu_vendor
|
||||
case "$cpu_vendor" in
|
||||
amd|AMD) iommu_param="amd_iommu=on iommu=pt" ;;
|
||||
*) iommu_param="intel_iommu=on iommu=pt" ;;
|
||||
esac
|
||||
case "$boot_type" in
|
||||
zfs|ZFS) cmd="sed -i 's/\$/ ${iommu_param}/' /etc/kernel/cmdline" ;;
|
||||
*) cmd="sed -i '/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param}\"|' /etc/default/grub" ;;
|
||||
esac
|
||||
mode="template"
|
||||
;;
|
||||
16)
|
||||
cmd="update-initramfs -u -k all && (proxmox-boot-tool refresh || update-grub)"
|
||||
mode="template"
|
||||
;;
|
||||
17) cmd="lsusb | grep -Ei '18d1:9302|1a6e:089a' ; lspci | grep -i 'Global Unichip'" ;;
|
||||
0) break ;;
|
||||
*)
|
||||
if [[ -n "$user_input" ]]; then
|
||||
cmd="$user_input"
|
||||
else
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$mode" == "template" ]]; then
|
||||
echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n"
|
||||
echo "$cmd"
|
||||
echo
|
||||
msg_success "$(translate 'Press ENTER to continue...')"
|
||||
read -r tmp
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}> $cmd${NC}\n"
|
||||
bash -c "$cmd"
|
||||
echo
|
||||
msg_success "$(translate 'Press ENTER to continue...')"
|
||||
read -r tmp
|
||||
done
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - Coral TPU Installer (unified: PCIe/M.2 + USB)
|
||||
# =========================================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 2.0 (unified PCIe+USB; auto-detect; feranick fork; libedgetpu runtime)
|
||||
# Last Updated: 17/04/2026
|
||||
# =========================================================
|
||||
#
|
||||
# One entry point for every Coral variant. At startup the script detects
|
||||
# what Coral hardware is present on the host and installs only what is
|
||||
# actually needed:
|
||||
#
|
||||
# • Coral M.2 / Mini-PCIe (vendor 1ac1 on PCIe)
|
||||
# → build and install `gasket` + `apex` kernel modules via DKMS
|
||||
# (feranick/gasket-driver fork; google as fallback with patches)
|
||||
# → create apex group + udev rules
|
||||
# → reboot required to load the fresh kernel module
|
||||
#
|
||||
# • Coral USB Accelerator (USB IDs 1a6e:089a / 18d1:9302)
|
||||
# → add the Google Coral APT repository (signed-by keyring)
|
||||
# → install libedgetpu1-std (Edge TPU runtime)
|
||||
# → udev rules come with the package
|
||||
# → no reboot required
|
||||
#
|
||||
# • Both present → both paths are run in sequence
|
||||
# • Neither present → informative dialog and clean exit
|
||||
#
|
||||
# The script is idempotent: reruns on already-configured hosts skip work
|
||||
# that is already done and recover from broken gasket-dkms package state
|
||||
# (typical after a kernel upgrade on PVE 9).
|
||||
|
||||
# Guarantee a valid working directory before anything else. When the user
|
||||
# re-runs the installer from a previous /tmp/gasket-driver/... path that our
|
||||
# own `rm -rf gasket-driver` removed, the inherited cwd is orphaned and bash
|
||||
# emits `chdir: error retrieving current directory` warnings from every
|
||||
# subprocess. Moving to / at launch makes the rest of the script immune to
|
||||
# that state.
|
||||
cd / 2>/dev/null || true
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
LOG_FILE="/tmp/coral_install.log"
|
||||
|
||||
# Hardware detection results, set by detect_coral_hardware().
|
||||
CORAL_PCIE_COUNT=0
|
||||
CORAL_USB_COUNT=0
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Hardware detection
|
||||
# ============================================================
|
||||
detect_coral_hardware() {
|
||||
CORAL_PCIE_COUNT=0
|
||||
CORAL_USB_COUNT=0
|
||||
|
||||
# PCIe / M.2 / Mini-PCIe — vendor 0x1ac1 (Global Unichip Corp.)
|
||||
if [[ -d /sys/bus/pci/devices ]]; then
|
||||
for dev in /sys/bus/pci/devices/*; do
|
||||
[[ -e "$dev/vendor" ]] || continue
|
||||
local vendor
|
||||
vendor=$(cat "$dev/vendor" 2>/dev/null)
|
||||
if [[ "$vendor" == "0x1ac1" ]]; then
|
||||
CORAL_PCIE_COUNT=$((CORAL_PCIE_COUNT + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# USB Accelerator
|
||||
# 1a6e:089a Global Unichip Corp. (unprogrammed state — before runtime loads fw)
|
||||
# 18d1:9302 Google Inc. (programmed state — after runtime talks to it)
|
||||
if command -v lsusb >/dev/null 2>&1; then
|
||||
CORAL_USB_COUNT=$(lsusb 2>/dev/null \
|
||||
| grep -cE 'ID (1a6e:089a|18d1:9302)' || true)
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Dialogs
|
||||
# ============================================================
|
||||
no_hardware_dialog() {
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'No Coral Detected')" \
|
||||
--msgbox "\n$(translate 'No Coral TPU device was found on this host (neither PCIe/M.2 nor USB).')\n\n$(translate 'Connect a Coral Accelerator and try again.')" \
|
||||
12 72
|
||||
}
|
||||
|
||||
pre_install_prompt() {
|
||||
local msg="\n"
|
||||
msg+="$(translate 'Detected Coral hardware:')\n\n"
|
||||
msg+=" • $(translate 'M.2 / PCIe devices:') ${CORAL_PCIE_COUNT}\n"
|
||||
msg+=" • $(translate 'USB Accelerators:') ${CORAL_USB_COUNT}\n\n"
|
||||
|
||||
msg+="$(translate 'This installer will:')\n"
|
||||
if [[ "$CORAL_PCIE_COUNT" -gt 0 ]]; then
|
||||
msg+=" • $(translate 'Build and install the gasket and apex kernel modules (DKMS)')\n"
|
||||
msg+=" • $(translate 'Set up the apex group and udev rules')\n"
|
||||
fi
|
||||
if [[ "$CORAL_USB_COUNT" -gt 0 ]]; then
|
||||
msg+=" • $(translate 'Configure the Google Coral APT repository')\n"
|
||||
msg+=" • $(translate 'Install the Edge TPU runtime (libedgetpu1-std)')\n"
|
||||
fi
|
||||
|
||||
if [[ "$CORAL_PCIE_COUNT" -gt 0 ]]; then
|
||||
msg+="\n$(translate 'A reboot is required after installation to load the new kernel modules.')"
|
||||
fi
|
||||
|
||||
msg+="\n\n$(translate 'Do you want to proceed?')"
|
||||
|
||||
if ! dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'Coral TPU Installation')" \
|
||||
--yesno "$msg" 20 78; then
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PCIe / M.2 branch — gasket + apex kernel modules via DKMS
|
||||
# ============================================================
|
||||
|
||||
ensure_apex_group_and_udev() {
|
||||
msg_info "$(translate 'Ensuring apex group and udev rules...')"
|
||||
|
||||
if ! getent group apex >/dev/null; then
|
||||
groupadd --system apex || true
|
||||
msg_ok "$(translate 'System group apex created.')"
|
||||
else
|
||||
msg_ok "$(translate 'System group apex already exists.')"
|
||||
fi
|
||||
|
||||
cat >/etc/udev/rules.d/99-coral-apex.rules <<'EOF'
|
||||
# Coral / Google APEX TPU (M.2 / PCIe)
|
||||
# Assign group "apex" and safe permissions to device nodes
|
||||
KERNEL=="apex_*", GROUP="apex", MODE="0660"
|
||||
SUBSYSTEM=="apex", GROUP="apex", MODE="0660"
|
||||
EOF
|
||||
|
||||
if [[ -f /usr/lib/udev/rules.d/60-gasket-dkms.rules ]]; then
|
||||
sed -i 's/GROUP="[^"]*"/GROUP="apex"/g' /usr/lib/udev/rules.d/60-gasket-dkms.rules || true
|
||||
fi
|
||||
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=apex || true
|
||||
|
||||
msg_ok "$(translate 'apex group and udev rules are in place.')"
|
||||
|
||||
if ls -l /dev/apex_* 2>/dev/null | grep -q ' apex '; then
|
||||
msg_ok "$(translate 'Coral TPU device nodes detected with correct group (apex).')"
|
||||
else
|
||||
msg_warn "$(translate 'apex device node not found yet; a reboot may be required.')"
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_broken_gasket_dkms() {
|
||||
# Recover from a broken gasket-dkms .deb state (half-configured, unpacked,
|
||||
# half-installed). This is a common failure mode on PVE 9 kernel upgrades:
|
||||
# dkms autoinstall tries to rebuild against the new kernel, fails, and
|
||||
# leaves dpkg stuck — which in turn blocks every subsequent apt-get call.
|
||||
local pkg_state
|
||||
pkg_state=$(dpkg -l gasket-dkms 2>/dev/null | awk '/^[a-zA-Z][a-zA-Z]/ {print $1}' | tail -1)
|
||||
|
||||
[[ -z "$pkg_state" ]] && return 0 # package not present — nothing to clean
|
||||
|
||||
case "$pkg_state" in
|
||||
ii|rc)
|
||||
msg_info "$(translate 'Removing any pre-existing gasket-dkms package...')"
|
||||
dpkg -r gasket-dkms >>"$LOG_FILE" 2>&1 || true
|
||||
dkms remove gasket/1.0 --all >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'Pre-existing gasket-dkms package removed.')"
|
||||
;;
|
||||
*)
|
||||
msg_warn "$(translate 'Detected broken gasket-dkms package state:') ${pkg_state}. $(translate 'Forcing removal...')"
|
||||
dpkg --remove --force-remove-reinstreq gasket-dkms >>"$LOG_FILE" 2>&1 || true
|
||||
dpkg --purge --force-all gasket-dkms >>"$LOG_FILE" 2>&1 || true
|
||||
dkms remove gasket/1.0 --all >>"$LOG_FILE" 2>&1 || true
|
||||
apt-get install -f -y >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'Broken gasket-dkms package state recovered.')"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
clone_gasket_sources() {
|
||||
# Primary: feranick/gasket-driver — community fork, actively maintained,
|
||||
# carries patches for kernel 6.10/6.12/6.13.
|
||||
# Fallback: google/gasket-driver — upstream, stale. Requires manual patches.
|
||||
# Sets GASKET_SOURCE_USED so the patch step knows whether to apply them.
|
||||
local FERANICK_URL="https://github.com/feranick/gasket-driver.git"
|
||||
local GOOGLE_URL="https://github.com/google/gasket-driver.git"
|
||||
|
||||
cd /tmp || exit 1
|
||||
rm -rf gasket-driver >>"$LOG_FILE" 2>&1
|
||||
|
||||
msg_info "$(translate 'Cloning Coral driver repository (feranick fork)...')"
|
||||
if git clone --depth=1 "$FERANICK_URL" gasket-driver >>"$LOG_FILE" 2>&1; then
|
||||
GASKET_SOURCE_USED="feranick"
|
||||
msg_ok "$(translate 'feranick/gasket-driver cloned (actively maintained, kernel 6.12+ ready).')"
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_warn "$(translate 'feranick fork unreachable. Falling back to google/gasket-driver...')"
|
||||
rm -rf gasket-driver >>"$LOG_FILE" 2>&1
|
||||
if git clone --depth=1 "$GOOGLE_URL" gasket-driver >>"$LOG_FILE" 2>&1; then
|
||||
GASKET_SOURCE_USED="google"
|
||||
msg_ok "$(translate 'google/gasket-driver cloned (fallback — will apply local patches).')"
|
||||
return 0
|
||||
fi
|
||||
|
||||
msg_error "$(translate 'Could not clone any gasket-driver repository. Check your internet connection and') ${LOG_FILE}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
show_dkms_build_failure() {
|
||||
# Print the last 50 lines of make.log on-screen so the user sees the real
|
||||
# compilation error without having to dig the log file.
|
||||
local make_log="/var/lib/dkms/gasket/1.0/build/make.log"
|
||||
echo "" >&2
|
||||
msg_warn "$(translate 'DKMS build failed. Last lines of make.log:')"
|
||||
if [[ -f "$make_log" ]]; then
|
||||
{
|
||||
echo "---- /var/lib/dkms/gasket/1.0/build/make.log ----"
|
||||
cat "$make_log"
|
||||
} >>"$LOG_FILE" 2>&1
|
||||
tail -n 50 "$make_log" >&2
|
||||
else
|
||||
echo "$(translate '(make.log not found — DKMS may have failed before invoking make)')" >&2
|
||||
fi
|
||||
echo "" >&2
|
||||
echo -e "${TAB}${BL}$(translate 'Full log:')${CL} ${LOG_FILE}" >&2
|
||||
echo "" >&2
|
||||
}
|
||||
|
||||
install_gasket_apex_dkms() {
|
||||
# Detect running kernel — used both to pull matching headers and to apply
|
||||
# kernel-version-specific patches if we fall back to google/gasket-driver.
|
||||
local KVER KMAJ KMIN
|
||||
KVER=$(uname -r)
|
||||
KMAJ=$(echo "$KVER" | cut -d. -f1)
|
||||
KMIN=$(echo "$KVER" | cut -d. -f2 | cut -d+ -f1 | cut -d- -f1)
|
||||
|
||||
cleanup_broken_gasket_dkms
|
||||
|
||||
msg_info "$(translate 'Installing build dependencies...')"
|
||||
apt-get update -qq >>"$LOG_FILE" 2>&1
|
||||
if ! apt-get install -y git dkms build-essential "proxmox-headers-${KVER}" >>"$LOG_FILE" 2>&1; then
|
||||
msg_error "$(translate 'Error installing build dependencies. Check') ${LOG_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "$(translate 'Build dependencies installed.')"
|
||||
|
||||
clone_gasket_sources
|
||||
|
||||
cd /tmp/gasket-driver || exit 1
|
||||
|
||||
# Patches are only needed for the stale google fork. feranick already carries
|
||||
# the equivalent fixes upstream; re-applying them would double-edit sources.
|
||||
if [[ "$GASKET_SOURCE_USED" == "google" ]]; then
|
||||
msg_info "$(translate 'Patching source for kernel compatibility...')"
|
||||
|
||||
# no_llseek was removed in kernel 6.5 — replace with noop_llseek
|
||||
if [[ "$KMAJ" -gt 6 ]] || [[ "$KMAJ" -eq 6 && "$KMIN" -ge 5 ]]; then
|
||||
sed -i 's/\.llseek = no_llseek/\.llseek = noop_llseek/' src/gasket_core.c
|
||||
fi
|
||||
|
||||
# MODULE_IMPORT_NS syntax changed to string-literal in 6.13.
|
||||
# Applying this patch on kernel <6.13 causes a compile error.
|
||||
if [[ "$KMAJ" -gt 6 ]] || [[ "$KMAJ" -eq 6 && "$KMIN" -ge 13 ]]; then
|
||||
sed -i 's/^MODULE_IMPORT_NS(DMA_BUF);/MODULE_IMPORT_NS("DMA_BUF");/' src/gasket_page_table.c
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Source patched successfully.') (kernel ${KVER})"
|
||||
else
|
||||
msg_info2 "$(translate 'Skipping manual patches — feranick fork already supports this kernel.')"
|
||||
fi
|
||||
|
||||
local GASKET_SRC="/usr/src/gasket-1.0"
|
||||
|
||||
if [[ ! -d /tmp/gasket-driver/src ]]; then
|
||||
msg_error "$(translate 'Expected /tmp/gasket-driver/src not found. The clone seems incomplete or uses an unknown layout.')"
|
||||
{ echo "---- /tmp/gasket-driver/ contents ----"; ls -la /tmp/gasket-driver 2>/dev/null || true; } >>"$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f /tmp/gasket-driver/src/Makefile ]]; then
|
||||
msg_error "$(translate 'Expected Makefile not found in /tmp/gasket-driver/src. Source tree is incomplete.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_info "$(translate 'Removing previous DKMS source tree...')"
|
||||
dkms remove gasket/1.0 --all >>"$LOG_FILE" 2>&1 || true
|
||||
if [[ -d "$GASKET_SRC" ]]; then
|
||||
if ! rm -rf "$GASKET_SRC" 2>>"$LOG_FILE"; then
|
||||
msg_error "$(translate 'Could not remove previous DKMS tree at') ${GASKET_SRC}. $(translate 'Check') ${LOG_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
msg_ok "$(translate 'Previous DKMS tree cleared.')"
|
||||
|
||||
# Copy only the `src/` contents (where the kernel sources live) so
|
||||
# Makefile + *.c + *.h sit at the DKMS tree root, matching the Debian
|
||||
# packaging layout (`dh_install src/* usr/src/gasket-$(VERSION)/`).
|
||||
msg_info "$(translate 'Copying sources to') ${GASKET_SRC}..."
|
||||
mkdir -p "$GASKET_SRC"
|
||||
if ! cp -a /tmp/gasket-driver/src/. "${GASKET_SRC}/" 2>>"$LOG_FILE"; then
|
||||
msg_error "$(translate 'Failed to copy sources into') ${GASKET_SRC}. $(translate 'Check') ${LOG_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$GASKET_SRC/Makefile" ]]; then
|
||||
msg_error "$(translate 'Makefile missing in') ${GASKET_SRC} $(translate 'after copy; source tree is incomplete.')"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "$(translate 'Sources copied to') ${GASKET_SRC}"
|
||||
|
||||
# The repo ships debian/gasket-dkms.dkms as a template with a
|
||||
# #MODULE_VERSION# placeholder that the .deb pipeline substitutes. Since we
|
||||
# install directly from sources (no .deb), we write our own dkms.conf.
|
||||
# MAKE[0] passes ${kernelver} to the Makefile so multi-kernel rebuilds
|
||||
# (PVE's autoinstall on new kernel installs) target the right headers.
|
||||
msg_info "$(translate 'Generating dkms.conf...')"
|
||||
cat > "$GASKET_SRC/dkms.conf" <<'EOF'
|
||||
PACKAGE_NAME="gasket"
|
||||
PACKAGE_VERSION="1.0"
|
||||
BUILT_MODULE_NAME[0]="gasket"
|
||||
BUILT_MODULE_NAME[1]="apex"
|
||||
DEST_MODULE_LOCATION[0]="/updates/dkms"
|
||||
DEST_MODULE_LOCATION[1]="/updates/dkms"
|
||||
MAKE[0]="make KVERSION=${kernelver}"
|
||||
CLEAN="make clean"
|
||||
AUTOINSTALL="yes"
|
||||
EOF
|
||||
if [[ ! -s "$GASKET_SRC/dkms.conf" ]]; then
|
||||
msg_error "$(translate 'Failed to write') ${GASKET_SRC}/dkms.conf"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "$(translate 'dkms.conf generated.')"
|
||||
|
||||
msg_info "$(translate 'Registering module with DKMS...')"
|
||||
if ! dkms add "$GASKET_SRC" >>"$LOG_FILE" 2>&1; then
|
||||
msg_error "$(translate 'DKMS add failed. Check') ${LOG_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "$(translate 'DKMS module registered.')"
|
||||
|
||||
msg_info "$(translate 'Compiling Coral TPU drivers for current kernel...')"
|
||||
if ! dkms build gasket/1.0 -k "$KVER" >>"$LOG_FILE" 2>&1; then
|
||||
show_dkms_build_failure
|
||||
msg_error "$(translate 'DKMS build failed.')"
|
||||
exit 1
|
||||
fi
|
||||
if ! dkms install gasket/1.0 -k "$KVER" >>"$LOG_FILE" 2>&1; then
|
||||
show_dkms_build_failure
|
||||
msg_error "$(translate 'DKMS install failed.')"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "$(translate 'Drivers compiled and installed via DKMS.') (source: ${GASKET_SOURCE_USED})"
|
||||
|
||||
ensure_apex_group_and_udev
|
||||
|
||||
msg_info "$(translate 'Loading modules...')"
|
||||
modprobe gasket >>"$LOG_FILE" 2>&1 || true
|
||||
modprobe apex >>"$LOG_FILE" 2>&1 || true
|
||||
if lsmod | grep -q '\bapex\b'; then
|
||||
msg_ok "$(translate 'Modules loaded.')"
|
||||
else
|
||||
msg_warn "$(translate 'Installation finished but drivers are not loaded. A reboot may be required.')"
|
||||
fi
|
||||
|
||||
echo "---- dmesg | grep -i apex (last lines) ----" >>"$LOG_FILE"
|
||||
dmesg | grep -i apex | tail -n 20 >>"$LOG_FILE" 2>&1
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USB branch — libedgetpu runtime from Google's APT repository
|
||||
# ============================================================
|
||||
|
||||
install_libedgetpu_runtime() {
|
||||
local KEYRING=/etc/apt/keyrings/coral-edgetpu.gpg
|
||||
local LIST_FILE=/etc/apt/sources.list.d/coral-edgetpu.list
|
||||
|
||||
# Modern repo configuration: one keyring file under /etc/apt/keyrings plus
|
||||
# a sources.list.d entry with `signed-by=`. Avoids the deprecated apt-key.
|
||||
msg_info "$(translate 'Setting up the Google Coral APT repository...')"
|
||||
mkdir -p /etc/apt/keyrings
|
||||
|
||||
if [[ ! -s "$KEYRING" ]]; then
|
||||
if ! curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \
|
||||
| gpg --dearmor -o "$KEYRING" 2>>"$LOG_FILE"; then
|
||||
msg_error "$(translate 'Failed to fetch the Google Coral GPG key. Check') ${LOG_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
chmod 0644 "$KEYRING"
|
||||
fi
|
||||
|
||||
cat > "$LIST_FILE" <<EOF
|
||||
deb [signed-by=${KEYRING}] https://packages.cloud.google.com/apt coral-edgetpu-stable main
|
||||
EOF
|
||||
|
||||
if ! apt-get update -qq >>"$LOG_FILE" 2>&1; then
|
||||
msg_warn "$(translate 'apt-get update returned warnings. Continuing anyway; check') ${LOG_FILE}"
|
||||
fi
|
||||
msg_ok "$(translate 'Coral APT repository ready.')"
|
||||
|
||||
# libedgetpu1-std = standard performance; libedgetpu1-max = overclocked mode
|
||||
# (more heat). We default to -std; users who explicitly want -max can install
|
||||
# it manually. Either way the udev rules come with the package.
|
||||
msg_info "$(translate 'Installing Edge TPU runtime (libedgetpu1-std)...')"
|
||||
if ! apt-get install -y libedgetpu1-std >>"$LOG_FILE" 2>&1; then
|
||||
msg_error "$(translate 'Failed to install libedgetpu1-std. Check') ${LOG_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "$(translate 'Edge TPU runtime installed.')"
|
||||
|
||||
# Reload udev so the rules shipped with libedgetpu1-std apply to any USB
|
||||
# Coral already plugged in (otherwise they would only apply after replug).
|
||||
udevadm control --reload-rules >/dev/null 2>&1 || true
|
||||
udevadm trigger --subsystem-match=usb >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Final prompt
|
||||
# ============================================================
|
||||
restart_prompt() {
|
||||
if whiptail --title "$(translate 'Coral TPU Installation')" --yesno \
|
||||
"$(translate 'The installation requires a server restart to apply changes. Do you want to restart now?')" 10 70; then
|
||||
msg_warn "$(translate 'Restarting the server...')"
|
||||
reboot
|
||||
else
|
||||
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
|
||||
read -r
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Main orchestrator
|
||||
# ============================================================
|
||||
main() {
|
||||
: >"$LOG_FILE"
|
||||
|
||||
detect_coral_hardware
|
||||
|
||||
# Nothing plugged in — nothing to do.
|
||||
if [[ "$CORAL_PCIE_COUNT" -eq 0 && "$CORAL_USB_COUNT" -eq 0 ]]; then
|
||||
no_hardware_dialog
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pre_install_prompt
|
||||
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate 'Coral TPU Installation')"
|
||||
|
||||
# Force non-interactive apt/dpkg for the whole run so cleanup_broken_gasket_dkms
|
||||
# and the two install paths never get blocked by package-maintainer prompts.
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Branch 1 — PCIe / M.2 (kernel modules). Runs first so the reboot reminder
|
||||
# at the end only appears when we actually touched kernel modules.
|
||||
if [[ "$CORAL_PCIE_COUNT" -gt 0 ]]; then
|
||||
msg_info2 "$(translate 'Coral M.2 / PCIe detected — installing gasket and apex kernel modules...')"
|
||||
install_gasket_apex_dkms
|
||||
fi
|
||||
|
||||
# Branch 2 — USB (user-space runtime).
|
||||
if [[ "$CORAL_USB_COUNT" -gt 0 ]]; then
|
||||
msg_info2 "$(translate 'Coral USB Accelerator detected — installing Edge TPU runtime...')"
|
||||
install_libedgetpu_runtime
|
||||
fi
|
||||
|
||||
echo
|
||||
if [[ "$CORAL_PCIE_COUNT" -gt 0 ]]; then
|
||||
msg_success "$(translate 'Coral TPU drivers installed and loaded successfully.')"
|
||||
restart_prompt
|
||||
else
|
||||
# USB-only install. No reboot required; the udev rules and runtime are
|
||||
# already active. Ready to passthrough the device to an LXC/VM.
|
||||
msg_success "$(translate 'Coral USB runtime installed. No reboot required.')"
|
||||
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
|
||||
read -r
|
||||
fi
|
||||
}
|
||||
|
||||
main
|
||||
@@ -7,8 +7,8 @@
|
||||
# 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
|
||||
# Version : 1.4 (unprivileged container support, PVE dev API for apex/iGPU)
|
||||
# Last Updated: 01/04/2026
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script automates the configuration and installation of
|
||||
@@ -21,6 +21,12 @@
|
||||
# Supports Coral USB and Coral M.2 (PCIe) devices.
|
||||
# Includes USB passthrough enhancement using persistent udev alias (/dev/coral).
|
||||
#
|
||||
# Changelog v1.3:
|
||||
# - Fixed Coral USB passthrough: mount /dev/bus/usb instead of /dev/coral symlink
|
||||
# The udev symlink /dev/coral is not passthrough-safe in LXC; mounting the full
|
||||
# USB bus tree ensures the real device node is accessible inside the container
|
||||
# regardless of which port the Coral USB is connected to.
|
||||
#
|
||||
# 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)
|
||||
@@ -152,13 +158,25 @@ add_mount_if_needed() {
|
||||
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"
|
||||
}
|
||||
|
||||
# Returns the next available dev index (dev0, dev1, ...) in a container config.
|
||||
# The PVE dev API (devN: /dev/foo,gid=N) works in both privileged and unprivileged
|
||||
# containers, handling cgroup2 permissions automatically.
|
||||
get_next_dev_index() {
|
||||
local config="$1"
|
||||
local idx=0
|
||||
while grep -q "^dev${idx}:" "$config" 2>/dev/null; do
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
echo "$idx"
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# CONFIGURE LXC HARDWARE PASSTHROUGH
|
||||
# ==========================================================
|
||||
@@ -174,25 +192,6 @@ configure_lxc_hardware() {
|
||||
|
||||
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
|
||||
# ============================================================
|
||||
@@ -211,19 +210,24 @@ configure_lxc_hardware() {
|
||||
# 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
|
||||
|
||||
# Bind-mount the /dev/dri directory so apps can enumerate available devices
|
||||
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"
|
||||
|
||||
|
||||
# Add each DRI device via the PVE dev API (gid=44 = render group).
|
||||
# This approach works in unprivileged containers: PVE manages cgroup2
|
||||
# permissions automatically and maps the GID into the container namespace.
|
||||
local igpu_dev_idx
|
||||
igpu_dev_idx=$(get_next_dev_index "$CONFIG_FILE")
|
||||
for dri_dev in /dev/dri/renderD128 /dev/dri/renderD129 /dev/dri/card0 /dev/dri/card1; do
|
||||
if [[ -c "$dri_dev" ]]; then
|
||||
if ! grep -q ":.*${dri_dev}" "$CONFIG_FILE"; then
|
||||
echo "dev${igpu_dev_idx}: ${dri_dev},gid=44" >> "$CONFIG_FILE"
|
||||
igpu_dev_idx=$((igpu_dev_idx + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
msg_ok "$(translate 'iGPU configuration added')"
|
||||
|
||||
# ============================================================
|
||||
@@ -250,8 +254,13 @@ configure_lxc_hardware() {
|
||||
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"
|
||||
|
||||
# FIX v1.3: Mount /dev/bus/usb instead of the /dev/coral symlink.
|
||||
# The udev symlink /dev/coral cannot be safely passed through to LXC because
|
||||
# it points to a dynamic path (e.g. /dev/bus/usb/001/005) that changes on
|
||||
# reconnect. Mounting the full USB bus tree makes the real device node
|
||||
# available inside the container regardless of port or reconnection.
|
||||
add_mount_if_needed "/dev/bus/usb" "dev/bus/usb" "$CONFIG_FILE"
|
||||
|
||||
if [ -L "/dev/coral" ]; then
|
||||
msg_ok "$(translate 'Coral USB configuration added - device detected')"
|
||||
@@ -266,18 +275,29 @@ configure_lxc_hardware() {
|
||||
|
||||
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
|
||||
|
||||
local APEX_GID apex_dev_idx
|
||||
APEX_GID=$(getent group apex 2>/dev/null | cut -d: -f3 || echo "0")
|
||||
apex_dev_idx=$(get_next_dev_index "$CONFIG_FILE")
|
||||
|
||||
add_mount_if_needed "/dev/apex_0" "dev/apex_0" "$CONFIG_FILE"
|
||||
|
||||
if [ -e "/dev/apex_0" ]; then
|
||||
# Device is visible — use PVE dev API (works in unprivileged containers).
|
||||
# PVE handles cgroup2 permissions automatically.
|
||||
if ! grep -q "dev.*apex_0" "$CONFIG_FILE"; then
|
||||
echo "dev${apex_dev_idx}: /dev/apex_0,gid=${APEX_GID}" >> "$CONFIG_FILE"
|
||||
fi
|
||||
msg_ok "$(translate 'Coral M.2 Apex configuration added - device ready')"
|
||||
else
|
||||
# Device not yet visible (host module not loaded or reboot pending).
|
||||
# Use cgroup2 + optional bind-mount as fallback; detect major number
|
||||
# dynamically from /proc/devices to avoid hardcoding it.
|
||||
local APEX_MAJOR
|
||||
APEX_MAJOR=$(awk '/\bapex\b/{print $1}' /proc/devices 2>/dev/null | head -1)
|
||||
[[ -z "$APEX_MAJOR" ]] && APEX_MAJOR="245"
|
||||
if ! grep -q "lxc.cgroup2.devices.allow: c ${APEX_MAJOR}:0 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c ${APEX_MAJOR}:0 rwm # Coral M2 Apex" >> "$CONFIG_FILE"
|
||||
fi
|
||||
add_mount_if_needed "/dev/apex_0" "dev/apex_0" "$CONFIG_FILE"
|
||||
msg_ok "$(translate 'Coral M.2 Apex configuration added - device will be available after reboot')"
|
||||
fi
|
||||
fi
|
||||
@@ -300,7 +320,13 @@ install_coral_in_container() {
|
||||
|
||||
if ! pct status "$CONTAINER_ID" | grep -q "running"; then
|
||||
pct start "$CONTAINER_ID"
|
||||
sleep 5
|
||||
for _ in {1..15}; do
|
||||
pct status "$CONTAINER_ID" | grep -q "running" && break
|
||||
sleep 1
|
||||
done
|
||||
if ! pct status "$CONTAINER_ID" | grep -q "running"; then
|
||||
msg_error "$(translate 'Container did not start in time.')"; exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -326,7 +352,8 @@ install_coral_in_container() {
|
||||
# Install drivers inside container
|
||||
script -q -c "pct exec \"$CONTAINER_ID\" -- bash -c '
|
||||
set -e
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo \"[1/6] Updating package lists...\"
|
||||
apt-get update -qq
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ProxMenux - Coral TPU Installer (PVE 9.x)
|
||||
# =========================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 1.3 (PVE9, silent build)
|
||||
# Last Updated: 25/09/2025
|
||||
# =========================================
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
LOG_FILE="/tmp/coral_install.log"
|
||||
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
|
||||
|
||||
|
||||
ensure_apex_group_and_udev() {
|
||||
msg_info "Ensuring apex group and udev rules..."
|
||||
|
||||
|
||||
if ! getent group apex >/dev/null; then
|
||||
groupadd --system apex || true
|
||||
msg_ok "System group 'apex' created"
|
||||
else
|
||||
msg_ok "System group 'apex' already exists"
|
||||
fi
|
||||
|
||||
|
||||
cat >/etc/udev/rules.d/99-coral-apex.rules <<'EOF'
|
||||
# Coral / Google APEX TPU (M.2 / PCIe)
|
||||
# Assign group "apex" and safe permissions to device nodes
|
||||
KERNEL=="apex_*", GROUP="apex", MODE="0660"
|
||||
SUBSYSTEM=="apex", GROUP="apex", MODE="0660"
|
||||
EOF
|
||||
|
||||
|
||||
if [[ -f /usr/lib/udev/rules.d/60-gasket-dkms.rules ]]; then
|
||||
sed -i 's/GROUP="[^"]*"/GROUP="apex"/g' /usr/lib/udev/rules.d/60-gasket-dkms.rules || true
|
||||
fi
|
||||
|
||||
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=apex || true
|
||||
|
||||
msg_ok "apex group and udev rules are in place"
|
||||
|
||||
|
||||
if ls -l /dev/apex_* 2>/dev/null | grep -q ' apex '; then
|
||||
msg_ok "Coral TPU device nodes detected with correct group (apex)"
|
||||
else
|
||||
msg_warn "apex device node not found yet; a reboot may be required"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
pre_install_prompt() {
|
||||
if ! dialog --title "$(translate 'Coral TPU Installation')" --yesno \
|
||||
"\n$(translate 'Installing Coral TPU drivers requires rebooting the server after installation. Do you want to proceed?')" 10 70; then
|
||||
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
install_coral_host() {
|
||||
show_proxmenux_logo
|
||||
: >"$LOG_FILE"
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate 'Installing build dependencies...')"
|
||||
apt-get update -qq >>"$LOG_FILE" 2>&1
|
||||
apt-get install -y git devscripts dh-dkms dkms proxmox-headers-$(uname -r) >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Error installing build dependencies. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'Build dependencies installed.')"
|
||||
|
||||
|
||||
|
||||
cd /tmp || exit 1
|
||||
rm -rf gasket-driver >>"$LOG_FILE" 2>&1
|
||||
msg_info "$(translate 'Cloning Google Coral driver repository...')"
|
||||
git clone https://github.com/google/gasket-driver.git >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Could not clone the repository. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'Repository cloned successfully.')"
|
||||
|
||||
|
||||
|
||||
cd /tmp/gasket-driver || exit 1
|
||||
msg_info "$(translate 'Patching source for kernel compatibility...')"
|
||||
|
||||
|
||||
sed -i 's/\.llseek = no_llseek/\.llseek = noop_llseek/' src/gasket_core.c
|
||||
|
||||
sed -i 's/^MODULE_IMPORT_NS(DMA_BUF);/MODULE_IMPORT_NS("DMA_BUF");/' src/gasket_page_table.c
|
||||
|
||||
sed -i "s/\(linux-headers-686-pae | linux-headers-amd64 | linux-headers-generic | linux-headers\)/\1 | proxmox-headers-$(uname -r) | pve-headers-$(uname -r)/" debian/control
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Patching failed. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'Source patched successfully.')"
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate 'Building DKMS package...')"
|
||||
debuild -us -uc -tc -b >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'Failed to build DKMS package. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'DKMS package built successfully.')"
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate 'Installing DKMS package...')"
|
||||
dpkg -i ../gasket-dkms_*.deb >>"$LOG_FILE" 2>&1 || true
|
||||
if ! dpkg -s gasket-dkms >/dev/null 2>&1; then
|
||||
msg_error "$(translate 'Failed to install DKMS package. Check /tmp/coral_install.log')"; exit 1
|
||||
fi
|
||||
msg_ok "$(translate 'DKMS package installed.')"
|
||||
|
||||
|
||||
|
||||
msg_info "$(translate 'Compiling Coral TPU drivers for current kernel...')"
|
||||
dkms remove -m gasket -v 1.0 -k "$(uname -r)" >>"$LOG_FILE" 2>&1 || true
|
||||
dkms add -m gasket -v 1.0 >>"$LOG_FILE" 2>&1 || true
|
||||
dkms build -m gasket -v 1.0 -k "$(uname -r)" >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then
|
||||
sed -n '1,200p' /var/lib/dkms/gasket/1.0/build/make.log >>"$LOG_FILE" 2>&1 || true
|
||||
msg_error "$(translate 'DKMS build failed. Check /tmp/coral_install.log')"; exit 1
|
||||
fi
|
||||
dkms install -m gasket -v 1.0 -k "$(uname -r)" >>"$LOG_FILE" 2>&1
|
||||
if [[ $? -ne 0 ]]; then msg_error "$(translate 'DKMS install failed. Check /tmp/coral_install.log')"; exit 1; fi
|
||||
msg_ok "$(translate 'Drivers compiled and installed via DKMS.')"
|
||||
|
||||
|
||||
ensure_apex_group_and_udev
|
||||
|
||||
msg_info "$(translate 'Loading modules...')"
|
||||
modprobe gasket >>"$LOG_FILE" 2>&1 || true
|
||||
modprobe apex >>"$LOG_FILE" 2>&1 || true
|
||||
if lsmod | grep -q '\bapex\b'; then
|
||||
msg_ok "$(translate 'Modules loaded.')"
|
||||
msg_success "$(translate 'Coral TPU drivers installed and loaded successfully.')"
|
||||
else
|
||||
msg_warn "$(translate 'Installation finished but drivers are not loaded. Please check dmesg and /tmp/coral_install.log')"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
echo "---- dmesg | grep -i apex (last lines) ----" >>"$LOG_FILE"
|
||||
dmesg | grep -i apex | tail -n 20 >>"$LOG_FILE" 2>&1
|
||||
}
|
||||
|
||||
restart_prompt() {
|
||||
if whiptail --title "$(translate 'Coral TPU Installation')" --yesno \
|
||||
"$(translate 'The installation requires a server restart to apply changes. Do you want to restart now?')" 10 70; then
|
||||
msg_warn "$(translate 'Restarting the server...')"
|
||||
reboot
|
||||
else
|
||||
msg_success "$(translate 'Completed. Press Enter to return to menu...')"
|
||||
read -r
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
pre_install_prompt
|
||||
install_coral_host
|
||||
restart_prompt
|
||||
@@ -2,9 +2,10 @@
|
||||
# ProxMenux - NVIDIA Driver Installer (PVE 9.x)
|
||||
# ============================================
|
||||
# Author : MacRimi
|
||||
# License : MIT
|
||||
# Version : 0.9 (PVE9, fixed download issues)
|
||||
# Last Updated: 29/11/2025
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.2 (PVE9, fixed download issues)
|
||||
# Last Updated: 26/03/2026
|
||||
# ============================================
|
||||
|
||||
SCRIPT_TITLE="NVIDIA GPU Driver Installer for Proxmox VE"
|
||||
@@ -19,6 +20,12 @@ screen_capture="/tmp/proxmenux_nvidia_screen_capture_$$.txt"
|
||||
NVIDIA_BASE_URL="https://download.nvidia.com/XFree86/Linux-x86_64"
|
||||
NVIDIA_WORKDIR="/opt/nvidia"
|
||||
|
||||
# LXC post-install update constants (used only when NVIDIA LXC passthrough
|
||||
# containers are detected and the user confirms updating them after the host
|
||||
# install/reinstall finishes).
|
||||
NVIDIA_INSTALL_MIN_MB=2048
|
||||
CT_ORIG_MEM=""
|
||||
|
||||
export BASE_DIR
|
||||
export COMPONENTS_STATUS_FILE
|
||||
|
||||
@@ -56,6 +63,33 @@ detect_nvidia_gpus() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_gpu_not_in_vm_passthrough() {
|
||||
local dev vendor driver vfio_list=""
|
||||
for dev in /sys/bus/pci/devices/*; do
|
||||
vendor=$(cat "$dev/vendor" 2>/dev/null)
|
||||
[[ "$vendor" != "0x10de" ]] && continue
|
||||
if [[ -L "$dev/driver" ]]; then
|
||||
driver=$(basename "$(readlink "$dev/driver")")
|
||||
if [[ "$driver" == "vfio-pci" ]]; then
|
||||
vfio_list+=" • $(basename "$dev")\n"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
[[ -z "$vfio_list" ]] && return 0
|
||||
|
||||
local msg
|
||||
msg="\n$(translate "One or more NVIDIA GPUs are currently configured for VM passthrough (vfio-pci):")\n\n"
|
||||
msg+="${vfio_list}\n"
|
||||
msg+="$(translate "Installing host drivers while the GPU is assigned to a VM could break passthrough and destabilize the system.")\n\n"
|
||||
msg+="$(translate "To install host drivers, first remove the GPU from VM passthrough configuration and reboot.")"
|
||||
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate "GPU in VM Passthrough Mode")" \
|
||||
--msgbox "$msg" 16 78
|
||||
exit 0
|
||||
}
|
||||
|
||||
detect_driver_status() {
|
||||
CURRENT_DRIVER_INSTALLED=false
|
||||
CURRENT_DRIVER_VERSION=""
|
||||
@@ -91,6 +125,272 @@ detect_driver_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# LXC NVIDIA passthrough — discovery & userspace-libs update
|
||||
# Invoked after the host install/reinstall completes. Aligned with the install
|
||||
# path used in add_gpu_lxc.sh (distro-aware, memory/disk checks, --no-dkms,
|
||||
# --no-install-compat32-libs, visible progress via tee).
|
||||
# ==========================================================
|
||||
find_nvidia_containers() {
|
||||
NVIDIA_CONTAINERS=()
|
||||
for conf in /etc/pve/lxc/*.conf; do
|
||||
[[ -f "$conf" ]] || continue
|
||||
if grep -qiE "dev[0-9]+:.*nvidia" "$conf"; then
|
||||
NVIDIA_CONTAINERS+=("$(basename "$conf" .conf)")
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
get_lxc_nvidia_version() {
|
||||
local ctid="$1"
|
||||
local version=""
|
||||
|
||||
# Prefer nvidia-smi when the container is running (works with .run-installed drivers)
|
||||
if pct status "$ctid" 2>/dev/null | grep -q "running"; then
|
||||
version=$(pct exec "$ctid" -- nvidia-smi \
|
||||
--query-gpu=driver_version --format=csv,noheader 2>/dev/null \
|
||||
| head -1 | tr -d '[:space:]' || true)
|
||||
fi
|
||||
|
||||
# Fallback: dpkg status for apt-installed libcuda1 (dir-type storage, no start needed)
|
||||
if [[ -z "$version" ]]; then
|
||||
local rootfs="/var/lib/lxc/${ctid}/rootfs"
|
||||
if [[ -f "${rootfs}/var/lib/dpkg/status" ]]; then
|
||||
version=$(grep -A5 "^Package: libcuda1$" "${rootfs}/var/lib/dpkg/status" \
|
||||
| grep "^Version:" | head -1 | awk '{print $2}' | cut -d- -f1)
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "${version:-$(translate 'not installed')}"
|
||||
}
|
||||
|
||||
_detect_container_distro() {
|
||||
local distro
|
||||
distro=$(pct exec "$1" -- grep "^ID=" /etc/os-release 2>/dev/null \
|
||||
| cut -d= -f2 | tr -d '[:space:]"')
|
||||
echo "${distro:-unknown}"
|
||||
}
|
||||
|
||||
_ensure_container_memory() {
|
||||
local ctid="$1"
|
||||
local cur_mem
|
||||
cur_mem=$(pct config "$ctid" 2>/dev/null | awk '/^memory:/{print $2}')
|
||||
[[ -z "$cur_mem" ]] && cur_mem=512
|
||||
|
||||
if [[ "$cur_mem" -lt "$NVIDIA_INSTALL_MIN_MB" ]]; then
|
||||
if whiptail --title "$(translate 'Low Container Memory')" --yesno \
|
||||
"$(translate 'Container') ${ctid} $(translate 'has') ${cur_mem}MB RAM.\n\n$(translate 'The NVIDIA installer needs at least') ${NVIDIA_INSTALL_MIN_MB}MB $(translate 'to run without being killed by the OOM killer.')\n\n$(translate 'Increase container RAM temporarily to') ${NVIDIA_INSTALL_MIN_MB}MB?" \
|
||||
13 72; then
|
||||
CT_ORIG_MEM="$cur_mem"
|
||||
pct set "$ctid" -memory "$NVIDIA_INSTALL_MIN_MB" >>"$LOG_FILE" 2>&1 || true
|
||||
else
|
||||
msg_warn "$(translate 'Insufficient memory. Skipping LXC') ${ctid}."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
_restore_container_memory() {
|
||||
local ctid="$1"
|
||||
if [[ -n "$CT_ORIG_MEM" ]]; then
|
||||
msg_info "$(translate 'Restoring container memory to') ${CT_ORIG_MEM}MB..."
|
||||
pct set "$ctid" -memory "$CT_ORIG_MEM" >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'Memory restored.')"
|
||||
CT_ORIG_MEM=""
|
||||
fi
|
||||
}
|
||||
|
||||
_start_container_and_wait() {
|
||||
local ctid="$1"
|
||||
msg_info "$(translate 'Starting container') ${ctid}..."
|
||||
pct start "$ctid" >>"$LOG_FILE" 2>&1 || true
|
||||
|
||||
local ready=false
|
||||
for _ in {1..15}; do
|
||||
sleep 2
|
||||
if pct exec "$ctid" -- true >/dev/null 2>&1; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $ready; then
|
||||
msg_warn "$(translate 'Container') ${ctid} $(translate 'did not become ready. Skipping.')"
|
||||
return 1
|
||||
fi
|
||||
msg_ok "$(translate 'Container') ${ctid} $(translate 'started.')" | tee -a "$screen_capture"
|
||||
return 0
|
||||
}
|
||||
|
||||
update_lxc_nvidia() {
|
||||
local ctid="$1"
|
||||
local version="$2"
|
||||
local started_here=false
|
||||
|
||||
local old_version
|
||||
old_version=$(get_lxc_nvidia_version "$ctid")
|
||||
|
||||
msg_info2 "$(translate 'Container') ${ctid}: $(translate 'updating NVIDIA userspace libs') (${old_version} → ${version})"
|
||||
|
||||
if ! pct status "$ctid" 2>/dev/null | grep -q "running"; then
|
||||
started_here=true
|
||||
_start_container_and_wait "$ctid" || return 1
|
||||
fi
|
||||
|
||||
msg_info "$(translate 'Detecting container OS...')"
|
||||
local distro
|
||||
distro=$(_detect_container_distro "$ctid")
|
||||
msg_ok "$(translate 'Container OS:') ${distro}" | tee -a "$screen_capture"
|
||||
|
||||
local install_rc=0
|
||||
|
||||
case "$distro" in
|
||||
alpine)
|
||||
msg_info2 "$(translate 'Upgrading NVIDIA utils (Alpine)...')"
|
||||
pct exec "$ctid" -- sh -c \
|
||||
"apk update && apk add --no-cache --upgrade nvidia-utils" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
install_rc=${PIPESTATUS[0]}
|
||||
;;
|
||||
arch|manjaro|endeavouros)
|
||||
msg_info2 "$(translate 'Upgrading NVIDIA utils (Arch)...')"
|
||||
pct exec "$ctid" -- bash -c \
|
||||
"pacman -Syu --noconfirm nvidia-utils" \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
install_rc=${PIPESTATUS[0]}
|
||||
;;
|
||||
*)
|
||||
local run_file="${NVIDIA_WORKDIR}/NVIDIA-Linux-x86_64-${version}.run"
|
||||
|
||||
if [[ ! -f "$run_file" ]]; then
|
||||
msg_warn "$(translate 'Installer not found:') ${run_file}. $(translate 'Skipping LXC') ${ctid}."
|
||||
install_rc=1
|
||||
elif ! _ensure_container_memory "$ctid"; then
|
||||
install_rc=1
|
||||
else
|
||||
local free_mb
|
||||
free_mb=$(pct exec "$ctid" -- df -m / 2>/dev/null | awk 'NR==2{print $4}' || echo 0)
|
||||
if [[ "$free_mb" -lt 1500 ]]; then
|
||||
_restore_container_memory "$ctid"
|
||||
dialog --backtitle "ProxMenux" \
|
||||
--title "$(translate 'Insufficient Disk Space')" \
|
||||
--msgbox "\n$(translate 'Container') ${ctid} $(translate 'has only') ${free_mb}MB $(translate 'of free disk space.')\n\n$(translate 'NVIDIA libs require approximately 1.5GB of free space.')" \
|
||||
11 72
|
||||
msg_warn "$(translate 'Insufficient disk space. Skipping LXC') ${ctid}."
|
||||
install_rc=1
|
||||
else
|
||||
local extract_dir="${NVIDIA_WORKDIR}/extracted_${version}"
|
||||
local archive="/tmp/nvidia_lxc_${version}.tar.gz"
|
||||
|
||||
msg_info2 "$(translate 'Extracting NVIDIA installer on host...')"
|
||||
rm -rf "$extract_dir"
|
||||
sh "$run_file" --extract-only --target "$extract_dir" 2>&1 | tee -a "$LOG_FILE"
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
msg_warn "$(translate 'Extraction failed. Check log:') ${LOG_FILE}"
|
||||
_restore_container_memory "$ctid"
|
||||
install_rc=1
|
||||
else
|
||||
msg_ok "$(translate 'NVIDIA installer extracted.')" | tee -a "$screen_capture"
|
||||
|
||||
msg_info2 "$(translate 'Packing installer archive...')"
|
||||
tar --checkpoint=5000 --checkpoint-action=dot \
|
||||
-czf "$archive" -C "$extract_dir" . 2>&1 | tee -a "$LOG_FILE"
|
||||
echo ""
|
||||
local archive_size
|
||||
archive_size=$(du -sh "$archive" 2>/dev/null | cut -f1)
|
||||
msg_ok "$(translate 'Archive ready') (${archive_size})." | tee -a "$screen_capture"
|
||||
|
||||
msg_info "$(translate 'Copying installer to container') ${ctid}..."
|
||||
if ! pct push "$ctid" "$archive" /tmp/nvidia_lxc.tar.gz >>"$LOG_FILE" 2>&1; then
|
||||
msg_warn "$(translate 'pct push failed. Check log:') ${LOG_FILE}"
|
||||
rm -f "$archive"
|
||||
rm -rf "$extract_dir"
|
||||
_restore_container_memory "$ctid"
|
||||
install_rc=1
|
||||
else
|
||||
rm -f "$archive"
|
||||
msg_ok "$(translate 'Installer copied to container.')" | tee -a "$screen_capture"
|
||||
|
||||
msg_info2 "$(translate 'Running NVIDIA installer in container. This may take several minutes...')"
|
||||
echo "" >>"$LOG_FILE"
|
||||
pct exec "$ctid" -- bash -c "
|
||||
mkdir -p /tmp/nvidia_lxc_install
|
||||
tar -xzf /tmp/nvidia_lxc.tar.gz -C /tmp/nvidia_lxc_install 2>&1
|
||||
/tmp/nvidia_lxc_install/nvidia-installer \
|
||||
--no-kernel-modules \
|
||||
--no-questions \
|
||||
--ui=none \
|
||||
--no-nouveau-check \
|
||||
--no-dkms \
|
||||
--no-install-compat32-libs
|
||||
EXIT=\$?
|
||||
rm -rf /tmp/nvidia_lxc_install /tmp/nvidia_lxc.tar.gz
|
||||
exit \$EXIT
|
||||
" 2>&1 | tee -a "$LOG_FILE"
|
||||
install_rc=${PIPESTATUS[0]}
|
||||
|
||||
rm -rf "$extract_dir"
|
||||
_restore_container_memory "$ctid"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $install_rc -ne 0 ]]; then
|
||||
msg_warn "$(translate 'NVIDIA update failed for LXC') ${ctid} (rc=${install_rc}). $(translate 'Check log:') ${LOG_FILE}"
|
||||
if $started_here; then
|
||||
pct stop "$ctid" >>"$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
if pct exec "$ctid" -- sh -c "which nvidia-smi" >/dev/null 2>&1; then
|
||||
local new_ver
|
||||
new_ver=$(pct exec "$ctid" -- nvidia-smi \
|
||||
--query-gpu=driver_version --format=csv,noheader 2>/dev/null \
|
||||
| head -1 | tr -d '[:space:]' || true)
|
||||
msg_ok "$(translate 'Container') ${ctid}: ${old_version} → ${new_ver:-$version}" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_warn "$(translate 'nvidia-smi not found in container') ${ctid} $(translate 'after update.')"
|
||||
fi
|
||||
|
||||
if $started_here; then
|
||||
msg_info "$(translate 'Stopping container') ${ctid}..."
|
||||
pct stop "$ctid" >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'Container stopped.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Post-host-install LXC update offer — scans for NVIDIA LXCs and, if any are
|
||||
# found, asks the user if they want to propagate the driver update to them.
|
||||
offer_lxc_updates_if_any() {
|
||||
local target_version="$1"
|
||||
find_nvidia_containers
|
||||
[[ ${#NVIDIA_CONTAINERS[@]} -eq 0 ]] && return 0
|
||||
|
||||
local info ctid lxc_ver ct_name
|
||||
info="\n$(translate 'The following LXC containers have NVIDIA passthrough configured:')\n\n"
|
||||
for ctid in "${NVIDIA_CONTAINERS[@]}"; do
|
||||
lxc_ver=$(get_lxc_nvidia_version "$ctid")
|
||||
ct_name=$(pct config "$ctid" 2>/dev/null | grep "^hostname:" | awk '{print $2}')
|
||||
info+=" CT ${ctid} ${ct_name:+(${ct_name})} — $(translate 'driver:') ${lxc_ver}\n"
|
||||
done
|
||||
info+="\n$(translate 'Do you want to update the NVIDIA userspace libraries inside these containers to match the host?')"
|
||||
|
||||
if ! hybrid_yesno "$(translate 'Update NVIDIA in LXC Containers')" "$info" 20 80; then
|
||||
msg_info2 "$(translate 'LXC update skipped by user.')"
|
||||
return 0
|
||||
fi
|
||||
|
||||
for ctid in "${NVIDIA_CONTAINERS[@]}"; do
|
||||
update_lxc_nvidia "$ctid" "$target_version" || true
|
||||
done
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
# System preparation (repos, headers, etc.)
|
||||
# ==========================================================
|
||||
@@ -114,10 +414,36 @@ ensure_repos_and_headers() {
|
||||
|
||||
blacklist_nouveau() {
|
||||
msg_info "$(translate 'Blacklisting nouveau driver...')"
|
||||
|
||||
# Write blacklist config files
|
||||
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"
|
||||
|
||||
# Also write explicit options file to ensure it's fully disabled
|
||||
cat > /etc/modprobe.d/nouveau-blacklist.conf <<'EOF'
|
||||
blacklist nouveau
|
||||
options nouveau modeset=0
|
||||
EOF
|
||||
|
||||
# Attempt to unload nouveau if currently loaded
|
||||
if lsmod | grep -q "^nouveau "; then
|
||||
msg_info "$(translate 'Nouveau module is loaded, attempting to unload...')"
|
||||
modprobe -r nouveau 2>/dev/null || true
|
||||
|
||||
# Check if unload succeeded
|
||||
if lsmod | grep -q "^nouveau "; then
|
||||
NOUVEAU_STILL_LOADED=true
|
||||
msg_warn "$(translate 'Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.')"
|
||||
echo "WARNING: nouveau module still loaded after unload attempt" >> "$LOG_FILE"
|
||||
else
|
||||
NOUVEAU_STILL_LOADED=false
|
||||
msg_ok "$(translate 'nouveau module unloaded successfully.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
else
|
||||
NOUVEAU_STILL_LOADED=false
|
||||
msg_ok "$(translate 'nouveau driver has been blacklisted.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_modules_config() {
|
||||
@@ -194,11 +520,12 @@ unload_nvidia_modules() {
|
||||
}
|
||||
|
||||
complete_nvidia_uninstall() {
|
||||
msg_info "$(translate 'Completing NVIDIA uninstallation...')"
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
if command -v nvidia-uninstall >/dev/null 2>&1; then
|
||||
msg_info "$(translate 'Running NVIDIA uninstaller...')"
|
||||
#msg_info "$(translate 'Running NVIDIA uninstaller...')"
|
||||
nvidia-uninstall --silent >>"$LOG_FILE" 2>&1 || true
|
||||
msg_ok "$(translate 'NVIDIA uninstaller completed.')"
|
||||
fi
|
||||
@@ -478,44 +805,72 @@ download_nvidia_installer() {
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}.run"
|
||||
"${NVIDIA_BASE_URL}/${version}/NVIDIA-Linux-x86_64-${version}-no-compat32.run"
|
||||
)
|
||||
|
||||
|
||||
# Web mode (ProxMenux Monitor) runs scripts without a controlling TTY, so
|
||||
# /dev/tty is not writable and progress-bar animations using \r don't render
|
||||
# in the web terminal. Fall back to a quiet wget in that case; interactive
|
||||
# users (SSH / console) still get the ISO-like progress bar.
|
||||
local _nv_has_tty=false
|
||||
if ! is_web_mode 2>/dev/null && [[ -t 2 ]]; then
|
||||
_nv_has_tty=true
|
||||
fi
|
||||
|
||||
if $_nv_has_tty; then
|
||||
printf '\n %s NVIDIA-Linux-x86_64-%s.run\n' \
|
||||
"$(translate 'Downloading')" "$version" >/dev/tty
|
||||
else
|
||||
echo " $(translate 'Downloading') NVIDIA-Linux-x86_64-${version}.run" >&2
|
||||
fi
|
||||
|
||||
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
|
||||
local _dl_ok=false
|
||||
if $_nv_has_tty; then
|
||||
# Interactive: progress bar to /dev/tty (bypasses any caller redirection).
|
||||
if wget --no-verbose --show-progress \
|
||||
--connect-timeout=30 --timeout=600 --tries=1 \
|
||||
-O "$run_file" "$url" 2>/dev/tty; then
|
||||
_dl_ok=true
|
||||
fi
|
||||
else
|
||||
# Web / no-TTY: silent wget, log errors only.
|
||||
if wget --quiet \
|
||||
--connect-timeout=30 --timeout=600 --tries=1 \
|
||||
-O "$run_file" "$url" 2>>"$LOG_FILE"; then
|
||||
_dl_ok=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if $_dl_ok; 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
|
||||
@@ -526,11 +881,11 @@ download_nvidia_installer() {
|
||||
rm -f "$run_file"
|
||||
fi
|
||||
else
|
||||
echo "ERROR: curl failed for $url (exit code: $?)" >> "$LOG_FILE"
|
||||
echo "ERROR: wget 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
|
||||
@@ -553,10 +908,37 @@ run_nvidia_installer() {
|
||||
echo "" >>"$LOG_FILE"
|
||||
echo "=== Running NVIDIA installer: $installer ===" >>"$LOG_FILE"
|
||||
|
||||
# If nouveau is still loaded, rebuild initramfs first so the blacklist takes
|
||||
# effect for the installer sanity checks. Without this the .run installer
|
||||
# detects nouveau as active and aborts even when --disable-nouveau is passed.
|
||||
if [[ "${NOUVEAU_STILL_LOADED:-false}" == "true" ]]; then
|
||||
msg_info "$(translate 'Rebuilding initramfs to apply nouveau blacklist before installation...')"
|
||||
update-initramfs -u -k all >>"$LOG_FILE" 2>&1 || true
|
||||
# Try one more time to unload nouveau after initramfs rebuild
|
||||
modprobe -r nouveau 2>/dev/null || true
|
||||
if lsmod | grep -q "^nouveau "; then
|
||||
echo "WARNING: nouveau still loaded after initramfs rebuild, proceeding with --no-nouveau-check" >> "$LOG_FILE"
|
||||
msg_warn "$(translate 'nouveau still active. Proceeding with installation. A reboot will be required for the driver to work.')"
|
||||
else
|
||||
NOUVEAU_STILL_LOADED=false
|
||||
msg_ok "$(translate 'nouveau module unloaded after initramfs rebuild.')" | tee -a "$screen_capture"
|
||||
fi
|
||||
fi
|
||||
|
||||
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"
|
||||
|
||||
# --no-nouveau-check: prevents the installer from aborting when nouveau is
|
||||
# still loaded. The blacklist files are already in place; nouveau will be
|
||||
# gone after the reboot that the script offers at the end.
|
||||
sh "$installer" \
|
||||
--tmpdir="$tmp_extract_dir" \
|
||||
--no-questions \
|
||||
--ui=none \
|
||||
--disable-nouveau \
|
||||
--no-nouveau-check \
|
||||
--dkms \
|
||||
2>&1 | tee -a "$LOG_FILE"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
echo "" >>"$LOG_FILE"
|
||||
|
||||
@@ -667,18 +1049,32 @@ show_install_overview() {
|
||||
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 'Enable IOMMU support if not enabled')\n"
|
||||
overview+=" • $(translate 'Optionally update NVIDIA libs in LXC containers with passthrough')\n\n"
|
||||
|
||||
overview+="$(translate 'Detected GPU(s):')\n"
|
||||
overview+="\Zb\Z4$DETECTED_GPUS_TEXT\Zn\n"
|
||||
overview+="\Zb\Z4$DETECTED_GPUS_TEXT\Zn\n"
|
||||
|
||||
overview+="\n\Zn$(translate 'Current status: ') "
|
||||
overview+="\Zb${CURRENT_STATUS_TEXT}\Zn\n\n"
|
||||
overview+="\Zb${CURRENT_STATUS_TEXT}\Zn\n"
|
||||
|
||||
overview+="$(translate 'After confirming, you will be asked to choose the NVIDIA driver version to install.')\n\n"
|
||||
# Scan for LXC containers with NVIDIA passthrough and surface them in the
|
||||
# overview so the user knows upfront they will be offered a driver update.
|
||||
find_nvidia_containers
|
||||
if [[ ${#NVIDIA_CONTAINERS[@]} -gt 0 ]]; then
|
||||
overview+="\n$(translate 'LXC containers with NVIDIA passthrough:')\n"
|
||||
local ctid lxc_ver ct_name
|
||||
for ctid in "${NVIDIA_CONTAINERS[@]}"; do
|
||||
lxc_ver=$(get_lxc_nvidia_version "$ctid")
|
||||
ct_name=$(pct config "$ctid" 2>/dev/null | grep "^hostname:" | awk '{print $2}')
|
||||
overview+=" \Zb\Z4CT ${ctid}\Zn ${ct_name:+(${ct_name})} — $(translate 'driver:') ${lxc_ver}\n"
|
||||
done
|
||||
fi
|
||||
|
||||
overview+="\n$(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
|
||||
hybrid_yesno "$(translate 'NVIDIA GPU Driver Installation')" "$overview" 24 90
|
||||
}
|
||||
|
||||
show_version_menu() {
|
||||
@@ -784,8 +1180,11 @@ main() {
|
||||
: >"$LOG_FILE"
|
||||
: >"$screen_capture"
|
||||
|
||||
NOUVEAU_STILL_LOADED=false
|
||||
|
||||
detect_nvidia_gpus
|
||||
detect_driver_status
|
||||
check_gpu_not_in_vm_passthrough
|
||||
|
||||
if ! $NVIDIA_GPU_PRESENT; then
|
||||
dialog --backtitle "ProxMenux" --title "$(translate 'NVIDIA GPU Driver Installation')" --msgbox \
|
||||
@@ -846,18 +1245,20 @@ main() {
|
||||
stop_and_disable_nvidia_services
|
||||
unload_nvidia_modules
|
||||
|
||||
msg_info "$(translate 'Downloading NVIDIA driver version:') $DRIVER_VERSION"
|
||||
|
||||
# No msg_info spinner here — it would clash with wget --show-progress,
|
||||
# which writes its progress bar directly to /dev/tty from inside the
|
||||
# download function. Stderr from the function is allowed through so
|
||||
# warnings/errors reach the user.
|
||||
local installer
|
||||
installer=$(download_nvidia_installer "$DRIVER_VERSION" 2>>"$LOG_FILE")
|
||||
installer=$(download_nvidia_installer "$DRIVER_VERSION")
|
||||
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')"
|
||||
|
||||
msg_ok "$(translate 'NVIDIA installer downloaded successfully')" | tee -a "$screen_capture"
|
||||
|
||||
if [[ -z "$installer" || ! -f "$installer" ]]; then
|
||||
msg_error "$(translate 'Internal error: NVIDIA installer path is empty or file not found.')"
|
||||
@@ -901,6 +1302,13 @@ main() {
|
||||
update_component_status "nvidia_driver" "failed" "" "gpu" '{"patched":false}'
|
||||
fi
|
||||
|
||||
# Propagate the new driver to LXC containers with NVIDIA passthrough, if any.
|
||||
# Uses the same .run installer cached in $NVIDIA_WORKDIR — runs only if the
|
||||
# host install succeeded and the user confirms.
|
||||
if [[ -n "$CURRENT_DRIVER_VERSION" ]]; then
|
||||
offer_lxc_updates_if_any "$CURRENT_DRIVER_VERSION"
|
||||
fi
|
||||
|
||||
apply_nvidia_patch_if_needed
|
||||
restart_prompt
|
||||
;;
|
||||
@@ -928,4 +1336,4 @@ main() {
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
main
|
||||
fi
|
||||
fi
|
||||
@@ -1,916 +0,0 @@
|
||||
#!/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
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+95
-21
@@ -303,7 +303,7 @@ show_storage_commands() {
|
||||
15) cmd="lvs" ;;
|
||||
16) cmd="cat /etc/pve/storage.cfg" ;;
|
||||
17) cmd="pvesm status" ;;
|
||||
19)
|
||||
18)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter storage ID: ')${CL}"
|
||||
read -r store
|
||||
cmd="pvesm list $store"
|
||||
@@ -591,42 +591,116 @@ show_update_commands() {
|
||||
|
||||
|
||||
# ===============================================================
|
||||
# 06 GPU Passthrough Commands
|
||||
# 06 GPU/TPU Passthrough Commands
|
||||
# ===============================================================
|
||||
show_gpu_commands() {
|
||||
while true; do
|
||||
clear
|
||||
echo -e "${YELLOW}$(translate 'GPU Passthrough Commands')${NC}"
|
||||
echo "------------------------------------------------"
|
||||
echo -e " 1) ${GREEN}lspci -nn | grep -i nvidia${NC} - $(translate 'List NVIDIA PCI devices')"
|
||||
echo -e " 2) ${GREEN}lspci -nn | grep -i vga${NC} - $(translate 'List all VGA compatible devices')"
|
||||
echo -e " 3) ${GREEN}dmesg | grep -i vfio${NC} - $(translate 'Check VFIO module messages')"
|
||||
echo -e " 4) ${GREEN}cat /etc/modprobe.d/vfio.conf${NC} - $(translate 'Review VFIO passthrough configuration')"
|
||||
echo -e " 5) ${GREEN}update-initramfs -u${NC} - $(translate 'Apply initramfs changes (VFIO)')"
|
||||
echo -e " 6) ${GREEN}cat /etc/default/grub${NC} - $(translate 'Review GRUB options for IOMMU')"
|
||||
echo -e " 7) ${GREEN}update-grub${NC} - $(translate 'Apply GRUB changes')"
|
||||
echo -e "${YELLOW}$(translate 'GPU/TPU Passthrough Commands')${NC}"
|
||||
echo -e "${TAB}${YW}$(translate 'Inspection commands run directly. Template commands [T] require parameter substitution.')${CL}"
|
||||
echo "------------------------------------------------------------"
|
||||
echo -e " 1) ${GREEN}lspci -nn | grep -iE 'VGA|3D|Display'${NC} - $(translate 'Detect GPUs in host')"
|
||||
echo -e " 2) ${GREEN}lspci -nnk | grep -A3 -Ei 'VGA|3D'${NC} - $(translate 'Show GPU kernel driver in use')"
|
||||
echo -e " 3) ${GREEN}cat /proc/cmdline${NC} - $(translate 'Check kernel params (IOMMU flags)')"
|
||||
echo -e " 4) ${GREEN}dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie'${NC} - $(translate 'Inspect passthrough/kernel events')"
|
||||
echo -e " 5) ${GREEN}find /sys/kernel/iommu_groups -type l${NC} - $(translate 'List IOMMU group mapping')"
|
||||
echo -e " 6) ${GREEN}lsmod | grep -E 'vfio|nvidia|amdgpu|apex'${NC} - $(translate 'Check loaded GPU/TPU modules')"
|
||||
echo -e " 7) ${GREEN}grep -R \"vfio-pci|blacklist\" /etc/modprobe.d${NC} - $(translate 'Review passthrough config files')"
|
||||
echo -e " 8) ${GREEN}nvidia-smi${NC} - $(translate 'Check NVIDIA driver and devices')"
|
||||
echo -e " 9) ${GREEN}qm config <vmid> | grep 'hostpci|bios'${NC} - [T] $(translate 'Check VM passthrough settings')"
|
||||
echo -e "10) ${GREEN}pct config <ctid> | grep 'dev|lxc.cgroup2'${NC} - [T] $(translate 'Check LXC GPU/TPU mapping')"
|
||||
echo -e "11) ${GREEN}ls -l /dev/dri /dev/kfd /dev/nvidia*${NC} - $(translate 'Inspect host device nodes')"
|
||||
echo -e "12) ${GREEN}qm set <vmid> --hostpci<slot> <BDF>,pcie=1${NC} - [T] $(translate 'Assign GPU PCI function to VM')"
|
||||
echo -e "13) ${GREEN}qm set <vmid> -delete hostpci<slot>${NC} - [T] $(translate 'Remove passthrough device from VM')"
|
||||
echo -e "14) ${GREEN}qm set <vmid> -onboot 0${NC} - [T] $(translate 'Disable autostart on conflicting VM')"
|
||||
echo -e "15) ${GREEN}sed -i '/GRUB_CMDLINE_LINUX_DEFAULT/ s|...|'${NC} - [T] $(translate 'Enable IOMMU in GRUB or ZFS boot')"
|
||||
echo -e "16) ${GREEN}update-initramfs -u && proxmox-boot-tool${NC} - [T] $(translate 'Apply boot/initramfs changes')"
|
||||
echo -e "17) ${GREEN}lsusb | grep Coral ; lspci | grep Unichip${NC} - $(translate 'Check Coral USB/M.2 detection')"
|
||||
echo -e " ${DEF}0) $(translate ' Back to previous menu or Esc + Enter')"
|
||||
echo
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter a number, or write or paste a command: ') ${CL}"
|
||||
read -r user_input
|
||||
|
||||
# Check for Esc key press
|
||||
if [[ "$user_input" == $'\x1b' ]]; then
|
||||
break
|
||||
fi
|
||||
|
||||
mode="exec"
|
||||
case "$user_input" in
|
||||
1) cmd="lspci -nn | grep -i nvidia" ;;
|
||||
2) cmd="lspci -nn | grep -i vga" ;;
|
||||
3) cmd="dmesg | grep -i vfio" ;;
|
||||
4) cmd="cat /etc/modprobe.d/vfio.conf" ;;
|
||||
5) cmd="update-initramfs -u" ;;
|
||||
6) cmd="cat /etc/default/grub" ;;
|
||||
7) cmd="update-grub" ;;
|
||||
1) cmd="lspci -nn | grep -iE 'VGA compatible|3D controller|Display controller'" ;;
|
||||
2) cmd="lspci -nnk | grep -A3 -Ei 'VGA compatible|3D controller|Display controller'" ;;
|
||||
3) cmd="cat /proc/cmdline" ;;
|
||||
4) cmd="dmesg -T | grep -Ei 'DMAR|IOMMU|vfio|pcie|AER|reset'" ;;
|
||||
5) cmd="find /sys/kernel/iommu_groups -type l" ;;
|
||||
6) cmd="lsmod | grep -E 'vfio|nvidia|amdgpu|i915|apex|gasket'" ;;
|
||||
7) cmd="grep -R \"vfio-pci\\|blacklist .*nvidia\\|blacklist .*amdgpu\\|blacklist .*radeon\" /etc/modprobe.d /etc/modules /etc/default/grub /etc/kernel/cmdline 2>/dev/null" ;;
|
||||
8) cmd="nvidia-smi" ;;
|
||||
9)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"
|
||||
read -r vmid
|
||||
cmd="qm config $vmid | grep -E '^(hostpci|cpu:|machine:|bios:|args:|boot:)'"
|
||||
;;
|
||||
10)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter CT ID: ')${CL}"
|
||||
read -r ctid
|
||||
cmd="pct config $ctid | grep -E '^(dev[0-9]+:|lxc\\.cgroup2\\.devices\\.allow:|lxc\\.mount\\.entry:|features:)'"
|
||||
;;
|
||||
11) cmd="ls -l /dev/dri /dev/kfd /dev/nvidia* /dev/apex* 2>/dev/null" ;;
|
||||
12)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter PCI BDF (e.g. 0000:01:00.0): ')${CL}"; read -r bdf
|
||||
cmd="qm set $vmid --hostpci${slot} ${bdf},pcie=1"
|
||||
mode="template"
|
||||
;;
|
||||
13)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter hostpci slot (e.g. 0): ')${CL}"; read -r slot
|
||||
cmd="qm set $vmid -delete hostpci${slot}"
|
||||
mode="template"
|
||||
;;
|
||||
14)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Enter VM ID: ')${CL}"; read -r vmid
|
||||
cmd="qm set $vmid -onboot 0"
|
||||
mode="template"
|
||||
;;
|
||||
15)
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'Boot type (grub/zfs): ')${CL}"; read -r boot_type
|
||||
echo -en "${TAB}${BOLD}${YW}${HOLD}$(translate 'CPU vendor (intel/amd): ')${CL}"; read -r cpu_vendor
|
||||
case "$cpu_vendor" in
|
||||
amd|AMD) iommu_param="amd_iommu=on iommu=pt" ;;
|
||||
*) iommu_param="intel_iommu=on iommu=pt" ;;
|
||||
esac
|
||||
case "$boot_type" in
|
||||
zfs|ZFS) cmd="sed -i 's/\$/ ${iommu_param}/' /etc/kernel/cmdline" ;;
|
||||
*) cmd="sed -i '/GRUB_CMDLINE_LINUX_DEFAULT=/ s|\"$| ${iommu_param}\"|' /etc/default/grub" ;;
|
||||
esac
|
||||
mode="template"
|
||||
;;
|
||||
16)
|
||||
cmd="update-initramfs -u -k all && (proxmox-boot-tool refresh || update-grub)"
|
||||
mode="template"
|
||||
;;
|
||||
17) cmd="lsusb | grep -Ei '18d1:9302|1a6e:089a' ; lspci | grep -i 'Global Unichip'" ;;
|
||||
0) break ;;
|
||||
*) cmd="$user_input" ;;
|
||||
*)
|
||||
if [[ -n "$user_input" ]]; then
|
||||
cmd="$user_input"
|
||||
else
|
||||
continue
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$mode" == "template" ]]; then
|
||||
echo -e "\n${GREEN}$(translate 'Manual command template (copy/paste):')${NC}\n"
|
||||
echo "$cmd"
|
||||
echo
|
||||
msg_success "$(translate 'Press ENTER to continue...')"
|
||||
read -r tmp
|
||||
continue
|
||||
fi
|
||||
|
||||
echo -e "\n${GREEN}> $cmd${NC}\n"
|
||||
bash -c "$cmd"
|
||||
echo
|
||||
@@ -913,7 +987,7 @@ show_tools_commands() {
|
||||
while true; do
|
||||
OPTION=$(dialog --stdout \
|
||||
--title "$(translate 'Help and Info')" \
|
||||
--menu "\n$(translate 'Select a category of useful commands:')" 20 70 9 \
|
||||
--menu "$(translate 'Select a category of useful commands:')" 20 70 9 \
|
||||
1 "$(translate 'Useful System Commands')" \
|
||||
2 "$(translate 'VM and CT Management Commands')" \
|
||||
3 "$(translate 'Storage and Disks Commands')" \
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.1
|
||||
# Last Updated: 29/05/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script automates the process of importing disk images into Proxmox VE virtual machines (VMs),
|
||||
# making it easy to attach pre-existing disk files without manual configuration.
|
||||
#
|
||||
# Before running the script, ensure that disk images are available in /var/lib/vz/template/images/.
|
||||
# The script scans this directory for compatible formats (.img, .qcow2, .vmdk, .raw) and lists the available files.
|
||||
#
|
||||
# Using an interactive menu, you can:
|
||||
# - Select a VM to attach the imported disk.
|
||||
# - Choose one or multiple disk images for import.
|
||||
# - Pick a storage volume in Proxmox for disk placement.
|
||||
# - Assign a suitable interface (SATA, SCSI, VirtIO, or IDE).
|
||||
# - Enable optional settings like SSD emulation or bootable disk configuration.
|
||||
#
|
||||
# Once completed, the script ensures the selected images are correctly attached and ready to use.
|
||||
# ==========================================================
|
||||
|
||||
# Configuration ============================================
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
[[ -f "$UTILS_FILE" ]] && source "$UTILS_FILE"
|
||||
load_language
|
||||
initialize_cache
|
||||
# Configuration ============================================
|
||||
|
||||
|
||||
detect_image_dir() {
|
||||
for store in $(pvesm status -content images | awk 'NR>1 {print $1}'); do
|
||||
path=$(pvesm path "${store}:template" 2>/dev/null)
|
||||
if [[ -d "$path" ]]; then
|
||||
for ext in raw img qcow2 vmdk; do
|
||||
if compgen -G "$path/*.$ext" > /dev/null; then
|
||||
echo "$path"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
for sub in images iso; do
|
||||
dir="$path/$sub"
|
||||
if [[ -d "$dir" ]]; then
|
||||
for ext in raw img qcow2 vmdk; do
|
||||
if compgen -G "$dir/*.$ext" > /dev/null; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
for fallback in /var/lib/vz/template/images /var/lib/vz/template/iso; do
|
||||
if [[ -d "$fallback" ]]; then
|
||||
for ext in raw img qcow2 vmdk; do
|
||||
if compgen -G "$fallback/*.$ext" > /dev/null; then
|
||||
echo "$fallback"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
IMAGES_DIR=$(detect_image_dir)
|
||||
if [[ -z "$IMAGES_DIR" ]]; then
|
||||
dialog --title "$(translate 'No Images Found')" \
|
||||
--msgbox "$(translate 'Could not find any directory containing disk images')\n\n$(translate 'Make sure there is at least one file with extension .img, .qcow2, .vmdk or .raw')" 15 60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGES=$(ls -A "$IMAGES_DIR" | grep -E "\.(img|qcow2|vmdk|raw)$")
|
||||
if [ -z "$IMAGES" ]; then
|
||||
dialog --title "$(translate 'No Disk Images Found')" \
|
||||
--msgbox "$(translate 'No compatible disk images found in:')\n\n$IMAGES_DIR\n\n$(translate 'Supported formats: .img, .qcow2, .vmdk, .raw')" 15 60
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# === Select VM
|
||||
msg_info "$(translate 'Getting VM list')"
|
||||
VM_LIST=$(qm list | awk 'NR>1 {print $1" "$2}')
|
||||
[[ -z "$VM_LIST" ]] && { msg_error "$(translate 'No VMs available in the system')"; exit 1; }
|
||||
msg_ok "$(translate 'VM list obtained')"
|
||||
|
||||
VMID=$(whiptail --title "$(translate 'Select VM')" \
|
||||
--menu "$(translate 'Select the VM where you want to import the disk image:')" 20 70 10 $VM_LIST 3>&1 1>&2 2>&3)
|
||||
[[ -z "$VMID" ]] && exit 1
|
||||
|
||||
|
||||
|
||||
|
||||
# === Select storage
|
||||
msg_info "$(translate 'Getting storage volumes')"
|
||||
STORAGE_LIST=$(pvesm status -content images | awk 'NR>1 {print $1}')
|
||||
[[ -z "$STORAGE_LIST" ]] && { msg_error "$(translate 'No storage volumes available')"; exit 1; }
|
||||
msg_ok "$(translate 'Storage volumes obtained')"
|
||||
|
||||
STORAGE_OPTIONS=()
|
||||
while read -r storage; do STORAGE_OPTIONS+=("$storage" ""); done <<< "$STORAGE_LIST"
|
||||
STORAGE=$(whiptail --title "$(translate 'Select Storage')" \
|
||||
--menu "$(translate 'Select the storage volume for disk import:')" 20 70 10 "${STORAGE_OPTIONS[@]}" 3>&1 1>&2 2>&3)
|
||||
[[ -z "$STORAGE" ]] && exit 1
|
||||
|
||||
|
||||
|
||||
# === Select images
|
||||
IMAGE_OPTIONS=()
|
||||
while read -r img; do IMAGE_OPTIONS+=("$img" "" "OFF"); done <<< "$IMAGES"
|
||||
SELECTED_IMAGES=$(whiptail --title "$(translate 'Select Disk Images')" \
|
||||
--checklist "$(translate 'Select the disk images to import:')" 20 70 12 "${IMAGE_OPTIONS[@]}" 3>&1 1>&2 2>&3)
|
||||
[[ -z "$SELECTED_IMAGES" ]] && exit 1
|
||||
|
||||
|
||||
|
||||
# === Import each selected image
|
||||
for IMAGE in $SELECTED_IMAGES; do
|
||||
IMAGE=$(echo "$IMAGE" | tr -d '"')
|
||||
INTERFACE=$(whiptail --title "$(translate 'Interface Type')" --menu "$(translate 'Select the interface type for the image:') $IMAGE" 15 40 4 \
|
||||
"sata" "SATA" "scsi" "SCSI" "virtio" "VirtIO" "ide" "IDE" 3>&1 1>&2 2>&3)
|
||||
[[ -z "$INTERFACE" ]] && { msg_error "$(translate 'No interface type selected for') $IMAGE"; continue; }
|
||||
|
||||
FULL_PATH="$IMAGES_DIR/$IMAGE"
|
||||
msg_info "$(translate 'Importing image:') $IMAGE"
|
||||
TEMP_DISK_FILE=$(mktemp)
|
||||
|
||||
qm importdisk "$VMID" "$FULL_PATH" "$STORAGE" 2>&1 | while read -r line; do
|
||||
if [[ "$line" =~ transferred ]]; then
|
||||
PERCENT=$(echo "$line" | grep -oP "\(\d+\.\d+%\)" | tr -d '()%')
|
||||
echo -ne "\r${TAB}${BL}-$(translate 'Importing image:') $IMAGE-${CL} ${PERCENT}%"
|
||||
elif [[ "$line" =~ successfully\ imported\ disk ]]; then
|
||||
echo "$line" | grep -oP "(?<=successfully imported disk ').*(?=')" > "$TEMP_DISK_FILE"
|
||||
fi
|
||||
done
|
||||
echo -ne "\n"
|
||||
IMPORT_STATUS=${PIPESTATUS[0]}
|
||||
|
||||
|
||||
|
||||
if [ "$IMPORT_STATUS" -eq 0 ]; then
|
||||
msg_ok "$(translate 'Image imported successfully')"
|
||||
IMPORTED_DISK=$(cat "$TEMP_DISK_FILE")
|
||||
rm -f "$TEMP_DISK_FILE"
|
||||
|
||||
if [ -n "$IMPORTED_DISK" ]; then
|
||||
EXISTING_DISKS=$(qm config "$VMID" | grep -oP "${INTERFACE}\d+" | sort -n)
|
||||
NEXT_SLOT=0
|
||||
[[ -n "$EXISTING_DISKS" ]] && NEXT_SLOT=$(( $(echo "$EXISTING_DISKS" | tail -n1 | sed "s/${INTERFACE}//") + 1 ))
|
||||
|
||||
SSD_OPTION=""
|
||||
if [ "$INTERFACE" != "virtio" ]; then
|
||||
whiptail --yesno "$(translate 'Do you want to use SSD emulation for this disk?')" 10 60 && SSD_OPTION=",ssd=1"
|
||||
fi
|
||||
|
||||
msg_info "$(translate 'Configuring disk')"
|
||||
if qm set "$VMID" --${INTERFACE}${NEXT_SLOT} "$IMPORTED_DISK${SSD_OPTION}" &>/dev/null; then
|
||||
msg_ok "$(translate 'Image') $IMAGE $(translate 'configured as') ${INTERFACE}${NEXT_SLOT}"
|
||||
whiptail --yesno "$(translate 'Do you want to make this disk bootable?')" 10 60 && {
|
||||
msg_info "$(translate 'Configuring disk as bootable')"
|
||||
if qm set "$VMID" --boot c --bootdisk ${INTERFACE}${NEXT_SLOT} &>/dev/null; then
|
||||
msg_ok "$(translate 'Disk configured as bootable')"
|
||||
else
|
||||
msg_error "$(translate 'Could not configure the disk as bootable')"
|
||||
fi
|
||||
}
|
||||
else
|
||||
msg_error "$(translate 'Could not configure disk') ${INTERFACE}${NEXT_SLOT} $(translate 'for VM') $VMID"
|
||||
fi
|
||||
else
|
||||
msg_error "$(translate 'Could not find the imported disk')"
|
||||
fi
|
||||
else
|
||||
msg_error "$(translate 'Could not import') $IMAGE"
|
||||
fi
|
||||
done
|
||||
|
||||
msg_ok "$(translate 'All selected images have been processed')"
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
@@ -1,256 +0,0 @@
|
||||
#!/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.1
|
||||
# Last Updated: 16/05/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).
|
||||
# ==========================================================
|
||||
|
||||
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
|
||||
|
||||
# ==========================================================
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
add_udev_rule_for_coral_usb_() {
|
||||
RULE_FILE="/etc/udev/rules.d/99-coral-usb.rules"
|
||||
RULE_CONTENT='SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="9302", MODE="0666", TAG+="uaccess"'
|
||||
|
||||
if [[ ! -f "$RULE_FILE" ]] || ! grep -qF "$RULE_CONTENT" "$RULE_FILE"; then
|
||||
echo "$RULE_CONTENT" > "$RULE_FILE"
|
||||
udevadm control --reload-rules && udevadm trigger
|
||||
msg_ok "$(translate 'Udev rule for Coral USB added and rules reloaded.')"
|
||||
else
|
||||
msg_ok "$(translate 'Udev rule for Coral USB already exists.')"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
add_mount_if_needed() {
|
||||
local DEVICE="$1"
|
||||
local DEST="$2"
|
||||
local CONFIG_FILE="$3"
|
||||
if [ -e "$DEVICE" ] && ! grep -q "lxc.mount.entry: $DEVICE" "$CONFIG_FILE"; then
|
||||
echo "lxc.mount.entry: $DEVICE $DEST none bind,optional,create=$( [ -c "$DEVICE" ] && echo file || echo dir )" >> "$CONFIG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Privileged container
|
||||
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 -q "features: nesting=1" "$CONFIG_FILE"; then
|
||||
echo "features: nesting=1" >> "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# iGPU support
|
||||
if ! grep -q "c 226:0 rwm" "$CONFIG_FILE"; then
|
||||
echo "lxc.cgroup2.devices.allow: c 226:0 rwm # iGPU" >> "$CONFIG_FILE"
|
||||
echo "lxc.cgroup2.devices.allow: c 226:128 rwm # iGPU" >> "$CONFIG_FILE"
|
||||
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"
|
||||
|
||||
# Framebuffer support
|
||||
if ! grep -q "c 29:0 rwm # Framebuffer" "$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"
|
||||
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Coral USB passthrough (via udev + /dev/coral)
|
||||
# ----------------------------------------------------------
|
||||
add_udev_rule_for_coral_usb
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 189:\* rwm # Coral USB$" "$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"
|
||||
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# Coral M.2 (PCIe) support
|
||||
# ----------------------------------------------------------
|
||||
if lspci | grep -iq "Global Unichip"; then
|
||||
if ! grep -Pq "^lxc.cgroup2.devices.allow: c 245:0 rwm # Coral M2 Apex$" "$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"
|
||||
fi
|
||||
|
||||
|
||||
msg_ok "$(translate 'Coral TPU and iGPU configuration added to container') $CONTAINER_ID."
|
||||
}
|
||||
|
||||
install_coral_in_container() {
|
||||
msg_info2 "$(translate 'Installing iGPU and Coral TPU drivers inside the container...')"
|
||||
tput sc
|
||||
LOG_FILE=$(mktemp)
|
||||
|
||||
pct start "$CONTAINER_ID"
|
||||
|
||||
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
|
||||
|
||||
script -q -c "pct exec \"$CONTAINER_ID\" -- bash -c '
|
||||
set -e
|
||||
echo \"- Updating package lists...\"
|
||||
apt-get update
|
||||
echo \"- Installing iGPU drivers...\"
|
||||
apt-get install -y va-driver-all ocl-icd-libopencl1 intel-opencl-icd vainfo intel-gpu-tools
|
||||
chgrp video /dev/dri && chmod 755 /dev/dri
|
||||
adduser root video && adduser root render
|
||||
|
||||
echo \"- Installing Coral TPU dependencies...\"
|
||||
apt-get install -y gnupg python3 python3-pip python3-venv
|
||||
|
||||
echo \"- 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
|
||||
|
||||
echo \"- Updating package lists again...\"
|
||||
apt-get update
|
||||
echo \"- Installing Coral TPU driver ($DRIVER_PACKAGE)...\"
|
||||
apt-get install -y $DRIVER_PACKAGE
|
||||
'" "$LOG_FILE"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
tput rc
|
||||
tput ed
|
||||
rm -f "$LOG_FILE"
|
||||
msg_ok "$(translate 'iGPU and Coral TPU drivers installed inside the container.')"
|
||||
else
|
||||
msg_error "$(translate 'Failed to install iGPU and Coral TPU drivers inside the container.')"
|
||||
cat "$LOG_FILE"
|
||||
rm -f "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
select_container
|
||||
show_proxmenux_logo
|
||||
configure_lxc_hardware
|
||||
install_coral_in_container
|
||||
|
||||
msg_ok "$(translate 'Configuration completed.')"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# This script installs the Coral TPU drivers on the Proxmox VE host.
|
||||
# It ensures that necessary packages are installed and compiles the
|
||||
# Coral TPU drivers for proper functionality.
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
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
|
||||
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Prompt before installation
|
||||
pre_install_prompt() {
|
||||
if ! whiptail --title "$(translate 'Coral TPU Installation')" --yesno "$(translate 'Installing Coral TPU drivers requires rebooting the server after installation. Do you want to proceed?')" 10 70; then
|
||||
msg_warn "$(translate 'Installation cancelled by user.')"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Verify and configure repositories on the host
|
||||
verify_and_add_repos() {
|
||||
msg_info "$(translate 'Configuring necessary repositories on the host...')"
|
||||
sleep 2
|
||||
|
||||
if ! grep -q "pve-no-subscription" /etc/apt/sources.list /etc/apt/sources.list.d/* 2>/dev/null; then
|
||||
echo "deb http://download.proxmox.com/debian/pve $(lsb_release -sc) pve-no-subscription" | tee /etc/apt/sources.list.d/pve-no-subscription.list
|
||||
msg_ok "$(translate 'pve-no-subscription repository added.')"
|
||||
fi
|
||||
|
||||
if ! grep -q "non-free-firmware" /etc/apt/sources.list; then
|
||||
echo "deb http://deb.debian.org/debian $(lsb_release -sc) main contrib non-free-firmware
|
||||
deb http://deb.debian.org/debian $(lsb_release -sc)-updates main contrib non-free-firmware
|
||||
deb http://security.debian.org/debian-security $(lsb_release -sc)-security main contrib non-free-firmware" | tee -a /etc/apt/sources.list
|
||||
msg_ok "$(translate 'non-free-firmware repositories added.')"
|
||||
fi
|
||||
|
||||
msg_ok "$(translate 'Added repositories')"
|
||||
sleep 2
|
||||
|
||||
msg_info "$(translate 'Verifying repositories...')"
|
||||
apt-get update &>/dev/null
|
||||
|
||||
msg_ok "$(translate 'Verified and updated repositories.')"
|
||||
}
|
||||
|
||||
# Function to install Coral TPU drivers on the host
|
||||
install_coral_host() {
|
||||
show_proxmenux_logo
|
||||
verify_and_add_repos
|
||||
|
||||
apt-get install -y git devscripts dh-dkms dkms pve-headers-$(uname -r) >/dev/null 2>&1
|
||||
|
||||
cd /tmp
|
||||
rm -rf gasket-driver
|
||||
git clone https://github.com/google/gasket-driver.git
|
||||
if [ $? -ne 0 ]; then
|
||||
msg_error "$(translate 'Error: Could not clone the repository.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd gasket-driver/
|
||||
debuild -us -uc -tc -b
|
||||
if [ $? -ne 0 ]; then
|
||||
msg_error "$(translate 'Error: Failed to build driver packages.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dpkg -i ../gasket-dkms_*.deb
|
||||
if [ $? -ne 0 ]; then
|
||||
msg_error "$(translate 'Error: Failed to install the driver packages.')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
msg_success "$(translate 'Coral TPU drivers installed successfully on the host.')"
|
||||
echo -e
|
||||
}
|
||||
|
||||
# Prompt for reboot after installation
|
||||
restart_prompt() {
|
||||
if whiptail --title "$(translate 'Coral TPU Installation')" --yesno "$(translate 'The installation requires a server restart to apply changes. Do you want to restart now?')" 10 70; then
|
||||
msg_warn "$(translate 'Restarting the server...')"
|
||||
reboot
|
||||
else
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
pre_install_prompt
|
||||
install_coral_host
|
||||
restart_prompt
|
||||
@@ -15,7 +15,7 @@
|
||||
# configurations, streamlining the deployment of Linux, Windows, and other systems.
|
||||
#
|
||||
# Key features:
|
||||
# - Supports both virtual disk creation and physical disk passthrough.
|
||||
# - Supports virtual disks, import disks, and Controller + NVMe passthrough.
|
||||
# - Automates CPU, RAM, BIOS, network and storage configuration.
|
||||
# - Provides a user-friendly menu to select OS type, ISO image and disk interface.
|
||||
# - Automatically generates a detailed and styled HTML description for each VM.
|
||||
@@ -24,14 +24,22 @@
|
||||
# consistent and maintainable way, using ProxMenux standards.
|
||||
# ==========================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOCAL_SCRIPTS_DEFAULT="/usr/local/share/proxmenux/scripts"
|
||||
LOCAL_SCRIPTS_LOCAL="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
if [[ -f "$LOCAL_SCRIPTS_LOCAL/vm/disk_selector.sh" ]]; then
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_LOCAL"
|
||||
else
|
||||
LOCAL_SCRIPTS="$LOCAL_SCRIPTS_DEFAULT"
|
||||
fi
|
||||
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
VM_REPO="$LOCAL_SCRIPTS/vm"
|
||||
ISO_REPO="$LOCAL_SCRIPTS/vm"
|
||||
MENU_REPO="$LOCAL_SCRIPTS/menus"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||
[[ ! -f "$UTILS_FILE" ]] && UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
VENV_PATH="/opt/googletrans-env"
|
||||
|
||||
# Source utilities and required scripts
|
||||
@@ -55,12 +63,40 @@ initialize_cache
|
||||
function header_info() {
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
echo -e "${BL}╔═══════════════════════════════════════════════╗${CL}"
|
||||
echo -e "${BL}║ ║${CL}"
|
||||
echo -e "${BL}║${YWB} ProxMenux VM Creator ${BL}║${CL}"
|
||||
echo -e "${BL}║ ║${CL}"
|
||||
echo -e "${BL}╚═══════════════════════════════════════════════╝${CL}"
|
||||
echo -e
|
||||
msg_title "ProxMenux VM Creator"
|
||||
}
|
||||
|
||||
VM_WIZARD_CAPTURE_FILE=""
|
||||
VM_WIZARD_CAPTURE_ACTIVE=0
|
||||
VM_STORAGE_IOMMU_PENDING_REBOOT=0
|
||||
|
||||
function start_vm_wizard_capture() {
|
||||
[[ "${VM_WIZARD_CAPTURE_ACTIVE:-0}" -eq 1 ]] && return 0
|
||||
VM_WIZARD_CAPTURE_FILE="/tmp/proxmenux_vm_wizard_screen_capture_$$.txt"
|
||||
: >"$VM_WIZARD_CAPTURE_FILE"
|
||||
exec 8>&1
|
||||
exec > >(tee -a "$VM_WIZARD_CAPTURE_FILE")
|
||||
VM_WIZARD_CAPTURE_ACTIVE=1
|
||||
}
|
||||
|
||||
function stop_vm_wizard_capture() {
|
||||
if [[ "${VM_WIZARD_CAPTURE_ACTIVE:-0}" -eq 1 ]]; then
|
||||
exec 1>&8
|
||||
exec 8>&-
|
||||
VM_WIZARD_CAPTURE_ACTIVE=0
|
||||
fi
|
||||
if [[ -n "${VM_WIZARD_CAPTURE_FILE:-}" && -f "$VM_WIZARD_CAPTURE_FILE" ]]; then
|
||||
rm -f "$VM_WIZARD_CAPTURE_FILE"
|
||||
fi
|
||||
VM_WIZARD_CAPTURE_FILE=""
|
||||
}
|
||||
|
||||
function has_usable_gpu_for_vm_passthrough() {
|
||||
lspci -nn 2>/dev/null \
|
||||
| grep -iE "VGA compatible controller|3D controller|Display controller" \
|
||||
| grep -ivE "Ethernet|Network|Audio" \
|
||||
| grep -ivE "ASPEED|AST[0-9]{3,4}|Matrox|G200e|BMC" \
|
||||
| grep -q .
|
||||
}
|
||||
|
||||
# ==========================================================
|
||||
@@ -77,14 +113,15 @@ function header_info() {
|
||||
function start_vm_configuration() {
|
||||
|
||||
if (whiptail --title "ProxMenux" --yesno "$(translate "Use Default Settings?")" --no-button "$(translate "Advanced")" 10 60); then
|
||||
header_info
|
||||
load_default_vm_config "$OS_TYPE"
|
||||
#header_info
|
||||
#load_default_vm_config "$OS_TYPE"
|
||||
|
||||
if [[ -z "$HN" ]]; then
|
||||
HN=$(whiptail --inputbox "$(translate "Enter a name for the new virtual machine:")" 10 60 --title "VM Hostname" 3>&1 1>&2 2>&3)
|
||||
[[ -z "$HN" ]] && HN="custom-vm"
|
||||
fi
|
||||
|
||||
header_info
|
||||
load_default_vm_config "$OS_TYPE"
|
||||
apply_default_vm_config
|
||||
else
|
||||
header_info
|
||||
@@ -96,14 +133,22 @@ function start_vm_configuration() {
|
||||
|
||||
|
||||
while true; do
|
||||
OS_TYPE=$(dialog --backtitle "ProxMenux" \
|
||||
VM_STORAGE_IOMMU_PENDING_REBOOT=0
|
||||
WIZARD_CONFLICT_POLICY=""
|
||||
WIZARD_CONFLICT_SCOPE=""
|
||||
export WIZARD_CONFLICT_POLICY WIZARD_CONFLICT_SCOPE
|
||||
OS_TYPE=$(dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$(translate "Select System Type")" \
|
||||
--menu "\n$(translate "Choose the type of virtual system to install:")" 20 70 10 \
|
||||
1 "$(translate "Create") VM System NAS" \
|
||||
2 "$(translate "Create") VM System Windows" \
|
||||
3 "$(translate "Create") VM System Linux" \
|
||||
"" "" \
|
||||
"" "\Z4──────────────────────────────────────────────────\Zn" \
|
||||
"" "" \
|
||||
4 "$(translate "Create") VM System macOS (OSX-PROXMOX)" \
|
||||
5 "$(translate "Create") VM System Others (based Linux)" \
|
||||
"" "" \
|
||||
6 "$(translate "Return to Main Menu")" \
|
||||
3>&1 1>&2 2>&3)
|
||||
|
||||
@@ -133,19 +178,45 @@ while true; do
|
||||
esac
|
||||
|
||||
if ! confirm_vm_creation; then
|
||||
stop_vm_wizard_capture
|
||||
continue
|
||||
fi
|
||||
|
||||
start_vm_wizard_capture
|
||||
|
||||
start_vm_configuration || continue
|
||||
if ! start_vm_configuration; then
|
||||
stop_vm_wizard_capture
|
||||
continue
|
||||
fi
|
||||
|
||||
|
||||
select_disk_type
|
||||
if [[ -z "$DISK_TYPE" ]]; then
|
||||
msg_error "$(translate "Disk type selection failed or cancelled")"
|
||||
unset DISK_TYPE
|
||||
if ! select_disk_type; then
|
||||
stop_vm_wizard_capture
|
||||
msg_error "$(translate "Storage plan selection failed or cancelled")"
|
||||
continue
|
||||
fi
|
||||
|
||||
create_vm
|
||||
WIZARD_ADD_GPU="no"
|
||||
if has_usable_gpu_for_vm_passthrough; then
|
||||
if whiptail --backtitle "ProxMenux" --title "$(translate "Optional GPU Passthrough")" \
|
||||
--yesno "$(translate "Do you want to configure GPU passthrough for this VM now?")\n\n$(translate "This will launch the GPU assistant after VM creation and may require a host reboot.")" 12 78 --defaultno; then
|
||||
WIZARD_ADD_GPU="yes"
|
||||
fi
|
||||
else
|
||||
msg_warn "$(translate "No compatible GPU detected for VM passthrough. Skipping GPU wizard option.")"
|
||||
fi
|
||||
export WIZARD_ADD_GPU
|
||||
|
||||
if [[ "$WIZARD_ADD_GPU" != "yes" ]]; then
|
||||
stop_vm_wizard_capture
|
||||
fi
|
||||
|
||||
if ! create_vm; then
|
||||
stop_vm_wizard_capture
|
||||
msg_error "$(translate "VM creation failed or was cancelled during storage setup.")"
|
||||
continue
|
||||
fi
|
||||
stop_vm_wizard_capture
|
||||
break
|
||||
done
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# ProxMenux - GPU and TPU Menu
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 28/01/2025
|
||||
# License : MIT
|
||||
# Version : 2.0
|
||||
# Last Updated: 01/04/2026
|
||||
# ==========================================================
|
||||
|
||||
|
||||
# Configuration ============================================
|
||||
# Configuration
|
||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||
@@ -20,39 +18,63 @@ VENV_PATH="/opt/googletrans-env"
|
||||
if [[ -f "$UTILS_FILE" ]]; then
|
||||
source "$UTILS_FILE"
|
||||
fi
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
# ==========================================================
|
||||
|
||||
while true; do
|
||||
OPTION=$(dialog --clear --backtitle "ProxMenux" --title "$(translate "GPUs and Coral-TPU Menu")" \
|
||||
--menu "\n$(translate "Select an option:")" 20 70 8 \
|
||||
"1" "$(translate "Add HW iGPU acceleration to an LXC")" \
|
||||
"2" "$(translate "Add Coral TPU to an LXC")" \
|
||||
"3" "$(translate "Install/Update Coral TPU on the Host")" \
|
||||
"4" "$(translate "Return to Main Menu")" \
|
||||
2>&1 >/dev/tty)
|
||||
while true; do
|
||||
OPTION=$(dialog --colors --backtitle "ProxMenux" \
|
||||
--title "$(translate "GPUs and Coral-TPU Menu")" \
|
||||
--menu "\n$(translate "Select an option:")" 24 78 16 \
|
||||
"" "\Z4──────────────────────── HOST ─────────────────────────\Zn" \
|
||||
"1" "$(translate "Install/Update NVIDIA Drivers (Host + LXC)")" \
|
||||
"2" "$(translate "Install/Update Coral TPU on Host")" \
|
||||
"" "" \
|
||||
"" "\Z4──────────────────────── LXC ──────────────────────────\Zn" \
|
||||
"3" "$(translate "Add GPU to LXC (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \
|
||||
"4" "$(translate "Add Coral TPU to LXC")" \
|
||||
"" "" \
|
||||
"" "\Z4──────────────────────── VM ───────────────────────────\Zn" \
|
||||
"5" "$(translate "Add GPU to VM (Intel | AMD | NVIDIA)") \Zb\Z4Switch Mode\Zn" \
|
||||
"" "" \
|
||||
"" "\Z4──────────────────── SWICHT MODE ───────────────────────\Zn" \
|
||||
"6" "$(translate "Switch GPU Mode (VM <-> LXC)")" \
|
||||
"" "" \
|
||||
"" "\Z4────────────────────── Utilities ───────────────────────\Zn" \
|
||||
"7" "$(translate "Manual CLI Guide (GPU/TPU)")" \
|
||||
"0" "$(translate "Return to Main Menu")" \
|
||||
2>&1 >/dev/tty
|
||||
) || { exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"; }
|
||||
|
||||
case $OPTION in
|
||||
1)
|
||||
bash "$LOCAL_SCRIPTS/configure_igpu_lxc.sh"
|
||||
if [ $? -ne 0 ]; then
|
||||
return
|
||||
fi
|
||||
;;
|
||||
2)
|
||||
bash "$LOCAL_SCRIPTS/install_coral_lxc.sh"
|
||||
if [ $? -ne 0 ]; then
|
||||
return
|
||||
fi
|
||||
;;
|
||||
3)
|
||||
bash "$LOCAL_SCRIPTS/gpu_tpu/install_coral_pve9.sh"
|
||||
if [ $? -ne 0 ]; then
|
||||
return
|
||||
fi
|
||||
;;
|
||||
4) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;;
|
||||
*) exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" ;;
|
||||
esac
|
||||
done
|
||||
case "$OPTION" in
|
||||
1)
|
||||
bash "$LOCAL_SCRIPTS/gpu_tpu/nvidia_installer.sh"
|
||||
;;
|
||||
2)
|
||||
bash "$LOCAL_SCRIPTS/gpu_tpu/install_coral.sh"
|
||||
;;
|
||||
3)
|
||||
bash "$LOCAL_SCRIPTS/gpu_tpu/add_gpu_lxc.sh"
|
||||
;;
|
||||
4)
|
||||
bash "$LOCAL_SCRIPTS/gpu_tpu/install_coral_lxc.sh"
|
||||
;;
|
||||
5)
|
||||
bash "$LOCAL_SCRIPTS/gpu_tpu/add_gpu_vm.sh"
|
||||
;;
|
||||
6)
|
||||
bash "$LOCAL_SCRIPTS/gpu_tpu/switch_gpu_mode.sh"
|
||||
;;
|
||||
7)
|
||||
bash "$LOCAL_SCRIPTS/gpu_tpu/gpu-tpu-manual-guide.sh"
|
||||
;;
|
||||
0)
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
;;
|
||||
*)
|
||||
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user