refine post-install and hardware GPU docs, Monitor UX and CLI styling

- rewrite the 15 post-install pages and the 3 hardware GPU pages so they reflect the current scripts (reversibility, tracked-tool counts, kernel parameters, per-tool commands, Alpine LXC propagation flow)
- migrate the legacy step-badge helper on post-install/optional and create-vm/synology to the canonical pill component, with the stepLabel key added in each locale
- fix rich-text i18n calls missing helpers across network, automated, optional, security, customization and the post-install landing pages, and escape the `<iface>` placeholder in automated so intl no longer parses it as a tag
- remove the mouse-follow blue overlay from the docs landing layout
- reposition the App-tab Edit button and stack the Search and Register controls vertically on mobile
- move the Bulk update Configure/Edit control into the section header so it behaves the same on desktop and mobile
- show a spinner during the final autoremove/autoclean pass of update-pve-safe so the cleanup step reads as active instead of silent
- restyle the shell spinner and msg_info in a distinctive purple and drop the unused msg_lang duplicate
- add a web-docs i18n build script and its CI workflow, plus tests for the pushover notification channel
This commit is contained in:
MacRimi
2026-08-26 17:23:09 +02:00
parent b71dd65898
commit fcfe8da765
106 changed files with 3376 additions and 1358 deletions
+114 -6
View File
@@ -1055,10 +1055,13 @@ PROXMOX_CUSTOM_CERT_PATH = "/etc/pve/local/pveproxy-ssl.pem"
PROXMOX_CUSTOM_KEY_PATH = "/etc/pve/local/pveproxy-ssl.key"
_SSL_RUNTIME_LOCK = threading.RLock()
_SSL_RUNTIME_REFRESH_LOCK = threading.Lock()
_SSL_RUNTIME_CONTEXT = None
_SSL_RUNTIME_FINGERPRINT = ""
_SSL_RUNTIME_CERT_PATH = ""
_SSL_RUNTIME_KEY_PATH = ""
_SSL_RUNTIME_SOURCE = "none"
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
def load_ssl_config():
@@ -1100,6 +1103,15 @@ def save_ssl_config(config):
return False
def _detect_proxmox_certificate_paths():
"""Return the certificate pair currently preferred by Proxmox."""
if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH):
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
return "", ""
def detect_proxmox_certificates():
"""
Detect available Proxmox certificates.
@@ -1117,11 +1129,10 @@ def detect_proxmox_certificates():
"cert_info": None
}
if os.path.isfile(PROXMOX_CUSTOM_CERT_PATH) and os.path.isfile(PROXMOX_CUSTOM_KEY_PATH):
result["proxmox_cert"] = PROXMOX_CUSTOM_CERT_PATH
result["proxmox_key"] = PROXMOX_CUSTOM_KEY_PATH
result["proxmox_available"] = True
elif os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
cert_path, key_path = _detect_proxmox_certificate_paths()
if cert_path and key_path:
result["proxmox_cert"] = cert_path
result["proxmox_key"] = key_path
result["proxmox_available"] = True
if result["proxmox_available"]:
@@ -1209,17 +1220,112 @@ def _build_server_ssl_context(cert_path, key_path):
return context
def _record_ssl_refresh_error(error):
"""Log one warning per distinct automatic-refresh failure."""
global _SSL_RUNTIME_LAST_REFRESH_ERROR
message = str(error)
with _SSL_RUNTIME_LOCK:
if message == _SSL_RUNTIME_LAST_REFRESH_ERROR:
return
_SSL_RUNTIME_LAST_REFRESH_ERROR = message
print(
"[ProxMenux] Proxmox TLS certificate refresh skipped; "
f"the active certificate remains unchanged: {message}",
flush=True,
)
def _persist_active_proxmox_certificate_paths(cert_path, key_path):
"""Keep the selected Proxmox pair in sync for the next service start."""
config = load_ssl_config()
if not config.get("enabled") or config.get("source") != "proxmox":
return
if config.get("cert_path") == cert_path and config.get("key_path") == key_path:
return
updated_config = dict(config)
updated_config["cert_path"] = cert_path
updated_config["key_path"] = key_path
if not save_ssl_config(updated_config):
print(
"[ProxMenux] Warning: the renewed Proxmox certificate is active, "
"but its paths could not be saved for the next service start",
flush=True,
)
def _refresh_proxmox_ssl_context_for_handshake():
"""Activate a renewed Proxmox pair just before a TLS handshake.
This deliberately has no timer and does not depend on inotify (pmxcfs can
update /etc/pve without emitting a local event). The small PEM pair is
inspected only when a client starts a new TLS connection. Any missing,
partial or mismatched pair leaves the already-active context untouched.
"""
global _SSL_RUNTIME_LAST_REFRESH_ERROR
with _SSL_RUNTIME_LOCK:
if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None:
return False
# Several browser connections can arrive together. Only one of them may
# validate/swap a newly written pair; the others reuse its result.
with _SSL_RUNTIME_REFRESH_LOCK:
with _SSL_RUNTIME_LOCK:
if _SSL_RUNTIME_SOURCE != "proxmox" or _SSL_RUNTIME_CONTEXT is None:
return False
active_fingerprint = _SSL_RUNTIME_FINGERPRINT
active_cert_path = _SSL_RUNTIME_CERT_PATH
active_key_path = _SSL_RUNTIME_KEY_PATH
cert_path, key_path = _detect_proxmox_certificate_paths()
if not cert_path or not key_path:
raise RuntimeError("No complete Proxmox certificate/key pair was detected")
candidate_fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
paths_changed = cert_path != active_cert_path or key_path != active_key_path
if candidate_fingerprint == active_fingerprint and not paths_changed:
return False
# reload_server_ssl_context builds and validates the replacement first
# and checks that neither PEM changed while it was being loaded. The
# global context is swapped only after all of those checks succeed.
changed = reload_server_ssl_context(cert_path, key_path)
_persist_active_proxmox_certificate_paths(cert_path, key_path)
with _SSL_RUNTIME_LOCK:
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
if changed:
print(
f"[ProxMenux] Renewed Proxmox TLS certificate activated from {cert_path}",
flush=True,
)
return changed
def create_reloadable_ssl_context(cert_path, key_path):
"""Create the server context and register it for manual hot reloads."""
"""Create the stable server context used by automatic and manual reloads."""
global _SSL_RUNTIME_CONTEXT
global _SSL_RUNTIME_FINGERPRINT
global _SSL_RUNTIME_CERT_PATH
global _SSL_RUNTIME_KEY_PATH
global _SSL_RUNTIME_SOURCE
global _SSL_RUNTIME_LAST_REFRESH_ERROR
context = _build_server_ssl_context(cert_path, key_path)
fingerprint = _certificate_pair_fingerprint(cert_path, key_path)
config = load_ssl_config()
source = config.get("source", "none") if config.get("enabled") else "none"
def _select_active_context(ssl_socket, _server_name, _initial_context):
try:
_refresh_proxmox_ssl_context_for_handshake()
except Exception as error:
# Never fail a client handshake because Proxmox is between the
# certificate and key writes. The previously validated context
# remains authoritative until a later connection can load both.
_record_ssl_refresh_error(error)
with _SSL_RUNTIME_LOCK:
active_context = _SSL_RUNTIME_CONTEXT
if active_context is not None and ssl_socket.context is not active_context:
@@ -1231,6 +1337,8 @@ def create_reloadable_ssl_context(cert_path, key_path):
_SSL_RUNTIME_FINGERPRINT = fingerprint
_SSL_RUNTIME_CERT_PATH = cert_path
_SSL_RUNTIME_KEY_PATH = key_path
_SSL_RUNTIME_SOURCE = source
_SSL_RUNTIME_LAST_REFRESH_ERROR = ""
return context
@@ -29,6 +29,9 @@ TOOL_METADATA = {
'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'},
'rpc': {'name': 'RPC / rpcbind Disable', 'function': 'disable_rpc', 'version': '1.0'},
'motd': {'name': 'Custom MOTD Banner', 'function': 'setup_motd', 'version': '1.0'},
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', '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'},
+2 -12
View File
@@ -1123,8 +1123,8 @@ def _check_oci_app(entry: dict) -> dict:
# returns the single newest version, e.g. "580.105.08"
# `https://download.nvidia.com/XFree86/Linux-x86_64/`
# HTML directory listing — we scrape it for per-branch latest
# (so a user on 570.x gets 570.x's latest, not pushed to 580.x
# unless their kernel forces a branch upgrade).
# (so a user on 570.x gets 570.x's latest, without an automatic
# cross-branch upgrade).
#
# Cache TTL is 7 days because NVIDIA's release cadence on each branch
# is roughly monthly. The cache is in-memory only; AppImage restarts
@@ -1135,15 +1135,6 @@ _NVIDIA_CACHE_TTL = 7 * 86400
_nvidia_cache: dict[str, Any] = {"versions": [], "fetched_at": 0}
def _kernel_string() -> str:
try:
return subprocess.run(
["uname", "-r"], capture_output=True, text=True, timeout=2,
).stdout.strip()
except (OSError, subprocess.TimeoutExpired):
return ""
def _version_tuple(v: str) -> tuple:
"""Convert ``580.105.08`` → ``(580, 105, 8)`` for comparison.
Pads to 3 components so ``580.82`` < ``580.105.08``."""
@@ -1216,7 +1207,6 @@ def _check_nvidia_xfree86(entry: dict) -> dict:
"last_check": _now_iso(),
"error": None,
"_upgrade_kind": "patch" if available else None,
"_kernel": _kernel_string(),
}
+134 -3
View File
@@ -1,6 +1,7 @@
"""
ProxMenux Notification Channels
Provides transport adapters for Telegram, Gotify, and Discord.
Provides transport adapters for Telegram, Gotify, Discord, Email, Pushover,
and Apprise.
Each channel implements send() and test() with:
- Retry with exponential backoff (3 attempts)
@@ -12,6 +13,7 @@ Author: MacRimi
import json
import logging
import re
import time
import urllib.request
import urllib.error
@@ -392,6 +394,119 @@ class GotifyChannel(NotificationChannel):
return self._http_request(url, payload, {'Content-Type': 'application/json'})
# ─── Pushover ────────────────────────────────────────────────────
class PushoverChannel(NotificationChannel):
"""Pushover Messages API channel."""
API_URL = 'https://api.pushover.net/1/messages.json'
MAX_TITLE_LENGTH = 250
MAX_MESSAGE_LENGTH = 1024
_CREDENTIAL_RE = re.compile(r'^[A-Za-z0-9]{30}$')
_OPTION_RE = re.compile(r'^[A-Za-z0-9_-]{1,25}$')
def __init__(self, user_key: str, api_token: str, device: str = '',
sound: str = '', critical_priority: str = 'true'):
super().__init__()
self.user_key = (user_key or '').strip()
self.api_token = (api_token or '').strip()
self.device = (device or '').strip()
self.sound = (sound or '').strip()
self.critical_priority = str(critical_priority).lower() == 'true'
def validate_config(self) -> Tuple[bool, str]:
if not self.user_key:
return False, 'Pushover user or group key is required'
if not self.api_token:
return False, 'Pushover application API token is required'
if not self._CREDENTIAL_RE.fullmatch(self.user_key):
return False, 'Invalid Pushover user or group key format'
if not self._CREDENTIAL_RE.fullmatch(self.api_token):
return False, 'Invalid Pushover application API token format'
if self.device and not self._OPTION_RE.fullmatch(self.device):
return False, 'Invalid Pushover device name format'
if self.sound and not self._OPTION_RE.fullmatch(self.sound):
return False, 'Invalid Pushover sound name format'
return True, ''
@staticmethod
def _truncate(value: str, limit: int) -> str:
value = value or ''
if len(value) <= limit:
return value
return value[:limit - 1].rstrip() + ''
@staticmethod
def _response_error(body: str) -> str:
try:
payload = json.loads(body or '{}')
errors = payload.get('errors')
if isinstance(errors, list):
clean = [str(item)[:160] for item in errors if item]
if clean:
return '; '.join(clean)
if isinstance(errors, str) and errors:
return errors[:200]
except (TypeError, ValueError):
pass
return 'Pushover API rejected the request'
def _post_message(self, title: str, message: str,
priority: int) -> Tuple[int, str]:
payload = {
'token': self.api_token,
'user': self.user_key,
'title': self._truncate(title, self.MAX_TITLE_LENGTH),
'message': self._truncate(message, self.MAX_MESSAGE_LENGTH),
'priority': str(priority),
}
if self.device:
payload['device'] = self.device
if self.sound:
payload['sound'] = self.sound
body = urllib.parse.urlencode(payload).encode('utf-8')
status, response_body = self._http_request(
self.API_URL,
body,
{'Content-Type': 'application/x-www-form-urlencoded'},
)
if 200 <= status < 300:
try:
response = json.loads(response_body or '{}')
if response.get('status') == 1:
return status, ''
except (TypeError, ValueError):
pass
return 400, self._response_error(response_body)
return status, self._response_error(response_body)
def send(self, title: str, message: str, severity: str = 'INFO',
data: Optional[Dict] = None) -> Dict[str, Any]:
valid, error = self.validate_config()
if not valid:
return {'success': False, 'error': error, 'channel': 'pushover'}
priority = (
1
if self.critical_priority and str(severity or '').upper() == 'CRITICAL'
else 0
)
result = self._send_with_retry(
lambda: self._post_message(title, message, priority)
)
result['channel'] = 'pushover'
return result
def test(self) -> Tuple[bool, str]:
result = self.send(
'ProxMenux Test',
'Pushover is configured correctly. This is a test message from ProxMenux Monitor.',
'INFO',
)
return result['success'], result.get('error', '')
# ─── Discord ─────────────────────────────────────────────────────
class DiscordChannel(NotificationChannel):
@@ -1189,7 +1304,7 @@ class AppriseChannel(NotificationChannel):
Apprise (https://github.com/caronc/apprise) is a Python library that
normalises a wide catalogue of notification destinations behind a
single URL scheme: `tgram://`, `discord://`, `slack://`, `gotify://`,
`ntfy://`, `matrix://`, `mailto://`, `pushover://`, `signal://`, etc.
`ntfy://`, `matrix://`, `mailto://`, `pover://`, `signal://`, etc.
The operator pastes one URL and ProxMenux delegates the transport.
Requested in issue #207 by @0berkampf. Implemented as a *separate
@@ -1347,6 +1462,13 @@ CHANNEL_TYPES = {
'from_address', 'to_addresses', 'subject_prefix'],
'class': EmailChannel,
},
'pushover': {
'name': 'Pushover',
'config_keys': ['user_key', 'api_token', 'device', 'sound',
'critical_priority'],
'required_keys': ['user_key', 'api_token'],
'class': PushoverChannel,
},
'apprise': {
'name': 'Apprise',
'config_keys': ['url'],
@@ -1359,7 +1481,8 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
"""Create a channel instance from type name and config dict.
Args:
channel_type: 'telegram', 'gotify', 'discord', 'email', or 'apprise'
channel_type: 'telegram', 'gotify', 'discord', 'email', 'pushover',
or 'apprise'
config: Dict with channel-specific keys (see CHANNEL_TYPES)
Returns:
@@ -1383,6 +1506,14 @@ def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[Notifi
)
elif channel_type == 'email':
return EmailChannel(config)
elif channel_type == 'pushover':
return PushoverChannel(
user_key=config.get('user_key', ''),
api_token=config.get('api_token', ''),
device=config.get('device', ''),
sound=config.get('sound', ''),
critical_priority=config.get('critical_priority', 'true'),
)
elif channel_type == 'apprise':
return AppriseChannel(url=config.get('url', ''))
except Exception as e:
+5 -13
View File
@@ -3817,21 +3817,13 @@ class PollingCollector:
return 'secure_gateway_update_available', data
if item_type == 'nvidia_xfree86':
kind = update.get('_upgrade_kind')
if kind == 'branch_upgrade':
upgrade_reason = (
"Your current driver branch is no longer compatible with "
f"kernel {update.get('_kernel') or 'this kernel'}. "
"Switch to the recommended branch — the installer will "
"rebuild against the running kernel."
)
else:
upgrade_reason = (
"Same-branch maintenance update with bug/security fixes."
)
upgrade_reason = (
"Same-branch maintenance update with bug/security fixes. "
"The installer validates the selected release by rebuilding "
"its DKMS module against the running kernel."
)
data = {
**common,
'kernel': update.get('_kernel') or '',
'upgrade_reason': upgrade_reason,
}
return 'nvidia_driver_update_available', data
+6 -2
View File
@@ -3,7 +3,8 @@ ProxMenux Notification Manager
Central orchestrator for the notification service.
Connects:
- notification_channels.py (transport: Telegram, Gotify, Discord)
- notification_channels.py (transport: Telegram, Gotify, Discord, Email,
Pushover, Apprise)
- notification_templates.py (message formatting + optional AI)
- notification_events.py (event detection: Journal, Task, Polling watchers)
- health_persistence.py (DB: config storage, notification_history)
@@ -79,6 +80,8 @@ SENSITIVE_KEYS = {
'gotify.token',
'discord.webhook_url',
'email.password',
'pushover.user_key',
'pushover.api_token',
'apprise.url',
'webhook_secret',
}
@@ -2520,9 +2523,10 @@ class NotificationManager:
channels_info = {}
for ch_type, info in CHANNEL_TYPES.items():
enabled = self._config.get(f'{ch_type}.enabled', 'false') == 'true'
required_keys = info.get('required_keys', info['config_keys'])
configured = all(
bool(self._config.get(f'{ch_type}.{k}', ''))
for k in info['config_keys']
for k in required_keys
)
channels_info[ch_type] = {
'name': info['name'],
+1 -1
View File
@@ -1318,7 +1318,7 @@ TEMPLATES = {
'nvidia_driver_update_available': {
'title': '{hostname}: NVIDIA driver update available — v{latest_version}',
'body': (
'A newer NVIDIA driver compatible with kernel {kernel} is available.\n'
'A newer maintenance release is available for the installed NVIDIA driver branch.\n'
'🔹 Currently installed: v{current_version}\n'
'🟢 Latest available: v{latest_version}\n\n'
'{upgrade_reason}\n\n'
@@ -0,0 +1,106 @@
import sys
import unittest
import urllib.parse
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from notification_channels import PushoverChannel, create_channel
class PushoverChannelTests(unittest.TestCase):
USER_KEY = "u" * 30
API_TOKEN = "a" * 30
def make_channel(self, **kwargs):
channel = PushoverChannel(
user_key=kwargs.pop("user_key", self.USER_KEY),
api_token=kwargs.pop("api_token", self.API_TOKEN),
**kwargs,
)
channel.MAX_RETRIES = 1
return channel
def test_requires_valid_user_key_and_api_token(self):
channel = self.make_channel(user_key="")
self.assertEqual(channel.validate_config(), (
False, "Pushover user or group key is required"
))
channel = self.make_channel(api_token="short")
self.assertEqual(channel.validate_config(), (
False, "Invalid Pushover application API token format"
))
def test_optional_device_sound_and_factory(self):
channel = create_channel("pushover", {
"user_key": self.USER_KEY,
"api_token": self.API_TOKEN,
"device": "iphone_15",
"sound": "magic",
"critical_priority": "false",
})
self.assertIsInstance(channel, PushoverChannel)
self.assertEqual(channel.validate_config(), (True, ""))
self.assertFalse(channel.critical_priority)
def test_critical_alert_uses_high_priority_and_api_limits(self):
channel = self.make_channel(
device="iphone_15",
sound="magic",
critical_priority="true",
)
request = {}
def fake_http(url, data, headers):
request["url"] = url
request["payload"] = urllib.parse.parse_qs(data.decode("utf-8"))
request["headers"] = headers
return 200, '{"status":1,"request":"test"}'
channel._http_request = fake_http
result = channel.send("T" * 300, "M" * 1200, "critical")
self.assertTrue(result["success"])
self.assertEqual(request["url"], PushoverChannel.API_URL)
self.assertEqual(request["payload"]["priority"], ["1"])
self.assertEqual(request["payload"]["device"], ["iphone_15"])
self.assertEqual(request["payload"]["sound"], ["magic"])
self.assertEqual(len(request["payload"]["title"][0]), 250)
self.assertEqual(len(request["payload"]["message"][0]), 1024)
self.assertTrue(request["payload"]["message"][0].endswith(""))
def test_noncritical_alert_uses_normal_priority(self):
channel = self.make_channel(critical_priority="true")
request = {}
def fake_http(url, data, headers):
request["payload"] = urllib.parse.parse_qs(data.decode("utf-8"))
return 200, '{"status":1}'
channel._http_request = fake_http
result = channel.send("Warning", "Message", "WARNING")
self.assertTrue(result["success"])
self.assertEqual(request["payload"]["priority"], ["0"])
def test_api_error_does_not_expose_credentials(self):
channel = self.make_channel()
channel._http_request = lambda *args: (
400, '{"status":0,"errors":["user identifier is invalid"]}'
)
result = channel.send("Title", "Message")
self.assertFalse(result["success"])
self.assertIn("user identifier is invalid", result["error"])
self.assertNotIn(self.USER_KEY, result["error"])
self.assertNotIn(self.API_TOKEN, result["error"])
if __name__ == "__main__":
unittest.main()