From 4dc8be7387f7f25aa5952fc10b56baba9b873a7b Mon Sep 17 00:00:00 2001 From: MacRimi Date: Wed, 10 Jun 2026 19:05:13 +0200 Subject: [PATCH] Add beta 1.2.2.2 --- .github/scripts/build_translation_cache.py | 362 ++++++++ .github/workflows/build-translation-cache.yml | 91 ++ AppImage/scripts/build_appimage.sh | 171 +--- AppImage/scripts/flask_notification_routes.py | 46 + AppImage/scripts/notification_templates.py | 8 + install_proxmenux.sh | 481 ++-------- lang/cache.json | 198 ---- lang/en.lang | 113 --- lang/es.lang | 115 --- .../backup_restore/apply_cluster_postboot.sh | 42 +- scripts/backup_restore/backup_host.sh | 601 +++++++++---- .../backup_restore/lib_host_backup_common.sh | 845 ++++++++++++++++-- scripts/emergency_repair.sh | 1 - scripts/global/common-functions.sh | 1 - scripts/global/remove-banner-pve8.sh | 1 - scripts/global/update-pve8.sh | 1 - scripts/global/update-pve9_2.sh | 1 - scripts/gpu_tpu/install_coral_lxc.sh | 1 - scripts/help_info_menu.sh | 1 - scripts/lxc/lxc-manual-guide.sh | 1 - scripts/lxc/lxc-privileged-to-unprivileged.sh | 1 - scripts/menus/config_menu.sh | 96 +- scripts/menus/create_vm_menu.sh | 1 - scripts/menus/main_menu.sh | 45 +- scripts/utils.sh | 99 +- web/app/[locale]/changelog/page.tsx | 12 +- .../CHANGELOG.md => web/data/changelog/es.md | 0 27 files changed, 1938 insertions(+), 1397 deletions(-) create mode 100644 .github/scripts/build_translation_cache.py create mode 100644 .github/workflows/build-translation-cache.yml delete mode 100644 lang/cache.json delete mode 100644 lang/en.lang delete mode 100644 lang/es.lang rename lang/es/CHANGELOG.md => web/data/changelog/es.md (100%) diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py new file mode 100644 index 00000000..e4848dd7 --- /dev/null +++ b/.github/scripts/build_translation_cache.py @@ -0,0 +1,362 @@ +#!/usr/bin/env python3 +""" +Build the ProxMenux translation cache from translate calls in scripts/. + +The generated JSON keeps the same shape used by scripts/utils.sh: + +{ + "Original English text": { + "es": "Translated text", + "fr": "Translated text" + } +} +""" + +from __future__ import annotations + +import argparse +import ast +import json +import os +import subprocess +import re +import sys +import time +from pathlib import Path +from typing import Iterable +from urllib.parse import quote +from urllib.request import Request, urlopen + + +DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt") +DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:" +TRANSLATE_CALL_RE = re.compile( + r"""translate\s+(?P["'])(?P(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""", + re.VERBOSE | re.DOTALL, +) + + +def iter_script_files(scripts_dir: Path) -> Iterable[Path]: + for path in sorted(scripts_dir.rglob("*")): + if not path.is_file(): + continue + if path.name == "utils.sh": + continue + if path.suffix not in {".sh", ".func"}: + continue + yield path + + +def decode_shell_string(raw: str, quote_char: str) -> str: + if quote_char == "'": + return raw + try: + return ast.literal_eval(f'"{raw}"') + except Exception: + return raw.replace(r"\"", '"').replace(r"\\", "\\") + + +def extract_translate_texts(scripts_dir: Path) -> list[str]: + found: dict[str, None] = {} + for path in iter_script_files(scripts_dir): + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + content = path.read_text(encoding="utf-8", errors="replace") + + for match in TRANSLATE_CALL_RE.finditer(content): + text = decode_shell_string(match.group("text"), match.group("quote")) + text = text.strip() + if text and "$" not in text and "`" not in text: + found.setdefault(text, None) + + return sorted(found) + + +def translate_googletrans(text: str, dest_lang: str, context: str) -> str: + try: + from googletrans import Translator # type: ignore + except Exception as exc: + raise RuntimeError( + "googletrans is not installed. Install googletrans==4.0.0-rc1 " + "or run with --provider google-web." + ) from exc + + translator = Translator() + full_text = f"{context} {text}".strip() + return translator.translate(full_text, dest=dest_lang).text + + +def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str: + # The public Google endpoint is not prompt-aware: if we prepend context, + # it often translates and returns that context as part of the result. + full_text = text + url = ( + "https://translate.googleapis.com/translate_a/single" + f"?client=gtx&sl=en&tl={quote(dest_lang)}&dt=t&q={quote(full_text)}" + ) + req = Request(url, headers={"User-Agent": "ProxMenux translation cache builder"}) + with urlopen(req, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + return "".join(part[0] for part in payload[0] if part and part[0]) + + +def translate_appimage( + text: str, + dest_lang: str, + context: str, + timeout: int, + appimage_path: Path, +) -> str: + if not appimage_path.exists(): + prev_path = appimage_path.with_name(appimage_path.name + ".prev") + if prev_path.exists(): + appimage_path = prev_path + else: + raise FileNotFoundError(f"AppImage not found: {appimage_path}") + + req = { + "text": text, + "dest_lang": dest_lang, + "context": context, + "cache_file": "", + } + env = os.environ.copy() + env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1") + completed = subprocess.run( + [str(appimage_path), "--translate"], + input=json.dumps(req, ensure_ascii=False), + text=True, + capture_output=True, + timeout=timeout, + check=False, + env=env, + ) + if completed.returncode != 0: + raise RuntimeError((completed.stderr or completed.stdout).strip()) + + # AppRun may print a startup line before translate_cli.py emits JSON. + for line in reversed(completed.stdout.splitlines()): + line = line.strip() + if not line.startswith("{"): + continue + payload = json.loads(line) + if payload.get("success"): + return str(payload.get("text", text)) + raise RuntimeError(str(payload.get("error", "unknown AppImage translation error"))) + + raise RuntimeError(f"AppImage did not return JSON: {completed.stdout.strip()}") + + +def clean_translation(value: str) -> str: + separator = r"[\s\u00a0]*[::]" + translate_labels = "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir" + context_labels = "Context|Contexto|Contexte|Kontext|Contesto" + value = re.sub( + rf"^.*?({translate_labels}){separator}", + "", + value, + flags=re.IGNORECASE | re.DOTALL, + ) + value = re.sub( + rf"^.*?({context_labels}){separator}.*?({translate_labels}){separator}", + "", + value, + flags=re.IGNORECASE | re.DOTALL, + ) + value = re.sub( + rf"^.*?({context_labels}){separator}", + "", + value, + flags=re.IGNORECASE | re.DOTALL, + ) + return value.strip() + + +def translate_text( + text: str, + dest_lang: str, + provider: str, + context: str, + timeout: int, + appimage_path: Path, +) -> str: + if provider == "googletrans": + translated = translate_googletrans(text, dest_lang, context) + elif provider == "google-web": + translated = translate_google_web(text, dest_lang, context, timeout) + elif provider == "appimage": + translated = translate_appimage(text, dest_lang, context, timeout, appimage_path) + else: + raise ValueError(f"Unknown provider: {provider}") + return clean_translation(translated) or text + + +def load_language_cache(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + if not isinstance(data, dict): + return {} + return {str(text): str(value) for text, value in data.items()} + + +def write_language_cache(path: Path, cache: dict[str, str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(path.suffix + ".tmp") + tmp_path.write_text( + json.dumps(cache, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + tmp_path.replace(path) + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Extract translate calls from scripts/ and build json/cache.json." + ) + parser.add_argument("--scripts-dir", default="scripts", type=Path) + parser.add_argument( + "--output-dir", + default=Path("lang"), + type=Path, + help="Directory where per-language JSON files are written. Default: lang", + ) + parser.add_argument( + "--output", + default=None, + type=Path, + help="Deprecated combined cache path. If used, per-language files are written next to it under its parent directory.", + ) + parser.add_argument( + "--languages", + default=",".join(DEFAULT_LANGUAGES), + help="Comma-separated destination languages. Default: es,fr,de,it,pt", + ) + parser.add_argument( + "--provider", + choices=("appimage", "googletrans", "google-web"), + default="appimage", + help="Translation provider to use. Default: appimage", + ) + parser.add_argument( + "--appimage-path", + default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"), + type=Path, + help="Path to the ProxMenux AppImage when using --provider appimage.", + ) + parser.add_argument("--context", default=DEFAULT_CONTEXT) + parser.add_argument("--timeout", default=30, type=int) + parser.add_argument("--sleep", default=0.15, type=float) + parser.add_argument( + "--refresh", + action="store_true", + help="Translate all entries again instead of reusing existing cache values.", + ) + parser.add_argument( + "--extract-only", + action="store_true", + help="Only update the cache keys; missing translations are left empty.", + ) + parser.add_argument( + "--limit", + type=int, + default=0, + help="Only process the first N extracted strings. Useful for test runs.", + ) + parser.add_argument( + "--save-every", + type=int, + default=1, + help="Write the output JSON every N translated items. Default: 1", + ) + return parser + + +def main() -> int: + args = build_arg_parser().parse_args() + scripts_dir = args.scripts_dir.resolve() + if args.output is not None: + output_dir = args.output.resolve().parent / "lang" + else: + output_dir = args.output_dir.resolve() + languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()] + + if not scripts_dir.is_dir(): + print(f"Scripts directory not found: {scripts_dir}", file=sys.stderr) + return 1 + if not languages: + print("No destination languages selected.", file=sys.stderr) + return 1 + + texts = extract_translate_texts(scripts_dir) + if args.limit > 0: + texts = texts[: args.limit] + existing_by_lang = { + lang: load_language_cache(output_dir / f"{lang}.json") + for lang in languages + } + next_by_lang: dict[str, dict[str, str]] = {lang: {} for lang in languages} + print(f"Found {len(texts)} unique translate strings.", flush=True) + print(f"Output directory: {output_dir}", flush=True) + print(f"Languages: {', '.join(languages)}", flush=True) + + failures: list[tuple[str, str, str]] = [] + total = len(texts) * len(languages) + done = 0 + + for lang in languages: + existing = existing_by_lang.get(lang, {}) + print(f"Starting language: {lang}", flush=True) + + for index, text in enumerate(texts, start=1): + done += 1 + if not args.refresh and existing.get(text): + next_by_lang[lang][text] = existing[text] + continue + if args.extract_only: + next_by_lang[lang][text] = existing.get(text, "") + continue + + print(f"[{done}/{total}] {lang} ({index}/{len(texts)}): {text[:80]}", flush=True) + try: + next_by_lang[lang][text] = translate_text( + text, + lang, + args.provider, + args.context, + args.timeout, + args.appimage_path, + ) + print(f" => {next_by_lang[lang][text][:100]}", flush=True) + except Exception as exc: + next_by_lang[lang][text] = existing.get(text, text) + failures.append((text, lang, str(exc))) + print(f" failed: {exc}", file=sys.stderr, flush=True) + if args.save_every > 0 and index % args.save_every == 0: + write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang]) + time.sleep(args.sleep) + + write_language_cache(output_dir / f"{lang}.json", next_by_lang[lang]) + print(f"Completed language: {lang}", flush=True) + + for lang, cache in next_by_lang.items(): + write_language_cache(output_dir / f"{lang}.json", cache) + + if failures: + print(f"Completed with {len(failures)} translation failures.", file=sys.stderr, flush=True) + for text, lang, error in failures[:20]: + print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True) + if len(failures) > 20: + print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True) + return 2 + + print("Translation cache generated successfully.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/build-translation-cache.yml b/.github/workflows/build-translation-cache.yml new file mode 100644 index 00000000..23c7a1dd --- /dev/null +++ b/.github/workflows/build-translation-cache.yml @@ -0,0 +1,91 @@ +name: Build translation cache + +# Regenerates lang/*.json whenever a bash script under scripts/ changes. +# The runtime translate() in scripts/utils.sh reads these JSON files and +# falls back to the English source on miss, so keeping them up-to-date is +# what makes ProxMenux multilingual without any runtime googletrans +# dependency on the user's host. +# +# Triggers: +# - push to develop touching scripts/**/*.sh +# - manual via workflow_dispatch (force --refresh) + +on: + push: + branches: [develop] + paths: + - 'scripts/**/*.sh' + - '.github/scripts/build_translation_cache.py' + - '.github/workflows/build-translation-cache.yml' + workflow_dispatch: + inputs: + refresh: + description: 'Re-translate every entry (ignore cached values)' + type: boolean + default: false + +# Avoid two cache rebuilds from racing each other on the same branch and +# fighting over the auto-commit. +concurrency: + group: build-translation-cache-${{ github.ref }} + cancel-in-progress: false + +jobs: + rebuild-cache: + runs-on: ubuntu-latest + permissions: + contents: write # auto-commit lang/*.json back to develop + + steps: + - name: Checkout develop + uses: actions/checkout@v4 + with: + ref: develop + # Need full history so the auto-commit doesn't fail when the + # cache job runs minutes after the trigger push (GH default + # fetch-depth=1 sometimes diverges from origin/develop after a + # quick follow-up push). + fetch-depth: 0 + # Use a PAT (or default GITHUB_TOKEN if branch protections allow + # it) so the push back to develop actually fires later steps + # (workflow runs from auto-commits) if you ever need them. + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install googletrans + run: | + python -m pip install --upgrade pip + # 4.0.0-rc1 is the same pin that build_translation_cache.py + # was written against. Bump in lockstep with the script. + pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0' + + - name: Regenerate lang/*.json + run: | + REFRESH_FLAG="" + if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then + REFRESH_FLAG="--refresh" + fi + python .github/scripts/build_translation_cache.py \ + --scripts-dir scripts \ + --output-dir lang \ + --provider googletrans \ + $REFRESH_FLAG + + - name: Commit + push if changed + run: | + if git diff --quiet -- lang/; then + echo "No translation changes — skipping commit." + exit 0 + fi + git config user.name "ProxMenuxBot" + git config user.email "bot@proxmenux.local" + git add lang/ + git commit -m "chore(lang): auto-rebuild translation cache + + Source: ${GITHUB_SHA::7} + Triggered by: ${{ github.event_name }}" + git push origin develop diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 1573b94a..ee0166b9 100644 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -166,129 +166,38 @@ else echo "⚠️ config directory not found" fi -echo "📋 Adding translation support..." -cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF' -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -ProxMenux translate CLI -stdin JSON -> {"text":"...", "dest_lang":"es", "context":"...", "cache_file":"/usr/local/share/proxmenux/cache.json"} -stdout JSON -> {"success":true,"text":"..."} or {"success":false,"error":"..."} -""" -import sys, json, re -from pathlib import Path +# Translation handling lives in scripts/utils.sh now. It reads +# /usr/local/share/proxmenux/lang/.json (pre-built by the +# build_translation_cache.py CI job) and falls back to the English +# source string on miss. The Monitor AppImage no longer ships the +# runtime translate_cli.py — the JSON files belong to the host install, +# not to the Flask dashboard. -# Ensure embedded site-packages are discoverable -HERE = Path(__file__).resolve().parents[2] # .../AppDir -DIST = HERE / "usr" / "lib" / "python3" / "dist-packages" -SITE = HERE / "usr" / "lib" / "python3" / "site-packages" -for p in (str(DIST), str(SITE)): - if p not in sys.path: - sys.path.insert(0, p) - -# Python 3.13 compat: inline 'cgi' shim -try: - import cgi -except Exception: - import types, html - def _parse_header(value: str): - value = str(value or "") - parts = [p.strip() for p in value.split(";")] - if not parts: - return "", {} - key = parts[0].lower() - params = {} - for item in parts[1:]: - if not item: - continue - if "=" in item: - k, v = item.split("=", 1) - k = k.strip().lower() - v = v.strip().strip('"').strip("'") - params[k] = v - else: - params[item.strip().lower()] = "" - return key, params - cgi = types.SimpleNamespace(parse_header=_parse_header, escape=html.escape) - -try: - from googletrans import Translator -except Exception as e: - print(json.dumps({"success": False, "error": f"ImportError: {e}"})) - sys.exit(0) - -def load_json_stdin(): - try: - return json.load(sys.stdin) - except Exception as e: - print(json.dumps({"success": False, "error": f"Invalid JSON input: {e}"})) - sys.exit(0) - -def ensure_cache(path: Path): - try: - path.parent.mkdir(parents=True, exist_ok=True) - if not path.exists(): - path.write_text("{}", encoding="utf-8") - json.loads(path.read_text(encoding="utf-8") or "{}") - except Exception: - path.write_text("{}", encoding="utf-8") - -def read_cache(path: Path): - try: - return json.loads(path.read_text(encoding="utf-8") or "{}") - except Exception: - return {} - -def write_cache(path: Path, cache: dict): - tmp = path.with_suffix(".tmp") - tmp.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8") - tmp.replace(path) - -def clean_translated(s: str) -> str: - s = re.sub(r'^.*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)', '', s, flags=re.IGNORECASE | re.DOTALL).strip() - s = re.sub(r'^.*?(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?:', '', s, flags=re.IGNORECASE | re.DOTALL).strip() - return s.strip() - -def main(): - req = load_json_stdin() - text = req.get("text", "") - dest = req.get("dest_lang", "en") or "en" - context = req.get("context", "") - cache_file = Path(req.get("cache_file", "")) if req.get("cache_file") else None - - if dest == "en": - print(json.dumps({"success": True, "text": text})) - return - - cache = {} - if cache_file: - ensure_cache(cache_file) - cache = read_cache(cache_file) - if text in cache and (dest in cache[text] or "notranslate" in cache[text]): - found = cache[text].get(dest) or cache[text].get("notranslate") - print(json.dumps({"success": True, "text": found})) - return - - try: - full = (context + " " + text).strip() if context else text - tr = Translator() - result = tr.translate(full, dest=dest).text - result = clean_translated(result) - - if cache_file: - cache.setdefault(text, {}) - cache[text][dest] = result - write_cache(cache_file, cache) - - print(json.dumps({"success": True, "text": result})) - except Exception as e: - print(json.dumps({"success": False, "error": str(e)})) - -if __name__ == "__main__": - main() -PYEOF - -chmod +x "$APP_DIR/usr/bin/translate_cli.py" +# ── Borg standalone binary ───────────────────────────────────────── +# Ship the official borg standalone binary inside the AppImage so the +# host-backup / restore workflows can run without an internet round-trip +# at install time. Pinned to the same version that proxmenux's +# hb_ensure_borg used to download on demand — kept in lockstep so both +# code paths see the same version semantics. SHA256 is the upstream +# release checksum; bump both together. +BORG_VERSION="1.2.8" +BORG_URL="https://github.com/borgbackup/borg/releases/download/${BORG_VERSION}/borg-linux64" +BORG_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b" +echo "📦 Downloading borg ${BORG_VERSION} into AppImage..." +BORG_TARGET="$APP_DIR/usr/bin/borg" +if wget -qO "$BORG_TARGET" "$BORG_URL"; then + if echo "${BORG_SHA256} ${BORG_TARGET}" | sha256sum -c - >/dev/null 2>&1; then + chmod +x "$BORG_TARGET" + echo "✅ borg ${BORG_VERSION} bundled (sha256 verified)" + else + echo "❌ borg sha256 verification failed — removing" + rm -f "$BORG_TARGET" + exit 1 + fi +else + echo "❌ borg download failed from $BORG_URL" + exit 1 +fi # Copy Next.js build echo "📋 Copying web dashboard..." @@ -332,7 +241,7 @@ cat > "$APP_DIR/proxmenux-monitor.desktop" << EOF [Desktop Entry] Type=Application Name=ProxMenux Monitor -Comment=Proxmox System Monitoring Dashboard with Translation Support +Comment=Proxmox System Monitoring Dashboard Exec=AppRun Icon=proxmenux-monitor Categories=System;Monitor; @@ -361,14 +270,12 @@ if [ -f "$APP_DIR/proxmenux-monitor.png" ]; then fi echo "📦 Installing Python dependencies..." -# Phase 1: Install googletrans with its old dependencies -pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \ - googletrans==4.0.0-rc1 \ - httpx==0.13.3 \ - httpcore==0.9.1 \ - h11==0.9.0 || true - -# Phase 2: Install modern Flask/WebSocket dependencies (will upgrade h11 and related packages) +# Flask/WebSocket dependencies for the Monitor dashboard. The previous +# Phase-1 (googletrans==4.0.0-rc1 + httpx 0.13.3 + httpcore 0.9.1 + +# h11 0.9.0) is gone — translation is now a static-lookup feature on +# the host, so the AppImage no longer needs any runtime translator. +# Removing those pins also unblocks the h11>=0.14.0 family without the +# conflict workaround we used to ship. # Note: cryptography removed due to Python version compatibility issues (PyO3 modules) pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \ flask \ @@ -380,7 +287,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-de segno \ beautifulsoup4 -# Phase 3: Install WebSocket with newer h11 +# WebSocket with modern h11 (no need for the legacy pin anymore) pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \ h11>=0.14.0 \ wsproto>=1.2.0 \ diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 0e822ada..eaf06565 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -1407,3 +1407,49 @@ def internal_shutdown_event(): return jsonify({'success': True, 'event_type': event_type}), 200 except Exception as e: return jsonify({'error': 'internal_error', 'detail': str(e)}), 500 + + +# ─── Internal Restore Event Endpoint ───────────────────────────── + +@notification_bp.route('/api/internal/restore-event', methods=['POST']) +def internal_restore_event(): + """ + Internal endpoint called by apply_cluster_postboot.sh when the post-boot + dispatcher finishes. Tells the user the backgrounded restore tasks + (DKMS compile, apt installs, cluster apply, ...) are done so commands + like nvidia-smi now work. + + Only accepts requests from localhost (127.0.0.1) for security. + """ + remote_addr = request.remote_addr + if remote_addr not in ('127.0.0.1', '::1', 'localhost'): + return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403 + + try: + data = request.get_json(silent=True) or {} + hostname = data.get('hostname', 'unknown') + guests = data.get('guests', '0') + stubs = data.get('stubs', '0') + stale_nodes = data.get('stale_nodes', '0') + components = data.get('components', 'none') + duration = data.get('duration', 'unknown') + + notification_manager.emit_event( + event_type='system_restore_completed', + severity='INFO', + data={ + 'hostname': hostname, + 'guests': guests, + 'stubs': stubs, + 'stale_nodes': stale_nodes, + 'components': components, + 'duration': duration, + }, + source='proxmenux', + entity='node', + entity_id='', + ) + + return jsonify({'success': True, 'event_type': 'system_restore_completed'}), 200 + except Exception as e: + return jsonify({'error': 'internal_error', 'detail': str(e)}), 500 diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index af0eca4b..1217d192 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -868,6 +868,13 @@ TEMPLATES = { 'group': 'services', 'default_enabled': True, }, + 'system_restore_completed': { + 'title': '{hostname}: Host restore finished', + 'body': 'Post-restore tasks completed in background.\n\nGuests applied: {guests}\nBind-mount stubs: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n\nThe node is now fully ready to use.', + 'label': 'Host restore completed', + 'group': 'services', + 'default_enabled': True, + }, 'system_problem': { 'title': '{hostname}: System problem detected', 'body': 'A system-level problem has been detected.\nReason: {reason}', @@ -1604,6 +1611,7 @@ EVENT_EMOJI = { 'system_startup': '\U0001F680', # rocket (startup) 'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode) 'system_reboot': '\U0001F504', + 'system_restore_completed': '✅', # check mark 'system_problem': '\u26A0\uFE0F', 'service_fail': '\u274C', 'oom_kill': '\U0001F4A3', # bomb diff --git a/install_proxmenux.sh b/install_proxmenux.sh index c76a4f38..581a1e54 100755 --- a/install_proxmenux.sh +++ b/install_proxmenux.sh @@ -44,11 +44,15 @@ LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" INSTALL_DIR="/usr/local/bin" BASE_DIR="/usr/local/share/proxmenux" CONFIG_FILE="$BASE_DIR/config.json" -CACHE_FILE="$BASE_DIR/cache.json" UTILS_FILE="$BASE_DIR/utils.sh" LOCAL_VERSION_FILE="$BASE_DIR/version.txt" MENU_SCRIPT="menu" -VENV_PATH="/opt/googletrans-env" + +# Legacy path that existed during the Python+googletrans era. The current +# translate flow uses pre-generated JSON files in lang/ — no virtualenv, +# no online translation at runtime — so this path is purged on install +# if present. Kept as a literal here so the cleanup is grep-able. +LEGACY_VENV_PATH="/opt/googletrans-env" MONITOR_INSTALL_DIR="$BASE_DIR" MONITOR_RUNTIME_DIR="$BASE_DIR/monitor-app" @@ -272,10 +276,6 @@ cleanup_corrupted_files() { echo "Cleaning up corrupted configuration file..." rm -f "$CONFIG_FILE" fi - if [ -f "$CACHE_FILE" ] && ! jq empty "$CACHE_FILE" >/dev/null 2>&1; then - echo "Cleaning up corrupted cache file..." - rm -f "$CACHE_FILE" - fi } # Cleanup function @@ -291,157 +291,27 @@ trap cleanup EXIT # ========================================================== check_existing_installation() { - local has_venv=false - local has_config=false - local has_language=false - local has_menu=false - + # After the googletrans removal there is only one install variant. + # The function still distinguishes "installed" vs "not installed" so + # show_installation_options can pick the right banner. if [ -f "$INSTALL_DIR/$MENU_SCRIPT" ]; then - has_menu=true - fi - - if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then - has_venv=true - fi - - if [ -f "$CONFIG_FILE" ]; then - if jq empty "$CONFIG_FILE" >/dev/null 2>&1; then - has_config=true - local current_language=$(jq -r '.language // empty' "$CONFIG_FILE" 2>/dev/null) - if [[ -n "$current_language" && "$current_language" != "null" && "$current_language" != "empty" ]]; then - has_language=true - fi - else - echo "Warning: Corrupted config file detected, removing..." + # Quietly fix a corrupted config so the install can proceed. + if [ -f "$CONFIG_FILE" ] && ! jq empty "$CONFIG_FILE" >/dev/null 2>&1; then + echo "Warning: Corrupted config file detected, removing..." >&2 rm -f "$CONFIG_FILE" fi - fi - - if [ "$has_venv" = true ] && [ "$has_language" = true ]; then - echo "translation" - elif [ "$has_menu" = true ] && [ "$has_venv" = false ]; then - echo "normal" - elif [ "$has_menu" = true ]; then - echo "unknown" + echo "installed" else echo "none" fi } -uninstall_proxmenux() { - local install_type="$1" - local force_clean="$2" - - if [ "$force_clean" != "force" ]; then - if ! whiptail --title "Uninstall ProxMenux" --yesno "Are you sure you want to uninstall ProxMenux?" 10 60; then - return 1 - fi - fi - - echo "Uninstalling ProxMenux..." - - if systemctl is-active --quiet proxmenux-monitor.service; then - echo "Stopping ProxMenux Monitor service..." - systemctl stop proxmenux-monitor.service - fi - - if systemctl is-enabled --quiet proxmenux-monitor.service 2>/dev/null; then - echo "Disabling ProxMenux Monitor service..." - systemctl disable proxmenux-monitor.service - fi - - if [ -f "$MONITOR_SERVICE_FILE" ]; then - echo "Removing ProxMenux Monitor service file..." - rm -f "$MONITOR_SERVICE_FILE" - systemctl daemon-reload - fi - - if [ -d "$MONITOR_INSTALL_DIR" ]; then - echo "Removing ProxMenux Monitor directory..." - rm -rf "$MONITOR_INSTALL_DIR" - fi - - if [ -f "$VENV_PATH/bin/activate" ]; then - echo "Removing googletrans and virtual environment..." - source "$VENV_PATH/bin/activate" - pip uninstall -y googletrans >/dev/null 2>&1 - deactivate - rm -rf "$VENV_PATH" - fi - - if [ "$install_type" = "translation" ] && [ "$force_clean" != "force" ]; then - DEPS_TO_REMOVE=$(whiptail --title "Remove Translation Dependencies" --checklist \ - "Select translation-specific dependencies to remove:" 15 60 3 \ - "python3-venv" "Python virtual environment" OFF \ - "python3-pip" "Python package installer" OFF \ - "python3" "Python interpreter" OFF \ - 3>&1 1>&2 2>&3) - - if [ -n "$DEPS_TO_REMOVE" ]; then - echo "Removing selected dependencies..." - read -r -a DEPS_ARRAY <<< "$(echo "$DEPS_TO_REMOVE" | tr -d '"')" - for dep in "${DEPS_ARRAY[@]}"; do - echo "Removing $dep..." - apt-mark auto "$dep" >/dev/null 2>&1 - apt-get -y --purge autoremove "$dep" >/dev/null 2>&1 - done - apt-get autoremove -y --purge >/dev/null 2>&1 - fi - fi - - rm -f "$INSTALL_DIR/$MENU_SCRIPT" - rm -rf "$BASE_DIR" - - [ -f /root/.bashrc.bak ] && mv /root/.bashrc.bak /root/.bashrc - if [ -f /etc/motd.bak ]; then - mv /etc/motd.bak /etc/motd - else - sed -i '/This system is optimised by: ProxMenux/d' /etc/motd - fi - - echo "ProxMenux has been uninstalled." - return 0 -} - -handle_installation_change() { - local current_type="$1" - local new_type="$2" - - if [ "$current_type" = "$new_type" ]; then - return 0 - fi - - case "$current_type-$new_type" in - "translation-1"|"translation-normal") - if whiptail --title "Installation Type Change" \ - --yesno "Switch from Translation to Normal Version?\n\nThis will remove translation components." 10 60; then - echo "Preparing for installation type change..." - uninstall_proxmenux "translation" "force" >/dev/null 2>&1 - return 0 - else - return 1 - fi - ;; - "normal-2"|"normal-translation") - if whiptail --title "Installation Type Change" \ - --yesno "Switch from Normal to Translation Version?\n\nThis will add translation components." 10 60; then - return 0 - else - return 1 - fi - ;; - *) - return 0 - ;; - esac -} - update_config() { local component="$1" local status="$2" local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - local tracked_components=("dialog" "curl" "jq" "git" "python3" "python3-venv" "python3-pip" "virtual_environment" "pip" "googletrans" "proxmenux_monitor") + local tracked_components=("dialog" "curl" "jq" "git" "python3" "python3-pip" "proxmenux_monitor") if [[ " ${tracked_components[@]} " =~ " ${component} " ]]; then mkdir -p "$(dirname "$CONFIG_FILE")" @@ -517,26 +387,12 @@ select_language() { # Show installation confirmation for new installations show_installation_confirmation() { - local install_type="$1" - - case "$install_type" in - "1") - if whiptail --title "ProxMenux - Normal Version Installation" \ - --yesno "ProxMenux Normal Version will install:\n\n• dialog (interactive menus) - Official Debian package\n• curl (file downloads) - Official Debian package\n• jq (JSON processing) - Official Debian package\n• ProxMenux core files (/usr/local/share/proxmenux)\n• ProxMenux Monitor (Web dashboard on port 8008)\n\nThis is a lightweight installation with minimal dependencies.\n\nProceed with installation?" 20 70; then - return 0 - else - return 1 - fi - ;; - "2") - if whiptail --title "ProxMenux - Translation Version Installation" \ - --yesno "ProxMenux Translation Version will install:\n\n• dialog (interactive menus)\n• curl (file downloads)\n• jq (JSON processing)\n• python3 + python3-venv + python3-pip\n• Google Translate library (googletrans)\n• Virtual environment (/opt/googletrans-env)\n• Translation cache system\n• ProxMenux core files\n• ProxMenux Monitor (Web dashboard on port 8008)\n\nThis version requires more dependencies for translation support.\n\nProceed with installation?" 20 70; then - return 0 - else - return 1 - fi - ;; - esac + if whiptail --title "ProxMenux Installation" \ + --yesno "ProxMenux will install:\n\n• dialog (interactive menus) - Official Debian package\n• curl (file downloads) - Official Debian package\n• jq (JSON processing) - Official Debian package\n• ProxMenux core files (/usr/local/share/proxmenux)\n• ProxMenux Monitor (Web dashboard on port 8008)\n• Pre-built translation files (English, Spanish, French, German, Italian, Portuguese)\n\nProceed with installation?" 20 70; then + return 0 + else + return 1 + fi } get_server_ip() { @@ -800,9 +656,25 @@ EOF } install_normal_version() { - local total_steps=5 + local total_steps=6 local current_step=1 - + + # Translations now live as pre-generated JSON files under lang/, so + # asking the language up front is the right place — every install is + # multilingual-capable and the user picks once. + show_progress $current_step $total_steps "Language selection" + select_language + ((current_step++)) + + # Purge the legacy googletrans virtualenv if a previous install left it + # behind. The new translate flow has no runtime Python/googletrans + # dependency — the venv is dead weight on disk now. + if [[ -d "$LEGACY_VENV_PATH" ]]; then + msg_info "Removing legacy translation virtualenv at $LEGACY_VENV_PATH..." + rm -rf "$LEGACY_VENV_PATH" + msg_ok "Legacy translation virtualenv removed." + fi + show_progress $current_step $total_steps "Installing basic dependencies." if ! command -v jq > /dev/null 2>&1; then @@ -842,7 +714,11 @@ install_normal_version() { fi for pkg in "${BASIC_DEPS[@]}"; do - # Strict per-package check — see comment in install_translation_version(). + # `dpkg -l | grep -qw "$pkg"` treats `-` as a word boundary, so a + # query for `python3` would falsely match `python3-pip` and skip + # the real `python3` install. `dpkg-query -W -f='${Status}'` asks + # for the EXACT package and reports "install ok installed" only + # when truly present. Issue #205 traced back here. if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "ok installed"; then if apt-get install -y "$pkg" > /dev/null 2>&1; then update_config "$pkg" "installed" @@ -931,6 +807,18 @@ install_normal_version() { cp "./version.txt" "$LOCAL_VERSION_FILE" cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh" + # Pre-built translation cache. The runtime translate() in utils.sh + # reads $BASE_DIR/lang/.json — these files ship with the repo + # (one per supported language) so the install ends up multilingual + # without any runtime download or Python dependency. Refresh the + # whole dir on every install so a language that was renamed or + # dropped upstream disappears here too. + if [ -d "./lang" ]; then + rm -rf "$BASE_DIR/lang" + mkdir -p "$BASE_DIR/lang" + cp -r "./lang/"* "$BASE_DIR/lang/" 2>/dev/null || true + fi + # A user that previously rode the beta train and then switched back # to stable would still have a leftover beta_version.txt under # $BASE_DIR, which makes the `menu` update check (check_updates_beta) @@ -942,7 +830,7 @@ install_normal_version() { # Wipe the scripts tree before copying so any file removed upstream # (renamed, consolidated, deprecated) disappears from the user install. - # Only $BASE_DIR/scripts/ is cleared; config.json, cache.json, + # Only $BASE_DIR/scripts/ is cleared; config.json, # components_status.json, version.txt, monitor.db, smart/, oci/ and # the AppImage live outside this path and are preserved. rm -rf "$BASE_DIR/scripts" @@ -969,210 +857,16 @@ install_normal_version() { msg_ok "ProxMenux Normal Version installation completed successfully." } -install_translation_version() { - local total_steps=5 - local current_step=1 - - show_progress $current_step $total_steps "Language selection" - select_language - ((current_step++)) - - show_progress $current_step $total_steps "Installing system dependencies" - - if ! command -v jq > /dev/null 2>&1; then - apt-get update > /dev/null 2>&1 - - if apt-get install -y jq > /dev/null 2>&1 && command -v jq > /dev/null 2>&1; then - update_config "jq" "installed" - else - local jq_url="https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" - if wget -q -O /usr/local/bin/jq "$jq_url" 2>/dev/null && chmod +x /usr/local/bin/jq; then - if command -v jq > /dev/null 2>&1; then - update_config "jq" "installed_from_github" - else - msg_error "Failed to install jq. Please install it manually." - update_config "jq" "failed" - return 1 - fi - else - msg_error "Failed to install jq from both APT and GitHub. Please install it manually." - update_config "jq" "failed" - return 1 - fi - fi - else - update_config "jq" "already_installed" - fi - - DEPS=("dialog" "curl" "git" "python3" "python3-venv" "python3-pip") - for pkg in "${DEPS[@]}"; do - # `dpkg -l | grep -qw "$pkg"` treats `-` as a word boundary, so a - # query for `python3` would falsely match `python3-pip` and skip - # the real `python3` install. `dpkg-query -W -f='${Status}'` asks - # for the EXACT package and reports "install ok installed" only - # when truly present. Issue #205 traced back here. - if ! dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null | grep -q "ok installed"; then - if apt-get install -y "$pkg" > /dev/null 2>&1; then - update_config "$pkg" "installed" - else - msg_error "Failed to install $pkg. Please install it manually." - update_config "$pkg" "failed" - return 1 - fi - else - update_config "$pkg" "already_installed" - fi - done - - msg_ok "jq, dialog, curl, git, python3, python3-venv and python3-pip installed successfully." - - ((current_step++)) - - show_progress $current_step $total_steps "Setting up translation environment" - - if [ ! -d "$VENV_PATH" ] || [ ! -f "$VENV_PATH/bin/activate" ]; then - python3 -m venv --system-site-packages "$VENV_PATH" > /dev/null 2>&1 - if [ ! -f "$VENV_PATH/bin/activate" ]; then - msg_error "Failed to create virtual environment. Please check your Python installation." - update_config "virtual_environment" "failed" - return 1 - else - update_config "virtual_environment" "created" - fi - else - update_config "virtual_environment" "already_exists" - fi - - source "$VENV_PATH/bin/activate" - - if pip install --upgrade pip > /dev/null 2>&1; then - update_config "pip" "upgraded" - else - msg_error "Failed to upgrade pip." - update_config "pip" "upgrade_failed" - return 1 - fi - - if pip install --break-system-packages --no-cache-dir googletrans==4.0.0-rc1 > /dev/null 2>&1; then - update_config "googletrans" "installed" - else - msg_error "Failed to install googletrans. Please check your internet connection." - update_config "googletrans" "failed" - deactivate - return 1 - fi - - deactivate - - show_progress $current_step $total_steps "Cloning ProxMenux repository" - if ! git clone --depth 1 "$REPO_URL" "$TEMP_DIR" 2>/dev/null; then - msg_error "Failed to clone repository from $REPO_URL" - exit 1 - fi - msg_ok "Repository cloned successfully." - - cd "$TEMP_DIR" - - ((current_step++)) - - show_progress $current_step $total_steps "Copying necessary files" - - mkdir -p "$BASE_DIR" - mkdir -p "$INSTALL_DIR" - - cp "./json/cache.json" "$CACHE_FILE" - msg_ok "Cache file copied with translations." - - cp "./scripts/utils.sh" "$UTILS_FILE" - # Atomic install of /usr/local/bin/menu: stage to .new on the same - # filesystem then mv. This protects any reader that happens to open - # the file mid-install from seeing a partial/half-written script - # (the suspected root cause of the post-1.2.2-update reports: - # "menu: line 138 syntax error near unexpected token `$REMOTE_VERSION`") - cp "./menu" "$INSTALL_DIR/${MENU_SCRIPT}.new" - mv -f "$INSTALL_DIR/${MENU_SCRIPT}.new" "$INSTALL_DIR/$MENU_SCRIPT" - cp "./version.txt" "$LOCAL_VERSION_FILE" - cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh" - - # Clear any leftover beta_version.txt — see the equivalent block - # in the update path above for the rationale (in short: prevents - # the menu from offering a phantom "Beta update available" after a - # user has switched back to the stable channel). - rm -f "$BASE_DIR/beta_version.txt" - - mkdir -p "$BASE_DIR/scripts" - cp -r "./scripts/"* "$BASE_DIR/scripts/" - chmod -R +x "$BASE_DIR/scripts/" - chmod +x "$BASE_DIR/install_proxmenux.sh" - msg_ok "Necessary files created." - - chmod +x "$INSTALL_DIR/$MENU_SCRIPT" - - ((current_step++)) - show_progress $current_step $total_steps "Installing ProxMenux Monitor" - - install_proxmenux_monitor - local monitor_status=$? - - if [ $monitor_status -eq 0 ]; then - create_monitor_service - elif [ $monitor_status -eq 2 ]; then - msg_ok "ProxMenux Monitor updated successfully." - fi - - msg_ok "ProxMenux Translation Version installation completed successfully." -} - show_installation_options() { local current_install_type current_install_type=$(check_existing_installation) - local pve_version - pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) - - local menu_title="ProxMenux Installation" - local menu_text="Choose installation type:" - - if [ "$current_install_type" != "none" ]; then - case "$current_install_type" in - "translation") - menu_title="ProxMenux Update - Translation Version Detected" - ;; - "normal") - menu_title="ProxMenux Update - Normal Version Detected" - ;; - "unknown") - menu_title="ProxMenux Update - Existing Installation Detected" - ;; - esac - fi - - if [[ "$pve_version" -ge 9 ]]; then - INSTALL_TYPE=$(whiptail --backtitle "ProxMenux" --title "$menu_title" --menu "\n$menu_text" 14 70 2 \ - "1" "Normal Version (English only)" 3>&1 1>&2 2>&3) - - if [ -z "$INSTALL_TYPE" ]; then - show_proxmenux_logo - msg_warn "Installation cancelled." - exit 1 - fi - else - INSTALL_TYPE=$(whiptail --backtitle "ProxMenux" --title "$menu_title" --menu "\n$menu_text" 14 70 2 \ - "1" "Normal Version (English only)" \ - "2" "Translation Version (Multi-language support)" 3>&1 1>&2 2>&3) - - if [ -z "$INSTALL_TYPE" ]; then - show_proxmenux_logo - msg_warn "Installation cancelled." - exit 1 - fi - fi - - if [ -z "$INSTALL_TYPE" ]; then - show_proxmenux_logo - msg_warn "Installation cancelled." - exit 1 - fi - + # Translation Version is gone — translations now ship as pre-built + # JSON files in lang/. There is only one install path, so this + # function just shows the confirmation dialog for new installs and + # then returns. Existing installs go straight through (they already + # consented to update via the menu). + INSTALL_TYPE="1" + if [ "$current_install_type" = "none" ]; then if ! show_installation_confirmation "$INSTALL_TYPE"; then show_proxmenux_logo @@ -1180,55 +874,22 @@ show_installation_options() { exit 1 fi fi - - if ! handle_installation_change "$current_install_type" "$INSTALL_TYPE"; then - show_proxmenux_logo - msg_warn "Installation cancelled." - exit 1 - fi } install_proxmenux() { if [[ "${UPDATE_MODE:-0}" == "1" ]]; then # Update path: the user already accepted "Update now?" in the - # menu. We skip the install-type chooser (their choice is - # preserved — Translation installs leave /opt/googletrans-env - # behind, Normal installs don't) and label the run as an - # "Update" instead of an "Install" so the operator can tell - # which flow they're in. The continuous hand-off back to the - # new menu at the end of this function (exec, see below) - # closes the entire class of bugs of shape - # "menu: line N syntax error" post-update - # because no shell ever returns to a half-written + # menu. Hand off to the freshly-installed menu binary at the end + # (exec, see below) so no shell ever returns to a half-written # /usr/local/bin/menu — the new copy is the only thing parsed. - if [[ -d "$VENV_PATH" && -f "$VENV_PATH/bin/activate" ]]; then - show_proxmenux_logo - msg_title "Updating ProxMenux - Translation Version" - install_translation_version - else - show_proxmenux_logo - msg_title "Updating ProxMenux - Normal Version" - install_normal_version - fi + show_proxmenux_logo + msg_title "Updating ProxMenux" + install_normal_version else show_installation_options - - case "$INSTALL_TYPE" in - "1") - show_proxmenux_logo - msg_title "Installing ProxMenux - Normal Version" - install_normal_version - ;; - "2") - show_proxmenux_logo - msg_title "Installing ProxMenux - Translation Version" - install_translation_version - ;; - *) - msg_error "Invalid option selected." - exit 1 - ;; - esac + show_proxmenux_logo + msg_title "Installing ProxMenux" + install_normal_version fi if [[ -f "$UTILS_FILE" ]]; then diff --git a/lang/cache.json b/lang/cache.json deleted file mode 100644 index 1b60a832..00000000 --- a/lang/cache.json +++ /dev/null @@ -1,198 +0,0 @@ -{ - "Language changed to": { - "es": "Idioma cambiado a", - "fr": "Langue changée en", - "de": "Sprache geändert zu", - "it": "Lingua cambiata in", - "pt": "Idioma alterado para" - }, - "Main Menu": { - "es": "Menú principal", - "fr": "Menu principal", - "de": "Hauptmenü", - "it": "Menu principale", - "pt": "Menu principal" - }, - "Select an option:": { - "es": "Seleccione una opción:", - "fr": "Sélectionnez une option :", - "de": "Wählen Sie eine Option aus:", - "it": "Selezionare un'opzione:", - "pt": "Selecione uma opção:" - }, - "GPUs and Coral-TPU": { - "es": "GPUs y Coral-TPU", - "fr": "GPUs et Coral-TPU", - "de": "GPUs und Coral-TPU", - "it": "GPUs e Coral-TPU", - "pt": "GPUs e Coral-TPU" - }, - "Hard Drives, Disk Images, and Storage": { - "es": "Discos duros, imágenes de disco y almacenamiento", - "fr": "Disques durs, images disque et stockage", - "de": "Festplatten, Festplattenabbilder und Speicherplatz", - "it": "Dischi rigidi, immagini del disco e archiviazione", - "pt": "Discos rígidos, imagens de disco e armazenamento" - }, - "Network": { - "es": "Red", - "fr": "Réseau", - "de": "Netzwerk", - "it": "Rete", - "pt": "Rede" - }, - "Settings": { - "es": "Configuración", - "fr": "Paramètres", - "de": "Einstellungen", - "it": "Impostazioni", - "pt": "Configurações" - }, - "Exit": { - "es": "Salir", - "fr": "Quitter", - "de": "Beenden", - "it": "Esci", - "pt": "Sair" - }, - "HW: GPUs and Coral": { - "es": "HW: GPUs y Coral", - "fr": "HW: GPUs et Coral", - "de": "HW: GPUs und Coral", - "it": "HW: GPUs e Coral", - "pt": "HW: GPUs e Coral" - }, - "Return to Main Menu": { - "es": "Volver al menú principal", - "fr": "Retour au menu principal", - "de": "Zum Hauptmenü zurückkehren", - "it": "Torna al menu principale", - "pt": "Retornar ao menu principal" - }, - "Disk and Storage Menu": { - "es": "Menú de discos y almacenamiento", - "fr": "Menu des disques et stockage", - "de": "Datenträger- und Speichermenü", - "it": "Menu dischi e archiviazione", - "pt": "Menu de discos e armazenamento" - }, - "Add Disk Passthrough to a VM": { - "es": "Añadir disco Passthrough a una VM", - "fr": "Ajouter un disque Passthrough à une VM", - "de": "Disk-Passthrough zu einer VM hinzufügen", - "it": "Aggiungi passthrough del disco a una VM", - "pt": "Adicionar passthrough de disco a uma VM" - }, - "Network Menu": { - "es": "Menú de red", - "fr": "Menu réseau", - "de": "Netzwerkmenü", - "it": "Menu di rete", - "pt": "Menu de rede" - }, - "Repair Network": { - "es": "Reparar red", - "fr": "Réparer le réseau", - "de": "Netzwerk reparieren", - "it": "Riparare la rete", - "pt": "Reparar rede" - }, - "Configuration Menu": { - "es": "Menú de configuración", - "fr": "Menu de configuration", - "de": "Konfigurationsmenü", - "it": "Menu di configurazione", - "pt": "Menu de configuração" - }, - "Change Language": { - "es": "Cambiar idioma", - "fr": "Changer de langue", - "de": "Sprache ändern", - "it": "Cambia lingua", - "pt": "Alterar idioma" - }, - "Show Version Information": { - "es": "Mostrar información de la versión", - "fr": "Afficher les informations de version", - "de": "Versionsinformationen anzeigen", - "it": "Mostra informazioni sulla versione", - "pt": "Mostrar informações da versão" - }, - "Uninstall ProxMenu": { - "es": "Desinstalar ProxMenu", - "fr": "Désinstaller ProxMenu", - "de": "ProxMenu deinstallieren", - "it": "Disinstallare ProxMenu", - "pt": "Desinstalar ProxMenu" - }, - "Select a new language for the menu:": { - "es": "Seleccione un nuevo idioma para el menú:", - "fr": "Sélectionnez une nouvelle langue pour le menu :", - "de": "Wählen Sie eine neue Sprache für das Menü:", - "it": "Seleziona una nuova lingua per il menu:", - "pt": "Selecione um novo idioma para o menu:" - }, - "English (Recommended)": { - "es": "Inglés (recomendado)", - "fr": "Anglais (recommandé)", - "de": "Englisch (empfohlen)", - "it": "Inglese (consigliato)", - "pt": "Inglês (recomendado)" - }, - "Spanish": { - "es": "Español", - "fr": "Espagnol", - "de": "Spanisch", - "it": "Spagnolo", - "pt": "Espanhol" - }, - "French": { - "es": "Francés", - "fr": "Français", - "de": "Französisch", - "it": "Francese", - "pt": "Francês" - }, - "German": { - "es": "Alemán", - "fr": "Allemand", - "de": "Deutsch", - "it": "Tedesco", - "pt": "Alemão" - }, - "Italian": { - "es": "Italiano", - "fr": "Italien", - "de": "Italienisch", - "it": "Italiano", - "pt": "Italiano" - }, - "Portuguese": { - "es": "Portugués", - "fr": "Portugais", - "de": "Portugiesisch", - "it": "Portoghese", - "pt": "Português" - }, - "Simplified Chinese": { - "es": "Chino simplificado", - "fr": "Chinois simplifié", - "de": "Vereinfachtes Chinesisch", - "it": "Cinese semplificato", - "pt": "Chinês simplificado" - }, - "Japanese": { - "es": "Japonés", - "fr": "Japonais", - "de": "Japanisch", - "it": "Giapponese", - "pt": "Japonês" - }, - "Thank you for using ProxMenu. Goodbye!": { - "es": "Gracias por usar ProxMenu. ¡Hasta luego!", - "fr": "Merci d'avoir utilisé ProxMenu. À bientôt !", - "de": "Vielen Dank, dass Sie ProxMenu verwendet haben. Bis bald!", - "it": "Grazie per aver usato ProxMenu. A presto!", - "pt": "Obrigado por usar o ProxMenu. Até logo!" - } -} diff --git a/lang/en.lang b/lang/en.lang deleted file mode 100644 index e4c938a3..00000000 --- a/lang/en.lang +++ /dev/null @@ -1,113 +0,0 @@ -# General system messages -MAIN_MENU_TITLE="ProxMenux - Main Menu" -CONFIG_TITLE="ProxMenux - Configuration" -SELECT_OPTION="Select an option:" -LANG_OPTION="Change language" -UNINSTALL_OPTION="Uninstall ProxMenu" -EXIT_MENU="Exit" -EXIT_MESSAGE="Exiting menu. Goodbye!" - -# Main menu options -OPTION_1="Configure iGPU + TPU" -OPTION_2="Repair network" -OPTION_3="Settings" - -# Version messages -VERSION_OPTION="Show version information" -VERSION_TITLE="Version Information" -VERSION_INFO="Current version: %s\n\nFor more information, visit:\nhttps://github.com/MacRimi/ProxMenux" - -# Update messages -UPDATE_CHECKING="Checking for updates..." -UPDATE_ERROR_REMOTE="Error checking remote version." -UPDATE_NEW_AVAILABLE="New version available: %s (current: %s)" -UPDATE_TITLE="Update available" -UPDATE_PROMPT="Do you want to update to the latest version %s?" -UPDATE_POSTPONED="Update postponed." -UPDATE_CURRENT="The menu is already up to date (%s)." -UPDATE_PROCESS="Updating to version %s..." -UPDATE_COMPLETE="Update completed to version %s." -UPDATE_ERROR_DOWNLOAD="Error downloading the update." - -# Uninstall messages -UNINSTALL_TITLE="Uninstall ProxMenu" -UNINSTALL_CONFIRM="Are you sure you want to uninstall ProxMenu?" -UNINSTALL_COMPLETE="ProxMenu has been uninstalled successfully." -UNINSTALL_PROCESS="Uninstalling ProxMenu..." - -# Script messages -SCRIPT_RUNNING="Running igpu_tpu.sh script..." -SCRIPT_SUCCESS="Script executed successfully." -SCRIPT_ERROR="Error executing the script." - -# Language messages -LANG_SELECT="Select Language" -LANG_PROMPT="Choose your language:" -LANG_ERROR="No language selected. Exiting..." -LANG_SUCCESS="Selected language:" -LANG_LOADED="Language loaded:" -LANG_DOWNLOAD="Downloading language file..." -LANG_DOWNLOAD_ERROR="Error downloading language file. Check your internet connection." -LANG_EXISTS="Language file exists locally." - -# Dependency messages -DEPS_INSTALLING="Installing necessary dependencies..." -DEPS_SUCCESS="Dependencies installed." -DEPS_ERROR="Error installing dependencies. Please install whiptail manually." - -# Bilingual messages (first run) -INITIAL_LANG_SELECT="Select Language / Seleccionar Idioma" -INITIAL_LANG_PROMPT="Choose your language / Elige tu idioma:" -INITIAL_LANG_ERROR="No language selected. Exiting... / No se seleccionó ningún idioma. Saliendo..." - -# --- Messages for the network repair script (repair_network.sh) --- -REPAIR_MENU_TITLE="Network Repair Menu" -MENU_PROMPT="Please select an option:" -MENU_REPAIR="Repair network" -MENU_VERIFY="Verify network configuration" -MENU_SHOW_IP="Show IP information" -MENU_EXIT="Exit" -MENU_CANCELED="Operation canceled by user." -MENU_EXIT_MSG="Exiting network repair script. Goodbye!" -INVALID_OPTION="Invalid option. Please try again." -PRESS_ENTER="Press Enter to continue..." -RESULT_TITLE="Operation Result" -REPAIR_COMPLETED="Network repair completed." -VERIFY_COMPLETED="Network verification completed." -IP_INFO_COMPLETED="IP information displayed." -NETWORK_ERROR="ERROR" -NETWORK_SUCCESS="SUCCESS" -NETWORK_WARNING="WARNING" -NETWORK_PHYSICAL_INTERFACES="Detected physical interfaces" -NETWORK_CONFIGURED_INTERFACES="Configured interfaces" -NETWORK_CHECKING_BRIDGES="Checking bridge configuration" -NETWORK_BRIDGE_PORT_MISSING="Bridge port non-existent or not active" -NETWORK_BRIDGE_PORT_UPDATED="Bridge port updated" -NETWORK_NO_PHYSICAL_INTERFACE="No suitable physical interface found" -NETWORK_BRIDGE_PORT_OK="Bridge port correct" -NETWORK_CLEANING_INTERFACES="Cleaning configurations of non-existent interfaces" -NETWORK_INTERFACE_REMOVED="Interface removed" -NETWORK_CONFIGURING_INTERFACES="Configuring interfaces" -NETWORK_INTERFACE_ADDED="Interface added" -NETWORK_RESTARTING="Restarting network service" -NETWORK_RESTART_SUCCESS="Network service restarted successfully" -NETWORK_RESTART_FAILED="Failed to restart network service" -NETWORK_CONNECTIVITY_OK="Network connectivity OK" -NETWORK_CONNECTIVITY_FAILED="Network connectivity failed" -NETWORK_IP_INFO="IP Information" -NETWORK_NO_IP="No IP" -NETWORK_REPAIR_STARTED="Starting network repair" -NETWORK_REPAIR_COMPLETED="Network repair completed" -NETWORK_REPAIR_FAILED="Network repair failed" -NETWORK_REPAIR_PROCESS_FINISHED="Network repair process finished" -NETWORK_VERIFY_STARTED="Starting network verification" -NETWORK_VERIFY_FINISHED="Network verification finished" -NETWORK_REPAIR_RUNNING="Running network repair..." -NETWORK_REPAIR_SUCCESS="Network repair executed successfully." -NETWORK_REPAIR_ERROR="Error executing network repair." -NETWORK_VERIFY_RUNNING="Running network verification..." -NETWORK_VERIFY_SUCCESS="Network verification completed successfully." -NETWORK_VERIFY_ERROR="Error executing network verification." -NETWORK_IP_INFO_RUNNING="Obtaining IP information..." -NETWORK_IP_INFO_SUCCESS="IP information obtained successfully." -NETWORK_IP_INFO_ERROR="Error obtaining IP information." diff --git a/lang/es.lang b/lang/es.lang deleted file mode 100644 index 64e96a43..00000000 --- a/lang/es.lang +++ /dev/null @@ -1,115 +0,0 @@ -# Mensajes generales del sistema -MAIN_MENU_TITLE="ProxMenux - Menú Principal" -CONFIG_TITLE="ProxMenux - Configuración" -SELECT_OPTION="Selecciona una opción:" -LANG_OPTION="Cambiar idioma" -UNINSTALL_OPTION="Desinstalar ProxMenu" -EXIT_MENU="Salir" -EXIT_MESSAGE="Saliendo del menú. ¡Hasta luego!" - -# Opciones del menú principal -OPTION_1="Configurar iGPU + TPU" -OPTION_2="Reparar red" -OPTION_3="Configuración" - -# Mensajes de versión -VERSION_OPTION="Mostrar información de versión" -VERSION_TITLE="Información de versión" -VERSION_INFO="Versión actual: %s\n\nPara más información, visita:\nhttps://github.com/MacRimi/ProxMenux" - -# Mensajes de actualización -UPDATE_CHECKING="Comprobando actualizaciones..." -UPDATE_ERROR_REMOTE="Error al comprobar la versión remota." -UPDATE_NEW_AVAILABLE="Nueva versión disponible: %s (actual: %s)" -UPDATE_TITLE="Actualización disponible" -UPDATE_PROMPT="¿Deseas actualizar a la última versión %s?" -UPDATE_POSTPONED="Actualización pospuesta." -UPDATE_CURRENT="El menú ya está actualizado (%s)." -UPDATE_PROCESS="Actualizando a la versión %s..." -UPDATE_COMPLETE="Actualización completada a la versión %s." -UPDATE_ERROR_DOWNLOAD="Error al descargar la actualización." - - -# Mensajes de desinstalación -UNINSTALL_TITLE="Desinstalar ProxMenu" -UNINSTALL_CONFIRM="¿Estás seguro de que quieres desinstalar ProxMenu?" -UNINSTALL_COMPLETE="ProxMenu ha sido desinstalado correctamente." -UNINSTALL_PROCESS="Desinstalando ProxMenu..." - -# Mensajes de script -SCRIPT_RUNNING="Ejecutando script igpu_tpu.sh..." -SCRIPT_SUCCESS="Script ejecutado correctamente." -SCRIPT_ERROR="Error al ejecutar el script." - -# Mensajes de idioma -LANG_SELECT="Seleccionar Idioma" -LANG_PROMPT="Elige tu idioma:" -LANG_ERROR="No se seleccionó ningún idioma. Saliendo..." -LANG_SUCCESS="Idioma seleccionado:" -LANG_LOADED="Idioma cargado:" -LANG_DOWNLOAD="Descargando archivo de idioma..." -LANG_DOWNLOAD_ERROR="Error al descargar el archivo de idioma. Verifica tu conexión a internet." -LANG_EXISTS="Archivo de idioma existe localmente." - -# Mensajes de dependencias -DEPS_INSTALLING="Instalando dependencias necesarias..." -DEPS_SUCCESS="Dependencias instaladas." -DEPS_ERROR="Error al instalar dependencias. Por favor, instala whiptail manualmente." - -# Mensajes bilingües (primera ejecución) -INITIAL_LANG_SELECT="Seleccionar Idioma / Select Language" -INITIAL_LANG_PROMPT="Elige tu idioma / Choose your language:" -INITIAL_LANG_ERROR="No se seleccionó ningún idioma. Saliendo... / No language selected. Exiting..." - -# --- Mensajes para el script de reparación de red (repair_network.sh) --- -REPAIR_MENU_TITLE="Menú de Reparación de Red" -MENU_PROMPT="Por favor, seleccione una opción:" -MENU_REPAIR="Reparar la red" -MENU_VERIFY="Verificar la configuración de red" -MENU_SHOW_IP="Mostrar información de IP" -MENU_EXIT="Salir" -MENU_CANCELED="Operación cancelada por el usuario." -MENU_EXIT_MSG="Saliendo del script de reparación de red. ¡Hasta luego!" -INVALID_OPTION="Opción no válida. Por favor, intente de nuevo." -PRESS_ENTER="Presione Enter para continuar..." -RESULT_TITLE="Resultado de la Operación" -REPAIR_COMPLETED="Reparación de red completada." -VERIFY_COMPLETED="Verificación de red completada." -IP_INFO_COMPLETED="Información de IP mostrada." -NETWORK_ERROR="ERROR" -NETWORK_SUCCESS="ÉXITO" -NETWORK_WARNING="ADVERTENCIA" -NETWORK_PHYSICAL_INTERFACES="Interfaces físicas detectadas" -NETWORK_CONFIGURED_INTERFACES="Interfaces configuradas" -NETWORK_CHECKING_BRIDGES="Verificando configuración de puentes" -NETWORK_BRIDGE_PORT_MISSING="Puerto de puente no existente o no activo" -NETWORK_BRIDGE_PORT_UPDATED="Puerto de puente actualizado" -NETWORK_NO_PHYSICAL_INTERFACE="No se encontró una interfaz física adecuada" -NETWORK_BRIDGE_PORT_OK="Puerto de puente correcto" -NETWORK_CLEANING_INTERFACES="Limpiando configuraciones de interfaces no existentes" -NETWORK_INTERFACE_REMOVED="Interfaz eliminada" -NETWORK_CONFIGURING_INTERFACES="Configurando interfaces" -NETWORK_INTERFACE_ADDED="Interfaz añadida" -NETWORK_RESTARTING="Reiniciando el servicio de red" -NETWORK_RESTART_SUCCESS="Servicio de red reiniciado con éxito" -NETWORK_RESTART_FAILED="Error al reiniciar el servicio de red" -NETWORK_CONNECTIVITY_OK="Conectividad de red OK" -NETWORK_CONNECTIVITY_FAILED="Fallo en la conectividad de red" -NETWORK_IP_INFO="Información de IP" -NETWORK_NO_IP="No IP" -NETWORK_REPAIR_STARTED="Iniciando reparación de red" -NETWORK_REPAIR_COMPLETED="Reparación de red completada" -NETWORK_REPAIR_FAILED="Fallo en la reparación de red" -NETWORK_REPAIR_PROCESS_FINISHED="Proceso de reparación de red finalizado" -NETWORK_VERIFY_STARTED="Iniciando verificación de red" -NETWORK_VERIFY_FINISHED="Verificación de red finalizada" -NETWORK_REPAIR_RUNNING="Ejecutando reparación de red..." -NETWORK_REPAIR_SUCCESS="Reparación de red ejecutada con éxito." -NETWORK_REPAIR_ERROR="Error al ejecutar la reparación de red." -NETWORK_VERIFY_RUNNING="Ejecutando verificación de red..." -NETWORK_VERIFY_SUCCESS="Verificación de red completada con éxito." -NETWORK_VERIFY_ERROR="Error al ejecutar la verificación de red." -NETWORK_IP_INFO_RUNNING="Obteniendo información de IP..." -NETWORK_IP_INFO_SUCCESS="Información de IP obtenida con éxito." -NETWORK_IP_INFO_ERROR="Error al obtener información de IP." -# --- Fin de mensajes para repair_network.sh --- diff --git a/scripts/backup_restore/apply_cluster_postboot.sh b/scripts/backup_restore/apply_cluster_postboot.sh index e38f0c06..6283792c 100755 --- a/scripts/backup_restore/apply_cluster_postboot.sh +++ b/scripts/backup_restore/apply_cluster_postboot.sh @@ -367,6 +367,46 @@ if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then done fi +POSTBOOT_END_EPOCH=$(date +%s) +POSTBOOT_DURATION=$((POSTBOOT_END_EPOCH - $(stat -c %Y "$LOG_FILE"))) +POSTBOOT_DURATION_FMT=$(printf '%dm%02ds' $((POSTBOOT_DURATION / 60)) $((POSTBOOT_DURATION % 60))) + +# ── Notify ProxMenux Monitor that we're done ─────────────────── +# Routes through the user's configured channels (Telegram, Discord, +# ntfy, etc.). Localhost-only endpoint, no auth needed. We try +# briefly — if the Monitor isn't running, just log and move on. +COMPONENTS_REINSTALLED_CSV="" +if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then + COMPONENTS_REINSTALLED_CSV=$( + for entry in "${COMPONENT_INSTALLERS[@]}"; do + comp="${entry%%:*}" + s=$(jq -r ".${comp}.status // \"\"" "$COMPONENTS_STATUS" 2>/dev/null) + [[ "$s" == "installed" ]] && printf '%s,' "$comp" + done | sed 's/,$//' + ) + [[ -z "$COMPONENTS_REINSTALLED_CSV" ]] && COMPONENTS_REINSTALLED_CSV="none" +fi + +if command -v curl >/dev/null 2>&1; then + PAYLOAD=$(printf '{"hostname":"%s","guests":"%s","stubs":"%s","stale_nodes":"%s","components":"%s","duration":"%s"}' \ + "$(hostname)" \ + "${copied_guests:-0}" \ + "${stub_created:-0}" \ + "${removed_nodes:-0}" \ + "${COMPONENTS_REINSTALLED_CSV:-none}" \ + "$POSTBOOT_DURATION_FMT") + NOTIFY_HTTP=$(curl -s -o /dev/null -w '%{http_code}' \ + -X POST "http://127.0.0.1:8008/api/internal/restore-event" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" \ + --max-time 5 2>/dev/null || echo "000") + if [[ "$NOTIFY_HTTP" == "200" ]]; then + echo "Notification sent (HTTP 200)" + else + echo "Notification skipped (Monitor not reachable or disabled — HTTP $NOTIFY_HTTP)" + fi +fi + echo "" -echo "=== Apply finished at $(date -Iseconds) ===" +echo "=== Apply finished at $(date -Iseconds) — total ${POSTBOOT_DURATION_FMT} ===" echo "Log: $LOG_FILE" diff --git a/scripts/backup_restore/backup_host.sh b/scripts/backup_restore/backup_host.sh index 66eda08c..7804c900 100644 --- a/scripts/backup_restore/backup_host.sh +++ b/scripts/backup_restore/backup_host.sh @@ -276,6 +276,38 @@ _bk_local() { dest_dir=$(hb_prompt_dest_dir) || return 1 hb_select_profile_paths "$profile_mode" paths || return 1 + # Safety check: if the destination directory is INSIDE any selected + # backup path, creating the archive would copy the backup into + # itself — recursion → corrupted archive or unbounded growth that + # fills the disk. Common footgun when an operator adds a custom + # path like /var/lib/vz and then picks /var/lib/vz/dump as + # destination, or the default profile's /root and a destination + # under /root/. + local dest_real conflict="" + dest_real=$(readlink -m "$dest_dir" 2>/dev/null || echo "$dest_dir") + local p_real p + for p in "${paths[@]}"; do + p_real=$(readlink -m "$p" 2>/dev/null || echo "$p") + if [[ "$dest_real" == "$p_real" || "$dest_real" == "$p_real"/* ]]; then + conflict="$p" + break + fi + done + if [[ -n "$conflict" ]]; then + local body + body="$(translate "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.")"$'\n\n' + body+="\Zb$(translate "Destination:")\ZB \Z4${dest_dir}\Zn"$'\n' + body+="\Zb$(translate "Conflicting path included in backup:")\ZB \Z1${conflict}\Zn"$'\n\n' + body+="$(translate "To fix this, do ONE of the following:")"$'\n' + body+=" • $(translate "Choose a destination directory OUTSIDE of") ${conflict}"$'\n' + body+=" • $(translate "Go to \"Manage custom paths\" and remove your custom entry that includes the destination")"$'\n' + body+=" • $(translate "Use Custom backup and uncheck the conflicting path from the list")" + dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Backup destination is inside the backup")" \ + --msgbox "$body" 20 88 + return 1 + fi + archive="$dest_dir/hostcfg-$(hostname)-$(date +%Y%m%d_%H%M%S).tar.zst" log_file="/tmp/proxmenux-local-backup-$(date +%Y%m%d_%H%M%S).log" staging_root=$(mktemp -d /tmp/proxmenux-local-stage.XXXXXX) @@ -382,6 +414,194 @@ _bk_scheduler() { bash "$scheduler" } +_bk_manage_local_destinations() { + while true; do + # Snapshot all currently mounted USB backup partitions with size info + local -a usb_mp=() + local -a usb_desc=() + local state path_or_dev label size fstype uuid + while IFS=$'\t' read -r state path_or_dev label size fstype uuid; do + [[ "$state" != "mounted" ]] && continue + local dfline + dfline=$(df -h "$path_or_dev" 2>/dev/null | tail -1) + local used="?" avail="?" pct="?" + if [[ -n "$dfline" ]]; then + used=$(awk '{print $3}' <<<"$dfline") + avail=$(awk '{print $4}' <<<"$dfline") + pct=$(awk '{print $5}' <<<"$dfline") + fi + usb_mp+=("$path_or_dev") + usb_desc+=("${label:-?} [${fstype}] $size → $path_or_dev ($used $(translate "used"), $avail $(translate "free"), $pct)") + done < <(hb_list_usb_partitions) + + local body="" + if (( ${#usb_desc[@]} == 0 )); then + body+="$(translate "No USB drives are currently mounted by ProxMenux.")" + else + body+="\Zb$(translate "Mounted USB drives:")\ZB"$'\n' + local d + for d in "${usb_desc[@]}"; do + body+=" • ${d}"$'\n' + done + fi + body+=$'\n'"$(translate "Local destinations are file paths — they are NOT registered as Proxmox storage.")" + + local -a menu_args=() + menu_args+=("mount" "+ $(translate "Mount a USB drive now")") + if (( ${#usb_mp[@]} > 0 )); then + menu_args+=("unmount" "− $(translate "Unmount a USB drive")") + fi + menu_args+=("back" "$(translate "← Return")") + + local choice + choice=$(dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Local archive destinations")" \ + --menu "\n${body}\n" \ + "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu_args[@]}" \ + 3>&1 1>&2 2>&3) || break + + case "$choice" in + mount) + # Reuse the runtime USB picker; result is discarded. + hb_prompt_mounted_path "/mnt/backup" >/dev/null || true + ;; + unmount) + if (( ${#usb_mp[@]} == 0 )); then + continue + fi + local unmenu=() j=1 mp + for mp in "${usb_mp[@]}"; do + unmenu+=("$j" "$mp"); ((j++)) + done + local pick + pick=$(dialog --backtitle "ProxMenux" \ + --title "$(translate "Unmount USB drive")" \ + --menu "\n$(translate "Pick a drive to unmount:")" \ + "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${unmenu[@]}" \ + 3>&1 1>&2 2>&3) || continue + local victim="${usb_mp[$((pick-1))]}" + if umount "$victim" 2>/tmp/proxmenux-umount.log; then + rmdir "$victim" 2>/dev/null || true + dialog --backtitle "ProxMenux" --colors \ + --msgbox "$(translate "Unmounted") \Z4${victim}\Zn" 8 70 + else + local err + err=$(cat /tmp/proxmenux-umount.log 2>/dev/null) + dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Unmount failed")" \ + --msgbox "$(translate "Could not unmount") \Z1${victim}\Zn.\n\n${err}" 12 78 + fi + ;; + back) break ;; + esac + done +} + +_bk_manage_destinations() { + while true; do + local choice + choice=$(dialog --backtitle "ProxMenux" \ + --title "$(translate "Configure backup destinations")" \ + --menu "\n$(translate "Pre-configure destinations so you don't have to enter them every time you back up.")" \ + "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ + 1 "$(translate "Proxmox Backup Server (PBS) destinations")" \ + 2 "$(translate "Borg repositories")" \ + 3 "$(translate "Local archive destinations (mounted USBs, mount, unmount)")" \ + 0 "$(translate "Return")" \ + 3>&1 1>&2 2>&3) || break + + case "$choice" in + 1) + hb_select_pbs_repository || true + ;; + 2) + local _discard="" + hb_select_borg_repo _discard || true + ;; + 3) + _bk_manage_local_destinations + ;; + 0) break ;; + esac + done +} + +_bk_manage_extra_paths() { + while true; do + local -a paths=() + mapfile -t paths < <(hb_load_extra_paths) + local count=${#paths[@]} + + # Descriptive header for the manage menu. We avoid listing the actual + # paths here — a user with dozens of entries would blow the dialog + # box height and force scrolling. The count is enough; "− Remove a + # path" shows the full list when the user actually needs to see it. + local preview="" + if (( count == 0 )); then + preview="$(hb_translate "You haven't added any custom paths yet.")" + else + preview="$(hb_translate "Currently"): \Zb\Z4${count}\Zn $(hb_translate "custom path(s) saved.")" + fi + preview+=$'\n\n'"$(hb_translate "Custom paths are included in BOTH default and custom backup profiles.")" + + local choice + choice=$(dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Manage custom backup paths")" \ + --menu "\n${preview}\n" \ + "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ + "add" "$(translate "+ Add a path")" \ + "del" "$(translate "− Remove a path")" \ + "back" "$(translate "← Return")" \ + 3>&1 1>&2 2>&3) || break + + case "$choice" in + add) + local new_path + new_path=$(dialog --backtitle "ProxMenux" \ + --title "$(translate "Add custom path")" \ + --inputbox "$(translate "Absolute path to a file or directory you want backed up:")" \ + "$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/root/" 3>&1 1>&2 2>&3) || continue + new_path="${new_path%/}" + [[ -z "$new_path" ]] && continue + if [[ ! -e "$new_path" ]]; then + dialog --backtitle "ProxMenux" --colors \ + --title "$(translate "Path not found")" \ + --msgbox "\Z1${new_path}\Zn\n\n$(translate "does not exist on this host. Path not added.")" 10 70 + continue + fi + hb_add_extra_path "$new_path" + ;; + del) + if (( count == 0 )); then + dialog --backtitle "ProxMenux" --msgbox \ + "$(translate "You haven't added any custom paths yet.")" 8 60 + continue + fi + local del_options=() j=1 p + for p in "${paths[@]}"; do + del_options+=("$j" "$p" "off"); ((j++)) + done + local del_selected + del_selected=$(dialog --backtitle "ProxMenux" \ + --title "$(translate "Remove custom paths")" \ + --default-button ok \ + --separate-output --checklist \ + "\n$(translate "Tick the paths to remove (they will not be deleted from disk — only from this list):")" \ + "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${del_options[@]}" \ + 3>&1 1>&2 2>&3) || continue + # Empty selection → nothing to do + [[ -z "$del_selected" ]] && continue + local sel + while read -r sel; do + [[ -z "$sel" ]] && continue + hb_del_extra_path "${paths[$((sel-1))]}" + done <<< "$del_selected" + ;; + back) break ;; + esac + done +} + backup_menu() { while true; do local choice @@ -397,8 +617,6 @@ backup_menu() { 4 "$(translate "Custom backup to PBS")" \ 5 "$(translate "Custom backup to Borg")" \ 6 "$(translate "Custom backup to local archive")" \ - "" "$(translate "─── Automation ─────────────────────────────────────")" \ - 7 "$(translate "Scheduled backups and retention policies")" \ 0 "$(translate "Return")" \ 3>&1 1>&2 2>&3) || return 0 @@ -409,7 +627,6 @@ backup_menu() { 4) _bk_pbs custom ;; 5) _bk_borg custom ;; 6) _bk_local custom ;; - 7) _bk_scheduler ;; 0) break ;; esac done @@ -600,32 +817,45 @@ _rs_extract_borg() { borg_bin=$(hb_ensure_borg) || return 1 hb_select_borg_repo repo || return 1 + # Same persistence path as backup: per-target pw file + # ($HB_STATE_DIR/borg-pass-.txt), legacy global pw, or + # prompt-once-and-save fallback. Bug fix: the old code only + # honored the legacy global file and re-prompted otherwise, + # defeating the saved-target UX. + hb_prepare_borg_passphrase || return 1 - local pass_file="$HB_STATE_DIR/borg-pass.txt" - if [[ -f "$pass_file" ]]; then - BORG_PASSPHRASE="$(<"$pass_file")" - export BORG_PASSPHRASE - else - BORG_PASSPHRASE=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \ - "$(hb_translate "Borg passphrase (leave empty if not encrypted):")" \ - "$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1 - export BORG_PASSPHRASE - fi - - mapfile -t archives < <( - "$borg_bin" list "$repo" --format '{archive}{NL}' 2>/dev/null | sort -r + # Pull NAME|START in one shot — borg supports strftime via :%fmt + # in --format. Sort newest-first by the ISO timestamp so the most + # recent backup is always on top regardless of archive naming. + local -a archive_lines=() + mapfile -t archive_lines < <( + "$borg_bin" list "$repo" \ + --format '{start:%Y-%m-%d %H:%M:%S}|{archive}{NL}' 2>/dev/null \ + | sort -r ) - if [[ ${#archives[@]} -eq 0 ]]; then + if [[ ${#archive_lines[@]} -eq 0 ]]; then msg_error "$(translate "No archives found in this Borg repository.")" return 1 fi + archives=() + local -a archive_labels=() + local _start _name + for line in "${archive_lines[@]}"; do + _start="${line%%|*}" + _name="${line#*|}" + archives+=("$_name") + # Menu label: ISO datetime first (sortable, fixed width), + # then archive name. Easier to scan when several backups + # ran the same day. + archive_labels+=("${_start} · ${_name}") + done local menu=() i=1 - for archive in "${archives[@]}"; do menu+=("$i" "$archive"); ((i++)); done + for archive in "${archive_labels[@]}"; do menu+=("$i" "$archive"); ((i++)); done local sel sel=$(dialog --backtitle "ProxMenux" \ --title "$(translate "Select archive to restore")" \ - --menu "\n$(translate "Available Borg archives:")" \ + --menu "\n$(translate "Available Borg archives (newest first):")" \ "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \ "${menu[@]}" 3>&1 1>&2 2>&3) || return 1 archive="${archives[$((sel-1))]}" @@ -1182,49 +1412,51 @@ _rs_collect_plan_stats() { _rs_show_plan_summary() { local staging_root="$1" local meta="$staging_root/metadata" - local tmp - tmp=$(mktemp) || return 1 - { - echo "═══ $(translate "Restore plan summary") ═══" - echo "" - if [[ -f "$meta/run_info.env" ]]; then - echo "$(translate "Backup origin metadata:")" - while IFS='=' read -r k v; do - [[ -n "$k" ]] && printf " %-20s %s\n" "${k}:" "$v" - done < "$meta/run_info.env" - echo "" - fi + # dialog --colors only fires inside --msgbox / --yesno / --infobox, not + # --textbox, so we build the body as a string. Color codes match the + # complete-restore confirm dialog for visual consistency. + local body + body="\Zb═══ $(translate "Restore plan summary") ═══\ZB"$'\n\n' - echo "$(translate "Detected paths in this backup:") ${RS_PLAN_TOTAL}" - echo " • $(translate "Safe to apply now"): ${RS_PLAN_HOT}" - echo " • $(translate "Require reboot"): ${RS_PLAN_REBOOT}" - echo " • $(translate "Risky on running system"): ${RS_PLAN_DANGEROUS}" - echo "" + if [[ -f "$meta/run_info.env" ]]; then + body+="\Zb$(translate "Backup origin metadata:")\ZB"$'\n' + while IFS='=' read -r k v; do + [[ -z "$k" ]] && continue + body+="$(printf ' %-20s \Z4%s\Zn' "${k}:" "$v")"$'\n' + done < "$meta/run_info.env" + body+=$'\n' + fi - if [[ "$RS_PLAN_HAS_NETWORK" -eq 1 ]]; then - echo " • $(translate "Includes /etc/network (may drop SSH immediately)")" - fi - if [[ "$RS_PLAN_HAS_CLUSTER" -eq 1 ]]; then - echo " • $(translate "Includes cluster data (/etc/pve, /var/lib/pve-cluster)")" - echo " $(translate "These paths will not be restored live and will be extracted for manual recovery.")" - fi - if [[ "$RS_PLAN_HAS_ZFS" -eq 1 ]]; then - if [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" == "1" ]]; then - echo " • $(translate "Includes /etc/zfs: ENABLED for restore")" - else - echo " • $(translate "Includes /etc/zfs: DISABLED unless you enable it")" - fi - fi - echo "" - echo "$(translate "Recommendation: start with Complete restore (guided — recommended).")" - } > "$tmp" + # Reboot-required and live-unsafe both go to the pending set and + # are applied by the post-boot dispatcher — to the operator they're + # the same bucket "things that complete after reboot". + local _reboot_total=$(( RS_PLAN_REBOOT + RS_PLAN_DANGEROUS )) + body+="\Zb$(translate "Detected paths in this backup:")\ZB \Zb\Z4${RS_PLAN_TOTAL}\Zn"$'\n' + body+=" • $(translate "Safe to apply now"): \Zb\Z4${RS_PLAN_HOT}\Zn"$'\n' + body+=" • $(translate "Require reboot"): \Zb\Z4${_reboot_total}\Zn"$'\n' + body+=$'\n' - dialog --backtitle "ProxMenux" \ + if [[ "$RS_PLAN_HAS_NETWORK" -eq 1 ]]; then + body+=" • $(translate "Includes /etc/network (may drop SSH immediately)")"$'\n' + fi + if [[ "$RS_PLAN_HAS_CLUSTER" -eq 1 ]]; then + body+=" • \Z4$(translate "Includes cluster data (/etc/pve, /var/lib/pve-cluster)")\Zn"$'\n' + body+=" $(translate "These paths will not be restored live and will be extracted for manual recovery.")"$'\n' + fi + if [[ "$RS_PLAN_HAS_ZFS" -eq 1 ]]; then + if [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" == "1" ]]; then + body+=" • $(translate "Includes /etc/zfs"): \Zb$(translate "ENABLED for restore")\ZB"$'\n' + else + body+=" • $(translate "Includes /etc/zfs"): \Zb$(translate "DISABLED unless you enable it")\ZB"$'\n' + fi + fi + body+=$'\n' + body+="\Zb$(translate "Recommendation: start with Complete restore.")\ZB" + + dialog --backtitle "ProxMenux" --colors \ --title "$(translate "Restore plan")" \ - --exit-label "OK" \ - --textbox "$tmp" 24 94 || true - rm -f "$tmp" + --msgbox "$body" 24 94 || true } _rs_prompt_zfs_opt_in() { @@ -1272,6 +1504,72 @@ _rs_finish_flow() { read -r } +# Lists components that the post-boot dispatcher will reinstall in background +# after reboot, by reading the backup's components_status.json. Mirrors the +# COMPONENT_INSTALLERS array in apply_cluster_postboot.sh — keep both in sync. +# Echoes "|