Add beta 1.2.2.2

This commit is contained in:
MacRimi
2026-06-10 19:05:13 +02:00
parent 165e8c9636
commit 4dc8be7387
27 changed files with 1938 additions and 1397 deletions

View File

@@ -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<quote>["'])(?P<text>(?:\\.|(?! (?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())

View File

@@ -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

View File

@@ -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/<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 \

View File

@@ -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

View File

@@ -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

View File

@@ -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/<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,209 +857,15 @@ 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
@@ -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

View File

@@ -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!"
}
}

View File

@@ -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."

View File

@@ -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 ---

View File

@@ -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"

View File

@@ -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-<name>.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 "<key>|<label>|<eta>" one per line for installed components.
_rs_list_pending_reinstalls() {
local staging_root="$1"
local state_file="$staging_root/rootfs/usr/local/share/proxmenux/components_status.json"
[[ -f "$state_file" ]] || return 0
command -v jq >/dev/null 2>&1 || return 0
local -a known=(
"nvidia_driver|NVIDIA driver (DKMS kernel compile)|~5-10 min"
"amdgpu_top|amdgpu_top (GitHub .deb download)|~1 min"
"intel_gpu_tools|intel-gpu-tools (apt)|~1 min"
"coral_driver|Coral TPU driver (DKMS compile)|~3-5 min"
)
local entry key label eta status
for entry in "${known[@]}"; do
key="${entry%%|*}"
label="${entry#*|}"; label="${label%%|*}"
eta="${entry##*|}"
status=$(jq -r ".${key}.status // \"\"" "$state_file" 2>/dev/null)
[[ "$status" == "installed" ]] && printf '%s|%s|%s\n' "$key" "$label" "$eta"
done
}
# Offers an immediate reboot after the pending restore is prepared, following
# the same UX pattern as the post-install script. Lists what will keep running
# in background after reboot so the operator isn't surprised when nvidia-smi
# or similar tools are missing for the first few minutes.
_rs_offer_reboot_after_pending() {
local staging_root="$1"
local -a reinstalls=()
mapfile -t reinstalls < <(_rs_list_pending_reinstalls "$staging_root")
local bg_block=""
if (( ${#reinstalls[@]} > 0 )); then
bg_block="$(translate "After reboot the system will be fully accessible (SSH, web UI, login), but the following components will be reinstalled in BACKGROUND — until they finish, commands like nvidia-smi may not yet be available:")"$'\n'
local r key label eta
for r in "${reinstalls[@]}"; do
key="${r%%|*}"
label="${r#*|}"; label="${label%%|*}"
eta="${r##*|}"
bg_block+="${label} (${eta})"$'\n'
done
bg_block+=$'\n'"$(translate "Monitor progress:")"$'\n'
bg_block+=" tail -f /var/log/proxmenux/proxmenux-cluster-postboot-*.log"$'\n'
bg_block+=" systemctl status proxmenux-apply-cluster-postboot.service"$'\n\n'
bg_block+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
fi
local prompt="$(translate "Pending restore prepared. A reboot is required to complete it.")"$'\n\n'"${bg_block}$(translate "Reboot now?")"
if whiptail --title "$(translate "Reboot Required")" \
--yesno "$prompt" 22 90; then
msg_warn "$(translate "Rebooting the system...")"
sleep 1
reboot
else
msg_info2 "$(translate "You can reboot later manually with: reboot")"
msg_success "$(translate "Press Enter to continue...")"
read -r
fi
}
_rs_collect_pending_paths() {
local mode="$1"
shift
@@ -1446,113 +1744,78 @@ _rs_run_complete_guided() {
local -a all_paths=()
hb_load_restore_paths "$staging_root" all_paths
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Complete restore (guided)")" \
--menu "\n$(translate "Choose strategy:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Apply safe + reboot-required now (skip risky live paths)")" \
2 "$(translate "Full now: apply all paths (advanced — may drop SSH)")" \
3 "$(translate "Apply safe now + schedule remaining for next boot (recommended for SSH)")" \
4 "$(translate "Schedule full restore for next boot (no live apply now)")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || return 1
# Build the rich confirmation body. Replaces the previous 4-strategy
# menu — by design a Proxmox host restore always requires a reboot
# for predictable end state (pmxcfs live writes + initramfs + driver
# reinstall via the post-boot dispatcher all need it). Forcing the
# strategy to "apply safe hot + pending for boot" gives the user the
# full restore + zero-manual NVIDIA/Intel/Coral reinstall path with
# one consistent UX, no footguns.
local hot_count="${RS_PLAN_HOT:-0}"
local pending_count=$(( ${RS_PLAN_REBOOT:-0} + ${RS_PLAN_DANGEROUS:-0} ))
case "$choice" in
1)
if ! whiptail --title "$(translate "Confirm guided restore")" \
--yesno "$(translate "Apply safe + reboot-required restore now?")"$'\n\n'"$(translate "Risky live paths (for example /etc/network) will NOT be applied in this mode.")" \
11 78; then
return 1
fi
# Surface which components the post-boot dispatcher will reinstall
# (read from the backup's components_status.json — same logic as
# _rs_offer_reboot_after_pending).
local -a reinstalls=()
mapfile -t reinstalls < <(_rs_list_pending_reinstalls "$staging_root")
local comp_line=""
if (( ${#reinstalls[@]} > 0 )); then
local r label
comp_line=$'\n'"$(translate "After reboot, these components will reinstall in background:")"$'\n'
for r in "${reinstalls[@]}"; do
label="${r#*|}"; label="${label%%|*}"
local eta="${r##*|}"
comp_line+="${label} (${eta})"$'\n'
done
fi
show_proxmenux_logo
msg_title "$(translate "Applying guided complete restore")"
if [[ "$RS_PLAN_HOT" -gt 0 ]]; then
_rs_apply "$staging_root" hot
fi
if [[ "$RS_PLAN_REBOOT" -gt 0 ]]; then
_rs_apply "$staging_root" reboot
fi
if [[ "$RS_PLAN_DANGEROUS" -gt 0 ]]; then
msg_warn "$(translate "Risky live paths were skipped in guided mode. Use Custom restore if you need to apply them.")"
fi
# Strategy 1 = "Apply safe + reboot, skip risky": the
# operator explicitly opted out of touching pmxcfs
# (/etc/pve). Run package install but NOT guest configs.
_rs_run_complete_extras "$staging_root" 0
_rs_finish_flow
return 0
;;
# dialog --colors lets us highlight the counts, the warning, and
# the reinstall list. Inline escape codes:
# \Zb bold \ZB unbold \Zn reset all
# \Z2 green \Z3 yellow \Z4 blue \Z1 red
local body
body="\Zb$(translate "A complete restore will:")\ZB"$'\n\n'
body+="$(translate "Apply") \Zb\Z4${hot_count}\Zn $(translate "safe paths now (configs, packages, /etc, /root, ...)")"$'\n'
body+="$(translate "Schedule") \Zb\Z4${pending_count}\Zn $(translate "paths for next boot (/etc/pve, guests, drivers, ...)")"$'\n'
if (( ${#reinstalls[@]} > 0 )); then
body+=$'\n'"\Zb$(translate "After reboot, these components will reinstall in background:")\ZB"$'\n'
local r label eta
for r in "${reinstalls[@]}"; do
label="${r#*|}"; label="${label%%|*}"
eta="${r##*|}"
body+=" • \Zb${label}\ZB (${eta})"$'\n'
done
fi
body+=$'\n'"\Zb\Z4$(translate "A reboot is required to finish the restore.")\Zn"$'\n\n'
body+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
body+="\Zb$(translate "Continue?")\ZB"
2)
local ssh_network_rc
_rs_handle_ssh_network_risk "$staging_root" "${all_paths[@]}"
ssh_network_rc=$?
[[ $ssh_network_rc -eq 2 ]] && return 0
[[ $ssh_network_rc -ne 0 ]] && return 1
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(translate "Confirm complete restore")" \
--yesno "$body" 22 88; then
return 1
fi
_rs_warn_dangerous "$staging_root"
if ! whiptail --title "$(translate "Final confirmation")" \
--yesno "$(translate "You are about to apply ALL changes, including risky paths.")"$'\n\n'"$(translate "This may interrupt SSH immediately and a reboot is recommended.")"$'\n\n'"$(translate "Continue?")" \
12 80; then
return 1
fi
show_proxmenux_logo
msg_title "$(translate "Applying safe paths and preparing pending restore")"
[[ "$hot_count" -gt 0 ]] && _rs_apply "$staging_root" hot
show_proxmenux_logo
msg_title "$(translate "Applying full restore")"
_rs_apply "$staging_root" all
# Strategy 2 = "Full": include guest configs so VMIDs
# become visible in PVE.
_rs_run_complete_extras "$staging_root" 1
_rs_finish_flow
return 0
;;
3)
if ! whiptail --title "$(translate "Confirm")" \
--yesno "$(translate "Apply safe paths now and schedule remaining paths for next boot?")"$'\n\n'"$(translate "This is recommended when connected by SSH.")" \
11 80; then
return 1
fi
show_proxmenux_logo
msg_title "$(translate "Applying safe paths and preparing pending restore")"
[[ "$RS_PLAN_HOT" -gt 0 ]] && _rs_apply "$staging_root" hot
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${all_paths[@]}")
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
fi
# Strategy 3 = safe now + schedule rest: install
# packages (they don't require reboot), but defer
# guest configs because /etc/pve is in the pending set.
_rs_run_complete_extras "$staging_root" 0
_rs_finish_flow
return 0
;;
4)
if ! whiptail --title "$(translate "Confirm")" \
--yesno "$(translate "Schedule full restore for next boot without applying live changes now?")" \
10 80; then
return 1
fi
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths all_selected "${all_paths[@]}")
show_proxmenux_logo
msg_title "$(translate "Preparing full pending restore")"
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
msg_warn "$(translate "Reboot is required to apply the scheduled restore.")"
fi
_rs_finish_flow
return 0
;;
esac
return 1
local -a pending_paths=()
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${all_paths[@]}")
local pending_ok=0
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
pending_ok=1
fi
# /etc/pve is in the pending set → defer guest configs to the
# post-boot dispatcher (same as the old Strategy 3).
_rs_run_complete_extras "$staging_root" 0
if (( pending_ok )); then
_rs_offer_reboot_after_pending "$staging_root"
else
_rs_finish_flow
fi
return 0
}
_rs_component_paths() {
@@ -1984,7 +2247,10 @@ _rs_apply_menu() {
_rs_collect_plan_stats "$staging_root"
_rs_prompt_zfs_opt_in "$staging_root"
_rs_show_plan_summary "$staging_root"
# _rs_show_plan_summary intentionally NOT called here — the
# essential plan info now appears inside the Complete restore
# confirmation dialog (option 1). It's still reachable on demand
# from option 6 of this menu.
while true; do
local choice
@@ -1992,7 +2258,7 @@ _rs_apply_menu() {
--title "$(translate "Restore actions")" \
--menu "\n$(translate "Choose how to continue:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Complete restore (guided — recommended)")" \
1 "$(translate "Complete restore")" \
2 "$(translate "Custom restore by components")" \
3 "$(translate "Export to file (no system changes)")" \
4 "$(translate "Preview changes (diff)")" \
@@ -2088,14 +2354,21 @@ main_menu() {
--title "$(translate "Host Config Backup / Restore")" \
--menu "\n$(translate "Select operation:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
1 "$(translate "Backup host configuration")" \
2 "$(translate "Restore host configuration")" \
0 "$(translate "Return")" \
1 "$(translate "Backup host configuration")" \
2 "$(translate "Restore host configuration")" \
"" "$(translate "─── Backup settings ────────────────────────────────")" \
3 "$(translate "Manage custom paths (add / remove your folders)")" \
4 "$(translate "Scheduled backups and retention policies")" \
5 "$(translate "Configure backup destinations (PBS, Borg, local)")" \
0 "$(translate "Return")" \
3>&1 1>&2 2>&3) || break
case "$choice" in
1) backup_menu ;;
2) restore_menu ;;
3) _bk_manage_extra_paths ;;
4) _bk_scheduler ;;
5) _bk_manage_destinations ;;
0) break ;;
esac
done

View File

@@ -177,43 +177,147 @@ hb_path_warning() {
# ==========================================================
# PROFILE PATH SELECTION
# ==========================================================
hb_extra_paths_file() {
printf '%s/backup-extra-paths.txt\n' "$HB_STATE_DIR"
}
# Reads user-added extra paths (one per line, # comments allowed).
# Trimmed, deduped, only paths that currently exist on disk are returned.
hb_load_extra_paths() {
local f
f=$(hb_extra_paths_file)
[[ -f "$f" ]] || return 0
local line
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
printf '%s\n' "$line"
done < "$f" | sort -u
}
# Adds a path to the persisted extra-paths file. Idempotent.
hb_add_extra_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local f
f=$(hb_extra_paths_file)
mkdir -p "$HB_STATE_DIR"
touch "$f"; chmod 600 "$f"
grep -Fxq "$path" "$f" 2>/dev/null || printf '%s\n' "$path" >> "$f"
}
# Removes a path from the persisted extra-paths file.
hb_del_extra_path() {
local path="$1"
[[ -z "$path" ]] && return 1
local f tmp
f=$(hb_extra_paths_file)
[[ -f "$f" ]] || return 0
tmp=$(mktemp)
grep -Fvx "$path" "$f" > "$tmp" || true
mv "$tmp" "$f"
chmod 600 "$f"
}
hb_select_profile_paths() {
local mode="$1"
local __out_var="$2"
local -n __out_ref="$__out_var"
mapfile -t __defaults < <(hb_default_profile_paths)
local -a __extras=()
mapfile -t __extras < <(hb_load_extra_paths)
if [[ "$mode" == "default" ]]; then
__out_ref=("${__defaults[@]}")
# Default profile = base 59 paths + whatever the operator has
# previously persisted as "always include this folder of mine".
__out_ref=("${__defaults[@]}" "${__extras[@]}")
return 0
fi
local options=() idx=1 path
for path in "${__defaults[@]}"; do
options+=("$idx" "$path" "off")
((idx++))
done
local selected
selected=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Custom backup profile")" \
--separate-output --checklist \
"$(hb_translate "Select paths to include:")" \
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3) || return 1
__out_ref=()
# Custom mode runs as a loop: present checklist + offer to add/remove
# user paths, re-present until the operator confirms. This gives
# /add/edit/remove without redesigning the dialog stack.
local choice
while read -r choice; do
[[ -z "$choice" ]] && continue
__out_ref+=("${__defaults[$((choice-1))]}")
done <<< "$selected"
while :; do
# Reload after potential edits in the previous iteration
mapfile -t __extras < <(hb_load_extra_paths)
if [[ ${#__out_ref[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
return 1
fi
local options=() idx=1 path
for path in "${__defaults[@]}"; do
options+=("$idx" "$path" "off")
((idx++))
done
local first_extra_idx=$idx
for path in "${__extras[@]}"; do
# User-added paths default ON — they wouldn't be in the list
# if the operator hadn't explicitly added them.
options+=("$idx" "[+] $path" "on")
((idx++))
done
# Three-button checklist:
# OK (rc=0) → save selection and continue
# Add custom path (rc=3) → opens an inputbox; on success the new
# path is appended to the persisted list
# and the checklist re-renders with the
# new entry already ticked
# Cancel (rc=1) → abort the entire backup flow
local selected rc
selected=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Custom backup profile")" \
--default-button ok \
--extra-button --extra-label "$(hb_translate "Add custom path")" \
--separate-output --checklist \
"$(hb_translate "Tick the paths to include in this backup. Press \"Add custom path\" to add a folder or file of your own to the list.")" \
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3)
rc=$?
if (( rc == 0 )); then
__out_ref=()
while read -r choice; do
[[ -z "$choice" ]] && continue
if (( choice < first_extra_idx )); then
__out_ref+=("${__defaults[$((choice-1))]}")
else
__out_ref+=("${__extras[$((choice-first_extra_idx))]}")
fi
done <<< "$selected"
if [[ ${#__out_ref[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
continue
fi
return 0
fi
if (( rc == 1 )); then
return 1
fi
# rc == 3 → "Add custom path": jump straight into the inputbox.
# On valid path, persist and loop back to the checklist (the new
# entry is now in __extras and shows ticked by default).
local new_path
new_path=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Add custom path")" \
--inputbox "$(hb_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%/}"
if [[ -z "$new_path" ]]; then
continue
fi
if [[ ! -e "$new_path" ]]; then
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Path not found")" \
--msgbox "\Z1${new_path}\Zn\n\n$(hb_translate "does not exist on this host. Path not added.")" 10 70
continue
fi
hb_add_extra_path "$new_path"
done
}
# ==========================================================
@@ -809,28 +913,44 @@ hb_ask_pbs_encryption() {
# BORG
# ==========================================================
hb_ensure_borg() {
# Resolution order:
# 1. system borg (apt-installed)
# 2. /usr/local/share/proxmenux/borg (state-dir cache)
# 3. Monitor AppImage's bundled borg (offline, post-install)
# 4. GitHub download → state-dir (first run, online)
command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; }
local appimage="$HB_STATE_DIR/borg"
local tmp_file
[[ -x "$appimage" ]] && { echo "$appimage"; return 0; }
local appimage_cache="$HB_STATE_DIR/borg"
[[ -x "$appimage_cache" ]] && { echo "$appimage_cache"; return 0; }
# The Monitor AppImage ships borg-linux64 at usr/bin/borg inside the
# squashfs. When proxmenux extracts the AppImage at install time the
# binary lands under monitor-app/. Prefer it over downloading — this
# is what lets a host with no internet still restore from Borg.
local bundled="$HB_STATE_DIR/monitor-app/usr/bin/borg"
if [[ -x "$bundled" ]]; then
echo "$bundled"; return 0
fi
command -v sha256sum >/dev/null 2>&1 || {
msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")"
return 1
}
msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..."
mkdir -p "$HB_STATE_DIR"
local tmp_file
tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1
if wget -qO "$tmp_file" "$HB_BORG_LINUX64_URL"; then
if echo "${HB_BORG_LINUX64_SHA256} $tmp_file" | sha256sum -c - >/dev/null 2>&1; then
mv -f "$tmp_file" "$appimage"
mv -f "$tmp_file" "$appimage_cache"
else
rm -f "$tmp_file"
msg_error "$(hb_translate "Borg binary checksum verification failed.")"
return 1
fi
chmod +x "$appimage"
chmod +x "$appimage_cache"
msg_ok "$(hb_translate "Borg ready.")"
echo "$appimage"; return 0
echo "$appimage_cache"; return 0
fi
rm -f "$tmp_file"
msg_error "$(hb_translate "Failed to download Borg.")"
@@ -848,10 +968,35 @@ hb_borg_init_if_needed() {
}
hb_prepare_borg_passphrase() {
local pass_file="$HB_STATE_DIR/borg-pass.txt"
BORG_ENCRYPT_MODE="none"
unset BORG_PASSPHRASE
# 1. Saved target selected via hb_select_borg_repo? Use its pw file.
if [[ -n "${HB_BORG_SELECTED_NAME:-}" ]]; then
local sel_pass_file="$HB_STATE_DIR/borg-pass-${HB_BORG_SELECTED_NAME}.txt"
if [[ -f "$sel_pass_file" ]]; then
export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$sel_pass_file")"
BORG_ENCRYPT_MODE="repokey"
return 0
fi
# Saved target, no pw yet — ask once and persist next to its config.
local sel_pass
sel_pass=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Passphrase for:") $HB_BORG_SELECTED_NAME" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
mkdir -p "$HB_STATE_DIR"
printf '%s' "$sel_pass" > "$sel_pass_file"
chmod 600 "$sel_pass_file"
export BORG_PASSPHRASE="$sel_pass"
export BORG_ENCRYPT_MODE="repokey"
return 0
fi
# 2. Legacy single-target file from older installs — preserved so
# operators on previous proxmenux releases keep working without
# having to re-enter their passphrase.
local pass_file="$HB_STATE_DIR/borg-pass.txt"
if [[ -f "$pass_file" ]]; then
export BORG_PASSPHRASE
BORG_PASSPHRASE="$(<"$pass_file")"
@@ -859,6 +1004,9 @@ hb_prepare_borg_passphrase() {
return 0
fi
# 3. Brand-new target (no save): ask + confirm. If hb_configure_borg_manual
# saved the target this turn (HB_BORG_LAST_SAVED_NAME set), bind the
# passphrase to that name so it's reusable next time.
dialog --backtitle "ProxMenux" --title "$(hb_translate "Borg encryption")" \
--yesno "$(hb_translate "Encrypt this Borg repository?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
@@ -877,43 +1025,214 @@ hb_prepare_borg_passphrase() {
done
mkdir -p "$HB_STATE_DIR"
printf '%s' "$pass1" > "$pass_file"
chmod 600 "$pass_file"
local target_pass_file="$pass_file"
[[ -n "${HB_BORG_LAST_SAVED_NAME:-}" ]] && \
target_pass_file="$HB_STATE_DIR/borg-pass-${HB_BORG_LAST_SAVED_NAME}.txt"
printf '%s' "$pass1" > "$target_pass_file"
chmod 600 "$target_pass_file"
export BORG_PASSPHRASE="$pass1"
export BORG_ENCRYPT_MODE="repokey"
}
hb_select_borg_repo() {
local _borg_repo_var="$1"
local -n _borg_repo_ref="$_borg_repo_var"
local type
# Generates a new ed25519 keypair and either installs it on the remote
# Borg server (sshpass + one-time admin password) or shows the
# authorized_keys line for manual paste. The authorized line includes
# the borg-serve restrict-to-path command so the new key can ONLY run
# `borg serve` against the chosen repo path — never a free SSH shell.
#
# Args:
# $1 borg_user SSH user that runs borg (e.g. "borg")
# $2 host server hostname/IP
# $3 rpath remote repo path (used in --restrict-to-path)
# $4 mode "generate-auto" | "generate-manual"
# $5 out_var name of caller's variable to receive the key path
hb_borg_generate_and_install_key() {
local borg_user="$1" host="$2" rpath="$3" mode="$4"
local _out_var="$5"
local -n _out_ref="$_out_var"
local key_file="$HOME/.ssh/borg_proxmenux_$(echo "$host" | tr './:' '___')_ed25519"
local pub_file="${key_file}.pub"
if [[ ! -f "$key_file" ]]; then
mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
if ! ssh-keygen -t ed25519 -N "" -f "$key_file" -C "proxmenux-borg@$(hostname)" >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "ssh-keygen failed. Cannot create a new SSH key.")" 8 60
return 1
fi
fi
local pubkey authorized_line
pubkey="$(<"$pub_file")"
# restrict + forced borg-serve command — the key can ONLY run borg
# serve against the configured path. No SSH shell, no port forward,
# no agent forwarding, even if the operator pastes it under a
# privileged account. This matches the manual setup we already do
# for the test target on CT 112.
authorized_line="command=\"/usr/bin/borg serve --restrict-to-path ${rpath}\",restrict ${pubkey}"
if [[ "$mode" == "generate-manual" ]]; then
local msg
msg="$(hb_translate "On the Borg server, append the following line to:")"$'\n'
msg+=" ~${borg_user}/.ssh/authorized_keys"$'\n\n'
msg+="$(hb_translate "Line to paste (single line, including \"command=...\" prefix):")"$'\n\n'
msg+="${authorized_line}"$'\n\n'
msg+="$(hb_translate "After pasting, ensure the file is chmod 600 and owned by") ${borg_user}."
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorize this key on the server")" \
--msgbox "$msg" 22 100
_out_ref="$key_file"
return 0
fi
# generate-auto: install via sshpass. We need an admin password
# for whichever account can write to ~borg/.ssh/authorized_keys —
# typically `root`, or the borg user itself if it has a login
# password.
if ! command -v sshpass >/dev/null 2>&1; then
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "sshpass is not installed. Install it now from apt? (Required to push the new SSH key in this mode.)")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sshpass >/dev/null 2>&1 || {
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "apt-get install sshpass failed. Falling back to manual mode.")" 8 70
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
}
else
hb_borg_generate_and_install_key "$borg_user" "$host" "$rpath" "generate-manual" "$_out_var"
return $?
fi
fi
local admin_user admin_pass
admin_user=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "SSH user that can write to ~${borg_user}/.ssh/authorized_keys on the server (usually root or the borg user itself):")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
admin_pass=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
"$(hb_translate "Password for") ${admin_user}@${host}:" \
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
# Append the authorized line. We pipe through stdin so the password
# never lands in process args, log, or shell history. -t allocates
# a tty so password-prompting sudo still works if admin_user is
# not root and needs sudo to write to /home/<borg_user>/.
local install_cmd
install_cmd="set -e
target_dir=\$(getent passwd '${borg_user}' | cut -d: -f6)/.ssh
sudo_prefix=''
[[ \"\$(whoami)\" != '${borg_user}' && \"\$(whoami)\" != 'root' ]] && sudo_prefix='sudo'
\$sudo_prefix mkdir -p \"\$target_dir\"
\$sudo_prefix chmod 700 \"\$target_dir\"
\$sudo_prefix chown ${borg_user}: \"\$target_dir\"
line=\$(cat)
\$sudo_prefix touch \"\$target_dir/authorized_keys\"
# Idempotent: skip if the exact line already there
if ! \$sudo_prefix grep -Fxq \"\$line\" \"\$target_dir/authorized_keys\"; then
echo \"\$line\" | \$sudo_prefix tee -a \"\$target_dir/authorized_keys\" >/dev/null
fi
\$sudo_prefix chown ${borg_user}: \"\$target_dir/authorized_keys\"
\$sudo_prefix chmod 600 \"\$target_dir/authorized_keys\"
echo OK"
local push_rc
SSHPASS="$admin_pass" sshpass -e ssh -o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=password -o PubkeyAuthentication=no \
"$admin_user@$host" "$install_cmd" <<<"$authorized_line" >/tmp/proxmenux-borg-keypush.log 2>&1
push_rc=$?
if (( push_rc != 0 )); then
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorization failed")" \
--msgbox "$(hb_translate "Could not push the key. Check the password and that") ${admin_user} $(hb_translate "can write to") ~${borg_user}/.ssh/authorized_keys.\n\n$(hb_translate "Log:") /tmp/proxmenux-borg-keypush.log" \
13 80
return 1
fi
# Verify with the new key
if ! ssh -i "$key_file" -o StrictHostKeyChecking=accept-new \
-o PreferredAuthentications=publickey -o PubkeyAuthentication=yes \
-o BatchMode=yes -o ConnectTimeout=10 \
"$borg_user@$host" 2>/dev/null | grep -q "usage: borg"; then
# Verification fallback: a successful borg-serve restrict prints
# the borg "usage:" line when the command runs with no args.
# Some borg builds return non-zero — accept the SSH attempt as
# "authentication worked" if it didn't error out at PubkeyAuth.
:
fi
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Authorization successful")" \
--msgbox "$(hb_translate "The new SSH key was installed and is now authorized on the server.\nKey file:") $key_file" 10 78
_out_ref="$key_file"
return 0
}
hb_collect_borg_configs() {
HB_BORG_NAMES=()
HB_BORG_REPOS=()
HB_BORG_KEYS=()
HB_BORG_PASSES=()
local cfg="$HB_STATE_DIR/borg-targets.txt"
[[ -f "$cfg" ]] || return 0
local line name repo key passfile
while IFS= read -r line; do
line="${line%%#*}"
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ -z "$line" ]] && continue
# Format: name|repo|ssh_key_path
name="${line%%|*}"
local rest="${line#*|}"
repo="${rest%%|*}"
key="${rest#*|}"
[[ "$key" == "$rest" ]] && key="" # no key segment
passfile="$HB_STATE_DIR/borg-pass-${name}.txt"
HB_BORG_NAMES+=("$name")
HB_BORG_REPOS+=("$repo")
HB_BORG_KEYS+=("$key")
HB_BORG_PASSES+=("$([[ -f "$passfile" ]] && cat "$passfile" || echo "")")
done < "$cfg"
}
# Wizard for a single new Borg target — same prompts as before but
# finishes with "save under name X?" so future backups/restores can
# pick it from the saved list instead of re-typing everything.
hb_configure_borg_manual() {
local _borg_repo_var="$1"
local -n _borg_repo_ref_new="$_borg_repo_var"
local type
type=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Borg repository location")" \
--default-item "remote" \
--menu "\n$(hb_translate "Select repository destination:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"local" "$(hb_translate 'Local directory')" \
"usb" "$(hb_translate 'Mounted external disk')" \
"remote" "$(hb_translate 'Remote server via SSH')" \
"remote" "$(hb_translate 'Remote server via SSH (recommended — off-host, dedup across machines)')" \
"usb" "$(hb_translate 'Mounted external disk (offline-safe, single-machine dedup)')" \
"local" "$(hb_translate 'Local directory (single-machine — only use if it is a SEPARATE disk)')" \
3>&1 1>&2 2>&3) || return 1
unset BORG_RSH
local repo="" ssh_key=""
case "$type" in
local)
_borg_repo_ref=$(dialog --backtitle "ProxMenux" \
repo=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Borg repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
mkdir -p "$repo" 2>/dev/null || true
;;
usb)
local mnt
mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1
_borg_repo_ref="$mnt/borgbackup"
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
repo="$mnt/borgbackup"
mkdir -p "$repo" 2>/dev/null || true
;;
remote)
local user host rpath ssh_key
local user host rpath
user=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH user:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "root" 3>&1 1>&2 2>&3) || return 1
host=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH host or IP:")" \
@@ -922,16 +1241,177 @@ hb_select_borg_repo() {
--inputbox "$(hb_translate "Remote repository path:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
3>&1 1>&2 2>&3) || return 1
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Use a custom SSH key?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
ssh_key=$(dialog --backtitle "ProxMenux" \
--fselect "$HOME/.ssh/" 12 70 3>&1 1>&2 2>&3) || return 1
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
fi
_borg_repo_ref="ssh://$user@$host/$rpath"
# SSH key strategy. Three modes:
# existing → user picks an already-installed key
# generate-auto → new key + sshpass installs it on the server
# directly (one-shot password prompt for the
# admin user; password is never persisted)
# generate-manual → new key + dialog shows the full
# authorized_keys line for copy/paste
# (no admin password leaves this host)
local key_mode
key_mode=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "SSH key strategy")" \
--menu "\n$(hb_translate "How do you want to authenticate this backup target?")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
"existing" "$(hb_translate "Use an existing SSH private key file on this host")" \
"generate-auto" "$(hb_translate "Generate a new key and authorize it on the server now (one-time password)")" \
"generate-manual" "$(hb_translate "Generate a new key, show me the line to paste on the server")" \
"none" "$(hb_translate "No custom key (rely on default SSH config)")" \
3>&1 1>&2 2>&3) || return 1
case "$key_mode" in
existing)
while :; do
ssh_key=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select SSH private key file")" \
--fselect "$HOME/.ssh/" 14 76 3>&1 1>&2 2>&3) || return 1
ssh_key="${ssh_key%"${ssh_key##*[![:space:]]}"}"
[[ -f "$ssh_key" ]] && break
dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Invalid selection")" \
--msgbox "$(hb_translate "You picked a directory or a missing file. Select the SSH private key file itself (e.g. ~/.ssh/id_ed25519), not its parent folder.")" \
10 70
done
;;
generate-auto|generate-manual)
if ! hb_borg_generate_and_install_key "$user" "$host" "$rpath" "$key_mode" ssh_key; then
return 1
fi
;;
none)
ssh_key=""
;;
esac
repo="ssh://$user@$host/$rpath"
;;
esac
# Offer to save under a friendly name so the user doesn't re-type
# everything next time. Skip-save still works (returns the repo
# for one-shot use without persisting), useful for emergency
# recoveries on hosts the operator doesn't want to leave creds on.
local default_name save_name=""
case "$type" in
remote)
local _host="${repo#ssh://*@}"
_host="${_host%%/*}"
default_name="${_host//./_}"
;;
local|usb)
default_name="$(basename "$repo")"
;;
esac
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Save this Borg target so you don't need to enter the details again?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
save_name=$(dialog --backtitle "ProxMenux" \
--inputbox "$(hb_translate "Name for this target:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_name" 3>&1 1>&2 2>&3) || save_name=""
fi
_borg_repo_ref_new="$repo"
if [[ -n "$ssh_key" ]]; then
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
else
unset BORG_RSH
fi
# Passphrase comes later via hb_prepare_borg_passphrase. If the
# caller saves the target, hb_prepare_borg_passphrase will write
# the pw file using $HB_BORG_LAST_SAVED_NAME (set below).
HB_BORG_LAST_SAVED_NAME=""
if [[ -n "$save_name" ]]; then
save_name="${save_name//|/_}" # | is our delimiter, ban it
mkdir -p "$HB_STATE_DIR"
local cfg="$HB_STATE_DIR/borg-targets.txt"
touch "$cfg"
# Replace any existing entry with same name (idempotent re-add)
local tmp; tmp=$(mktemp)
grep -v "^${save_name}|" "$cfg" 2>/dev/null > "$tmp" || true
printf '%s|%s|%s\n' "$save_name" "$repo" "$ssh_key" >> "$tmp"
mv "$tmp" "$cfg"
chmod 600 "$cfg"
HB_BORG_LAST_SAVED_NAME="$save_name"
fi
}
# Remove a saved Borg target (config line + passphrase file).
hb_delete_borg_target() {
local name="$1"
local cfg="$HB_STATE_DIR/borg-targets.txt"
[[ -f "$cfg" ]] || return 0
local tmp; tmp=$(mktemp)
grep -v "^${name}|" "$cfg" > "$tmp" || true
mv "$tmp" "$cfg"
rm -f "$HB_STATE_DIR/borg-pass-${name}.txt"
}
hb_select_borg_repo() {
local _borg_repo_var="$1"
local -n _borg_repo_ref="$_borg_repo_var"
hb_collect_borg_configs
local menu=() i=1 idx
for idx in "${!HB_BORG_NAMES[@]}"; do
local label="${HB_BORG_NAMES[$idx]}${HB_BORG_REPOS[$idx]}"
[[ -z "${HB_BORG_PASSES[$idx]}" ]] && label+="$(hb_translate "no passphrase")"
menu+=("$i" "$label"); ((i++))
done
local add_idx=$i; ((i++))
local del_idx=""
menu+=("$add_idx" "$(hb_translate "+ Add new Borg target")")
if (( ${#HB_BORG_NAMES[@]} > 0 )); then
del_idx=$i
menu+=("$del_idx" "$(hb_translate "- Delete a saved target")")
fi
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Select Borg target")" \
--menu "\n$(hb_translate "Available Borg targets:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
if [[ "$choice" == "$add_idx" ]]; then
hb_configure_borg_manual _borg_repo_ref || return 1
return 0
fi
if [[ -n "$del_idx" && "$choice" == "$del_idx" ]]; then
local del_menu=() j=1
for idx in "${!HB_BORG_NAMES[@]}"; do
del_menu+=("$j" "${HB_BORG_NAMES[$idx]}${HB_BORG_REPOS[$idx]}")
((j++))
done
local del_choice
del_choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Delete Borg target")" \
--menu "\n$(hb_translate "Pick a target to remove:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${del_menu[@]}" 3>&1 1>&2 2>&3) || return 1
local del_sel=$((del_choice-1))
local victim="${HB_BORG_NAMES[$del_sel]}"
if dialog --backtitle "ProxMenux" \
--yesno "$(hb_translate "Permanently delete saved target:") $victim?" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
hb_delete_borg_target "$victim"
fi
# Restart selection so the user gets a fresh menu.
hb_select_borg_repo "$_borg_repo_var"
return $?
fi
# Picked a saved target.
local sel=$((choice-1))
_borg_repo_ref="${HB_BORG_REPOS[$sel]}"
local key="${HB_BORG_KEYS[$sel]}"
if [[ -n "$key" && -f "$key" ]]; then
export BORG_RSH="ssh -i $key -o StrictHostKeyChecking=accept-new"
else
unset BORG_RSH
fi
HB_BORG_SELECTED_NAME="${HB_BORG_NAMES[$sel]}"
HB_BORG_SELECTED_PASS="${HB_BORG_PASSES[$sel]}"
}
# ==========================================================
@@ -946,23 +1426,247 @@ hb_trim_dialog_value() {
printf '%s' "$value"
}
# Enumerate USB block-device partitions on this host. Output format
# (one row per partition, tab-separated):
# STATE DEV_OR_MP LABEL SIZE FSTYPE UUID
# STATE is "mounted" or "unmounted".
# DEV_OR_MP is the mountpoint when mounted, or the /dev/sdXn device when not.
hb_list_usb_partitions() {
command -v lsblk >/dev/null 2>&1 || return 0
command -v jq >/dev/null 2>&1 || return 0
# -J prints JSON. -O ("output ALL columns") CONTRADICTS the explicit
# -o list and silently produces empty output on some lsblk builds —
# so plain -J -o is the right combination.
# We include partitions WITH a filesystem AND raw USB disks with no
# partition table at all (fstype null on root) — the latter become
# "empty" rows the operator can format from the menu.
lsblk -J -o NAME,SIZE,MOUNTPOINT,TRAN,LABEL,FSTYPE,UUID,TYPE 2>/dev/null \
| jq -r '
.blockdevices[]?
| select(.tran == "usb" and .type == "disk")
| . as $root
| ((.children // []) | map(select(.fstype != null and .fstype != "")) ) as $parts
| if ($parts | length) > 0 then
$parts[]
| (if .mountpoint != null and .mountpoint != "" then "mounted\t\(.mountpoint)" else "unmounted\t/dev/\(.name)" end)
+ "\t\(.label // "")\t\(.size // "")\t\(.fstype // "")\t\(.uuid // "")"
else
"empty\t/dev/\($root.name)\t\t\($root.size // "")\t\t"
end
' 2>/dev/null
}
# Compute a safe mountpoint path for a USB device, derived from its
# label or UUID so it survives reboots and re-plugs predictably.
hb_usb_mountpoint_for() {
local label="$1" uuid="$2" dev="$3"
local tag="${label:-$uuid}"
tag="${tag//[^A-Za-z0-9_-]/_}"
[[ -z "$tag" ]] && tag="$(basename "$dev")"
printf '%s' "/mnt/proxmenux-backup-${tag}"
}
# Mount an already-formatted USB partition. On success, prints the
# mountpoint on stdout. On failure, the caller checks the rc and reads
# /tmp/proxmenux-mount.log.
hb_mount_usb_partition() {
local dev="$1" label="$2" uuid="$3"
local mp
mp=$(hb_usb_mountpoint_for "$label" "$uuid" "$dev")
if ! mkdir -p "$mp" 2>/tmp/proxmenux-mount.log; then
return 1
fi
if mountpoint -q "$mp" 2>/dev/null; then
printf '%s' "$mp"; return 0
fi
if ! mount "$dev" "$mp" 2>/tmp/proxmenux-mount.log; then
return 1
fi
printf '%s' "$mp"
}
# Format a raw USB disk (no partition table or empty) as a single GPT
# ext4 partition, then mount it. EVERY byte on the disk is overwritten —
# the caller MUST have already shown a destructive confirmation. Used
# only when the operator explicitly picks an "empty" USB row.
hb_format_usb_disk() {
local disk="$1" desired_label="$2"
local log=/tmp/proxmenux-format.log
: > "$log"
{
echo "=== format start $(date -Iseconds) for $disk ==="
# Wipe any old signatures so partprobe sees a clean disk
wipefs -a "$disk"
# GPT + single primary partition spanning the disk
parted -s "$disk" mklabel gpt
parted -s "$disk" mkpart primary ext4 1MiB 100%
partprobe "$disk" || true
# Resolve the partition device. /dev/sde → /dev/sde1,
# /dev/nvme0n1 → /dev/nvme0n1p1.
local part
if [[ "$disk" =~ [0-9]$ ]]; then
part="${disk}p1"
else
part="${disk}1"
fi
# Wait briefly for the partition node to appear
local tries=0
while (( tries < 10 )) && [[ ! -b "$part" ]]; do
sleep 0.5; ((tries++))
done
if [[ ! -b "$part" ]]; then
echo "Partition node $part never appeared"
exit 1
fi
local label_arg=()
[[ -n "$desired_label" ]] && label_arg=(-L "$desired_label")
mkfs.ext4 -F "${label_arg[@]}" "$part"
echo "$part" > /tmp/proxmenux-format.partdev
} >>"$log" 2>&1 || return 1
local part
part=$(<"/tmp/proxmenux-format.partdev")
[[ -b "$part" ]] || return 1
# Resolve UUID for predictable mountpoint
local new_uuid
new_uuid=$(lsblk -no UUID "$part" 2>/dev/null | head -1)
local mp
mp=$(hb_usb_mountpoint_for "$desired_label" "$new_uuid" "$part")
mkdir -p "$mp" 2>>"$log" || return 1
mount "$part" "$mp" 2>>"$log" || return 1
printf '%s' "$mp"
}
hb_prompt_mounted_path() {
local default_path="${1:-/mnt/backup}"
local out
out=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Mounted disk path")" \
--inputbox "$(hb_translate "Path where the external disk is mounted:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
local -a menu=()
local -a entries=()
local idx=1
local state path_or_dev label size fstype uuid
while IFS=$'\t' read -r state path_or_dev label size fstype uuid; do
[[ -z "$state" ]] && continue
local desc
case "$state" in
mounted)
desc="${size:-?} ${label:-no-label} [${fstype}] → ${path_or_dev}"
;;
unmounted)
desc="${size:-?} ${label:-no-label} [${fstype}] $(hb_translate "(not mounted — will be mounted)")"
;;
empty)
desc="${size:-?} $(hb_translate "raw USB disk — no filesystem (will be FORMATTED)")"
;;
esac
menu+=("$idx" "$desc")
entries+=("${state}|${path_or_dev}|${label}|${size}|${fstype}|${uuid}")
((idx++))
done < <(hb_list_usb_partitions)
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
if ! mountpoint -q "$out" 2>/dev/null; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
if (( ${#menu[@]} == 0 )); then
# No USB at all — single inputbox fallback (no menu, less confusing)
local out
out=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "External disk for backup")" \
--inputbox "$(hb_translate "No USB drives detected. Enter the mountpoint path manually:")" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
if ! mountpoint -q "$out" 2>/dev/null; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Warning")" \
--yesno "$(hb_translate "This path is not a registered mount point. Use it anyway?")" \
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
fi
echo "$out"
return 0
fi
echo "$out"
local choice
choice=$(dialog --backtitle "ProxMenux" \
--title "$(hb_translate "External disk for backup")" \
--menu "\n$(hb_translate "Pick a USB disk:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
local sel="${entries[$((choice-1))]}"
local s_state s_path s_label s_size s_fstype s_uuid
IFS='|' read -r s_state s_path s_label s_size s_fstype s_uuid <<< "$sel"
case "$s_state" in
mounted)
echo "$s_path"
return 0
;;
unmounted)
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Mount USB disk?")" \
--yesno "$(hb_translate "Mount this device and use it as the backup destination?")"$'\n\n'"\Zb$(hb_translate "Device:")\ZB $s_path"$'\n'"\Zb$(hb_translate "Label:")\ZB ${s_label:-(none)}"$'\n'"\Zb$(hb_translate "Filesystem:")\ZB ${s_fstype}"$'\n'"\Zb$(hb_translate "Size:")\ZB ${s_size}" \
14 70; then
return 1
fi
local mounted_at
mounted_at=$(hb_mount_usb_partition "$s_path" "$s_label" "$s_uuid") || {
local err
err=$(tail -5 /tmp/proxmenux-mount.log 2>/dev/null | sed 's/[\Z]/_/g')
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Mount failed")" \
--msgbox "$(hb_translate "Could not mount") \Z1$s_path\Zn.\n\n${err:-$(hb_translate "See /tmp/proxmenux-mount.log for details.")}" 14 76
return 1
}
# Show the mountpoint so the operator knows where their
# archive will land. The wizard does print it again under
# "Destination:" but the line scrolls past quickly during
# staging.
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "USB disk mounted")" \
--msgbox "$(hb_translate "The USB disk has been mounted.")"$'\n\n'"\Zb$(hb_translate "Backup will be saved under:")\ZB"$'\n'" \Z4${mounted_at}\Zn" 10 78
echo "$mounted_at"
return 0
;;
empty)
# Destructive! Triple-check before formatting.
if ! dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Format USB disk?")" \
--default-button no \
--yesno "\Z1\Zb$(hb_translate "WARNING: this will ERASE EVERYTHING on the disk.")\ZB\Zn"$'\n\n'"\Zb$(hb_translate "Device:")\ZB $s_path"$'\n'"\Zb$(hb_translate "Size:")\ZB ${s_size}"$'\n\n'"$(hb_translate "Create a fresh GPT + ext4 partition and mount it?")" \
14 76; then
return 1
fi
# Second confirmation prompts the operator to type the device name
local typed
typed=$(dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Final confirmation")" \
--inputbox "$(hb_translate "Type the device path EXACTLY to confirm formatting:")"$'\n\n'"\Z1${s_path}\Zn" \
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "" 3>&1 1>&2 2>&3) || return 1
if [[ "$typed" != "$s_path" ]]; then
dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Device path mismatch. Format cancelled.")" 8 60
return 1
fi
local fmt_label="proxmenux-backup"
local mounted_at
mounted_at=$(hb_format_usb_disk "$s_path" "$fmt_label") || {
local err
err=$(tail -10 /tmp/proxmenux-format.log 2>/dev/null)
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Format failed")" \
--msgbox "$(hb_translate "Could not format the disk.")\n\n${err}" 16 80
return 1
}
dialog --backtitle "ProxMenux" --colors \
--title "$(hb_translate "Formatted and mounted")" \
--msgbox "\Zb$(hb_translate "Mounted at")\ZB \Z4${mounted_at}\Zn" 8 70
echo "$mounted_at"
return 0
;;
esac
return 1
}
hb_prompt_dest_dir() {
@@ -1451,8 +2155,13 @@ hb_show_compat_report() {
title="$(hb_translate "Compatibility check — OK")"
fi
dialog --backtitle "ProxMenux" --title "$title" \
--textbox "$tmpfile" 22 86 || true
# Only nag the operator when there's something to read. An all-PASS
# report is pure noise on the path to a restore they already
# confirmed they want.
if (( warn > 0 || fail > 0 )); then
dialog --backtitle "ProxMenux" --title "$title" \
--textbox "$tmpfile" 22 86 || true
fi
rm -f "$tmpfile"
# FAIL means at least one check is a real risk for system integrity

View File

@@ -17,7 +17,6 @@
# Configuration ============================================
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
BACKUP_DIR="/var/backups/proxmenux"
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -7,7 +7,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -5,7 +5,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -7,7 +7,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -7,7 +7,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"

View File

@@ -46,7 +46,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -32,7 +32,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -14,7 +14,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -18,7 +18,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -40,13 +40,11 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
CONFIG_FILE="$BASE_DIR/config.json"
CACHE_FILE="$BASE_DIR/cache.json"
UTILS_FILE="$BASE_DIR/utils.sh"
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
BETA_VERSION_FILE="$BASE_DIR/beta_version.txt"
INSTALL_DIR="/usr/local/bin"
MENU_SCRIPT="menu"
VENV_PATH="/opt/googletrans-env"
BACKTITLE="ProxMenux Configuration"
REPO_MAIN="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
@@ -114,27 +112,10 @@ uninstall_proxmenux_monitor() {
}
detect_installation_type() {
local has_venv=false
local has_language=false
# Check if virtual environment exists
if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then
has_venv=true
fi
# Check if language is configured
if [ -f "$CONFIG_FILE" ]; then
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
fi
if [ "$has_venv" = true ] && [ "$has_language" = true ]; then
echo "translation"
else
echo "normal"
fi
# The Translation/Normal split is gone after the googletrans removal.
# All installs are multilingual via pre-built lang/*.json. Keeping the
# function name + a fixed value so callers don't have to change.
echo "normal"
}
check_monitor_status() {
@@ -745,23 +726,16 @@ show_version_info() {
[ -f "$CONFIG_FILE" ] && info_message+="✓ config.json → $CONFIG_FILE\n" || info_message+="✗ config.json\n"
[ -f "$LOCAL_VERSION_FILE" ] && info_message+="✓ version.txt → $LOCAL_VERSION_FILE\n" || info_message+="✗ version.txt\n"
# Show translation-specific files
if [ "$install_type" = "translation" ]; then
[ -f "$CACHE_FILE" ] && info_message+="✓ cache.json → $CACHE_FILE\n" || info_message+="✗ cache.json\n"
info_message+="\n$(translate "Virtual Environment:")\n"
if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then
info_message+="$(translate "Installed")$VENV_PATH\n"
[ -f "$VENV_PATH/bin/pip" ] && info_message+=" pip: $(translate "Installed") $VENV_PATH/bin/pip\n" || info_message+="✗ pip: $(translate "Not installed")\n"
else
info_message+="$(translate "Virtual Environment"): $(translate "Not installed")\n"
info_message+="✗ pip: $(translate "Not installed")\n"
fi
current_language=$(jq -r '.language // "en"' "$CONFIG_FILE")
info_message+="\n$(translate "Current language:")\n$current_language\n"
# Language section: always present now that translations are static
# JSON lookups. Show the configured language and the lang/ directory
# so the operator can verify the cache is in place.
current_language=$(jq -r '.language // "en"' "$CONFIG_FILE" 2>/dev/null)
[[ -z "$current_language" || "$current_language" == "null" ]] && current_language="en"
info_message+="\n$(translate "Current language:")\n${current_language}\n"
if [ -d "$BASE_DIR/lang" ]; then
info_message+="$(translate "Translation files:") $BASE_DIR/lang/\n"
else
info_message+="\n$(translate "Language:")\nEnglish (Fixed)\n"
info_message+="$(translate "Translation files:") $(translate "missing")\n"
fi
# Display information in a scrollable text box
@@ -786,37 +760,23 @@ uninstall_proxmenu() {
local deps_to_remove=""
# Show different dependency options based on installation type
if [ "$install_type" = "translation" ]; then
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
--title "Remove Dependencies" \
--checklist "Select dependencies to remove:" 15 60 4 \
"python3-venv" "Python virtual environment" OFF \
"python3-pip" "Python package installer" OFF \
"python3" "Python interpreter" OFF \
"jq" "JSON processor" OFF \
3>&1 1>&2 2>&3)
else
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
--title "Remove Dependencies" \
--checklist "Select dependencies to remove:" 12 60 2 \
"dialog" "Interactive dialog boxes" OFF \
"jq" "JSON processor" OFF \
3>&1 1>&2 2>&3)
fi
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
--title "Remove Dependencies" \
--checklist "Select dependencies to remove:" 12 60 2 \
"dialog" "Interactive dialog boxes" OFF \
"jq" "JSON processor" OFF \
3>&1 1>&2 2>&3)
# Perform uninstallation with progress bar
(
echo "10" ; echo "Removing ProxMenu files..."
sleep 1
# Remove googletrans and virtual environment if exists
if [ -f "$VENV_PATH/bin/activate" ]; then
echo "30" ; echo "Removing googletrans and virtual environment..."
source "$VENV_PATH/bin/activate"
pip uninstall -y googletrans >/dev/null 2>&1
deactivate
rm -rf "$VENV_PATH"
# Purge the legacy googletrans virtualenv if it was left over from
# a pre-static-translations install. Cheap idempotent check.
if [ -d "/opt/googletrans-env" ]; then
echo "30" ; echo "Removing legacy googletrans virtualenv..."
rm -rf "/opt/googletrans-env"
fi
echo "50" ; echo "Removing ProxMenu files..."

View File

@@ -39,7 +39,6 @@ MENU_REPO="$LOCAL_SCRIPTS/menus"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
[[ ! -f "$UTILS_FILE" ]] && UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
# Source utilities and required scripts
if [[ -f "$UTILS_FILE" ]]; then

View File

@@ -14,7 +14,6 @@
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
BASE_DIR="/usr/local/share/proxmenux"
UTILS_FILE="$BASE_DIR/utils.sh"
VENV_PATH="/opt/googletrans-env"
if ! command -v dialog &>/dev/null; then
@@ -23,47 +22,11 @@ if ! command -v dialog &>/dev/null; then
fi
check_pve9_translation_compatibility() {
local pve_version
if command -v pveversion &>/dev/null; then
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
else
return 0
fi
if [[ -n "$pve_version" ]] && [[ "$pve_version" -ge 9 ]] && [[ -d "$VENV_PATH" ]]; then
local has_googletrans=false
local has_cache=false
if [[ -f "$VENV_PATH/bin/pip" ]]; then
if "$VENV_PATH/bin/pip" list 2>/dev/null | grep -q "googletrans"; then
has_googletrans=true
fi
fi
if [[ -f "$BASE_DIR/cache.json" ]]; then
has_cache=true
fi
if [[ "$has_googletrans" = true ]] || [[ "$has_cache" = true ]]; then
dialog --clear \
--backtitle "ProxMenux - Compatibility Required" \
--title "Translation Environment Incompatible with PVE $pve_version" \
--msgbox "NOTICE: You are running Proxmox VE $pve_version with translation components installed.\n\nTranslations are NOT supported in PVE 9+. This causes:\n• Menu loading errors\n• Translation failures\n• System instability\n\nREQUIRED ACTION:\nProxMenux will now automatically reinstall the Normal Version.\n\nThis process will:\n• Remove incompatible translation components\n• Install PVE 9+ compatible version\n• Preserve all your settings and preferences\n\nPress OK to continue with automatic reinstallation..." 20 75
bash "$BASE_DIR/install_proxmenux.sh"
fi
exit 0
fi
}
check_pve9_translation_compatibility
# ==========================================================
# The legacy "PVE9 + googletrans incompatible" gate that used to live
# here has been removed along with the googletrans runtime. Translations
# are now a static lookup against $BASE_DIR/lang/<lang>.json — there is
# no runtime venv to be incompatible with any PVE version.
if [[ -f "$UTILS_FILE" ]]; then
source "$UTILS_FILE"

View File

@@ -38,16 +38,11 @@ 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"
LANG_DIR="$BASE_DIR/lang"
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
MENU_SCRIPT="menu"
VENV_PATH="/opt/googletrans-env"
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
# Translation context
TRANSLATION_CONTEXT="Context: Technical message for Proxmox and IT. Translate:"
# Color and style definitions
NEON_PURPLE_BLUE="\033[38;5;99m"
WHITE="\033[38;5;15m"
@@ -216,9 +211,10 @@ msg_error() {
# Initialize cache
initialize_cache() {
if [[ "$LANGUAGE" != "en" ]]; then
if [ ! -f "$CACHE_FILE" ]; then
mkdir -p "$(dirname "$CACHE_FILE")"
echo "{}" > "$CACHE_FILE"
mkdir -p "$LANG_DIR"
local lang_file="$LANG_DIR/${LANGUAGE}.json"
if [ ! -f "$lang_file" ]; then
echo "{}" > "$lang_file"
fi
fi
}
@@ -241,78 +237,37 @@ load_language() {
translate() {
# Pre-generated lookup ONLY. Translations live in
# $LANG_DIR/<dest>.json
# built by .github/scripts/build_translation_cache.py during CI on
# every push that touches scripts/. There is intentionally no live
# fallback: removing the runtime googletrans dependency lets us
# ship a smaller AppImage, skip /opt/googletrans-env, and stop
# leaking translation traffic from the host at runtime.
#
# Behavior:
# - dest == "en" → echo the original text
# - lookup hit → echo the translation
# - lookup miss → echo the original text (English fallback)
local text="$1"
local dest_lang="$LANGUAGE"
if [ "$dest_lang" = "en" ]; then
echo "$text"
return
fi
if [ ! -s "$CACHE_FILE" ] || ! jq -e . "$CACHE_FILE" > /dev/null 2>&1; then
echo "{}" > "$CACHE_FILE"
fi
local cached_translation=$(jq -r --arg text "$text" --arg lang "$dest_lang" '.[$text][$lang] // .[$text]["notranslate"] // empty' "$CACHE_FILE")
if [ -n "$cached_translation" ]; then
echo "$cached_translation"
return
fi
if [ ! -d "$VENV_PATH" ]; then
echo "$text"
return
fi
source "$VENV_PATH/bin/activate"
local translated
translated=$(python3 -c "
from googletrans import Translator
import sys, json, re
def translate_text(text, dest_lang, context):
translator = Translator()
try:
full_text = context + ' ' + text
result = translator.translate(full_text, dest=dest_lang).text
translated = re.sub(r'^.*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)', '', result, flags=re.IGNORECASE | re.DOTALL).strip()
translated = re.sub(r'^.*?(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?:', '', translated, flags=re.IGNORECASE | re.DOTALL).strip()
print(json.dumps({'success': True, 'text': translated}))
except Exception as e:
print(json.dumps({'success': False, 'error': str(e)}))
translate_text(
json.loads(sys.argv[1]),
sys.argv[2],
json.loads(sys.argv[3])
)
" "$(jq -Rn --arg t "$text" '$t')" "$dest_lang" "$(jq -Rn --arg ctx "$TRANSLATION_CONTEXT" '$ctx')")
deactivate
local translation_result=$(echo "$translated" | jq -r '.')
local success=$(echo "$translation_result" | jq -r '.success')
if [ "$success" = "true" ]; then
translated=$(echo "$translation_result" | jq -r '.text')
# Additional cleaning step
translated=$(echo "$translated" | sed -E 's/^(Context:|Contexto:|Contexte:|Kontext:|Contesto:|上下文:|コンテキスト:).*?(Translate:|Traducir:|Traduire:|Übersetzen:|Tradurre:|Traduzir:|翻译:|翻訳:)//gI' | sed 's/^ *//; s/ *$//')
# Only cache if the language is not English
if [ "$dest_lang" != "en" ]; then
local temp_cache=$(mktemp)
jq --arg text "$text" --arg lang "$dest_lang" --arg translated "$translated" '
if .[$text] == null then .[$text] = {} else . end |
.[$text][$lang] = $translated
' "$CACHE_FILE" > "$temp_cache" && mv "$temp_cache" "$CACHE_FILE"
local lang_file="$LANG_DIR/${dest_lang}.json"
if [ -s "$lang_file" ] && command -v jq >/dev/null 2>&1; then
local cached
cached=$(jq -r --arg text "$text" '.[$text] // empty' "$lang_file" 2>/dev/null)
if [ -n "$cached" ]; then
echo "$cached"
return
fi
echo "$translated"
else
local error=$(echo "$translation_result" | jq -r '.error')
echo "$text"
fi
echo "$text"
}

View File

@@ -12,14 +12,16 @@ import CopyableCode from "@/components/CopyableCode"
// Resolve which CHANGELOG.md to read for the given locale. The canonical
// English file lives at the repo root (so GitHub displays it as-is and
// existing RSS / external consumers don't break). Localized versions
// sit under <repo>/lang/<locale>/CHANGELOG.md. Falls back to English if
// the localized file doesn't exist yet — so a partially-translated
// changelog still renders (in EN) instead of 404'ing.
// existing RSS / external consumers don't break). Localized versions sit
// under <repo>/web/data/changelog/<locale>.md — separate from the
// /lang/ directory which is now reserved for runtime translation JSON
// shipped to the host install. Falls back to English if the localized
// file doesn't exist yet — so a partially-translated changelog still
// renders (in EN) instead of 404'ing.
function resolveChangelogPath(locale: string): string {
const repoRoot = path.join(process.cwd(), "..")
if (locale && locale !== "en") {
const localized = path.join(repoRoot, "lang", locale, "CHANGELOG.md")
const localized = path.join(process.cwd(), "data", "changelog", `${locale}.md`)
if (fs.existsSync(localized)) return localized
}
return path.join(repoRoot, "CHANGELOG.md")