)}
+ >}
+
setShow2FASetup(false)}
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json
index ca7e536d..d038616a 100644
--- a/AppImage/messages/en/common.json
+++ b/AppImage/messages/en/common.json
@@ -1,4 +1,57 @@
{
+ "pbs": {
+ "title": "Proxmox Backup Server",
+ "server": "PBS server",
+ "chooseServer": "Choose a PBS server",
+ "local": "Local PBS",
+ "refresh": "Refresh",
+ "manage": "Manage PBS connections",
+ "loadFailed": "Could not load PBS status.",
+ "loading": "Loading PBS data…",
+ "noServer": "Add a PBS connection to view datastores, tasks and schedules.",
+ "permissionsHelp": "Enable Monitor authentication before saving credentials. Use a dedicated PBS API token with the Audit role on / for both its user and token ACLs, with propagation. This panel only reads PBS data.",
+ "editConnection": "Connection to edit",
+ "add": "Add a new connection",
+ "name": "Name",
+ "url": "HTTPS URL (include :8007 for the native PBS port)",
+ "token_id": "API token ID (user@realm!token)",
+ "token_secret": "API token secret (leave empty to keep the stored secret)",
+ "fingerprint": "SHA256 certificate fingerprint (optional)",
+ "tlsHelp": "Leave the fingerprint empty for normal CA and hostname verification. For a self-signed certificate, copy its SHA256 fingerprint from the PBS console or another trusted channel. The pin is checked before sending credentials.",
+ "saved": "PBS connection verified and saved.",
+ "saveFailed": "Could not save the PBS connection.",
+ "testing": "Verifying connection…",
+ "save": "Verify and save",
+ "remove": "Remove connection",
+ "removeConfirm": "Remove this saved PBS connection and its token from Monitor? PBS data and jobs are unaffected.",
+ "sampled": "Sampled at",
+ "datastores": "Datastores",
+ "total": "Filesystem capacity",
+ "used": "Used",
+ "free": "Available",
+ "noDatastores": "No accessible datastores. Check datastore configuration and the user/token permissions.",
+ "tasks": "Recent tasks",
+ "noTasks": "No recent tasks were returned.",
+ "task": "Task",
+ "started": "Started",
+ "status": "Status",
+ "running": "Running",
+ "jobs": "Maintenance schedules",
+ "job": "Job",
+ "schedule": "Schedule",
+ "sync": "Sync",
+ "verify": "Verification",
+ "prune": "Prune",
+ "overview": "Overview",
+ "logs": "System logs",
+ "terminal": "Terminal",
+ "security": "Security",
+ "logout": "Log out",
+ "navigation": "PBS navigation",
+ "cpu": "CPU",
+ "memory": "Memory",
+ "uptime": "Uptime"
+ },
"app": {
"title": "ProxMenux Monitor",
"description": "Proxmox System Dashboard",
diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json
index 166bf6a5..ed316824 100644
--- a/AppImage/messages/es/common.json
+++ b/AppImage/messages/es/common.json
@@ -1,4 +1,57 @@
{
+ "pbs": {
+ "title": "Proxmox Backup Server",
+ "server": "Servidor PBS",
+ "chooseServer": "Selecciona un servidor PBS",
+ "local": "PBS local",
+ "refresh": "Actualizar",
+ "manage": "Gestionar conexiones PBS",
+ "loadFailed": "No se pudo consultar el estado de PBS.",
+ "loading": "Cargando datos de PBS…",
+ "noServer": "Añade una conexión PBS para ver datastores, tareas y programaciones.",
+ "permissionsHelp": "Activa la autenticación de Monitor antes de guardar credenciales. Usa un token API dedicado de PBS con el rol Audit en / para el usuario y el token, con propagación. Este panel solo consulta datos de PBS.",
+ "editConnection": "Conexión que deseas editar",
+ "add": "Añadir una conexión",
+ "name": "Nombre",
+ "url": "URL HTTPS (incluye :8007 para el puerto nativo de PBS)",
+ "token_id": "ID del token API (usuario@realm!token)",
+ "token_secret": "Secreto del token API (vacío para conservar el guardado)",
+ "fingerprint": "Huella SHA256 del certificado (opcional)",
+ "tlsHelp": "Deja la huella vacía para verificar la CA y el nombre del servidor. Si el certificado es autofirmado, copia su huella SHA256 desde la consola de PBS u otro canal fiable. Se verifica antes de enviar las credenciales.",
+ "saved": "Conexión PBS verificada y guardada.",
+ "saveFailed": "No se pudo guardar la conexión PBS.",
+ "testing": "Verificando conexión…",
+ "save": "Verificar y guardar",
+ "remove": "Eliminar conexión",
+ "removeConfirm": "¿Eliminar de Monitor esta conexión PBS y su token guardado? Los datos y las tareas de PBS no se modifican.",
+ "sampled": "Consulta realizada a las",
+ "datastores": "Datastores",
+ "total": "Capacidad del sistema de archivos",
+ "used": "Usado",
+ "free": "Disponible",
+ "noDatastores": "No hay datastores accesibles. Revisa su configuración y los permisos del usuario y del token.",
+ "tasks": "Tareas recientes",
+ "noTasks": "No se han devuelto tareas recientes.",
+ "task": "Tarea",
+ "started": "Inicio",
+ "status": "Estado",
+ "running": "En ejecución",
+ "jobs": "Programaciones de mantenimiento",
+ "job": "Tarea",
+ "schedule": "Programación",
+ "sync": "Sincronización",
+ "verify": "Verificación",
+ "prune": "Prune",
+ "overview": "Resumen",
+ "logs": "Registros del sistema",
+ "terminal": "Terminal",
+ "security": "Seguridad",
+ "logout": "Cerrar sesión",
+ "navigation": "Navegación de PBS",
+ "cpu": "CPU",
+ "memory": "Memoria",
+ "uptime": "Tiempo de actividad"
+ },
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
"app": {
"title": "ProxMenux Monitor",
diff --git a/AppImage/scripts/auth_manager.py b/AppImage/scripts/auth_manager.py
index dbfb96ae..c405fb5d 100644
--- a/AppImage/scripts/auth_manager.py
+++ b/AppImage/scripts/auth_manager.py
@@ -1115,6 +1115,9 @@ def _detect_proxmox_certificate_paths():
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
+ pbs_cert, pbs_key = "/etc/proxmox-backup/proxy.pem", "/etc/proxmox-backup/proxy.key"
+ if os.path.isfile(pbs_cert) and os.path.isfile(pbs_key):
+ return pbs_cert, pbs_key
return "", ""
diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh
index b0df1302..a932ec90 100755
--- a/AppImage/scripts/build_appimage.sh
+++ b/AppImage/scripts/build_appimage.sh
@@ -160,6 +160,9 @@ cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
+cp "$SCRIPT_DIR/pbs_manager.py" "$APP_DIR/usr/bin/"
+cp "$SCRIPT_DIR/flask_pbs_routes.py" "$APP_DIR/usr/bin/"
+cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
diff --git a/AppImage/scripts/flask_pbs_routes.py b/AppImage/scripts/flask_pbs_routes.py
new file mode 100644
index 00000000..4e41936f
--- /dev/null
+++ b/AppImage/scripts/flask_pbs_routes.py
@@ -0,0 +1,71 @@
+"""Read-only PBS status and administrative connection management."""
+
+from flask import Blueprint, jsonify, request
+import auth_manager
+import pbs_manager as pbs
+from jwt_middleware import require_auth, require_admin_scope
+
+pbs_bp = Blueprint("pbs", __name__)
+
+
+@pbs_bp.after_request
+def no_cache(response):
+ response.headers["Cache-Control"] = "no-store"
+ return response
+
+
+@pbs_bp.route("/api/platform")
+@require_auth
+def platform():
+ return jsonify(pbs.get_platform())
+
+
+@pbs_bp.route("/api/pbs/servers")
+@require_auth
+def servers():
+ try:
+ return jsonify(servers=pbs.public_servers(), local_available=pbs.is_pbs_host())
+ except pbs.PBSError as exc:
+ return jsonify(error=str(exc)), 500
+
+
+def _protected_configuration():
+ local = auth_manager.load_auth_config()
+ return local.get("enabled") and not local.get("declined") and local.get("password_hash")
+
+
+@pbs_bp.route("/api/pbs/servers", methods=["POST"])
+@require_admin_scope
+def save_server():
+ if not _protected_configuration():
+ return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
+ try:
+ identifier = pbs.save_server(request.get_json(silent=True))
+ return jsonify(success=True, id=identifier, servers=pbs.public_servers())
+ except pbs.PBSError as exc:
+ return jsonify(error=str(exc)), 400
+ except OSError:
+ return jsonify(error="Could not save PBS connection."), 500
+
+
+@pbs_bp.route("/api/pbs/servers/", methods=["DELETE"])
+@require_admin_scope
+def delete_server(identifier):
+ if not _protected_configuration():
+ return jsonify(error="Enable local authentication before changing PBS connections."), 409
+ try:
+ pbs.delete_server(identifier)
+ return jsonify(success=True)
+ except pbs.PBSError as exc:
+ return jsonify(error=str(exc)), 400
+ except OSError:
+ return jsonify(error="Could not remove PBS connection."), 500
+
+
+@pbs_bp.route("/api/pbs/status")
+@require_auth
+def status():
+ try:
+ return jsonify(pbs.get_status(request.args.get("server", "local")))
+ except pbs.PBSError as exc:
+ return jsonify(error=str(exc)), 502
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 13da0971..7ae21e38 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -90,6 +90,8 @@ from flask_terminal_routes import ( # noqa: E402
from flask_health_routes import health_bp # noqa: E402
from flask_auth_routes import auth_bp # noqa: E402
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
+from flask_pbs_routes import pbs_bp # noqa: E402
+from pbs_manager import get_platform, is_pbs_host # noqa: E402
from flask_security_routes import security_bp # noqa: E402
from flask_notification_routes import notification_bp # noqa: E402
from flask_oci_routes import oci_bp # noqa: E402
@@ -193,6 +195,7 @@ post_install_versions.scan_at_startup()
app.register_blueprint(auth_bp)
app.register_blueprint(health_bp)
app.register_blueprint(proxmenux_bp)
+app.register_blueprint(pbs_bp)
app.register_blueprint(security_bp)
app.register_blueprint(notification_bp)
app.register_blueprint(oci_bp)
@@ -3474,6 +3477,8 @@ def get_coral_info():
def get_proxmox_version():
"""Get Proxmox version if available. Cached for 6 hours."""
global _system_info_cache
+ if is_pbs_host():
+ return get_platform().get('version')
now = time.time()
if _system_info_cache['proxmox_version'] is not None and \
@@ -9863,6 +9868,7 @@ def api_system():
'cpu_cores': cpu_cores,
'cpu_threads': cpu_threads,
'proxmox_version': proxmox_version,
+ 'platform': get_platform(),
'kernel_version': kernel_version,
'available_updates': available_updates
})
diff --git a/AppImage/scripts/health_monitor.py b/AppImage/scripts/health_monitor.py
index 23b6d7f1..f7dbe5ad 100644
--- a/AppImage/scripts/health_monitor.py
+++ b/AppImage/scripts/health_monitor.py
@@ -51,6 +51,7 @@ DEBUG_PERF = False
# ─── Startup Grace Period ────────────────────────────────────────────────────
# Import centralized startup grace management for consistent behavior
import startup_grace
+from pbs_manager import get_platform, PBS_SERVICES
def _is_startup_health_grace() -> bool:
"""Check if we're within the startup health grace period (5 min).
@@ -466,6 +467,7 @@ class HealthMonitor:
def __init__(self):
"""Initialize health monitor with state tracking"""
+ self.platform_type = get_platform().get("product")
self.state_history = defaultdict(list)
self.last_check_times = {}
self.cached_results = {}
@@ -919,9 +921,9 @@ class HealthMonitor:
_perf_log("services", (time.time() - _t) * 1000)
details['services'] = services_status
if services_status['status'] == 'CRITICAL':
- critical_issues.append(f"PVE Services: {services_status.get('reason', 'Service failure')}")
+ critical_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service failure')}")
elif services_status['status'] == 'WARNING':
- warning_issues.append(f"PVE Services: {services_status.get('reason', 'Service issue')}")
+ warning_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service issue')}")
# Priority 1.5: Proxmox Storage Check (External Module)
_t = time.time()
@@ -2347,6 +2349,8 @@ class HealthMonitor:
# The new _check_proxmox_storage function handles this logic better.
def _check_proxmox_storages(self) -> Dict[str, Any]:
"""Check Proxmox-specific storages (only report problems)"""
+ if getattr(self, "platform_type", "pve") == "pbs":
+ return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
storages = {}
try:
@@ -3515,6 +3519,8 @@ class HealthMonitor:
Optimized VM/CT check - detects qmp failures and startup errors from logs.
Improved detection of container and VM errors from journalctl.
"""
+ if getattr(self, "platform_type", "pve") == "pbs":
+ return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
try:
# First: auto-resolve any persisted VM/CT errors where the guest
# is now running. This clears stale "Failed to start" / QMP
@@ -3678,6 +3684,8 @@ class HealthMonitor:
Check VMs/CTs with persistent error tracking.
Errors persist until VM starts or 48h elapsed.
"""
+ if getattr(self, "platform_type", "pve") == "pbs":
+ return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
try:
issues = []
vm_details = {}
@@ -3919,8 +3927,18 @@ class HealthMonitor:
"""
try:
# Build service list: base PVE services + corosync if clustered
- services_to_check = list(self.PVE_SERVICES)
- is_cluster = os.path.exists('/etc/corosync/corosync.conf')
+ pbs_host = getattr(self, "platform_type", "pve") == "pbs"
+ service_prefix = "pbs" if pbs_host else "pve"
+ product_name = "PBS" if pbs_host else "PVE"
+ services_to_check = list(PBS_SERVICES) if pbs_host else list(self.PVE_SERVICES)
+ if pbs_host:
+ # Old Monitor versions incorrectly treated absent PVE services
+ # as PBS failures; remove only those known inapplicable errors.
+ for service in self.PVE_SERVICES:
+ old_key = f'pve_service_{service}'
+ if health_persistence.is_error_active(old_key):
+ health_persistence.clear_error(old_key)
+ is_cluster = not pbs_host and os.path.exists('/etc/corosync/corosync.conf')
if is_cluster and 'corosync' not in services_to_check:
services_to_check.append('corosync')
@@ -3952,14 +3970,14 @@ class HealthMonitor:
# cleanup loop). Audit Tier 5 — Health stack.
for svc in services_to_check:
if svc not in failed_services:
- error_key = f'pve_service_{svc}'
+ error_key = f'{service_prefix}_service_{svc}'
if health_persistence.is_error_active(error_key):
health_persistence.clear_error(error_key)
# Build checks dict with status per service
checks = {}
for svc in services_to_check:
- error_key = f'pve_service_{svc}'
+ error_key = f'{service_prefix}_service_{svc}'
if svc in failed_services:
state = service_details.get(svc, 'inactive')
checks[svc] = {
@@ -3987,12 +4005,12 @@ class HealthMonitor:
# Record each failed service in persistence, respecting dismiss
active_failed = []
for svc in failed_services:
- error_key = f'pve_service_{svc}'
+ error_key = f'{service_prefix}_service_{svc}'
rec_result = health_persistence.record_error(
error_key=error_key,
category='pve_services',
severity='CRITICAL',
- reason=f'PVE service {svc} is {service_details.get(svc, "inactive")}',
+ reason=f'{product_name} service {svc} is {service_details.get(svc, "inactive")}',
details={'service': svc, 'state': service_details.get(svc, 'inactive')}
)
if rec_result and rec_result.get('type') == 'skipped_acknowledged':
@@ -5816,6 +5834,8 @@ class HealthMonitor:
- No persistent errors are recorded
This prevents false positives when NFS/PBS/remote storage is still mounting.
"""
+ if getattr(self, "platform_type", "pve") == "pbs":
+ return None
if not PROXMOX_STORAGE_AVAILABLE:
return None
@@ -6205,6 +6225,8 @@ class HealthMonitor:
Notification cooldown is the standard 1h via the storage
category, so a CT teetering at 96% won't spam the channel.
"""
+ if getattr(self, "platform_type", "pve") == "pbs":
+ return None
# Cheap pre-check: bail out when there are no running LXCs. On
# nodes without CTs, the previous version still paid ~860ms per
# cycle for a `pvesh /cluster/resources` call that produced no
@@ -6365,6 +6387,8 @@ class HealthMonitor:
path, so a slow / dead guest agent can't stretch the health
cycle.
"""
+ if getattr(self, "platform_type", "pve") == "pbs":
+ return None
try:
import flask_server # deferred — avoids circular import
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
@@ -6524,6 +6548,8 @@ class HealthMonitor:
check (already covered) because those are usually large data
mounts whose fill rate is driven by user content, not log spam.
"""
+ if getattr(self, "platform_type", "pve") == "pbs":
+ return None
if not MOUNT_MONITOR_AVAILABLE:
return None
try:
@@ -6627,6 +6653,8 @@ class HealthMonitor:
check via _check_filesystem. Re-alerting on the same condition
through two channels just clutters the inbox.
"""
+ if getattr(self, "platform_type", "pve") == "pbs":
+ return None
if not PROXMOX_STORAGE_AVAILABLE:
return None
try:
diff --git a/AppImage/scripts/pbs_manager.py b/AppImage/scripts/pbs_manager.py
new file mode 100644
index 00000000..83eedac8
--- /dev/null
+++ b/AppImage/scripts/pbs_manager.py
@@ -0,0 +1,288 @@
+"""Local PBS maintenance information and remote, read-only PBS monitoring."""
+
+from concurrent.futures import ThreadPoolExecutor
+import hashlib
+import hmac
+import http.client
+import importlib.util
+import json
+import os
+from pathlib import Path
+import re
+import secrets
+import shutil
+import ssl
+import subprocess
+import tempfile
+import threading
+import time
+from urllib.parse import urlsplit
+
+import auth_manager
+
+try:
+ from proxmox_platform import detect_platform
+except ModuleNotFoundError:
+ # Development checkout; AppImage bundles the canonical shared detector.
+ _path = Path(__file__).resolve().parents[2] / "scripts/global/proxmox_platform.py"
+ _spec = importlib.util.spec_from_file_location("proxmox_platform", _path)
+ _platform_module = importlib.util.module_from_spec(_spec)
+ _spec.loader.exec_module(_platform_module)
+ detect_platform = _platform_module.detect_platform
+
+_lock = threading.RLock()
+_platform_cache = (0, {})
+_status_cache = {}
+PBS_SERVICES = ("proxmox-backup", "proxmox-backup-proxy")
+MAX_RESPONSE = 4 * 1024 * 1024
+
+
+class PBSError(ValueError):
+ pass
+
+
+def get_platform():
+ global _platform_cache
+ with _lock:
+ if _platform_cache[0] <= time.monotonic():
+ _platform_cache = (time.monotonic() + 60, detect_platform())
+ return dict(_platform_cache[1])
+
+
+def is_pbs_host():
+ return get_platform().get("product") == "pbs"
+
+
+def _servers_path():
+ return auth_manager.CONFIG_DIR / "pbs_servers.json"
+
+
+def _load_servers():
+ try:
+ data = json.loads(_servers_path().read_text())
+ if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
+ raise ValueError()
+ return data
+ except FileNotFoundError:
+ return []
+ except (OSError, ValueError) as exc:
+ raise PBSError("Could not read the PBS server configuration.") from exc
+
+
+def _save_servers(servers):
+ auth_manager.ensure_config_dir()
+ descriptor, temporary = tempfile.mkstemp(prefix=".pbs-servers-", dir=auth_manager.CONFIG_DIR)
+ try:
+ with os.fdopen(descriptor, "w") as handle:
+ os.fchmod(handle.fileno(), 0o600)
+ json.dump(servers, handle, indent=2)
+ os.replace(temporary, _servers_path())
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+ _status_cache.clear()
+
+
+def public_servers():
+ fields = ("id", "name", "url", "token_id", "fingerprint")
+ with _lock:
+ return [{**{key: row.get(key, "") for key in fields}, "has_token": bool(row.get("token_secret"))}
+ for row in _load_servers()]
+
+
+def _validate_server(data, previous=None):
+ if not isinstance(data, dict):
+ raise PBSError("Invalid PBS server configuration.")
+ previous = previous or {}
+ row = {field: data.get(field, previous.get(field, ""))
+ for field in ("name", "url", "token_id", "token_secret", "fingerprint")}
+ if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
+ raise PBSError("Invalid PBS connection field.")
+ row["name"] = row["name"].strip()
+ row["url"] = row["url"].strip().rstrip("/")
+ try:
+ parts = urlsplit(row["url"])
+ parts.port
+ if (parts.scheme != "https" or not parts.hostname or parts.username or parts.password
+ or parts.query or parts.fragment):
+ raise ValueError()
+ except ValueError as exc:
+ raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from exc
+ if not row["name"] or len(row["name"]) > 128:
+ raise PBSError("A server name of at most 128 characters is required.")
+ row["token_id"] = row["token_id"].strip()
+ if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
+ raise PBSError("Enter a PBS API token ID in user@realm!token format.")
+ if (not row["token_secret"] or not row["token_secret"].isascii()
+ or any(char.isspace() or ord(char) < 32 for char in row["token_secret"])):
+ raise PBSError("A valid API token secret is required.")
+ if not row["token_id"].isascii():
+ raise PBSError("Invalid API token ID.")
+ fingerprint = row["fingerprint"].strip().lower().replace(":", "")
+ if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint):
+ raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).")
+ row["fingerprint"] = fingerprint
+ return row
+
+
+def _remote_get(server, endpoint):
+ """Validate the TLS peer BEFORE transmitting the PBS token (no redirects)."""
+ parts = urlsplit(server["url"])
+ fingerprint = server.get("fingerprint")
+ if fingerprint:
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ context.check_hostname = False
+ context.verify_mode = ssl.CERT_NONE # Authenticated by the explicit pin below.
+ else:
+ context = ssl.create_default_context()
+ connection = http.client.HTTPSConnection(parts.hostname, parts.port or 443,
+ timeout=5, context=context)
+ try:
+ connection.connect()
+ if fingerprint:
+ actual = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).hexdigest()
+ if not hmac.compare_digest(actual, fingerprint):
+ raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.")
+ path = parts.path.rstrip("/") + "/api2/json" + endpoint
+ connection.request("GET", path, headers={
+ "Accept": "application/json",
+ "Authorization": "PBSAPIToken=" + server["token_id"] + ":" + server["token_secret"],
+ })
+ response = connection.getresponse()
+ content = response.read(MAX_RESPONSE + 1)
+ if response.status in (401, 403):
+ raise PBSError("PBS rejected the API token or its permissions. Check both the user and token ACLs.")
+ if response.status != 200 or len(content) > MAX_RESPONSE:
+ raise PBSError("PBS returned an invalid response. Redirects are not followed.")
+ document = json.loads(content)
+ if not isinstance(document, dict) or "data" not in document:
+ raise PBSError("PBS returned an invalid JSON document.")
+ return document["data"]
+ except PBSError:
+ raise
+ except (OSError, http.client.HTTPException, ValueError) as exc:
+ raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.") from exc
+ finally:
+ connection.close()
+
+
+def save_server(data):
+ identifier = data.get("id") if isinstance(data, dict) else None
+ if identifier is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(identifier)):
+ raise PBSError("Invalid PBS server ID.")
+ with _lock:
+ previous = next((row for row in _load_servers() if row["id"] == identifier), None)
+ row = _validate_server(data, previous)
+ # /version may be public; validate credentials on the protected status API.
+ usage = _remote_get(row, "/status/datastore-usage")
+ if not isinstance(usage, list):
+ raise PBSError("PBS returned an invalid datastore list.")
+ row.update(id=identifier or "pbs-" + secrets.token_hex(6), revision=secrets.token_hex(16))
+ with _lock:
+ servers = _load_servers()
+ if previous is None and len(servers) >= 32:
+ raise PBSError("At most 32 PBS connections may be configured.")
+ servers = [existing for existing in servers if existing["id"] != row["id"]]
+ _save_servers([*servers, row])
+ return row["id"]
+
+
+def delete_server(identifier):
+ with _lock:
+ servers = _load_servers()
+ remaining = [row for row in servers if row["id"] != identifier]
+ if len(remaining) == len(servers):
+ raise PBSError("PBS server not found.")
+ _save_servers(remaining)
+
+
+def _cli(*arguments):
+ try:
+ result = subprocess.run(["proxmox-backup-manager", *arguments, "--output-format", "json"],
+ check=True, text=True, capture_output=True, timeout=8)
+ data = json.loads(result.stdout)
+ if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
+ raise ValueError()
+ return data
+ except (OSError, subprocess.SubprocessError, ValueError) as exc:
+ raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.") from exc
+
+
+def _local_datastores():
+ stores = _cli("datastore", "list")
+ result = []
+ for store in stores:
+ row = {"name": store.get("name"), "path": store.get("path"),
+ "maintenance_mode": store.get("maintenance-mode"), "total": None, "used": None, "avail": None}
+ path = store.get("path")
+ # A removable datastore directory may survive unmounting. Do not report
+ # its underlying root filesystem as the missing datastore's capacity.
+ inactive = store.get("backing-device") and not (path and os.path.ismount(path))
+ if not path or inactive or store.get("maintenance-mode"):
+ row["error"] = "Datastore is offline, in maintenance or has no mounted backing device."
+ else:
+ try:
+ capacity = shutil.disk_usage(path)
+ row.update(total=capacity.total, used=capacity.used, avail=capacity.free)
+ except OSError:
+ row["error"] = "Datastore filesystem is not available."
+ result.append(row)
+ return result
+
+
+def _local_services():
+ result = {}
+ for service in PBS_SERVICES:
+ try:
+ check = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=3)
+ result[service] = check.stdout.strip() if check.returncode == 0 else "inactive"
+ except (OSError, subprocess.SubprocessError):
+ result[service] = "unknown"
+ return result
+
+
+def get_status(identifier):
+ with _lock:
+ cached = _status_cache.get(identifier)
+ if cached and cached[0] > time.monotonic():
+ return cached[1]
+ errors = {}
+ if identifier == "local":
+ if not is_pbs_host():
+ raise PBSError("The local host is not a PBS server.")
+ name, version = "Local PBS", get_platform().get("version")
+ services = _local_services()
+ operations = {"datastores": _local_datastores,
+ "tasks": lambda: _cli("task", "list", "--all", "true", "--limit", "20"),
+ "sync": lambda: _cli("sync-job", "list"),
+ "verify": lambda: _cli("verify-job", "list"),
+ "prune": lambda: _cli("prune-job", "list")}
+ else:
+ with _lock:
+ server = next((row for row in _load_servers() if row["id"] == identifier), None)
+ if not server:
+ raise PBSError("PBS server not found.")
+ metadata = _remote_get(server, "/version")
+ name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None
+ services = {}
+ endpoints = {"datastores": "/status/datastore-usage", "tasks": "/nodes/localhost/tasks?limit=20",
+ "sync": "/admin/sync", "verify": "/admin/verify", "prune": "/admin/prune"}
+ operations = {key: (lambda endpoint=path: _remote_get(server, endpoint)) for key, path in endpoints.items()}
+ result = {"server": identifier, "name": name, "version": version, "services": services,
+ "fetched_at": int(time.time()), "errors": errors}
+ with ThreadPoolExecutor(max_workers=5) as executor:
+ futures = {key: executor.submit(operation) for key, operation in operations.items()}
+ for key, future in futures.items():
+ try:
+ data = future.result()
+ if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
+ raise PBSError("PBS returned an invalid list.")
+ result[key] = [{**row, "name": row.get("store", row.get("name"))} for row in data] if key == "datastores" else data
+ except PBSError as exc:
+ result[key] = None # Unknown/forbidden is never presented as zero.
+ errors[key] = str(exc)
+ with _lock:
+ if len(_status_cache) >= 33:
+ _status_cache.clear()
+ _status_cache[identifier] = (time.monotonic() + 15, result)
+ return result
diff --git a/AppImage/scripts/tests/test_pbs.py b/AppImage/scripts/tests/test_pbs.py
new file mode 100644
index 00000000..878debc7
--- /dev/null
+++ b/AppImage/scripts/tests/test_pbs.py
@@ -0,0 +1,289 @@
+"""PBS platform, HTTPS protocol, authorization and isolated AppImage regressions."""
+
+import ast
+from datetime import datetime, timedelta, timezone
+import hashlib
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+import ipaddress
+import json
+from pathlib import Path
+import shlex
+import ssl
+import subprocess
+import sys
+import tempfile
+import threading
+from types import SimpleNamespace
+import unittest
+from unittest import mock
+
+ROOT = Path(__file__).resolve().parents[3]
+sys.path.insert(0, str(ROOT / "AppImage/scripts"))
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+from flask import Flask
+import jwt
+import auth_manager
+import pbs_manager as pbs
+from flask_pbs_routes import pbs_bp
+
+
+class PlatformTests(unittest.TestCase):
+ def test_supported_platforms_and_partial_dpkg_failure(self):
+ module = pbs.detect_platform.__globals__
+ with tempfile.TemporaryDirectory() as directory:
+ release = Path(directory) / "os-release"
+ for package, version, suite, product in (
+ ("pve-manager", "8.4.15", "bookworm", "pve"),
+ ("pve-manager", "9.2.1", "trixie", "pve"),
+ ("proxmox-backup-server", "3.4.0-1", "bookworm", "pbs"),
+ ("proxmox-backup-server", "4.2.5-1", "trixie", "pbs"),
+ ):
+ release.write_text(f'VERSION_CODENAME="{suite}"\n')
+ output = f"{package}\tinstall ok installed\t{version}\n"
+ with self.subTest(product=product, version=version), \
+ mock.patch.dict(module, {"OS_RELEASE": release}), \
+ mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output, returncode=1)):
+ detected = pbs.detect_platform()
+ self.assertEqual(detected["product"], product)
+ self.assertEqual(detected["version"], version)
+ self.assertTrue(detected["supported"])
+
+ def test_client_only_and_removed_server_are_not_pbs(self):
+ for output in ("proxmox-backup-client\tinstall ok installed\t4.2.5\n",
+ "proxmox-backup-server\tdeinstall ok config-files\t4.2.5\n", ""):
+ with self.subTest(output=output), mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output)):
+ self.assertEqual(pbs.detect_platform()["product"], "unknown")
+
+ def test_pbs_utilities_preserve_sources_and_never_call_pve_policy(self):
+ helper = ROOT / "scripts/global/repository-functions.sh"
+ for supported in (True, False):
+ harness = f'''
+source {shlex.quote(str(helper))}
+pmx_platform_field() {{ if [[ "$1" == product ]]; then echo pbs; else echo {str(supported).lower()}; fi; }}
+msg_info() {{ :; }}
+msg_error() {{ :; }}
+translate() {{ printf '%s' "$1"; }}
+pveversion() {{ echo 'FORBIDDEN: pveversion'; return 90; }}
+repository_policy() {{ echo 'FORBIDDEN: repository mutation'; return 90; }}
+apt-get() {{ echo 'FORBIDDEN: apt'; return 90; }}
+ensure_repositories
+'''
+ result = subprocess.run(["bash"], input=harness, text=True, capture_output=True)
+ self.assertNotIn("FORBIDDEN", result.stdout + result.stderr)
+ self.assertEqual(result.returncode, 0 if supported else 1)
+
+
+class PBSManagerTests(unittest.TestCase):
+ def setUp(self):
+ temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(temporary.cleanup)
+ self.root = Path(temporary.name)
+ patch = mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json")
+ patch.start(); self.addCleanup(patch.stop)
+ auth_manager.save_auth_config({"enabled": True, "username": "admin", "password_hash": "configured",
+ "jwt_secret": "s" * 48, "api_tokens": [], "revoked_tokens": []})
+ auth_manager._invalidate_revoked_cache()
+ pbs._status_cache.clear()
+ self.server = {"name": "Backup", "url": "https://pbs.test:8007", "token_id": "audit@pbs!monitor",
+ "token_secret": "fixture-token-secret", "fingerprint": "a" * 64}
+ app = Flask(__name__); app.register_blueprint(pbs_bp)
+ self.client = app.test_client()
+ self.headers = {"Authorization": "Bearer " + auth_manager.generate_token("admin")}
+
+ def test_save_redacts_secret_and_probes_authenticated_endpoint(self):
+ with mock.patch.object(pbs, "_remote_get", return_value=[]) as request:
+ identifier = pbs.save_server(self.server)
+ request.assert_called_once()
+ self.assertEqual(request.call_args.args[1], "/status/datastore-usage")
+ self.assertEqual((self.root / "pbs_servers.json").stat().st_mode & 0o777, 0o600)
+ public = pbs.public_servers()
+ self.assertEqual(public[0]["id"], identifier)
+ self.assertNotIn("fixture-token-secret", json.dumps(public))
+ self.assertTrue(public[0]["has_token"])
+ with mock.patch.object(pbs, "_remote_get", return_value=[]):
+ pbs.save_server({"id": identifier, "name": "Renamed"})
+ self.assertEqual(pbs._load_servers()[0]["token_secret"], "fixture-token-secret")
+ pbs.delete_server(identifier)
+ self.assertEqual(pbs.public_servers(), [])
+
+ def test_failed_credentials_or_invalid_config_do_not_replace_saved_connection(self):
+ with mock.patch.object(pbs, "_remote_get", return_value=[]):
+ identifier = pbs.save_server(self.server)
+ original = (self.root / "pbs_servers.json").read_bytes()
+ with mock.patch.object(pbs, "_remote_get", side_effect=pbs.PBSError("Rejected token")):
+ with self.assertRaises(pbs.PBSError):
+ pbs.save_server({**self.server, "id": identifier, "token_secret": "wrong"})
+ for changes in ({"url": "http://pbs.test"}, {"url": "https://user:pass@pbs.test"},
+ {"fingerprint": "bad-pin"}, {"token_id": "not-a-token"},
+ {"token_secret": "line\nbreak"}):
+ with self.subTest(changes=changes), self.assertRaises(pbs.PBSError):
+ pbs.save_server({**self.server, **changes, "id": identifier})
+ self.assertEqual((self.root / "pbs_servers.json").read_bytes(), original)
+
+ def test_remote_partial_failure_is_unknown_instead_of_zero(self):
+ def remote(server, endpoint):
+ if endpoint == "/version": return {"version": "4.2.5"}
+ if endpoint == "/status/datastore-usage": return [{"store": "backup", "total": 100, "used": 30, "avail": 70}]
+ if endpoint == "/admin/verify": raise pbs.PBSError("Permission denied")
+ return []
+ with mock.patch.object(pbs, "_remote_get", side_effect=remote) as request:
+ identifier = pbs.save_server(self.server)
+ result = pbs.get_status(identifier)
+ count = request.call_count
+ self.assertEqual(pbs.get_status(identifier), result)
+ self.assertEqual(request.call_count, count)
+ self.assertEqual(result["datastores"][0]["name"], "backup")
+ self.assertIsNone(result["verify"])
+ self.assertIn("verify", result["errors"])
+
+ def test_unmounted_removable_datastore_has_no_fabricated_capacity(self):
+ with mock.patch.object(pbs, "_cli", return_value=[{"name": "removable", "path": "/not-mounted", "backing-device": "uuid"}]), \
+ mock.patch.object(pbs.os.path, "ismount", return_value=False), \
+ mock.patch.object(pbs.shutil, "disk_usage") as usage:
+ result = pbs._local_datastores()
+ usage.assert_not_called()
+ self.assertIsNone(result[0]["total"])
+ self.assertIn("error", result[0])
+
+ def test_local_status_reads_pbs_commands_and_filesystem_capacity(self):
+ def run(command, **kwargs):
+ self.assertNotIn("shell", kwargs)
+ self.assertEqual(command[0], "proxmox-backup-manager")
+ self.assertEqual(command[-2:], ["--output-format", "json"])
+ data = [{"name": "backup", "path": str(self.root)}] if command[1:3] == ["datastore", "list"] else []
+ return SimpleNamespace(stdout=json.dumps(data), returncode=0)
+ with mock.patch.object(pbs, "get_platform", return_value={"product": "pbs", "version": "4.2.5"}), \
+ mock.patch.object(pbs, "_local_services", return_value={key: "active" for key in pbs.PBS_SERVICES}), \
+ mock.patch.object(pbs.subprocess, "run", side_effect=run), \
+ mock.patch.object(pbs.shutil, "disk_usage", return_value=SimpleNamespace(total=100, used=30, free=70)):
+ result = pbs.get_status("local")
+ self.assertEqual(result["version"], "4.2.5")
+ self.assertEqual(result["errors"], {})
+ self.assertEqual(result["datastores"][0]["used"], 30)
+ self.assertEqual(set(result["services"]), set(pbs.PBS_SERVICES))
+
+ def test_connection_api_requires_full_admin_and_does_not_return_secret(self):
+ self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401)
+ with mock.patch.object(pbs, "_remote_get", return_value=[]):
+ response = self.client.post("/api/pbs/servers", headers=self.headers, json=self.server)
+ self.assertEqual(response.status_code, 200)
+ self.assertNotIn("fixture-token-secret", response.text)
+ payload = jwt.decode(self.headers["Authorization"].split()[1], "s" * 48,
+ algorithms=["HS256"], audience=auth_manager.JWT_AUDIENCE)
+ payload["scope"] = "read_only"
+ read_only = {"Authorization": "Bearer " + jwt.encode(payload, "s" * 48, algorithm="HS256")}
+ self.assertEqual(self.client.post("/api/pbs/servers", headers=read_only, json=self.server).status_code, 403)
+ self.assertEqual(self.client.get("/api/pbs/servers", headers=read_only).status_code, 200)
+ auth_manager.save_auth_config({"enabled": False})
+ self.assertEqual(self.client.post("/api/pbs/servers", json=self.server).status_code, 409)
+
+ def test_health_uses_pbs_services_and_skips_pve_only_checks(self):
+ source = ast.parse((ROOT / "AppImage/scripts/health_monitor.py").read_text())
+ names = ("_check_pve_services", "_check_vms_cts_optimized", "_check_vms_cts_with_persistence",
+ "_check_proxmox_storages", "_check_proxmox_storage", "_check_lxc_disk_usage",
+ "_check_vm_disk_usage", "_check_lxc_mount_capacity", "_check_pve_storage_capacity")
+ functions = [node for node in ast.walk(source) if isinstance(node, ast.FunctionDef) and node.name in names]
+ persistence = mock.Mock(); persistence.is_error_active.return_value = False
+ run = mock.Mock(return_value=SimpleNamespace(returncode=0, stdout="active\n"))
+ namespace = {"Dict": dict, "Any": object, "Optional": __import__('typing').Optional,
+ "PBS_SERVICES": pbs.PBS_SERVICES, "health_persistence": persistence,
+ "subprocess": SimpleNamespace(run=run), "os": __import__('os')}
+ exec(compile(ast.Module(body=functions, type_ignores=[]), '', 'exec'), namespace)
+ instance = SimpleNamespace(platform_type="pbs", PVE_SERVICES=["pveproxy", "pvedaemon", "pvestatd", "pve-cluster"])
+ services = namespace["_check_pve_services"](instance)
+ self.assertEqual(services["status"], "OK")
+ self.assertEqual([call.args[0][2] for call in run.call_args_list], list(pbs.PBS_SERVICES))
+ for name in names[1:]:
+ result = namespace[name](instance)
+ self.assertTrue(result is None or result.get("not_applicable"))
+
+ def test_appimage_copies_and_imports_pbs_dependencies_outside_source_tree(self):
+ builder = (ROOT / "AppImage/scripts/build_appimage.sh").read_text()
+ modules = ("pbs_manager", "flask_pbs_routes", "auth_manager", "jwt_middleware")
+ copies = []
+ for name in modules:
+ matches = [line for line in builder.splitlines() if line.startswith(f'cp "$SCRIPT_DIR/{name}.py"')]
+ self.assertEqual(len(matches), 1, name)
+ copies.extend(matches)
+ copies.extend(line for line in builder.splitlines() if line.startswith('cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py"'))
+ self.assertEqual(len(copies), len(modules) + 1)
+ app = self.root / 'AppDir'; (app / 'usr/bin').mkdir(parents=True)
+ q = shlex.quote
+ shell = f'APP_DIR={q(str(app))}\nSCRIPT_DIR={q(str(ROOT / "AppImage/scripts"))}\nAPPIMAGE_ROOT={q(str(ROOT / "AppImage"))}\n' + '\n'.join(copies)
+ subprocess.run(['bash', '-e', '-c', shell], check=True)
+ code = f'import sys; sys.path.insert(0, {str(app / "usr/bin")!r}); import pbs_manager, flask_pbs_routes, proxmox_platform'
+ subprocess.run([sys.executable, '-I', '-B', '-c', code], cwd=self.root, check=True, capture_output=True)
+
+
+class TLSTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.temporary = tempfile.TemporaryDirectory()
+ root = Path(cls.temporary.name)
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
+ certificate = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key())
+ .serial_number(x509.random_serial_number()).not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1))
+ .not_valid_after(datetime.now(timezone.utc) + timedelta(hours=1))
+ .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False)
+ .sign(key, hashes.SHA256()))
+ cert_path, key_path = root / 'server.pem', root / 'server.key'
+ cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
+ key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
+ cls.fingerprint = hashlib.sha256(certificate.public_bytes(serialization.Encoding.DER)).hexdigest()
+ cls.requests = []
+ cls.response_status = 200
+
+ class Handler(BaseHTTPRequestHandler):
+ def handle(self):
+ try:
+ super().handle()
+ except ConnectionResetError:
+ pass # A rejected TLS pin closes before any HTTP request.
+
+ def do_GET(self):
+ cls.requests.append((self.path, self.headers.get("Authorization")))
+ body = json.dumps({"data": []}).encode()
+ self.send_response(cls.response_status)
+ self.send_header('Content-Length', str(len(body)))
+ self.end_headers(); self.wfile.write(body)
+
+ def log_message(self, *args): pass
+
+ cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(cert_path, key_path)
+ cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True)
+ cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True); cls.thread.start()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.server.shutdown(); cls.server.server_close(); cls.thread.join(timeout=5); cls.temporary.cleanup()
+
+ def setUp(self):
+ self.requests.clear(); type(self).response_status = 200
+ self.connection = {"url": f'https://127.0.0.1:{self.server.server_port}',
+ "fingerprint": self.fingerprint, "token_id": "audit@pbs!monitor", "token_secret": "fake-secret"}
+
+ def test_real_tls_matching_pin_sends_correct_pbs_authorization(self):
+ self.assertEqual(pbs._remote_get(self.connection, '/status/datastore-usage'), [])
+ self.assertEqual(self.requests, [('/api2/json/status/datastore-usage', 'PBSAPIToken=audit@pbs!monitor:fake-secret')])
+
+ def test_bad_pin_and_untrusted_ca_send_no_http_credentials(self):
+ for fingerprint in ('0' * 64, ''):
+ with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError):
+ pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage')
+ self.assertEqual(self.requests, [])
+
+ def test_bad_credentials_and_redirects_are_rejected(self):
+ for status in (401, 403, 302):
+ type(self).response_status = status
+ with self.subTest(status=status), self.assertRaises(pbs.PBSError):
+ pbs._remote_get(self.connection, '/status/datastore-usage')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/AppImage/tests/pbs-navigation.cjs b/AppImage/tests/pbs-navigation.cjs
new file mode 100644
index 00000000..5ceac8dc
--- /dev/null
+++ b/AppImage/tests/pbs-navigation.cjs
@@ -0,0 +1,26 @@
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+const vm = require('node:vm')
+const ts = require('typescript')
+const source = fs.readFileSync(path.join(__dirname, '../components/proxmox-dashboard.tsx'), 'utf8')
+const tree = ts.createSourceFile('dashboard.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
+const wrapper = tree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'ProxmoxDashboard')
+assert.ok(wrapper, 'platform gate must remain separate from PVE hooks/prewarmers')
+const code = ts.transpileModule(ts.createPrinter().printNode(ts.EmitHint.Unspecified, wrapper, tree), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
+}).outputText
+const PBS = () => {}, PVE = () => {}
+for (const [data, error, expected] of [
+ [{ product: 'pbs' }, null, PBS], [{ product: 'pve' }, null, PVE],
+ [undefined, new Error('older API'), PVE], [undefined, null, 'p'],
+]) {
+ const sandbox = {
+ exports: {}, PBSDashboard: PBS, PVEDashboard: PVE,
+ useT: () => key => key, useSWR: () => ({ data, error }),
+ require: () => ({ jsx: (type, props) => ({ type, props }) }),
+ }
+ vm.runInNewContext(code, sandbox)
+ assert.equal(sandbox.exports.ProxmoxDashboard().type, expected)
+}
+console.log('PASS: PBS dashboard routing and loading gate do not mount PVE prewarmers')
diff --git a/docs/pbs.md b/docs/pbs.md
new file mode 100644
index 00000000..542f0472
--- /dev/null
+++ b/docs/pbs.md
@@ -0,0 +1,66 @@
+# Proxmox Backup Server support
+
+ProxMenux detects PBS from the installed **proxmox-backup-server** package.
+Installing proxmox-backup-client on PVE or another Debian system does not turn
+that host into PBS. Supported maintenance platforms are PBS 3/Debian 12 and
+PBS 4/Debian 13; PVE 8/9 detection remains separate. If both server products
+are co-installed, PVE takes precedence.
+
+## On a PBS host
+
+The main CLI opens a PBS menu with package/version information, datastores,
+recent tasks, the existing system-utility installer, settings and a PBS package
+update. Utility installation preserves the operator's APT sources instead of
+trying to find a PVE version or enabling PVE/Ceph repositories. Configure a
+working subscription or no-subscription PBS repository in PBS itself if APT
+reports an error.
+
+The update refreshes configured indexes, displays the simulated full-upgrade
+plan and requires confirmation before applying it. It does not run PVE repository
+cleanup, major-version migration, kernel cleanup or guest scripts. The automatic
+and customizable PVE post-install entry points reject PBS before adjustments.
+
+Monitor opens a PBS dashboard with CPU, RAM, uptime, local PBS status,
+datastores, recent tasks and maintenance schedules, plus logs, terminal and
+Monitor authentication/HTTPS settings. PVE guest prewarmers, firewall controls
+and guest-storage checks are not part of this dashboard. Health checks monitor
+proxmox-backup and proxmox-backup-proxy rather than reporting absent PVE daemons.
+Monitor can reuse the native PBS proxy.pem/proxy.key pair, including renewal.
+
+Local data is read with proxmox-backup-manager JSON commands. Datastore capacity
+is filesystem capacity, not the logical sum of backups; datastores sharing one
+filesystem can show the same capacity. An unmounted removable datastore or a
+failed check has unknown capacity, not zero/free space.
+
+## Monitor a remote PBS from PVE
+
+Open **Backup → Proxmox Backup Server → Manage PBS connections**. Enable local
+Monitor authentication first, then add a named HTTPS endpoint (for example
+https://pbs.example:8007), a dedicated API token ID (`user@pbs!monitor`) and its
+secret. Give both the user and token the PBS **Audit** role on `/`, with
+propagation, to view system tasks and datastore status. Token ACLs cannot exceed
+the user's effective permissions. A restricted token may show only its permitted
+data; denied sections are displayed as unavailable.
+
+Use a CA-trusted certificate or copy the certificate's SHA256 fingerprint from
+the PBS console/a trusted channel. With a pin, Monitor checks the peer certificate
+before sending the token. With no pin, the normal system CA and hostname checks
+apply. Connections go directly to the configured HTTPS endpoint and do not follow
+redirects. A reverse-proxy prefix is supported; omit the port for HTTPS 443 or
+include the native PBS port explicitly.
+
+Saving tests an authenticated datastore-status endpoint. Secrets are stored
+atomically with mode 0600 in `~/.config/proxmenux-monitor/pbs_servers.json`, never
+returned by the API, and retained when the edit form's secret is left empty.
+Connection changes require full administrator access; read-only Monitor API tokens
+can read monitoring data but cannot change connections. Removing a connection
+removes its saved credentials only. Monitoring does not start prune, garbage
+collection, verification or sync jobs and does not modify PBS backups.
+
+Status is cached for 15 seconds and the panel refreshes every 30 seconds. Task or
+schedule permission failures are shown independently, without hiding successful
+datastore data or presenting failed queries as empty healthy results.
+
+References: [PBS command syntax](https://pbs.proxmox.com/docs/command-syntax.html),
+[PBS API](https://pbs.proxmox.com/docs/api-viewer/index.html),
+[PBS users, tokens and ACLs](https://pbs.proxmox.com/docs/user-management.html).
diff --git a/scripts/global/platform-functions.sh b/scripts/global/platform-functions.sh
new file mode 100644
index 00000000..1bfb3e49
--- /dev/null
+++ b/scripts/global/platform-functions.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# Canonical detection is shared with the Monitor; never infer PBS from a client package.
+pmx_platform_field() {
+ python3 "$(dirname "${BASH_SOURCE[0]}")/proxmox_platform.py" --field "$1"
+}
+
+pmx_is_pbs() {
+ [[ "$(pmx_platform_field product)" == pbs ]]
+}
+
+pmx_require_pve() {
+ if [[ "$(pmx_platform_field product)" != pve ]]; then
+ msg_error "$(translate 'This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.')"
+ return 1
+ fi
+}
diff --git a/scripts/global/proxmox_platform.py b/scripts/global/proxmox_platform.py
new file mode 100644
index 00000000..f6744f43
--- /dev/null
+++ b/scripts/global/proxmox_platform.py
@@ -0,0 +1,56 @@
+"""Shared PVE/PBS platform detection for the menus and the Monitor (stdlib only)."""
+
+import argparse
+import json
+from pathlib import Path
+import re
+import subprocess
+
+OS_RELEASE = Path("/etc/os-release")
+SUPPORTED = {("pve", 8): "bookworm", ("pve", 9): "trixie",
+ ("pbs", 3): "bookworm", ("pbs", 4): "trixie"}
+
+
+def detect_platform():
+ codename = ""
+ try:
+ match = re.search(r'^VERSION_CODENAME=["\']?([a-z0-9-]+)', OS_RELEASE.read_text(), re.M)
+ codename = match.group(1) if match else ""
+ except OSError:
+ pass
+ installed = {}
+ try:
+ result = subprocess.run(
+ ["dpkg-query", "-W", "-f=${Package}\t${Status}\t${Version}\n",
+ "pve-manager", "proxmox-backup-server"], capture_output=True, text=True, timeout=5,
+ )
+ # dpkg returns nonzero if ONE requested package is missing; its other
+ # installed rows remain authoritative. A PBS client alone is not PBS.
+ for line in result.stdout.splitlines():
+ columns = line.split("\t")
+ if len(columns) == 3 and columns[1] == "install ok installed":
+ installed[columns[0]] = columns[2]
+ except (OSError, subprocess.TimeoutExpired):
+ pass
+ for package, product in (("pve-manager", "pve"), ("proxmox-backup-server", "pbs")):
+ version = installed.get(package, "")
+ match = re.fullmatch(r"(?:[0-9]+:)?([0-9]+)\.[0-9]+(?:\.[0-9]+)?(?:[-+~.a-zA-Z0-9]*)", version)
+ if match:
+ major = int(match.group(1))
+ return {"product": product, "version": version, "major": major,
+ "codename": codename, "supported": SUPPORTED.get((product, major)) == codename}
+ return {"product": "unknown", "version": None, "major": None,
+ "codename": codename, "supported": False}
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--field", choices=("product", "version", "major", "codename", "supported"))
+ arguments = parser.parse_args()
+ data = detect_platform()
+ value = data.get(arguments.field) if arguments.field else data
+ print(value if isinstance(value, str) else json.dumps(value))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/global/repository-functions.sh b/scripts/global/repository-functions.sh
index 32dd8364..d6114085 100644
--- a/scripts/global/repository-functions.sh
+++ b/scripts/global/repository-functions.sh
@@ -2,12 +2,21 @@
# Shared repository policy for package-install and safe-update flows.
# Proxmox's own repository API parses .list/.sources and edits individual
# entries. No shell rewriting of operator-maintained APT files.
+source "$(dirname "${BASH_SOURCE[0]}")/platform-functions.sh"
repository_policy() {
python3 "$(dirname "${BASH_SOURCE[0]}")/repository_policy.py" "$@"
}
ensure_repositories() {
local version suite decision refresh_status
+ if pmx_is_pbs; then
+ if [[ "$(pmx_platform_field supported)" != true ]]; then
+ msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
+ return 1
+ fi
+ msg_info "$(translate 'PBS: using the configured APT repositories without changing sources.')"
+ return 0
+ fi
version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
case "$version" in
8) suite=bookworm ;;
diff --git a/scripts/menus/main_menu.sh b/scripts/menus/main_menu.sh
index fb3d806d..69e81bd3 100644
--- a/scripts/menus/main_menu.sh
+++ b/scripts/menus/main_menu.sh
@@ -128,4 +128,8 @@ show_menu() {
done
}
+source "$LOCAL_SCRIPTS/global/platform-functions.sh"
+if pmx_is_pbs; then
+ exec bash "$LOCAL_SCRIPTS/menus/pbs_menu.sh"
+fi
show_menu
diff --git a/scripts/menus/pbs_menu.sh b/scripts/menus/pbs_menu.sh
new file mode 100644
index 00000000..4a04c2a4
--- /dev/null
+++ b/scripts/menus/pbs_menu.sh
@@ -0,0 +1,46 @@
+#!/bin/bash
+# PBS exposes compatible host tools; PVE guest/repository optimizations stay in the PVE menu.
+BASE_DIR="/usr/local/share/proxmenux"
+LOCAL_SCRIPTS="$BASE_DIR/scripts"
+source "$BASE_DIR/utils.sh"
+source "$LOCAL_SCRIPTS/global/platform-functions.sh"
+load_language
+initialize_cache
+
+if ! pmx_is_pbs; then
+ msg_error "$(translate 'This menu requires Proxmox Backup Server.')"
+ exit 1
+fi
+
+pbs_show() {
+ local output
+ output=$(mktemp)
+ if "$@" > "$output" 2>&1; then
+ dialog --backtitle "ProxMenux · PBS" --title "Proxmox Backup Server" --textbox "$output" 24 100
+ else
+ dialog --backtitle "ProxMenux · PBS" --title "$(translate 'Command failed')" --textbox "$output" 24 100
+ fi
+ rm -f "$output"
+}
+
+while true; do
+ choice=$(dialog --clear --backtitle "ProxMenux · PBS" --title "Proxmox Backup Server" \
+ --menu "$(translate 'Select an option:')" 20 80 10 \
+ status "$(translate 'PBS version and packages')" \
+ stores "$(translate 'PBS datastores')" \
+ tasks "$(translate 'Recent PBS tasks')" \
+ utilities "$(translate 'System Utilities Installer')" \
+ update "$(translate 'Update PBS using configured repositories')" \
+ settings "$(translate 'Settings')" \
+ exit "$(translate 'Exit')" 2>&1 >/dev/tty) || break
+ case "$choice" in
+ status) pbs_show proxmox-backup-manager versions --verbose --output-format json-pretty ;;
+ stores) pbs_show proxmox-backup-manager datastore list --output-format json-pretty ;;
+ tasks) pbs_show proxmox-backup-manager task list --all true --limit 20 --output-format json-pretty ;;
+ utilities) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;;
+ update) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;;
+ settings) bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;;
+ exit) break ;;
+ esac
+done
+clear
diff --git a/scripts/post_install/auto_post_install.sh b/scripts/post_install/auto_post_install.sh
index a158942d..25e6289f 100644
--- a/scripts/post_install/auto_post_install.sh
+++ b/scripts/post_install/auto_post_install.sh
@@ -57,6 +57,11 @@ fi
load_language
initialize_cache
+if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
+ source "$LOCAL_SCRIPTS/global/platform-functions.sh"
+ pmx_require_pve || exit 1
+fi
+
# Global variables
OS_CODENAME="$(grep "VERSION_CODENAME=" /etc/os-release | cut -d"=" -f 2 | xargs)"
RAM_SIZE_GB=$(awk '/^MemTotal:/{print int($2/1024/1024)}' /proc/meminfo)
diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh
index 49ed0f3c..93db9dc1 100644
--- a/scripts/post_install/customizable_post_install.sh
+++ b/scripts/post_install/customizable_post_install.sh
@@ -51,6 +51,11 @@ fi
load_language
initialize_cache
+if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then
+ source "$LOCAL_SCRIPTS/global/platform-functions.sh"
+ pmx_require_pve || exit 1
+fi
+
# Load shared global functions
if [[ -f "$LOCAL_SCRIPTS/global/common-functions.sh" ]]; then
source "$LOCAL_SCRIPTS/global/common-functions.sh"
diff --git a/scripts/utilities/pbs_update.sh b/scripts/utilities/pbs_update.sh
new file mode 100644
index 00000000..05a453eb
--- /dev/null
+++ b/scripts/utilities/pbs_update.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+# Use PBS's existing repositories; never run PVE repo, Ceph or major-upgrade scripts.
+BASE_DIR="/usr/local/share/proxmenux"
+LOCAL_SCRIPTS="$BASE_DIR/scripts"
+source "$BASE_DIR/utils.sh"
+source "$LOCAL_SCRIPTS/global/platform-functions.sh"
+load_language
+initialize_cache
+
+if ! pmx_is_pbs || [[ "$(pmx_platform_field supported)" != true ]]; then
+ msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')"
+ exit 1
+fi
+[[ $EUID -eq 0 ]] || { msg_error "$(translate 'Run this operation as root.')"; exit 1; }
+dialog --backtitle "ProxMenux · PBS" --title "$(translate 'PBS package update')" \
+ --yesno "$(translate 'Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.')" 10 80 || exit 0
+if ! apt-get -o APT::Update::Error-Mode=any update; then
+ msg_error "$(translate 'APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.')"
+ read -r _
+ exit 1
+fi
+plan=$(mktemp)
+trap 'rm -f "$plan"' EXIT
+if ! apt-get -s full-upgrade > "$plan" 2>&1; then
+ dialog --textbox "$plan" 24 100
+ exit 1
+fi
+dialog --backtitle "ProxMenux · PBS" --title "$(translate 'Review package upgrade')" --textbox "$plan" 24 100
+dialog --defaultno --title "$(translate 'Apply PBS package upgrade')" \
+ --yesno "$(translate 'Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.')" 11 80 || exit 0
+if apt-get -y full-upgrade; then
+ msg_ok "$(translate 'PBS package upgrade completed. Check whether a reboot is required before your next backup window.')"
+else
+ msg_error "$(translate 'PBS package upgrade failed. Review the APT output before retrying.')"
+ read -r _
+ exit 1
+fi
+read -r _
From 816efa533eae281922de12f3c1dd7404f4dba398 Mon Sep 17 00:00:00 2001
From: Jonatan <190036+jcastro@users.noreply.github.com>
Date: Fri, 16 Oct 2026 21:51:39 +0200
Subject: [PATCH 2/5] Scope menu search coverage to the PVE platform
---
.github/scripts/tests/test_menu_search.py | 31 +++++++++++++++++++++--
1 file changed, 29 insertions(+), 2 deletions(-)
diff --git a/.github/scripts/tests/test_menu_search.py b/.github/scripts/tests/test_menu_search.py
index 2b867a90..72e0db8b 100644
--- a/.github/scripts/tests/test_menu_search.py
+++ b/.github/scripts/tests/test_menu_search.py
@@ -4,9 +4,13 @@ lives: the scripts it launches are the ones the menus launch."""
import importlib.util
import json
+import os
from pathlib import Path
import re
import runpy
+import shlex
+import subprocess
+import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[3]
@@ -42,11 +46,13 @@ class IndexTests(unittest.TestCase):
self.assertEqual(unknown, [])
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
- def test_every_script_a_menu_launches_can_be_found(self):
+ def test_every_script_a_pve_menu_launches_can_be_found(self):
indexed = {item["script"] for item in self.index}
launched = set()
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
- if menu.name in ("main_menu.sh", "lxc_menu.sh"):
+ # PBS replaces the PVE main menu and does not use its search.
+ # The exclusive platform dispatch is executed in the test below.
+ if menu.name in ("main_menu.sh", "lxc_menu.sh", "pbs_menu.sh"):
continue
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
launched -= {"menus/main_menu.sh"}
@@ -54,6 +60,27 @@ class IndexTests(unittest.TestCase):
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
self.assertEqual(sorted(launched - indexed), [])
+ def test_pbs_replaces_the_searchable_pve_menu(self):
+ menu = (SCRIPTS / "menus/main_menu.sh").read_text()
+ dispatch = menu[menu.rindex('source "$LOCAL_SCRIPTS/global/platform-functions.sh"'):]
+ with tempfile.TemporaryDirectory() as directory:
+ scripts = Path(directory)
+ (scripts / "global").mkdir()
+ (scripts / "menus").mkdir()
+ helper = scripts / "global/platform-functions.sh"
+ helper.write_text(
+ "source " + shlex.quote(str(SCRIPTS / "global/platform-functions.sh")) + "\n"
+ 'pmx_platform_field() { printf "%s" "$TEST_PROXMENUX_PRODUCT"; }\n')
+ (scripts / "menus/pbs_menu.sh").write_text('printf "PBS menu\\n"\n')
+ harness = 'show_menu() { printf "PVE search menu\\n"; }\n' + dispatch
+ for product, expected in (("pve", "PVE search menu"), ("pbs", "PBS menu")):
+ with self.subTest(product=product):
+ result = subprocess.run(["bash", "-e", "-c", harness], text=True, capture_output=True,
+ env={**os.environ, "LOCAL_SCRIPTS": str(scripts),
+ "TEST_PROXMENUX_PRODUCT": product})
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.strip(), expected)
+
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
self.assertIn('search "$(translate "Search options and applications")"', menu)
From 52632abbd64f3bf3e8e088f7f95457c877e85639 Mon Sep 17 00:00:00 2001
From: Jonatan <190036+jcastro@users.noreply.github.com>
Date: Fri, 16 Oct 2026 22:15:49 +0200
Subject: [PATCH 3/5] Make i18n steady-state tests handle pending translations
---
.../tests/test_command_descriptions.py | 227 ++++++------------
.github/workflows/test-i18n.yml | 6 +-
CONTRIBUTING.md | 15 +-
3 files changed, 90 insertions(+), 158 deletions(-)
diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py
index 1976095f..f3b48675 100644
--- a/.github/scripts/tests/test_command_descriptions.py
+++ b/.github/scripts/tests/test_command_descriptions.py
@@ -80,168 +80,74 @@ class CommandDescriptionsTests(unittest.TestCase):
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
FIXTURE["locales"]["sv"][index])
- def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
+ def assert_completed_catalogs_are_stable(self, *, future_messages=None):
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES))
+ locales = ["en", *languages.split(",")]
+ shipped = {ROOT / f"AppImage/messages/{lang}/common.json": catalog(lang) for lang in locales}
+ original_bytes = {path: path.read_bytes() for path in shipped}
+
+ def strings(node, path=()):
+ # Independent traversal: every existing nonempty string, including
+ # explicit English and obsolete human keys, must survive completion.
+ if isinstance(node, str) and node:
+ yield path, node
+ elif isinstance(node, dict):
+ for key, value in node.items():
+ yield from strings(value, (*path, key))
+ elif isinstance(node, list):
+ for index, value in enumerate(node):
+ yield from strings(value, (*path, index))
+
with tempfile.TemporaryDirectory() as directory:
- root = Path(directory)
- for lang in ["en", *languages.split(",")]:
- path = root / lang / "common.json"
- path.parent.mkdir()
- shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path)
- # This steady-state fixture assumes generation already finished.
- # Seed only intentional message additions; keep the
- # recovered command arrays and the repo catalogs exact. Runtime
- # missing-key fallback is covered by the JSX seam tests and by
- # test_storage_messages.cjs and test_health_text.cjs.
- if lang != "en":
- temporary = json.loads(path.read_text())
- for section, key in (
- ("archives", "emptyMessage"),
- ("archives", "formerJob"),
- ("archives", "formerJobTitle"),
- ("archives", "hiddenOtherNodes"),
- ("archives", "scopeAllNodes"),
- ("archives", "scopeAriaLabel"),
- ("archives", "scopeShowAll"),
- ("archives", "scopeThisNode"),
- ("destinations", "backupsKeptMessage"),
- ("destinations", "localAttachHelpMessage"),
- ):
- temporary["backup"][section].setdefault(
- key, catalog("en")["backup"][section][key])
- temporary["storage"].setdefault(
- "savedSmartData", catalog("en")["storage"]["savedSmartData"])
- report = temporary["storage"]["smartReport"]
- source_report = catalog("en")["storage"]["smartReport"]
- for key in ("passedAssessment", "noReallocatedSectorsReported",
- "passedMeaningTitle", "passedMeaning"):
- report.setdefault(key, source_report[key])
- for key in ("passedTitle", "passedText"):
- report["recommendations"].setdefault(
- key, source_report["recommendations"][key])
- # New health-popup messages deliberately use runtime English
- # fallback until each locale is translated. Model generator
- # steady state only in this disposable copy, as for thermal keys.
- for key in (
- 'certificateExpired',
- 'certificateExpiresDays',
- 'certificateInconclusive',
- 'cpuCheckFailed',
- 'cpuHighSustained',
- 'cpuSustained',
- 'fail2banCheckUnavailable',
- 'fail2banInactive',
- 'failedLoginsWithinThreshold',
- 'kernelPackagesMore',
- 'kernelPveUpdates',
- 'loginCheckUnavailable',
- 'memoryCheckFailed',
- 'memoryPressure',
- 'noFailedLogins',
- 'noSwapConfigured',
- 'noTemperatureSensor',
- 'nonRunningKernelUpdates',
- 'pendingPackageUpdates',
- 'pendingSecurityUpdates',
- 'pendingSecurityUpdatesDays',
- 'pendingSecurityUpdatesUnpatched',
- 'pveVersionAvailable',
- 'ramHighSustained',
- 'ramSustained',
- 'runningKernelUpdate',
- 'securityCheckUnavailable',
- 'sensorHighSamplesSpan',
- 'swapRamPressure',
- 'systemNotUpdatedMonths',
- 'systemNotUpdatedYear',
- 'temperatureElevated',
- 'unknownDetail',
- 'updatesAptError',
- 'updatesAptTimeout',
- 'updatesCheckUnavailable',
- 'uptimeKernelAdvice',
- 'uptimeUnavailable',
- ):
- temporary["healthStatus"]["details"].setdefault(
- key, catalog("en")["healthStatus"]["details"][key])
- # New thermal messages also await automatic translation.
- # Seed only these intentional additions in the temporary copy.
- for key in (
- "temperatureNoDisks", "temperatureNormal", "temperatureHighCount",
- "temperatureCriticalCount", "temperatureUnavailable",
- "temperatureIncomplete", "temperatureUnavailableCount",
- ):
- temporary["storage"].setdefault(key, catalog("en")["storage"][key])
- # Every shipped locale carries the temperature alert fallback
- # keys; the seed only guards a catalog that lacks them. The
- # runtime fallback is tested separately.
- source_fallback = catalog("en")["runtime"]["notifications"]["fallback"]
- fallback = temporary["runtime"]["notifications"]["fallback"]
- for key in ("temperatureAlertTitle", "temperatureAlertBody",
- "recordedReason", "recordedDetails"):
- fallback.setdefault(key, source_fallback[key])
- # Slovak remains the exact upstream catalog; model the
- # pending outcome-key generator additions in disposable
- # copies rather than modifying its curated values.
- if lang == 'sk':
- local = temporary['runtime']['notifications']
- source = catalog('en')['runtime']['notifications']
- for key, value in source['backup'].items():
- local.setdefault('backup', {}).setdefault(key, value)
- local['channels']['email']['status'].setdefault(
- 'unconfirmed', source['channels']['email']['status']['unconfirmed'])
- local['channels']['email']['status'].setdefault(
- 'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings'])
- path.write_text(json.dumps(temporary, ensure_ascii=False))
- # Model steady state after the bot fills these intentional new
- # messages; keep repository locales and all other leaves intact.
- if lang != "en":
- messages = json.loads(path.read_text())
- # PBS is a new section; model the bot's future output only
- # in disposable catalogs, preserving the shipped locales.
- messages.setdefault("pbs", catalog("en")["pbs"])
- english = catalog("en")
- for section, key in (("encryption", "pbsHelp"),
- ("keyfileActions", "pveKeyDescription")):
- messages["backup"][section].setdefault(key, english["backup"][section][key])
- for key in ("sshUserHelpMessage", "sshKeyHelpMessage", "sshKeySetupTitle",
- "sshKeySetupHelp", "sshAuthorizedKeyHelp"):
- messages["backup"]["destinations"].setdefault(
- key, english["backup"]["destinations"][key])
- messages["backup"]["actions"].setdefault(
- "prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
- # New LXC observation labels intentionally ship only EN/SK.
- # Model the bot's future output in temporary catalogs, just
- # like the other pending keys above; never rewrite locales.
- messages["scriptTerminal"].setdefault("endOfInput", english["scriptTerminal"]["endOfInput"])
- for key in ("noShell", "enterFailed"):
- messages["lxcTerminal"]["errors"].setdefault(key, english["lxcTerminal"]["errors"][key])
- # The notification history panel texts await the bot as well.
- for key, value in english["settings"]["notifications"]["history"].items():
- messages["settings"]["notifications"].setdefault("history", {}).setdefault(key, value)
- for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown",
- "checkStale", "checkStaleHelp", "checkBeforeUpdate",
- "startBeforeCheck", "notCheckedYet",
- "checkStillRunning", "refreshCheckHelp",
- "checkInProgress", "checkSystemPackages", "runCustomCommand",
- "installedVersionUnverified", "versionStatusUnknown"):
- messages["vmLxc"]["updates"].setdefault(
- key, english["vmLxc"]["updates"][key])
- for key in ("startPending", "shutdownPending", "rebootPending", "stopPending"):
- messages["vmLxc"]["actions"].setdefault(
- key, english["vmLxc"]["actions"][key])
- for key in ("osDockerScope", "dockerImageMethod", "unverifiedTargetsHelp"):
- messages["vmLxc"]["bulkUpdate"].setdefault(
- key, english["vmLxc"]["bulkUpdate"][key])
- messages["vmLxc"]["errors"].setdefault(
- "controlNotConfirmed", english["vmLxc"]["errors"]["controlNotConfirmed"])
- path.write_text(json.dumps(messages, ensure_ascii=False))
- before = {p: p.read_bytes() for p in root.glob("*/common.json")}
+ root = Path(directory).resolve()
+ for path in shipped:
+ destination = root / path.parent.name / "common.json"
+ destination.parent.mkdir()
+ shutil.copyfile(path, destination)
+ if future_messages is not None:
+ source = json.loads((root / "en/common.json").read_text())
+ source["futureFeatureFixture"] = future_messages
+ (root / "en/common.json").write_text(json.dumps(source))
+
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
output = io.StringIO()
+ # Establish steady state with the real generator and a deterministic
+ # offline provider. Translation happens only in disposable catalogs;
+ # adding an EN-only feature never needs a hand-maintained seed list.
+ with mock.patch.object(sys, "argv", argv), \
+ mock.patch.object(MODULE, "translate_one", side_effect=lambda text, lang, *_: f"TEST {lang} {text}") as seed, \
+ mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as initial_writer, \
+ redirect_stdout(output), redirect_stderr(output):
+ self.assertEqual(MODULE.main(), 0, output.getvalue())
+ self.assertNotRegex(output.getvalue(), r"(?m)^Completed with [0-9]+ translation failures")
+ self.assertTrue(all(call.args[0].is_relative_to(root) for call in initial_writer.call_args_list))
+ for path, original in shipped.items():
+ completed = json.loads((root / path.parent.name / "common.json").read_text())
+ for keys, value in strings(original):
+ actual = completed
+ for key in keys:
+ actual = actual[key]
+ self.assertEqual(actual, value, f"{path.parent.name}: {keys}")
+ # The recovered arrays remain exact, including frozen English.
+ if path.parent.name != "en":
+ self.assertEqual(completed["terminal"]["commandDescriptions"],
+ FIXTURE["locales"].get(path.parent.name,
+ original["terminal"]["commandDescriptions"]))
+ if future_messages is not None and path.parent.name != "en":
+ self.assertEqual(completed["futureFeatureFixture"], {
+ "title": f"TEST {path.parent.name} Future feature {{count}}",
+ "items": [f"TEST {path.parent.name} First item", {"label": f"TEST {path.parent.name} Second item"}],
+ "enabled": False, "count": 2, "empty": [],
+ })
+ if future_messages is not None:
+ self.assertGreaterEqual(seed.call_count, 3 * len(languages.split(",")))
+ before = {p: p.read_bytes() for p in root.glob("*/common.json")}
+ output = io.StringIO()
+ # Keep the original strict assertion: once completed, any provider
+ # call or catalog write is a regression. No production locale edits.
with mock.patch.object(sys, "argv", argv), \
mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \
mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \
@@ -253,6 +159,17 @@ class CommandDescriptionsTests(unittest.TestCase):
self.assertEqual(before, {p: p.read_bytes() for p in before})
for lang in languages.split(","):
self.assertIn(f"{lang}: nothing to translate", output.getvalue())
+ self.assertEqual(original_bytes, {path: path.read_bytes() for path in shipped})
+
+ def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
+ self.assert_completed_catalogs_are_stable()
+
+ def test_new_english_feature_preserves_curated_values_and_reruns_without_writes(self):
+ self.assert_completed_catalogs_are_stable(future_messages={
+ "title": "Future feature {count}",
+ "items": ["First item", {"label": "Second item"}],
+ "enabled": False, "count": 2, "empty": [],
+ })
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
node = require_node()
diff --git a/.github/workflows/test-i18n.yml b/.github/workflows/test-i18n.yml
index 3a2fc5f0..2b3e4685 100644
--- a/.github/workflows/test-i18n.yml
+++ b/.github/workflows/test-i18n.yml
@@ -1,4 +1,4 @@
-name: Offline i18n tests
+name: Offline script regressions
on:
pull_request:
@@ -42,6 +42,8 @@ permissions:
contents: read
jobs:
+ # Preserve this check ID for existing branch protection rules. This suite
+ # includes menu, download and scheduler regressions as well as i18n tests.
offline-i18n:
runs-on: ubuntu-latest
timeout-minutes: 5
@@ -61,7 +63,7 @@ jobs:
test -x /usr/bin/jq
- name: Install OCI wording test dependency
run: python3 -m pip install 'PyYAML>=6,<7'
- - name: Run complete offline script test suite
+ - name: Run offline i18n and script regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s .github/scripts/tests -v
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index aab13a74..5e76a028 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -818,7 +818,7 @@ Note that the dev server has no Flask backend on the same host by default: the a
### Testing
-#### Offline i18n script tests
+#### Offline script regressions (including i18n)
From the repository root, run the complete script suite:
@@ -836,6 +836,14 @@ It does not launch React or require `npm install`, `pip install`, a Proxmox host
API keys, or a translation provider. Generator tests use isolated temporary
catalogs and fake/forbidden providers; repository catalogs are not regenerated.
+The command-description recovery regression first completes disposable copies
+of the shipped catalogs with the real generator and a deterministic offline
+provider, then requires a second run to make zero provider calls and zero
+writes. It checks that all existing nonempty translations and the frozen
+command-description arrays survive unchanged. A synthetic EN-only feature tests
+new nested strings, arrays, placeholders and non-text values. New message keys
+do not need to be added to a test-specific translation seed list.
+
Scheduler fixtures discover every shipped `lang/*.json` cache and run the actual
`translate` lookup at both empty-job consumers for PBS and local archive backends.
They accept English fallback for missing/empty entries; complete translations are
@@ -859,6 +867,11 @@ than silently skipping the consumer assertions. `.github/workflows/test-i18n.yml
provisions Python and Node and runs this same command on relevant pull requests
and main/develop pushes (or manually). It is separate from the translation
publication workflow and has read-only repository permissions.
+Its Actions name is **Offline script regressions** because it also covers menus,
+downloads, scheduler behavior and other scripts. The `offline-i18n` check ID is
+retained for compatibility with existing branch protection rules; a failure of
+that check is not necessarily a translation failure. Read its failing test name
+and traceback before changing catalogs or translation-provider settings.
#### Qualified offline `tests/` CI lanes
From 8bbb475614f6a1779abb62baa4f164e781d8e5c0 Mon Sep 17 00:00:00 2001
From: Jonatan <190036+jcastro@users.noreply.github.com>
Date: Fri, 16 Oct 2026 23:00:22 +0200
Subject: [PATCH 4/5] Complete translations for all supported languages
---
AppImage/messages/de/common.json | 53 ++++++++++++++++++++++++++++++++
AppImage/messages/fr/common.json | 53 ++++++++++++++++++++++++++++++++
AppImage/messages/it/common.json | 53 ++++++++++++++++++++++++++++++++
AppImage/messages/pt/common.json | 53 ++++++++++++++++++++++++++++++++
AppImage/messages/sk/common.json | 53 ++++++++++++++++++++++++++++++++
AppImage/messages/sv/common.json | 53 ++++++++++++++++++++++++++++++++
lang/de.json | 20 +++++++++++-
lang/es.json | 20 +++++++++++-
lang/fr.json | 20 +++++++++++-
lang/it.json | 20 +++++++++++-
lang/pt.json | 20 +++++++++++-
lang/sk.json | 20 +++++++++++-
lang/sv.json | 20 +++++++++++-
13 files changed, 451 insertions(+), 7 deletions(-)
diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json
index 4093529a..37000513 100644
--- a/AppImage/messages/de/common.json
+++ b/AppImage/messages/de/common.json
@@ -7227,5 +7227,58 @@
"diagnosticsOmitted": "Weitere Diagnosezeilen oder Text ausgelassen: {count}. Originalbericht bleibt erhalten."
}
}
+ },
+ "pbs": {
+ "title": "Proxmox Backup Server",
+ "server": "PBS-Server",
+ "chooseServer": "PBS-Server auswählen",
+ "local": "Lokaler PBS",
+ "refresh": "Aktualisieren",
+ "manage": "PBS-Verbindungen verwalten",
+ "loadFailed": "PBS-Status konnte nicht geladen werden.",
+ "loading": "PBS-Daten werden geladen…",
+ "noServer": "Fügen Sie eine PBS-Verbindung hinzu, um Datenspeicher, Aufgaben und Zeitpläne anzuzeigen.",
+ "permissionsHelp": "Aktivieren Sie die Monitor-Authentifizierung, bevor Sie Zugangsdaten speichern. Verwenden Sie ein eigenes PBS-API-Token mit der Rolle Audit auf / sowohl für den Benutzer als auch für die Token-ACLs, jeweils mit Vererbung. Dieses Panel liest PBS-Daten ausschließlich.",
+ "editConnection": "Zu bearbeitende Verbindung",
+ "add": "Neue Verbindung hinzufügen",
+ "name": "Name",
+ "url": "HTTPS-URL (für den nativen PBS-Port :8007 angeben)",
+ "token_id": "API-Token-ID (user@realm!token)",
+ "token_secret": "API-Token-Geheimnis (leer lassen, um das gespeicherte Geheimnis beizubehalten)",
+ "fingerprint": "SHA256-Zertifikatfingerabdruck (optional)",
+ "tlsHelp": "Lassen Sie den Fingerabdruck für die normale Prüfung von Zertifizierungsstelle und Hostname leer. Kopieren Sie bei einem selbstsignierten Zertifikat dessen SHA256-Fingerabdruck aus der PBS-Konsole oder einem anderen vertrauenswürdigen Kanal. Der Fingerabdruck wird vor der Übermittlung von Zugangsdaten geprüft.",
+ "saved": "PBS-Verbindung geprüft und gespeichert.",
+ "saveFailed": "PBS-Verbindung konnte nicht gespeichert werden.",
+ "testing": "Verbindung wird geprüft…",
+ "save": "Prüfen und speichern",
+ "remove": "Verbindung entfernen",
+ "removeConfirm": "Diese gespeicherte PBS-Verbindung und ihr Token aus Monitor entfernen? PBS-Daten und -Aufträge bleiben unverändert.",
+ "sampled": "Erfasst am",
+ "datastores": "Datenspeicher",
+ "total": "Dateisystemkapazität",
+ "used": "Belegt",
+ "free": "Verfügbar",
+ "noDatastores": "Keine zugänglichen Datenspeicher. Prüfen Sie die Datenspeicherkonfiguration und die Berechtigungen des Benutzers und Tokens.",
+ "tasks": "Letzte Aufgaben",
+ "noTasks": "Es wurden keine aktuellen Aufgaben zurückgegeben.",
+ "task": "Aufgabe",
+ "started": "Gestartet",
+ "status": "Status",
+ "running": "Wird ausgeführt",
+ "jobs": "Wartungszeitpläne",
+ "job": "Auftrag",
+ "schedule": "Zeitplan",
+ "sync": "Synchronisierung",
+ "verify": "Verifizierung",
+ "prune": "Bereinigung",
+ "overview": "Übersicht",
+ "logs": "Systemprotokolle",
+ "terminal": "Terminal",
+ "security": "Sicherheit",
+ "logout": "Abmelden",
+ "navigation": "PBS-Navigation",
+ "cpu": "CPU",
+ "memory": "Arbeitsspeicher",
+ "uptime": "Betriebszeit"
}
}
diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json
index 80582c89..14fcff72 100644
--- a/AppImage/messages/fr/common.json
+++ b/AppImage/messages/fr/common.json
@@ -7227,5 +7227,58 @@
"diagnosticsOmitted": "Lignes ou texte de diagnostic omis : {count}. Rapport original conservé."
}
}
+ },
+ "pbs": {
+ "title": "Proxmox Backup Server",
+ "server": "Serveur PBS",
+ "chooseServer": "Choisir un serveur PBS",
+ "local": "PBS local",
+ "refresh": "Actualiser",
+ "manage": "Gérer les connexions PBS",
+ "loadFailed": "Impossible de charger l'état de PBS.",
+ "loading": "Chargement des données PBS…",
+ "noServer": "Ajoutez une connexion PBS pour afficher les magasins de données, les tâches et les planifications.",
+ "permissionsHelp": "Activez l'authentification Monitor avant d'enregistrer des identifiants. Utilisez un jeton API PBS dédié avec le rôle Audit sur / pour les ACL de l'utilisateur et du jeton, avec propagation. Ce panneau accède aux données PBS uniquement en lecture.",
+ "editConnection": "Connexion à modifier",
+ "add": "Ajouter une connexion",
+ "name": "Nom",
+ "url": "URL HTTPS (inclure :8007 pour le port natif de PBS)",
+ "token_id": "Identifiant du jeton API (user@realm!token)",
+ "token_secret": "Secret du jeton API (laisser vide pour conserver le secret enregistré)",
+ "fingerprint": "Empreinte SHA256 du certificat (facultative)",
+ "tlsHelp": "Laissez l'empreinte vide pour la vérification habituelle de l'autorité de certification et du nom d'hôte. Pour un certificat autosigné, copiez son empreinte SHA256 depuis la console PBS ou un autre canal de confiance. L'empreinte est vérifiée avant l'envoi des identifiants.",
+ "saved": "Connexion PBS vérifiée et enregistrée.",
+ "saveFailed": "Impossible d'enregistrer la connexion PBS.",
+ "testing": "Vérification de la connexion…",
+ "save": "Vérifier et enregistrer",
+ "remove": "Supprimer la connexion",
+ "removeConfirm": "Supprimer de Monitor cette connexion PBS enregistrée et son jeton ? Les données et tâches PBS restent inchangées.",
+ "sampled": "Relevé effectué à",
+ "datastores": "Magasins de données",
+ "total": "Capacité du système de fichiers",
+ "used": "Utilisé",
+ "free": "Disponible",
+ "noDatastores": "Aucun magasin de données accessible. Vérifiez leur configuration et les permissions de l'utilisateur et du jeton.",
+ "tasks": "Tâches récentes",
+ "noTasks": "Aucune tâche récente n'a été renvoyée.",
+ "task": "Tâche",
+ "started": "Démarrée",
+ "status": "État",
+ "running": "En cours",
+ "jobs": "Planifications de maintenance",
+ "job": "Tâche planifiée",
+ "schedule": "Planification",
+ "sync": "Synchronisation",
+ "verify": "Vérification",
+ "prune": "Purge",
+ "overview": "Vue d'ensemble",
+ "logs": "Journaux système",
+ "terminal": "Terminal",
+ "security": "Sécurité",
+ "logout": "Se déconnecter",
+ "navigation": "Navigation PBS",
+ "cpu": "CPU",
+ "memory": "Mémoire",
+ "uptime": "Durée de fonctionnement"
}
}
diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json
index 27a5a76b..7e8d0e47 100644
--- a/AppImage/messages/it/common.json
+++ b/AppImage/messages/it/common.json
@@ -7227,5 +7227,58 @@
"diagnosticsOmitted": "Righe o testo diagnostico omessi: {count}. Il report originale è conservato."
}
}
+ },
+ "pbs": {
+ "title": "Proxmox Backup Server",
+ "server": "Server PBS",
+ "chooseServer": "Scegli un server PBS",
+ "local": "PBS locale",
+ "refresh": "Aggiorna",
+ "manage": "Gestisci le connessioni PBS",
+ "loadFailed": "Impossibile caricare lo stato di PBS.",
+ "loading": "Caricamento dei dati PBS…",
+ "noServer": "Aggiungi una connessione PBS per visualizzare datastore, attività e pianificazioni.",
+ "permissionsHelp": "Abilita l'autenticazione di Monitor prima di salvare le credenziali. Usa un token API PBS dedicato con il ruolo Audit su / sia nelle ACL dell'utente sia in quelle del token, con propagazione. Questo pannello legge soltanto i dati PBS.",
+ "editConnection": "Connessione da modificare",
+ "add": "Aggiungi una nuova connessione",
+ "name": "Nome",
+ "url": "URL HTTPS (includere :8007 per la porta nativa di PBS)",
+ "token_id": "ID del token API (user@realm!token)",
+ "token_secret": "Segreto del token API (lasciare vuoto per mantenere quello salvato)",
+ "fingerprint": "Impronta SHA256 del certificato (facoltativa)",
+ "tlsHelp": "Lascia vuota l'impronta per la normale verifica dell'autorità di certificazione e del nome host. Per un certificato autofirmato, copia la sua impronta SHA256 dalla console PBS o da un altro canale attendibile. L'impronta viene verificata prima dell'invio delle credenziali.",
+ "saved": "Connessione PBS verificata e salvata.",
+ "saveFailed": "Impossibile salvare la connessione PBS.",
+ "testing": "Verifica della connessione…",
+ "save": "Verifica e salva",
+ "remove": "Rimuovi connessione",
+ "removeConfirm": "Rimuovere da Monitor questa connessione PBS salvata e il suo token? I dati e le attività PBS non vengono modificati.",
+ "sampled": "Rilevato alle",
+ "datastores": "Datastore",
+ "total": "Capacità del filesystem",
+ "used": "Usato",
+ "free": "Disponibile",
+ "noDatastores": "Nessun datastore accessibile. Controlla la configurazione dei datastore e i permessi dell'utente e del token.",
+ "tasks": "Attività recenti",
+ "noTasks": "Non sono state restituite attività recenti.",
+ "task": "Attività",
+ "started": "Avviata",
+ "status": "Stato",
+ "running": "In esecuzione",
+ "jobs": "Pianificazioni di manutenzione",
+ "job": "Attività pianificata",
+ "schedule": "Pianificazione",
+ "sync": "Sincronizzazione",
+ "verify": "Verifica",
+ "prune": "Pulizia",
+ "overview": "Panoramica",
+ "logs": "Registri di sistema",
+ "terminal": "Terminale",
+ "security": "Sicurezza",
+ "logout": "Disconnetti",
+ "navigation": "Navigazione PBS",
+ "cpu": "CPU",
+ "memory": "Memoria",
+ "uptime": "Tempo di attività"
}
}
diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json
index 2c688625..8756c280 100644
--- a/AppImage/messages/pt/common.json
+++ b/AppImage/messages/pt/common.json
@@ -7227,5 +7227,58 @@
"diagnosticsOmitted": "Linhas ou texto de diagnóstico omitidos: {count}. Relatório original preservado."
}
}
+ },
+ "pbs": {
+ "title": "Proxmox Backup Server",
+ "server": "Servidor PBS",
+ "chooseServer": "Escolher um servidor PBS",
+ "local": "PBS local",
+ "refresh": "Atualizar",
+ "manage": "Gerir ligações PBS",
+ "loadFailed": "Não foi possível carregar o estado do PBS.",
+ "loading": "A carregar dados do PBS…",
+ "noServer": "Adicione uma ligação PBS para ver os armazenamentos de dados, tarefas e agendamentos.",
+ "permissionsHelp": "Ative a autenticação do Monitor antes de guardar credenciais. Utilize um token API PBS dedicado com a função Audit em / nas ACL do utilizador e do token, com propagação. Este painel apenas lê dados do PBS.",
+ "editConnection": "Ligação a editar",
+ "add": "Adicionar uma nova ligação",
+ "name": "Nome",
+ "url": "URL HTTPS (inclua :8007 para a porta nativa do PBS)",
+ "token_id": "ID do token API (user@realm!token)",
+ "token_secret": "Segredo do token API (deixe vazio para manter o segredo guardado)",
+ "fingerprint": "Impressão digital SHA256 do certificado (opcional)",
+ "tlsHelp": "Deixe a impressão digital vazia para a verificação normal da autoridade de certificação e do nome de anfitrião. Para um certificado autoassinado, copie a sua impressão digital SHA256 da consola PBS ou de outro canal de confiança. A impressão digital é verificada antes do envio de credenciais.",
+ "saved": "Ligação PBS verificada e guardada.",
+ "saveFailed": "Não foi possível guardar a ligação PBS.",
+ "testing": "A verificar a ligação…",
+ "save": "Verificar e guardar",
+ "remove": "Remover ligação",
+ "removeConfirm": "Remover do Monitor esta ligação PBS guardada e o respetivo token? Os dados e as tarefas PBS não são afetados.",
+ "sampled": "Recolhido em",
+ "datastores": "Armazenamentos de dados",
+ "total": "Capacidade do sistema de ficheiros",
+ "used": "Utilizado",
+ "free": "Disponível",
+ "noDatastores": "Nenhum armazenamento de dados acessível. Verifique a configuração e as permissões do utilizador e do token.",
+ "tasks": "Tarefas recentes",
+ "noTasks": "Não foram devolvidas tarefas recentes.",
+ "task": "Tarefa",
+ "started": "Iniciada",
+ "status": "Estado",
+ "running": "Em execução",
+ "jobs": "Agendamentos de manutenção",
+ "job": "Tarefa agendada",
+ "schedule": "Agendamento",
+ "sync": "Sincronização",
+ "verify": "Verificação",
+ "prune": "Limpeza",
+ "overview": "Visão geral",
+ "logs": "Registos do sistema",
+ "terminal": "Terminal",
+ "security": "Segurança",
+ "logout": "Terminar sessão",
+ "navigation": "Navegação PBS",
+ "cpu": "CPU",
+ "memory": "Memória",
+ "uptime": "Tempo de atividade"
}
}
diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json
index dc5b7b80..82a65bdc 100644
--- a/AppImage/messages/sk/common.json
+++ b/AppImage/messages/sk/common.json
@@ -7226,5 +7226,58 @@
"diagnosticsOmitted": "Ďalšie diagnostické riadky alebo text vynechaný: {count}. Pôvodná správa sa zachovala."
}
}
+ },
+ "pbs": {
+ "title": "Proxmox Backup Server",
+ "server": "Server PBS",
+ "chooseServer": "Vybrať server PBS",
+ "local": "Miestny PBS",
+ "refresh": "Obnoviť",
+ "manage": "Spravovať pripojenia PBS",
+ "loadFailed": "Nepodarilo sa načítať stav PBS.",
+ "loading": "Načítavajú sa údaje PBS…",
+ "noServer": "Pridajte pripojenie PBS na zobrazenie dátových úložísk, úloh a plánov.",
+ "permissionsHelp": "Pred uložením prihlasovacích údajov povoľte overovanie v Monitore. Použite vyhradený API token PBS s rolou Audit na / v ACL používateľa aj tokenu, so zapnutým dedením. Tento panel údaje PBS iba číta.",
+ "editConnection": "Pripojenie na úpravu",
+ "add": "Pridať nové pripojenie",
+ "name": "Názov",
+ "url": "HTTPS URL (pre pôvodný port PBS uveďte :8007)",
+ "token_id": "ID API tokenu (user@realm!token)",
+ "token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať uložený kľúč)",
+ "fingerprint": "Odtlačok SHA256 certifikátu (voliteľný)",
+ "tlsHelp": "Pre bežné overenie certifikačnej autority a názvu hostiteľa ponechajte odtlačok prázdny. Pri certifikáte s vlastným podpisom skopírujte jeho odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného kanála. Odtlačok sa kontroluje pred odoslaním prihlasovacích údajov.",
+ "saved": "Pripojenie PBS bolo overené a uložené.",
+ "saveFailed": "Nepodarilo sa uložiť pripojenie PBS.",
+ "testing": "Overuje sa pripojenie…",
+ "save": "Overiť a uložiť",
+ "remove": "Odstrániť pripojenie",
+ "removeConfirm": "Odstrániť z Monitora toto uložené pripojenie PBS a jeho token? Údaje a úlohy PBS zostanú nezmenené.",
+ "sampled": "Údaje získané",
+ "datastores": "Dátové úložiská",
+ "total": "Kapacita súborového systému",
+ "used": "Použité",
+ "free": "Dostupné",
+ "noDatastores": "Žiadne dostupné dátové úložiská. Skontrolujte konfiguráciu úložísk a oprávnenia používateľa a tokenu.",
+ "tasks": "Nedávne úlohy",
+ "noTasks": "Neboli vrátené žiadne nedávne úlohy.",
+ "task": "Úloha",
+ "started": "Spustené",
+ "status": "Stav",
+ "running": "Prebieha",
+ "jobs": "Plány údržby",
+ "job": "Plánovaná úloha",
+ "schedule": "Plán",
+ "sync": "Synchronizácia",
+ "verify": "Overenie",
+ "prune": "Prerezávanie",
+ "overview": "Prehľad",
+ "logs": "Systémové záznamy",
+ "terminal": "Terminál",
+ "security": "Zabezpečenie",
+ "logout": "Odhlásiť sa",
+ "navigation": "Navigácia PBS",
+ "cpu": "CPU",
+ "memory": "Pamäť",
+ "uptime": "Čas prevádzky"
}
}
diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json
index a40378c9..c035bf7d 100644
--- a/AppImage/messages/sv/common.json
+++ b/AppImage/messages/sv/common.json
@@ -7227,5 +7227,58 @@
"diagnosticsOmitted": "Utelämnade diagnosrader eller text: {count}. Originalrapporten bevaras."
}
}
+ },
+ "pbs": {
+ "title": "Proxmox Backup Server",
+ "server": "PBS-server",
+ "chooseServer": "Välj en PBS-server",
+ "local": "Lokal PBS",
+ "refresh": "Uppdatera",
+ "manage": "Hantera PBS-anslutningar",
+ "loadFailed": "Det gick inte att läsa in PBS-status.",
+ "loading": "Läser in PBS-data…",
+ "noServer": "Lägg till en PBS-anslutning för att visa datalager, uppgifter och scheman.",
+ "permissionsHelp": "Aktivera Monitor-autentisering innan du sparar inloggningsuppgifter. Använd en särskild PBS-API-token med rollen Audit på / i både användarens och tokenens ACL, med nedärvning. Denna panel läser endast PBS-data.",
+ "editConnection": "Anslutning att redigera",
+ "add": "Lägg till en ny anslutning",
+ "name": "Namn",
+ "url": "HTTPS-URL (ta med :8007 för PBS ordinarie port)",
+ "token_id": "API-token-ID (user@realm!token)",
+ "token_secret": "API-tokenhemlighet (lämna tomt för att behålla den sparade hemligheten)",
+ "fingerprint": "Certifikatets SHA256-fingeravtryck (valfritt)",
+ "tlsHelp": "Lämna fingeravtrycket tomt för vanlig kontroll av certifikatutfärdare och värdnamn. För ett självsignerat certifikat kopierar du dess SHA256-fingeravtryck från PBS-konsolen eller en annan betrodd kanal. Fingeravtrycket kontrolleras innan inloggningsuppgifter skickas.",
+ "saved": "PBS-anslutningen har verifierats och sparats.",
+ "saveFailed": "Det gick inte att spara PBS-anslutningen.",
+ "testing": "Verifierar anslutningen…",
+ "save": "Verifiera och spara",
+ "remove": "Ta bort anslutningen",
+ "removeConfirm": "Ta bort denna sparade PBS-anslutning och dess token från Monitor? PBS-data och jobb påverkas inte.",
+ "sampled": "Avläst",
+ "datastores": "Datalager",
+ "total": "Filsystemets kapacitet",
+ "used": "Använt",
+ "free": "Tillgängligt",
+ "noDatastores": "Inga tillgängliga datalager. Kontrollera datalagrets konfiguration samt användarens och tokenens behörigheter.",
+ "tasks": "Senaste uppgifter",
+ "noTasks": "Inga senaste uppgifter returnerades.",
+ "task": "Uppgift",
+ "started": "Startad",
+ "status": "Status",
+ "running": "Körs",
+ "jobs": "Underhållsscheman",
+ "job": "Jobb",
+ "schedule": "Schema",
+ "sync": "Synkronisering",
+ "verify": "Verifiering",
+ "prune": "Gallring",
+ "overview": "Översikt",
+ "logs": "Systemloggar",
+ "terminal": "Terminal",
+ "security": "Säkerhet",
+ "logout": "Logga ut",
+ "navigation": "PBS-navigering",
+ "cpu": "CPU",
+ "memory": "Minne",
+ "uptime": "Drifttid"
}
}
diff --git a/lang/de.json b/lang/de.json
index 7a074cc3..269f1afa 100644
--- a/lang/de.json
+++ b/lang/de.json
@@ -8064,5 +8064,23 @@
"⚠ Disk data will NOT be erased.": "⚠ Festplattendaten werden NICHT gelöscht.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Die Festplatte wird ausgehängt und aus /etc/fstab entfernt.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Der /etc/fstab-Eintrag wird entfernt.",
- "⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt."
+ "⚠ The disk will be unmounted.": "⚠ Die Festplatte wird ausgehängt.",
+ "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "APT-Aktualisierung fehlgeschlagen. Prüfen Sie das PBS-Abonnement und die Repositories in der nativen PBS-Oberfläche; es wurden keine Paketquellen geändert.",
+ "Apply PBS package upgrade": "PBS-Paketaktualisierung anwenden",
+ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Die geprüfte Paketaktualisierung jetzt anwenden? Dienste können neu starten und zur Entfernung aufgeführte Pakete werden entfernt.",
+ "Command failed": "Befehl fehlgeschlagen",
+ "PBS datastores": "PBS-Datenspeicher",
+ "PBS package update": "PBS-Paketaktualisierung",
+ "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "PBS-Paketaktualisierung abgeschlossen. Prüfen Sie vor dem nächsten Sicherungszeitfenster, ob ein Neustart erforderlich ist.",
+ "PBS package upgrade failed. Review the APT output before retrying.": "PBS-Paketaktualisierung fehlgeschlagen. Prüfen Sie vor einem erneuten Versuch die APT-Ausgabe.",
+ "PBS version and packages": "PBS-Version und Pakete",
+ "PBS: using the configured APT repositories without changing sources.": "PBS: Die konfigurierten APT-Repositories werden verwendet, ohne die Paketquellen zu ändern.",
+ "Recent PBS tasks": "Letzte PBS-Aufgaben",
+ "Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Die konfigurierten APT-Repositories aktualisieren und die PBS-Paketaktualisierung prüfen? Repository-Dateien bleiben erhalten.",
+ "Review package upgrade": "Paketaktualisierung prüfen",
+ "Run this operation as root.": "Führen Sie diesen Vorgang als root aus.",
+ "This menu requires Proxmox Backup Server.": "Dieses Menü erfordert Proxmox Backup Server.",
+ "This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Dieser Vorgang erfordert einen Proxmox-VE-Host. Verwenden Sie auf Proxmox Backup Server das PBS-Menü.",
+ "This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Dieser Vorgang unterstützt PBS 3 unter Debian 12 und PBS 4 unter Debian 13.",
+ "Update PBS using configured repositories": "PBS mit den konfigurierten Repositories aktualisieren"
}
diff --git a/lang/es.json b/lang/es.json
index 153a0133..2ab81803 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -8036,5 +8036,23 @@
"⚠ Disk data will NOT be erased.": "⚠ Los datos del disco NO se borrarán.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ El disco se desmontará y se eliminará de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Se eliminará la entrada /etc/fstab.",
- "⚠ The disk will be unmounted.": "⚠ El disco se desmontará."
+ "⚠ The disk will be unmounted.": "⚠ El disco se desmontará.",
+ "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "No se pudieron actualizar los índices de APT. Comprueba la suscripción y los repositorios de PBS en su interfaz nativa; no se ha cambiado ninguna fuente.",
+ "Apply PBS package upgrade": "Aplicar la actualización de paquetes de PBS",
+ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "¿Aplicar ahora la actualización de paquetes revisada? Los servicios podrían reiniciarse y se eliminarán los paquetes indicados para su eliminación.",
+ "Command failed": "El comando ha fallado",
+ "PBS datastores": "Almacenes de datos de PBS",
+ "PBS package update": "Actualización de paquetes de PBS",
+ "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Actualización de paquetes de PBS completada. Comprueba si es necesario reiniciar antes del próximo período de copias de seguridad.",
+ "PBS package upgrade failed. Review the APT output before retrying.": "La actualización de paquetes de PBS ha fallado. Revisa la salida de APT antes de volver a intentarlo.",
+ "PBS version and packages": "Versión y paquetes de PBS",
+ "PBS: using the configured APT repositories without changing sources.": "PBS: se utilizan los repositorios de APT configurados sin cambiar las fuentes.",
+ "Recent PBS tasks": "Tareas recientes de PBS",
+ "Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "¿Actualizar los índices de los repositorios de APT configurados y revisar la actualización de paquetes de PBS? Se conservarán los archivos de los repositorios.",
+ "Review package upgrade": "Revisar la actualización de paquetes",
+ "Run this operation as root.": "Ejecuta esta operación como root.",
+ "This menu requires Proxmox Backup Server.": "Este menú requiere Proxmox Backup Server.",
+ "This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Esta operación requiere un host de Proxmox VE. Utiliza el menú de PBS en Proxmox Backup Server.",
+ "This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Esta operación admite PBS 3 en Debian 12 y PBS 4 en Debian 13.",
+ "Update PBS using configured repositories": "Actualizar PBS con los repositorios configurados"
}
diff --git a/lang/fr.json b/lang/fr.json
index 0147e0ab..a99b7191 100644
--- a/lang/fr.json
+++ b/lang/fr.json
@@ -8063,5 +8063,23 @@
"⚠ Disk data will NOT be erased.": "⚠ Les données du disque ne seront PAS effacées.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Le disque sera démonté et supprimé de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ L'entrée /etc/fstab sera supprimée.",
- "⚠ The disk will be unmounted.": "⚠ Le disque sera démonté."
+ "⚠ The disk will be unmounted.": "⚠ Le disque sera démonté.",
+ "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Échec de l'actualisation APT. Vérifiez l'abonnement et les dépôts de PBS dans son interface native ; aucune source n'a été modifiée.",
+ "Apply PBS package upgrade": "Appliquer la mise à niveau des paquets PBS",
+ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Appliquer maintenant la mise à niveau des paquets examinée ? Des services peuvent redémarrer et les paquets indiqués pour suppression seront supprimés.",
+ "Command failed": "Échec de la commande",
+ "PBS datastores": "Magasins de données PBS",
+ "PBS package update": "Mise à jour des paquets PBS",
+ "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Mise à niveau des paquets PBS terminée. Vérifiez si un redémarrage est nécessaire avant votre prochaine plage de sauvegarde.",
+ "PBS package upgrade failed. Review the APT output before retrying.": "Échec de la mise à niveau des paquets PBS. Consultez la sortie APT avant de réessayer.",
+ "PBS version and packages": "Version et paquets PBS",
+ "PBS: using the configured APT repositories without changing sources.": "PBS : utilisation des dépôts APT configurés sans modifier les sources.",
+ "Recent PBS tasks": "Tâches PBS récentes",
+ "Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Actualiser les dépôts APT configurés et examiner la mise à niveau des paquets PBS ? Les fichiers des dépôts seront conservés.",
+ "Review package upgrade": "Examiner la mise à niveau des paquets",
+ "Run this operation as root.": "Exécutez cette opération en tant que root.",
+ "This menu requires Proxmox Backup Server.": "Ce menu nécessite Proxmox Backup Server.",
+ "This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Cette opération nécessite un hôte Proxmox VE. Utilisez le menu PBS sur Proxmox Backup Server.",
+ "This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Cette opération prend en charge PBS 3 sur Debian 12 et PBS 4 sur Debian 13.",
+ "Update PBS using configured repositories": "Mettre à jour PBS avec les dépôts configurés"
}
diff --git a/lang/it.json b/lang/it.json
index 7f80fc8e..57452dcf 100644
--- a/lang/it.json
+++ b/lang/it.json
@@ -8064,5 +8064,23 @@
"⚠ Disk data will NOT be erased.": "⚠ I dati del disco NON verranno cancellati.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Il disco verrà smontato e rimosso da /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ La voce /etc/fstab verrà rimossa.",
- "⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato."
+ "⚠ The disk will be unmounted.": "⚠ Il disco verrà smontato.",
+ "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Aggiornamento degli indici APT non riuscito. Controlla l'abbonamento e i repository PBS nell'interfaccia nativa di PBS; nessuna sorgente è stata modificata.",
+ "Apply PBS package upgrade": "Applica l'aggiornamento dei pacchetti PBS",
+ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Applicare ora l'aggiornamento dei pacchetti esaminato? I servizi potrebbero riavviarsi e i pacchetti indicati per la rimozione verranno rimossi.",
+ "Command failed": "Comando non riuscito",
+ "PBS datastores": "Datastore PBS",
+ "PBS package update": "Aggiornamento dei pacchetti PBS",
+ "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Aggiornamento dei pacchetti PBS completato. Verifica se è necessario un riavvio prima della prossima finestra di backup.",
+ "PBS package upgrade failed. Review the APT output before retrying.": "Aggiornamento dei pacchetti PBS non riuscito. Esamina l'output di APT prima di riprovare.",
+ "PBS version and packages": "Versione e pacchetti PBS",
+ "PBS: using the configured APT repositories without changing sources.": "PBS: vengono usati i repository APT configurati senza modificare le sorgenti.",
+ "Recent PBS tasks": "Attività PBS recenti",
+ "Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Aggiornare gli indici dei repository APT configurati ed esaminare l'aggiornamento dei pacchetti PBS? I file dei repository verranno conservati.",
+ "Review package upgrade": "Esamina l'aggiornamento dei pacchetti",
+ "Run this operation as root.": "Esegui questa operazione come root.",
+ "This menu requires Proxmox Backup Server.": "Questo menu richiede Proxmox Backup Server.",
+ "This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Questa operazione richiede un host Proxmox VE. Usa il menu PBS su Proxmox Backup Server.",
+ "This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Questa operazione supporta PBS 3 su Debian 12 e PBS 4 su Debian 13.",
+ "Update PBS using configured repositories": "Aggiorna PBS usando i repository configurati"
}
diff --git a/lang/pt.json b/lang/pt.json
index f066ced5..8f199fd2 100644
--- a/lang/pt.json
+++ b/lang/pt.json
@@ -8064,5 +8064,23 @@
"⚠ Disk data will NOT be erased.": "⚠ Os dados do disco NÃO serão apagados.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ O disco será desmontado e removido de /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ A entrada /etc/fstab será removida.",
- "⚠ The disk will be unmounted.": "⚠ O disco será desmontado."
+ "⚠ The disk will be unmounted.": "⚠ O disco será desmontado.",
+ "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Falha ao atualizar os índices APT. Verifique a subscrição e os repositórios PBS na interface nativa do PBS; nenhuma fonte foi alterada.",
+ "Apply PBS package upgrade": "Aplicar a atualização de pacotes PBS",
+ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Aplicar agora a atualização de pacotes revista? Os serviços podem reiniciar e os pacotes indicados para remoção serão removidos.",
+ "Command failed": "O comando falhou",
+ "PBS datastores": "Armazenamentos de dados PBS",
+ "PBS package update": "Atualização de pacotes PBS",
+ "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Atualização de pacotes PBS concluída. Verifique se é necessário reiniciar antes do próximo período de cópias de segurança.",
+ "PBS package upgrade failed. Review the APT output before retrying.": "A atualização de pacotes PBS falhou. Reveja a saída do APT antes de tentar novamente.",
+ "PBS version and packages": "Versão e pacotes PBS",
+ "PBS: using the configured APT repositories without changing sources.": "PBS: a usar os repositórios APT configurados sem alterar as fontes.",
+ "Recent PBS tasks": "Tarefas PBS recentes",
+ "Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Atualizar os índices dos repositórios APT configurados e rever a atualização de pacotes PBS? Os ficheiros dos repositórios serão preservados.",
+ "Review package upgrade": "Rever a atualização de pacotes",
+ "Run this operation as root.": "Execute esta operação como root.",
+ "This menu requires Proxmox Backup Server.": "Este menu requer Proxmox Backup Server.",
+ "This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Esta operação requer um anfitrião Proxmox VE. Utilize o menu PBS no Proxmox Backup Server.",
+ "This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Esta operação suporta PBS 3 em Debian 12 e PBS 4 em Debian 13.",
+ "Update PBS using configured repositories": "Atualizar PBS com os repositórios configurados"
}
diff --git a/lang/sk.json b/lang/sk.json
index 0bfa4645..ec0c3a9a 100644
--- a/lang/sk.json
+++ b/lang/sk.json
@@ -8065,5 +8065,23 @@
"⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disk sa odpojí a odstráni z /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ Záznam v /etc/fstab bude odstránený.",
- "⚠ The disk will be unmounted.": "⚠ Disk bude odpojený."
+ "⚠ The disk will be unmounted.": "⚠ Disk bude odpojený.",
+ "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Obnovenie indexov APT zlyhalo. Skontrolujte predplatné a repozitáre PBS v pôvodnom rozhraní PBS; žiadne zdroje sa nezmenili.",
+ "Apply PBS package upgrade": "Použiť aktualizáciu balíkov PBS",
+ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Použiť teraz skontrolovanú aktualizáciu balíkov? Služby sa môžu reštartovať a balíky označené na odstránenie budú odstránené.",
+ "Command failed": "Príkaz zlyhal",
+ "PBS datastores": "Dátové úložiská PBS",
+ "PBS package update": "Aktualizácia balíkov PBS",
+ "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Aktualizácia balíkov PBS bola dokončená. Pred ďalším zálohovacím oknom skontrolujte, či je potrebný reštart.",
+ "PBS package upgrade failed. Review the APT output before retrying.": "Aktualizácia balíkov PBS zlyhala. Pred ďalším pokusom skontrolujte výstup APT.",
+ "PBS version and packages": "Verzia PBS a balíky",
+ "PBS: using the configured APT repositories without changing sources.": "PBS: používajú sa nakonfigurované repozitáre APT bez zmeny zdrojov.",
+ "Recent PBS tasks": "Nedávne úlohy PBS",
+ "Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Obnoviť indexy nakonfigurovaných repozitárov APT a skontrolovať aktualizáciu balíkov PBS? Súbory repozitárov sa zachovajú.",
+ "Review package upgrade": "Skontrolovať aktualizáciu balíkov",
+ "Run this operation as root.": "Túto operáciu spustite ako root.",
+ "This menu requires Proxmox Backup Server.": "Táto ponuka vyžaduje Proxmox Backup Server.",
+ "This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Táto operácia vyžaduje hostiteľa Proxmox VE. Na Proxmox Backup Serveri použite ponuku PBS.",
+ "This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Táto operácia podporuje PBS 3 na Debiane 12 a PBS 4 na Debiane 13.",
+ "Update PBS using configured repositories": "Aktualizovať PBS pomocou nakonfigurovaných repozitárov"
}
diff --git a/lang/sv.json b/lang/sv.json
index 197444db..50407c6c 100644
--- a/lang/sv.json
+++ b/lang/sv.json
@@ -8064,5 +8064,23 @@
"⚠ Disk data will NOT be erased.": "⚠ Diskdata kommer INTE att raderas.",
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disken kommer att avmonteras och tas bort från /etc/fstab.",
"⚠ The /etc/fstab entry will be removed.": "⚠ /etc/fstab-posten kommer att tas bort.",
- "⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras."
+ "⚠ The disk will be unmounted.": "⚠ Disken kommer att avmonteras.",
+ "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "APT-uppdateringen misslyckades. Kontrollera PBS-prenumerationen och paketförråden i PBS eget gränssnitt; inga paketkällor ändrades.",
+ "Apply PBS package upgrade": "Tillämpa PBS-paketuppgraderingen",
+ "Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Tillämpa den granskade paketuppgraderingen nu? Tjänster kan startas om och paket som anges för borttagning kommer att tas bort.",
+ "Command failed": "Kommandot misslyckades",
+ "PBS datastores": "PBS-datalager",
+ "PBS package update": "PBS-paketuppdatering",
+ "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "PBS-paketuppgraderingen är klar. Kontrollera om en omstart behövs före nästa säkerhetskopieringsfönster.",
+ "PBS package upgrade failed. Review the APT output before retrying.": "PBS-paketuppgraderingen misslyckades. Granska APT-utdata innan du försöker igen.",
+ "PBS version and packages": "PBS-version och paket",
+ "PBS: using the configured APT repositories without changing sources.": "PBS: använder de konfigurerade APT-paketförråden utan att ändra källor.",
+ "Recent PBS tasks": "Senaste PBS-uppgifter",
+ "Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Uppdatera de konfigurerade APT-paketförråden och granska PBS-paketuppgraderingen? Paketförrådens filer bevaras.",
+ "Review package upgrade": "Granska paketuppgraderingen",
+ "Run this operation as root.": "Kör denna åtgärd som root.",
+ "This menu requires Proxmox Backup Server.": "Denna meny kräver Proxmox Backup Server.",
+ "This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Denna åtgärd kräver en Proxmox VE-värd. Använd PBS-menyn på Proxmox Backup Server.",
+ "This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Denna åtgärd stöder PBS 3 på Debian 12 och PBS 4 på Debian 13.",
+ "Update PBS using configured repositories": "Uppdatera PBS med de konfigurerade paketförråden"
}
From 08ae13486010e98ac5cd58b02c6b2b71e0bcc60a Mon Sep 17 00:00:00 2001
From: MacRimi <123239993+MacRimi@users.noreply.github.com>
Date: Sat, 17 Oct 2026 10:01:37 +0200
Subject: [PATCH 5/5] Keep guest services off PBS hosts, refuse only known PBS
in PVE scripts and follow the menu conventions
- On a PBS host the Monitor no longer starts the node metrics and VM prewarmers, the LXC update scheduler, the managed installs detector or the app update emitter, which read guests and PVE metrics that do not exist there
- pmx_require_pve refuses only a host known to be PBS, so an unreadable package list leaves the PVE scripts working as before
- The PBS menu numbers its options, shows the tables proxmox-backup-manager prints instead of raw JSON, and says Goodbye on exit
- The PBS update asks to press Enter before returning
- Spanish: Datastores de PBS
---
AppImage/scripts/flask_server.py | 35 +++++++++++-------
AppImage/scripts/notification_events.py | 7 ++++
lang/es.json | 2 +-
scripts/global/platform-functions.sh | 4 ++-
scripts/menus/pbs_menu.sh | 47 ++++++++++++++-----------
scripts/utilities/pbs_update.sh | 17 +++++----
6 files changed, 70 insertions(+), 42 deletions(-)
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index d49452fc..3ffa0c4c 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -23193,9 +23193,11 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] Disk temperature history init failed: {e}")
try:
- import managed_installs
- managed_installs.detect_and_register()
- print("[ProxMenux] Managed-installs registry initialised")
+ # A PBS host has no guests whose applications could be tracked.
+ if not is_pbs_host():
+ import managed_installs
+ managed_installs.detect_and_register()
+ print("[ProxMenux] Managed-installs registry initialised")
except Exception as e:
print(f"[ProxMenux] managed_installs init failed: {e}")
# Docker inventories are derived runtime data and intentionally
@@ -23337,6 +23339,9 @@ if __name__ == '__main__':
# the 24 h PollingCollector cycle. Runs in a daemon thread with
# a delay so it doesn't block startup — notification_manager needs
# a moment to bind channels before we emit.
+ # The guest services below read VMs, LXCs and the PVE node metrics,
+ # none of which exist on a Proxmox Backup Server.
+ pve_host = not is_pbs_host()
try:
def _startup_emit_pending_app_updates():
time.sleep(15) # let channels bind + first metrics collect
@@ -23346,16 +23351,18 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] startup emit_all_pending_updates failed: {e}",
file=sys.stderr, flush=True)
- threading.Thread(target=_startup_emit_pending_app_updates, daemon=True,
- name='app-updates-startup-emit').start()
+ if pve_host:
+ threading.Thread(target=_startup_emit_pending_app_updates, daemon=True,
+ name='app-updates-startup-emit').start()
except Exception as e:
print(f"[ProxMenux] app-updates startup emitter failed to arm: {e}")
# Prewarm only the Overview's default day range; other ranges are lazy.
try:
- metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer')
- metrics_thread.start()
- print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)")
+ if pve_host:
+ metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer')
+ metrics_thread.start()
+ print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)")
except Exception as e:
print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}")
@@ -23366,9 +23373,10 @@ if __name__ == '__main__':
# after the tab has been closed for minutes. The React-side
# prefetcher only ran while the page was open.
try:
- vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
- vm_modal_thread.start()
- print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
+ if pve_host:
+ vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
+ vm_modal_thread.start()
+ print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
except Exception as e:
print(f"[ProxMenux] VM-modal prewarmer failed to start: {e}")
@@ -23392,8 +23400,9 @@ if __name__ == '__main__':
# identical (backup, restart, /usr/bin/update + custom command
# chain) between manual and scheduled invocations.
try:
- scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
- scheduler_thread.start()
+ if pve_host:
+ scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
+ scheduler_thread.start()
except Exception as e:
print(f"[ProxMenux] LXC update scheduler failed to start: {e}")
diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py
index cadfc136..2f71a2d4 100644
--- a/AppImage/scripts/notification_events.py
+++ b/AppImage/scripts/notification_events.py
@@ -3850,6 +3850,13 @@ class PollingCollector:
return
self._last_managed_check = now
+ try:
+ from pbs_manager import is_pbs_host
+ if is_pbs_host():
+ return # a Proxmox Backup Server has no guests or integrations to track
+ except Exception:
+ pass
+
try:
import managed_installs
except Exception:
diff --git a/lang/es.json b/lang/es.json
index 4de8be5d..58020c86 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -8132,7 +8132,7 @@
"Apply PBS package upgrade": "Aplicar la actualización de paquetes de PBS",
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "¿Aplicar ahora la actualización de paquetes revisada? Los servicios podrían reiniciarse y se eliminarán los paquetes indicados para su eliminación.",
"Command failed": "El comando ha fallado",
- "PBS datastores": "Almacenes de datos de PBS",
+ "PBS datastores": "Datastores de PBS",
"PBS package update": "Actualización de paquetes de PBS",
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Actualización de paquetes de PBS completada. Comprueba si es necesario reiniciar antes del próximo período de copias de seguridad.",
"PBS package upgrade failed. Review the APT output before retrying.": "La actualización de paquetes de PBS ha fallado. Revisa la salida de APT antes de volver a intentarlo.",
diff --git a/scripts/global/platform-functions.sh b/scripts/global/platform-functions.sh
index 1bfb3e49..7ca2b240 100644
--- a/scripts/global/platform-functions.sh
+++ b/scripts/global/platform-functions.sh
@@ -8,8 +8,10 @@ pmx_is_pbs() {
[[ "$(pmx_platform_field product)" == pbs ]]
}
+# Only a host known to be PBS is refused: if the package list cannot be read,
+# the PVE scripts keep working as they did before PBS support.
pmx_require_pve() {
- if [[ "$(pmx_platform_field product)" != pve ]]; then
+ if pmx_is_pbs; then
msg_error "$(translate 'This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.')"
return 1
fi
diff --git a/scripts/menus/pbs_menu.sh b/scripts/menus/pbs_menu.sh
index 4a04c2a4..86f30ca2 100644
--- a/scripts/menus/pbs_menu.sh
+++ b/scripts/menus/pbs_menu.sh
@@ -1,5 +1,10 @@
#!/bin/bash
-# PBS exposes compatible host tools; PVE guest/repository optimizations stay in the PVE menu.
+# ==========================================================
+# ProxMenux - Proxmox Backup Server menu
+# ==========================================================
+# PBS exposes compatible host tools; PVE guest/repository optimizations
+# stay in the PVE menu.
+# ==========================================================
BASE_DIR="/usr/local/share/proxmenux"
LOCAL_SCRIPTS="$BASE_DIR/scripts"
source "$BASE_DIR/utils.sh"
@@ -13,34 +18,36 @@ if ! pmx_is_pbs; then
fi
pbs_show() {
- local output
+ local title="$1" output
+ shift
output=$(mktemp)
if "$@" > "$output" 2>&1; then
- dialog --backtitle "ProxMenux · PBS" --title "Proxmox Backup Server" --textbox "$output" 24 100
+ dialog --backtitle "ProxMenux" --title "$title" --textbox "$output" 24 110
else
- dialog --backtitle "ProxMenux · PBS" --title "$(translate 'Command failed')" --textbox "$output" 24 100
+ dialog --backtitle "ProxMenux" --title "$(translate 'Command failed')" --textbox "$output" 24 110
fi
rm -f "$output"
}
while true; do
- choice=$(dialog --clear --backtitle "ProxMenux · PBS" --title "Proxmox Backup Server" \
- --menu "$(translate 'Select an option:')" 20 80 10 \
- status "$(translate 'PBS version and packages')" \
- stores "$(translate 'PBS datastores')" \
- tasks "$(translate 'Recent PBS tasks')" \
- utilities "$(translate 'System Utilities Installer')" \
- update "$(translate 'Update PBS using configured repositories')" \
- settings "$(translate 'Settings')" \
- exit "$(translate 'Exit')" 2>&1 >/dev/tty) || break
+ choice=$(dialog --clear --backtitle "ProxMenux" --title "Proxmox Backup Server" \
+ --menu "$(translate 'Select an option:')" 18 75 8 \
+ 1 "$(translate 'PBS version and packages')" \
+ 2 "$(translate 'PBS datastores')" \
+ 3 "$(translate 'Recent PBS tasks')" \
+ 4 "$(translate 'System Utilities Installer')" \
+ 5 "$(translate 'Update PBS using configured repositories')" \
+ s "$(translate 'Settings')" \
+ 0 "$(translate 'Exit')" 2>&1 >/dev/tty) || break
case "$choice" in
- status) pbs_show proxmox-backup-manager versions --verbose --output-format json-pretty ;;
- stores) pbs_show proxmox-backup-manager datastore list --output-format json-pretty ;;
- tasks) pbs_show proxmox-backup-manager task list --all true --limit 20 --output-format json-pretty ;;
- utilities) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;;
- update) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;;
- settings) bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;;
- exit) break ;;
+ 1) pbs_show "$(translate 'PBS version and packages')" proxmox-backup-manager versions --verbose ;;
+ 2) pbs_show "$(translate 'PBS datastores')" proxmox-backup-manager datastore list ;;
+ 3) pbs_show "$(translate 'Recent PBS tasks')" proxmox-backup-manager task list --all true --limit 20 ;;
+ 4) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;;
+ 5) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;;
+ s) exec bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;;
+ 0) break ;;
esac
done
clear
+msg_ok "$(translate "Thank you for using ProxMenux. Goodbye!")"
diff --git a/scripts/utilities/pbs_update.sh b/scripts/utilities/pbs_update.sh
index 05a453eb..608440b9 100644
--- a/scripts/utilities/pbs_update.sh
+++ b/scripts/utilities/pbs_update.sh
@@ -12,27 +12,30 @@ if ! pmx_is_pbs || [[ "$(pmx_platform_field supported)" != true ]]; then
exit 1
fi
[[ $EUID -eq 0 ]] || { msg_error "$(translate 'Run this operation as root.')"; exit 1; }
-dialog --backtitle "ProxMenux · PBS" --title "$(translate 'PBS package update')" \
+dialog --backtitle "ProxMenux" --title "$(translate 'PBS package update')" \
--yesno "$(translate 'Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.')" 10 80 || exit 0
if ! apt-get -o APT::Update::Error-Mode=any update; then
msg_error "$(translate 'APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.')"
- read -r _
+ msg_success "$(translate "Press Enter to continue...")"
+ read -r
exit 1
fi
plan=$(mktemp)
trap 'rm -f "$plan"' EXIT
if ! apt-get -s full-upgrade > "$plan" 2>&1; then
- dialog --textbox "$plan" 24 100
+ dialog --backtitle "ProxMenux" --title "$(translate 'Review package upgrade')" --textbox "$plan" 24 100
exit 1
fi
-dialog --backtitle "ProxMenux · PBS" --title "$(translate 'Review package upgrade')" --textbox "$plan" 24 100
-dialog --defaultno --title "$(translate 'Apply PBS package upgrade')" \
+dialog --backtitle "ProxMenux" --title "$(translate 'Review package upgrade')" --textbox "$plan" 24 100
+dialog --backtitle "ProxMenux" --defaultno --title "$(translate 'Apply PBS package upgrade')" \
--yesno "$(translate 'Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.')" 11 80 || exit 0
if apt-get -y full-upgrade; then
msg_ok "$(translate 'PBS package upgrade completed. Check whether a reboot is required before your next backup window.')"
else
msg_error "$(translate 'PBS package upgrade failed. Review the APT output before retrying.')"
- read -r _
+ msg_success "$(translate "Press Enter to continue...")"
+ read -r
exit 1
fi
-read -r _
+msg_success "$(translate "Press Enter to continue...")"
+read -r