mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-06-11 11:06:24 +00:00
Add beta 1.2.2.2
This commit is contained in:
362
.github/scripts/build_translation_cache.py
vendored
Normal file
362
.github/scripts/build_translation_cache.py
vendored
Normal 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())
|
||||||
91
.github/workflows/build-translation-cache.yml
vendored
Normal file
91
.github/workflows/build-translation-cache.yml
vendored
Normal 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
|
||||||
@@ -166,129 +166,38 @@ else
|
|||||||
echo "⚠️ config directory not found"
|
echo "⚠️ config directory not found"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📋 Adding translation support..."
|
# Translation handling lives in scripts/utils.sh now. It reads
|
||||||
cat > "$APP_DIR/usr/bin/translate_cli.py" << 'PYEOF'
|
# /usr/local/share/proxmenux/lang/<lang>.json (pre-built by the
|
||||||
#!/usr/bin/env python3
|
# build_translation_cache.py CI job) and falls back to the English
|
||||||
# -*- coding: utf-8 -*-
|
# source string on miss. The Monitor AppImage no longer ships the
|
||||||
"""
|
# runtime translate_cli.py — the JSON files belong to the host install,
|
||||||
ProxMenux translate CLI
|
# not to the Flask dashboard.
|
||||||
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
|
|
||||||
|
|
||||||
# Ensure embedded site-packages are discoverable
|
# ── Borg standalone binary ─────────────────────────────────────────
|
||||||
HERE = Path(__file__).resolve().parents[2] # .../AppDir
|
# Ship the official borg standalone binary inside the AppImage so the
|
||||||
DIST = HERE / "usr" / "lib" / "python3" / "dist-packages"
|
# host-backup / restore workflows can run without an internet round-trip
|
||||||
SITE = HERE / "usr" / "lib" / "python3" / "site-packages"
|
# at install time. Pinned to the same version that proxmenux's
|
||||||
for p in (str(DIST), str(SITE)):
|
# hb_ensure_borg used to download on demand — kept in lockstep so both
|
||||||
if p not in sys.path:
|
# code paths see the same version semantics. SHA256 is the upstream
|
||||||
sys.path.insert(0, p)
|
# release checksum; bump both together.
|
||||||
|
BORG_VERSION="1.2.8"
|
||||||
# Python 3.13 compat: inline 'cgi' shim
|
BORG_URL="https://github.com/borgbackup/borg/releases/download/${BORG_VERSION}/borg-linux64"
|
||||||
try:
|
BORG_SHA256="cfa50fb704a93d3a4fa258120966345fddb394f960dca7c47fcb774d0172f40b"
|
||||||
import cgi
|
echo "📦 Downloading borg ${BORG_VERSION} into AppImage..."
|
||||||
except Exception:
|
BORG_TARGET="$APP_DIR/usr/bin/borg"
|
||||||
import types, html
|
if wget -qO "$BORG_TARGET" "$BORG_URL"; then
|
||||||
def _parse_header(value: str):
|
if echo "${BORG_SHA256} ${BORG_TARGET}" | sha256sum -c - >/dev/null 2>&1; then
|
||||||
value = str(value or "")
|
chmod +x "$BORG_TARGET"
|
||||||
parts = [p.strip() for p in value.split(";")]
|
echo "✅ borg ${BORG_VERSION} bundled (sha256 verified)"
|
||||||
if not parts:
|
else
|
||||||
return "", {}
|
echo "❌ borg sha256 verification failed — removing"
|
||||||
key = parts[0].lower()
|
rm -f "$BORG_TARGET"
|
||||||
params = {}
|
exit 1
|
||||||
for item in parts[1:]:
|
fi
|
||||||
if not item:
|
else
|
||||||
continue
|
echo "❌ borg download failed from $BORG_URL"
|
||||||
if "=" in item:
|
exit 1
|
||||||
k, v = item.split("=", 1)
|
fi
|
||||||
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"
|
|
||||||
|
|
||||||
# Copy Next.js build
|
# Copy Next.js build
|
||||||
echo "📋 Copying web dashboard..."
|
echo "📋 Copying web dashboard..."
|
||||||
@@ -332,7 +241,7 @@ cat > "$APP_DIR/proxmenux-monitor.desktop" << EOF
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=ProxMenux Monitor
|
Name=ProxMenux Monitor
|
||||||
Comment=Proxmox System Monitoring Dashboard with Translation Support
|
Comment=Proxmox System Monitoring Dashboard
|
||||||
Exec=AppRun
|
Exec=AppRun
|
||||||
Icon=proxmenux-monitor
|
Icon=proxmenux-monitor
|
||||||
Categories=System;Monitor;
|
Categories=System;Monitor;
|
||||||
@@ -361,14 +270,12 @@ if [ -f "$APP_DIR/proxmenux-monitor.png" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "📦 Installing Python dependencies..."
|
echo "📦 Installing Python dependencies..."
|
||||||
# Phase 1: Install googletrans with its old dependencies
|
# Flask/WebSocket dependencies for the Monitor dashboard. The previous
|
||||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" \
|
# Phase-1 (googletrans==4.0.0-rc1 + httpx 0.13.3 + httpcore 0.9.1 +
|
||||||
googletrans==4.0.0-rc1 \
|
# h11 0.9.0) is gone — translation is now a static-lookup feature on
|
||||||
httpx==0.13.3 \
|
# the host, so the AppImage no longer needs any runtime translator.
|
||||||
httpcore==0.9.1 \
|
# Removing those pins also unblocks the h11>=0.14.0 family without the
|
||||||
h11==0.9.0 || true
|
# conflict workaround we used to ship.
|
||||||
|
|
||||||
# Phase 2: Install modern Flask/WebSocket dependencies (will upgrade h11 and related packages)
|
|
||||||
# Note: cryptography removed due to Python version compatibility issues (PyO3 modules)
|
# Note: cryptography removed due to Python version compatibility issues (PyO3 modules)
|
||||||
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \
|
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-deps \
|
||||||
flask \
|
flask \
|
||||||
@@ -380,7 +287,7 @@ pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade --no-de
|
|||||||
segno \
|
segno \
|
||||||
beautifulsoup4
|
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 \
|
pip3 install --target "$APP_DIR/usr/lib/python3/dist-packages" --upgrade \
|
||||||
h11>=0.14.0 \
|
h11>=0.14.0 \
|
||||||
wsproto>=1.2.0 \
|
wsproto>=1.2.0 \
|
||||||
|
|||||||
@@ -1407,3 +1407,49 @@ def internal_shutdown_event():
|
|||||||
return jsonify({'success': True, 'event_type': event_type}), 200
|
return jsonify({'success': True, 'event_type': event_type}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
|
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
|
||||||
|
|||||||
@@ -868,6 +868,13 @@ TEMPLATES = {
|
|||||||
'group': 'services',
|
'group': 'services',
|
||||||
'default_enabled': True,
|
'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': {
|
'system_problem': {
|
||||||
'title': '{hostname}: System problem detected',
|
'title': '{hostname}: System problem detected',
|
||||||
'body': 'A system-level problem has been detected.\nReason: {reason}',
|
'body': 'A system-level problem has been detected.\nReason: {reason}',
|
||||||
@@ -1604,6 +1611,7 @@ EVENT_EMOJI = {
|
|||||||
'system_startup': '\U0001F680', # rocket (startup)
|
'system_startup': '\U0001F680', # rocket (startup)
|
||||||
'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode)
|
'system_shutdown': '\u23FB\uFE0F', # power symbol (Unicode)
|
||||||
'system_reboot': '\U0001F504',
|
'system_reboot': '\U0001F504',
|
||||||
|
'system_restore_completed': '✅', # check mark
|
||||||
'system_problem': '\u26A0\uFE0F',
|
'system_problem': '\u26A0\uFE0F',
|
||||||
'service_fail': '\u274C',
|
'service_fail': '\u274C',
|
||||||
'oom_kill': '\U0001F4A3', # bomb
|
'oom_kill': '\U0001F4A3', # bomb
|
||||||
|
|||||||
@@ -44,11 +44,15 @@ LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
|||||||
INSTALL_DIR="/usr/local/bin"
|
INSTALL_DIR="/usr/local/bin"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
CONFIG_FILE="$BASE_DIR/config.json"
|
CONFIG_FILE="$BASE_DIR/config.json"
|
||||||
CACHE_FILE="$BASE_DIR/cache.json"
|
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||||
MENU_SCRIPT="menu"
|
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_INSTALL_DIR="$BASE_DIR"
|
||||||
MONITOR_RUNTIME_DIR="$BASE_DIR/monitor-app"
|
MONITOR_RUNTIME_DIR="$BASE_DIR/monitor-app"
|
||||||
@@ -272,10 +276,6 @@ cleanup_corrupted_files() {
|
|||||||
echo "Cleaning up corrupted configuration file..."
|
echo "Cleaning up corrupted configuration file..."
|
||||||
rm -f "$CONFIG_FILE"
|
rm -f "$CONFIG_FILE"
|
||||||
fi
|
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
|
# Cleanup function
|
||||||
@@ -291,157 +291,27 @@ trap cleanup EXIT
|
|||||||
|
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
check_existing_installation() {
|
check_existing_installation() {
|
||||||
local has_venv=false
|
# After the googletrans removal there is only one install variant.
|
||||||
local has_config=false
|
# The function still distinguishes "installed" vs "not installed" so
|
||||||
local has_language=false
|
# show_installation_options can pick the right banner.
|
||||||
local has_menu=false
|
|
||||||
|
|
||||||
if [ -f "$INSTALL_DIR/$MENU_SCRIPT" ]; then
|
if [ -f "$INSTALL_DIR/$MENU_SCRIPT" ]; then
|
||||||
has_menu=true
|
# Quietly fix a corrupted config so the install can proceed.
|
||||||
fi
|
if [ -f "$CONFIG_FILE" ] && ! jq empty "$CONFIG_FILE" >/dev/null 2>&1; then
|
||||||
|
echo "Warning: Corrupted config file detected, removing..." >&2
|
||||||
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..."
|
|
||||||
rm -f "$CONFIG_FILE"
|
rm -f "$CONFIG_FILE"
|
||||||
fi
|
fi
|
||||||
fi
|
echo "installed"
|
||||||
|
|
||||||
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"
|
|
||||||
else
|
else
|
||||||
echo "none"
|
echo "none"
|
||||||
fi
|
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() {
|
update_config() {
|
||||||
local component="$1"
|
local component="$1"
|
||||||
local status="$2"
|
local status="$2"
|
||||||
local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
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
|
if [[ " ${tracked_components[@]} " =~ " ${component} " ]]; then
|
||||||
mkdir -p "$(dirname "$CONFIG_FILE")"
|
mkdir -p "$(dirname "$CONFIG_FILE")"
|
||||||
@@ -517,26 +387,12 @@ select_language() {
|
|||||||
|
|
||||||
# Show installation confirmation for new installations
|
# Show installation confirmation for new installations
|
||||||
show_installation_confirmation() {
|
show_installation_confirmation() {
|
||||||
local install_type="$1"
|
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
|
||||||
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
|
return 0
|
||||||
else
|
else
|
||||||
return 1
|
return 1
|
||||||
fi
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get_server_ip() {
|
get_server_ip() {
|
||||||
@@ -800,9 +656,25 @@ EOF
|
|||||||
}
|
}
|
||||||
|
|
||||||
install_normal_version() {
|
install_normal_version() {
|
||||||
local total_steps=5
|
local total_steps=6
|
||||||
local current_step=1
|
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."
|
show_progress $current_step $total_steps "Installing basic dependencies."
|
||||||
|
|
||||||
if ! command -v jq > /dev/null 2>&1; then
|
if ! command -v jq > /dev/null 2>&1; then
|
||||||
@@ -842,7 +714,11 @@ install_normal_version() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
for pkg in "${BASIC_DEPS[@]}"; do
|
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 ! 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
|
if apt-get install -y "$pkg" > /dev/null 2>&1; then
|
||||||
update_config "$pkg" "installed"
|
update_config "$pkg" "installed"
|
||||||
@@ -931,6 +807,18 @@ install_normal_version() {
|
|||||||
cp "./version.txt" "$LOCAL_VERSION_FILE"
|
cp "./version.txt" "$LOCAL_VERSION_FILE"
|
||||||
cp "./install_proxmenux.sh" "$BASE_DIR/install_proxmenux.sh"
|
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
|
# A user that previously rode the beta train and then switched back
|
||||||
# to stable would still have a leftover beta_version.txt under
|
# to stable would still have a leftover beta_version.txt under
|
||||||
# $BASE_DIR, which makes the `menu` update check (check_updates_beta)
|
# $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
|
# Wipe the scripts tree before copying so any file removed upstream
|
||||||
# (renamed, consolidated, deprecated) disappears from the user install.
|
# (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
|
# components_status.json, version.txt, monitor.db, smart/, oci/ and
|
||||||
# the AppImage live outside this path and are preserved.
|
# the AppImage live outside this path and are preserved.
|
||||||
rm -rf "$BASE_DIR/scripts"
|
rm -rf "$BASE_DIR/scripts"
|
||||||
@@ -969,209 +857,15 @@ install_normal_version() {
|
|||||||
msg_ok "ProxMenux Normal Version installation completed successfully."
|
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() {
|
show_installation_options() {
|
||||||
local current_install_type
|
local current_install_type
|
||||||
current_install_type=$(check_existing_installation)
|
current_install_type=$(check_existing_installation)
|
||||||
local pve_version
|
# Translation Version is gone — translations now ship as pre-built
|
||||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
# JSON files in lang/. There is only one install path, so this
|
||||||
|
# function just shows the confirmation dialog for new installs and
|
||||||
local menu_title="ProxMenux Installation"
|
# then returns. Existing installs go straight through (they already
|
||||||
local menu_text="Choose installation type:"
|
# consented to update via the menu).
|
||||||
|
INSTALL_TYPE="1"
|
||||||
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
|
|
||||||
|
|
||||||
if [ "$current_install_type" = "none" ]; then
|
if [ "$current_install_type" = "none" ]; then
|
||||||
if ! show_installation_confirmation "$INSTALL_TYPE"; then
|
if ! show_installation_confirmation "$INSTALL_TYPE"; then
|
||||||
@@ -1180,55 +874,22 @@ show_installation_options() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! handle_installation_change "$current_install_type" "$INSTALL_TYPE"; then
|
|
||||||
show_proxmenux_logo
|
|
||||||
msg_warn "Installation cancelled."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
install_proxmenux() {
|
install_proxmenux() {
|
||||||
if [[ "${UPDATE_MODE:-0}" == "1" ]]; then
|
if [[ "${UPDATE_MODE:-0}" == "1" ]]; then
|
||||||
# Update path: the user already accepted "Update now?" in the
|
# Update path: the user already accepted "Update now?" in the
|
||||||
# menu. We skip the install-type chooser (their choice is
|
# menu. Hand off to the freshly-installed menu binary at the end
|
||||||
# preserved — Translation installs leave /opt/googletrans-env
|
# (exec, see below) so no shell ever returns to a half-written
|
||||||
# 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
|
|
||||||
# /usr/local/bin/menu — the new copy is the only thing parsed.
|
# /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
|
show_proxmenux_logo
|
||||||
msg_title "Updating ProxMenux - Translation Version"
|
msg_title "Updating ProxMenux"
|
||||||
install_translation_version
|
|
||||||
else
|
|
||||||
show_proxmenux_logo
|
|
||||||
msg_title "Updating ProxMenux - Normal Version"
|
|
||||||
install_normal_version
|
install_normal_version
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
show_installation_options
|
show_installation_options
|
||||||
|
|
||||||
case "$INSTALL_TYPE" in
|
|
||||||
"1")
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "Installing ProxMenux - Normal Version"
|
msg_title "Installing ProxMenux"
|
||||||
install_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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
|
|||||||
198
lang/cache.json
198
lang/cache.json
@@ -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!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
113
lang/en.lang
113
lang/en.lang
@@ -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."
|
|
||||||
115
lang/es.lang
115
lang/es.lang
@@ -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 ---
|
|
||||||
@@ -367,6 +367,46 @@ if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then
|
|||||||
done
|
done
|
||||||
fi
|
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 ""
|
||||||
echo "=== Apply finished at $(date -Iseconds) ==="
|
echo "=== Apply finished at $(date -Iseconds) — total ${POSTBOOT_DURATION_FMT} ==="
|
||||||
echo "Log: $LOG_FILE"
|
echo "Log: $LOG_FILE"
|
||||||
|
|||||||
@@ -276,6 +276,38 @@ _bk_local() {
|
|||||||
dest_dir=$(hb_prompt_dest_dir) || return 1
|
dest_dir=$(hb_prompt_dest_dir) || return 1
|
||||||
hb_select_profile_paths "$profile_mode" paths || 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"
|
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"
|
log_file="/tmp/proxmenux-local-backup-$(date +%Y%m%d_%H%M%S).log"
|
||||||
staging_root=$(mktemp -d /tmp/proxmenux-local-stage.XXXXXX)
|
staging_root=$(mktemp -d /tmp/proxmenux-local-stage.XXXXXX)
|
||||||
@@ -382,6 +414,194 @@ _bk_scheduler() {
|
|||||||
bash "$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() {
|
backup_menu() {
|
||||||
while true; do
|
while true; do
|
||||||
local choice
|
local choice
|
||||||
@@ -397,8 +617,6 @@ backup_menu() {
|
|||||||
4 "$(translate "Custom backup to PBS")" \
|
4 "$(translate "Custom backup to PBS")" \
|
||||||
5 "$(translate "Custom backup to Borg")" \
|
5 "$(translate "Custom backup to Borg")" \
|
||||||
6 "$(translate "Custom backup to local archive")" \
|
6 "$(translate "Custom backup to local archive")" \
|
||||||
"" "$(translate "─── Automation ─────────────────────────────────────")" \
|
|
||||||
7 "$(translate "Scheduled backups and retention policies")" \
|
|
||||||
0 "$(translate "Return")" \
|
0 "$(translate "Return")" \
|
||||||
3>&1 1>&2 2>&3) || return 0
|
3>&1 1>&2 2>&3) || return 0
|
||||||
|
|
||||||
@@ -409,7 +627,6 @@ backup_menu() {
|
|||||||
4) _bk_pbs custom ;;
|
4) _bk_pbs custom ;;
|
||||||
5) _bk_borg custom ;;
|
5) _bk_borg custom ;;
|
||||||
6) _bk_local custom ;;
|
6) _bk_local custom ;;
|
||||||
7) _bk_scheduler ;;
|
|
||||||
0) break ;;
|
0) break ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -600,32 +817,45 @@ _rs_extract_borg() {
|
|||||||
|
|
||||||
borg_bin=$(hb_ensure_borg) || return 1
|
borg_bin=$(hb_ensure_borg) || return 1
|
||||||
hb_select_borg_repo repo || 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"
|
# Pull NAME|START in one shot — borg supports strftime via :%fmt
|
||||||
if [[ -f "$pass_file" ]]; then
|
# in --format. Sort newest-first by the ISO timestamp so the most
|
||||||
BORG_PASSPHRASE="$(<"$pass_file")"
|
# recent backup is always on top regardless of archive naming.
|
||||||
export BORG_PASSPHRASE
|
local -a archive_lines=()
|
||||||
else
|
mapfile -t archive_lines < <(
|
||||||
BORG_PASSPHRASE=$(dialog --backtitle "ProxMenux" --insecure --passwordbox \
|
"$borg_bin" list "$repo" \
|
||||||
"$(hb_translate "Borg passphrase (leave empty if not encrypted):")" \
|
--format '{start:%Y-%m-%d %H:%M:%S}|{archive}{NL}' 2>/dev/null \
|
||||||
"$HB_UI_PASS_H" "$HB_UI_PASS_W" "" 3>&1 1>&2 2>&3) || return 1
|
| sort -r
|
||||||
export BORG_PASSPHRASE
|
|
||||||
fi
|
|
||||||
|
|
||||||
mapfile -t archives < <(
|
|
||||||
"$borg_bin" list "$repo" --format '{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.")"
|
msg_error "$(translate "No archives found in this Borg repository.")"
|
||||||
return 1
|
return 1
|
||||||
fi
|
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
|
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
|
local sel
|
||||||
sel=$(dialog --backtitle "ProxMenux" \
|
sel=$(dialog --backtitle "ProxMenux" \
|
||||||
--title "$(translate "Select archive to restore")" \
|
--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" \
|
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||||
"${menu[@]}" 3>&1 1>&2 2>&3) || return 1
|
"${menu[@]}" 3>&1 1>&2 2>&3) || return 1
|
||||||
archive="${archives[$((sel-1))]}"
|
archive="${archives[$((sel-1))]}"
|
||||||
@@ -1182,49 +1412,51 @@ _rs_collect_plan_stats() {
|
|||||||
_rs_show_plan_summary() {
|
_rs_show_plan_summary() {
|
||||||
local staging_root="$1"
|
local staging_root="$1"
|
||||||
local meta="$staging_root/metadata"
|
local meta="$staging_root/metadata"
|
||||||
local tmp
|
|
||||||
tmp=$(mktemp) || return 1
|
|
||||||
|
|
||||||
{
|
# dialog --colors only fires inside --msgbox / --yesno / --infobox, not
|
||||||
echo "═══ $(translate "Restore plan summary") ═══"
|
# --textbox, so we build the body as a string. Color codes match the
|
||||||
echo ""
|
# complete-restore confirm dialog for visual consistency.
|
||||||
|
local body
|
||||||
|
body="\Zb═══ $(translate "Restore plan summary") ═══\ZB"$'\n\n'
|
||||||
|
|
||||||
if [[ -f "$meta/run_info.env" ]]; then
|
if [[ -f "$meta/run_info.env" ]]; then
|
||||||
echo "$(translate "Backup origin metadata:")"
|
body+="\Zb$(translate "Backup origin metadata:")\ZB"$'\n'
|
||||||
while IFS='=' read -r k v; do
|
while IFS='=' read -r k v; do
|
||||||
[[ -n "$k" ]] && printf " %-20s %s\n" "${k}:" "$v"
|
[[ -z "$k" ]] && continue
|
||||||
|
body+="$(printf ' %-20s \Z4%s\Zn' "${k}:" "$v")"$'\n'
|
||||||
done < "$meta/run_info.env"
|
done < "$meta/run_info.env"
|
||||||
echo ""
|
body+=$'\n'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$(translate "Detected paths in this backup:") ${RS_PLAN_TOTAL}"
|
# Reboot-required and live-unsafe both go to the pending set and
|
||||||
echo " • $(translate "Safe to apply now"): ${RS_PLAN_HOT}"
|
# are applied by the post-boot dispatcher — to the operator they're
|
||||||
echo " • $(translate "Require reboot"): ${RS_PLAN_REBOOT}"
|
# the same bucket "things that complete after reboot".
|
||||||
echo " • $(translate "Risky on running system"): ${RS_PLAN_DANGEROUS}"
|
local _reboot_total=$(( RS_PLAN_REBOOT + RS_PLAN_DANGEROUS ))
|
||||||
echo ""
|
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'
|
||||||
|
|
||||||
if [[ "$RS_PLAN_HAS_NETWORK" -eq 1 ]]; then
|
if [[ "$RS_PLAN_HAS_NETWORK" -eq 1 ]]; then
|
||||||
echo " • $(translate "Includes /etc/network (may drop SSH immediately)")"
|
body+=" • $(translate "Includes /etc/network (may drop SSH immediately)")"$'\n'
|
||||||
fi
|
fi
|
||||||
if [[ "$RS_PLAN_HAS_CLUSTER" -eq 1 ]]; then
|
if [[ "$RS_PLAN_HAS_CLUSTER" -eq 1 ]]; then
|
||||||
echo " • $(translate "Includes cluster data (/etc/pve, /var/lib/pve-cluster)")"
|
body+=" • \Z4$(translate "Includes cluster data (/etc/pve, /var/lib/pve-cluster)")\Zn"$'\n'
|
||||||
echo " $(translate "These paths will not be restored live and will be extracted for manual recovery.")"
|
body+=" $(translate "These paths will not be restored live and will be extracted for manual recovery.")"$'\n'
|
||||||
fi
|
fi
|
||||||
if [[ "$RS_PLAN_HAS_ZFS" -eq 1 ]]; then
|
if [[ "$RS_PLAN_HAS_ZFS" -eq 1 ]]; then
|
||||||
if [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" == "1" ]]; then
|
if [[ "${HB_RESTORE_INCLUDE_ZFS:-0}" == "1" ]]; then
|
||||||
echo " • $(translate "Includes /etc/zfs: ENABLED for restore")"
|
body+=" • $(translate "Includes /etc/zfs"): \Zb$(translate "ENABLED for restore")\ZB"$'\n'
|
||||||
else
|
else
|
||||||
echo " • $(translate "Includes /etc/zfs: DISABLED unless you enable it")"
|
body+=" • $(translate "Includes /etc/zfs"): \Zb$(translate "DISABLED unless you enable it")\ZB"$'\n'
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
echo ""
|
body+=$'\n'
|
||||||
echo "$(translate "Recommendation: start with Complete restore (guided — recommended).")"
|
body+="\Zb$(translate "Recommendation: start with Complete restore.")\ZB"
|
||||||
} > "$tmp"
|
|
||||||
|
|
||||||
dialog --backtitle "ProxMenux" \
|
dialog --backtitle "ProxMenux" --colors \
|
||||||
--title "$(translate "Restore plan")" \
|
--title "$(translate "Restore plan")" \
|
||||||
--exit-label "OK" \
|
--msgbox "$body" 24 94 || true
|
||||||
--textbox "$tmp" 24 94 || true
|
|
||||||
rm -f "$tmp"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_rs_prompt_zfs_opt_in() {
|
_rs_prompt_zfs_opt_in() {
|
||||||
@@ -1272,6 +1504,72 @@ _rs_finish_flow() {
|
|||||||
read -r
|
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() {
|
_rs_collect_pending_paths() {
|
||||||
local mode="$1"
|
local mode="$1"
|
||||||
shift
|
shift
|
||||||
@@ -1446,113 +1744,78 @@ _rs_run_complete_guided() {
|
|||||||
local -a all_paths=()
|
local -a all_paths=()
|
||||||
hb_load_restore_paths "$staging_root" all_paths
|
hb_load_restore_paths "$staging_root" all_paths
|
||||||
|
|
||||||
local choice
|
# Build the rich confirmation body. Replaces the previous 4-strategy
|
||||||
choice=$(dialog --backtitle "ProxMenux" \
|
# menu — by design a Proxmox host restore always requires a reboot
|
||||||
--title "$(translate "Complete restore (guided)")" \
|
# for predictable end state (pmxcfs live writes + initramfs + driver
|
||||||
--menu "\n$(translate "Choose strategy:")" \
|
# reinstall via the post-boot dispatcher all need it). Forcing the
|
||||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
# strategy to "apply safe hot + pending for boot" gives the user the
|
||||||
1 "$(translate "Apply safe + reboot-required now (skip risky live paths)")" \
|
# full restore + zero-manual NVIDIA/Intel/Coral reinstall path with
|
||||||
2 "$(translate "Full now: apply all paths (advanced — may drop SSH)")" \
|
# one consistent UX, no footguns.
|
||||||
3 "$(translate "Apply safe now + schedule remaining for next boot (recommended for SSH)")" \
|
local hot_count="${RS_PLAN_HOT:-0}"
|
||||||
4 "$(translate "Schedule full restore for next boot (no live apply now)")" \
|
local pending_count=$(( ${RS_PLAN_REBOOT:-0} + ${RS_PLAN_DANGEROUS:-0} ))
|
||||||
0 "$(translate "Return")" \
|
|
||||||
3>&1 1>&2 2>&3) || return 1
|
|
||||||
|
|
||||||
case "$choice" in
|
# Surface which components the post-boot dispatcher will reinstall
|
||||||
1)
|
# (read from the backup's components_status.json — same logic as
|
||||||
if ! whiptail --title "$(translate "Confirm guided restore")" \
|
# _rs_offer_reboot_after_pending).
|
||||||
--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.")" \
|
local -a reinstalls=()
|
||||||
11 78; then
|
mapfile -t reinstalls < <(_rs_list_pending_reinstalls "$staging_root")
|
||||||
return 1
|
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
|
fi
|
||||||
|
|
||||||
show_proxmenux_logo
|
# dialog --colors lets us highlight the counts, the warning, and
|
||||||
msg_title "$(translate "Applying guided complete restore")"
|
# the reinstall list. Inline escape codes:
|
||||||
if [[ "$RS_PLAN_HOT" -gt 0 ]]; then
|
# \Zb bold \ZB unbold \Zn reset all
|
||||||
_rs_apply "$staging_root" hot
|
# \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
|
fi
|
||||||
if [[ "$RS_PLAN_REBOOT" -gt 0 ]]; then
|
body+=$'\n'"\Zb\Z4$(translate "A reboot is required to finish the restore.")\Zn"$'\n\n'
|
||||||
_rs_apply "$staging_root" reboot
|
body+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
|
||||||
fi
|
body+="\Zb$(translate "Continue?")\ZB"
|
||||||
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
|
|
||||||
;;
|
|
||||||
|
|
||||||
2)
|
if ! dialog --backtitle "ProxMenux" --colors \
|
||||||
local ssh_network_rc
|
--title "$(translate "Confirm complete restore")" \
|
||||||
_rs_handle_ssh_network_risk "$staging_root" "${all_paths[@]}"
|
--yesno "$body" 22 88; then
|
||||||
ssh_network_rc=$?
|
|
||||||
[[ $ssh_network_rc -eq 2 ]] && return 0
|
|
||||||
[[ $ssh_network_rc -ne 0 ]] && return 1
|
|
||||||
|
|
||||||
_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 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
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
show_proxmenux_logo
|
show_proxmenux_logo
|
||||||
msg_title "$(translate "Applying safe paths and preparing pending restore")"
|
msg_title "$(translate "Applying safe paths and preparing pending restore")"
|
||||||
[[ "$RS_PLAN_HOT" -gt 0 ]] && _rs_apply "$staging_root" hot
|
[[ "$hot_count" -gt 0 ]] && _rs_apply "$staging_root" hot
|
||||||
|
|
||||||
local -a pending_paths=()
|
local -a pending_paths=()
|
||||||
mapfile -t pending_paths < <(_rs_collect_pending_paths remaining_after_hot "${all_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
|
if _rs_prepare_pending_restore "$staging_root" "${pending_paths[@]}"; then
|
||||||
msg_warn "$(translate "Reboot is required to complete the pending restore.")"
|
pending_ok=1
|
||||||
fi
|
fi
|
||||||
# Strategy 3 = safe now + schedule rest: install
|
# /etc/pve is in the pending set → defer guest configs to the
|
||||||
# packages (they don't require reboot), but defer
|
# post-boot dispatcher (same as the old Strategy 3).
|
||||||
# guest configs because /etc/pve is in the pending set.
|
|
||||||
_rs_run_complete_extras "$staging_root" 0
|
_rs_run_complete_extras "$staging_root" 0
|
||||||
|
if (( pending_ok )); then
|
||||||
|
_rs_offer_reboot_after_pending "$staging_root"
|
||||||
|
else
|
||||||
_rs_finish_flow
|
_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
|
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
|
return 0
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_rs_component_paths() {
|
_rs_component_paths() {
|
||||||
@@ -1984,7 +2247,10 @@ _rs_apply_menu() {
|
|||||||
|
|
||||||
_rs_collect_plan_stats "$staging_root"
|
_rs_collect_plan_stats "$staging_root"
|
||||||
_rs_prompt_zfs_opt_in "$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
|
while true; do
|
||||||
local choice
|
local choice
|
||||||
@@ -1992,7 +2258,7 @@ _rs_apply_menu() {
|
|||||||
--title "$(translate "Restore actions")" \
|
--title "$(translate "Restore actions")" \
|
||||||
--menu "\n$(translate "Choose how to continue:")" \
|
--menu "\n$(translate "Choose how to continue:")" \
|
||||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
"$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")" \
|
2 "$(translate "Custom restore by components")" \
|
||||||
3 "$(translate "Export to file (no system changes)")" \
|
3 "$(translate "Export to file (no system changes)")" \
|
||||||
4 "$(translate "Preview changes (diff)")" \
|
4 "$(translate "Preview changes (diff)")" \
|
||||||
@@ -2090,12 +2356,19 @@ main_menu() {
|
|||||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||||
1 "$(translate "Backup host configuration")" \
|
1 "$(translate "Backup host configuration")" \
|
||||||
2 "$(translate "Restore 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")" \
|
0 "$(translate "Return")" \
|
||||||
3>&1 1>&2 2>&3) || break
|
3>&1 1>&2 2>&3) || break
|
||||||
|
|
||||||
case "$choice" in
|
case "$choice" in
|
||||||
1) backup_menu ;;
|
1) backup_menu ;;
|
||||||
2) restore_menu ;;
|
2) restore_menu ;;
|
||||||
|
3) _bk_manage_extra_paths ;;
|
||||||
|
4) _bk_scheduler ;;
|
||||||
|
5) _bk_manage_destinations ;;
|
||||||
0) break ;;
|
0) break ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -177,43 +177,147 @@ hb_path_warning() {
|
|||||||
# ==========================================================
|
# ==========================================================
|
||||||
# PROFILE PATH SELECTION
|
# 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() {
|
hb_select_profile_paths() {
|
||||||
local mode="$1"
|
local mode="$1"
|
||||||
local __out_var="$2"
|
local __out_var="$2"
|
||||||
local -n __out_ref="$__out_var"
|
local -n __out_ref="$__out_var"
|
||||||
|
|
||||||
mapfile -t __defaults < <(hb_default_profile_paths)
|
mapfile -t __defaults < <(hb_default_profile_paths)
|
||||||
|
local -a __extras=()
|
||||||
|
mapfile -t __extras < <(hb_load_extra_paths)
|
||||||
|
|
||||||
if [[ "$mode" == "default" ]]; then
|
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
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 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 :; do
|
||||||
|
# Reload after potential edits in the previous iteration
|
||||||
|
mapfile -t __extras < <(hb_load_extra_paths)
|
||||||
|
|
||||||
local options=() idx=1 path
|
local options=() idx=1 path
|
||||||
for path in "${__defaults[@]}"; do
|
for path in "${__defaults[@]}"; do
|
||||||
options+=("$idx" "$path" "off")
|
options+=("$idx" "$path" "off")
|
||||||
((idx++))
|
((idx++))
|
||||||
done
|
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
|
||||||
|
|
||||||
local selected
|
# 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" \
|
selected=$(dialog --backtitle "ProxMenux" \
|
||||||
--title "$(hb_translate "Custom backup profile")" \
|
--title "$(hb_translate "Custom backup profile")" \
|
||||||
|
--default-button ok \
|
||||||
|
--extra-button --extra-label "$(hb_translate "Add custom path")" \
|
||||||
--separate-output --checklist \
|
--separate-output --checklist \
|
||||||
"$(hb_translate "Select paths to include:")" \
|
"$(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) || return 1
|
26 86 18 "${options[@]}" 3>&1 1>&2 2>&3)
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
if (( rc == 0 )); then
|
||||||
__out_ref=()
|
__out_ref=()
|
||||||
local choice
|
|
||||||
while read -r choice; do
|
while read -r choice; do
|
||||||
[[ -z "$choice" ]] && continue
|
[[ -z "$choice" ]] && continue
|
||||||
|
if (( choice < first_extra_idx )); then
|
||||||
__out_ref+=("${__defaults[$((choice-1))]}")
|
__out_ref+=("${__defaults[$((choice-1))]}")
|
||||||
|
else
|
||||||
|
__out_ref+=("${__extras[$((choice-first_extra_idx))]}")
|
||||||
|
fi
|
||||||
done <<< "$selected"
|
done <<< "$selected"
|
||||||
|
|
||||||
if [[ ${#__out_ref[@]} -eq 0 ]]; then
|
if [[ ${#__out_ref[@]} -eq 0 ]]; then
|
||||||
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
|
dialog --backtitle "ProxMenux" --title "$(hb_translate "Error")" \
|
||||||
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
|
--msgbox "$(hb_translate "No paths selected. Select at least one path.")" 8 60
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( rc == 1 )); then
|
||||||
return 1
|
return 1
|
||||||
fi
|
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
|
# BORG
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
hb_ensure_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; }
|
command -v borg >/dev/null 2>&1 && { echo "borg"; return 0; }
|
||||||
local appimage="$HB_STATE_DIR/borg"
|
|
||||||
local tmp_file
|
local appimage_cache="$HB_STATE_DIR/borg"
|
||||||
[[ -x "$appimage" ]] && { echo "$appimage"; return 0; }
|
[[ -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 || {
|
command -v sha256sum >/dev/null 2>&1 || {
|
||||||
msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")"
|
msg_error "$(hb_translate "sha256sum not found. Cannot verify Borg binary.")"
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..."
|
msg_info "$(hb_translate "Borg not found. Downloading borg") ${HB_BORG_VERSION}..."
|
||||||
mkdir -p "$HB_STATE_DIR"
|
mkdir -p "$HB_STATE_DIR"
|
||||||
|
local tmp_file
|
||||||
tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1
|
tmp_file=$(mktemp "$HB_STATE_DIR/.borg-download.XXXXXX") || return 1
|
||||||
if wget -qO "$tmp_file" "$HB_BORG_LINUX64_URL"; then
|
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
|
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
|
else
|
||||||
rm -f "$tmp_file"
|
rm -f "$tmp_file"
|
||||||
msg_error "$(hb_translate "Borg binary checksum verification failed.")"
|
msg_error "$(hb_translate "Borg binary checksum verification failed.")"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
chmod +x "$appimage"
|
chmod +x "$appimage_cache"
|
||||||
msg_ok "$(hb_translate "Borg ready.")"
|
msg_ok "$(hb_translate "Borg ready.")"
|
||||||
echo "$appimage"; return 0
|
echo "$appimage_cache"; return 0
|
||||||
fi
|
fi
|
||||||
rm -f "$tmp_file"
|
rm -f "$tmp_file"
|
||||||
msg_error "$(hb_translate "Failed to download Borg.")"
|
msg_error "$(hb_translate "Failed to download Borg.")"
|
||||||
@@ -848,10 +968,35 @@ hb_borg_init_if_needed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hb_prepare_borg_passphrase() {
|
hb_prepare_borg_passphrase() {
|
||||||
local pass_file="$HB_STATE_DIR/borg-pass.txt"
|
|
||||||
BORG_ENCRYPT_MODE="none"
|
BORG_ENCRYPT_MODE="none"
|
||||||
unset BORG_PASSPHRASE
|
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
|
if [[ -f "$pass_file" ]]; then
|
||||||
export BORG_PASSPHRASE
|
export BORG_PASSPHRASE
|
||||||
BORG_PASSPHRASE="$(<"$pass_file")"
|
BORG_PASSPHRASE="$(<"$pass_file")"
|
||||||
@@ -859,6 +1004,9 @@ hb_prepare_borg_passphrase() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
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")" \
|
dialog --backtitle "ProxMenux" --title "$(hb_translate "Borg encryption")" \
|
||||||
--yesno "$(hb_translate "Encrypt this Borg repository?")" \
|
--yesno "$(hb_translate "Encrypt this Borg repository?")" \
|
||||||
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
|
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 0
|
||||||
@@ -877,43 +1025,214 @@ hb_prepare_borg_passphrase() {
|
|||||||
done
|
done
|
||||||
|
|
||||||
mkdir -p "$HB_STATE_DIR"
|
mkdir -p "$HB_STATE_DIR"
|
||||||
printf '%s' "$pass1" > "$pass_file"
|
local target_pass_file="$pass_file"
|
||||||
chmod 600 "$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_PASSPHRASE="$pass1"
|
||||||
export BORG_ENCRYPT_MODE="repokey"
|
export BORG_ENCRYPT_MODE="repokey"
|
||||||
}
|
}
|
||||||
|
|
||||||
hb_select_borg_repo() {
|
# Generates a new ed25519 keypair and either installs it on the remote
|
||||||
local _borg_repo_var="$1"
|
# Borg server (sshpass + one-time admin password) or shows the
|
||||||
local -n _borg_repo_ref="$_borg_repo_var"
|
# authorized_keys line for manual paste. The authorized line includes
|
||||||
local type
|
# 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" \
|
type=$(dialog --backtitle "ProxMenux" \
|
||||||
--title "$(hb_translate "Borg repository location")" \
|
--title "$(hb_translate "Borg repository location")" \
|
||||||
|
--default-item "remote" \
|
||||||
--menu "\n$(hb_translate "Select repository destination:")" \
|
--menu "\n$(hb_translate "Select repository destination:")" \
|
||||||
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" \
|
||||||
"local" "$(hb_translate 'Local directory')" \
|
"remote" "$(hb_translate 'Remote server via SSH (recommended — off-host, dedup across machines)')" \
|
||||||
"usb" "$(hb_translate 'Mounted external disk')" \
|
"usb" "$(hb_translate 'Mounted external disk (offline-safe, single-machine dedup)')" \
|
||||||
"remote" "$(hb_translate 'Remote server via SSH')" \
|
"local" "$(hb_translate 'Local directory (single-machine — only use if it is a SEPARATE disk)')" \
|
||||||
3>&1 1>&2 2>&3) || return 1
|
3>&1 1>&2 2>&3) || return 1
|
||||||
|
|
||||||
unset BORG_RSH
|
local repo="" ssh_key=""
|
||||||
|
|
||||||
case "$type" in
|
case "$type" in
|
||||||
local)
|
local)
|
||||||
_borg_repo_ref=$(dialog --backtitle "ProxMenux" \
|
repo=$(dialog --backtitle "ProxMenux" \
|
||||||
--inputbox "$(hb_translate "Borg repository path:")" \
|
--inputbox "$(hb_translate "Borg repository path:")" \
|
||||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
|
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
|
||||||
3>&1 1>&2 2>&3) || return 1
|
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)
|
usb)
|
||||||
local mnt
|
local mnt
|
||||||
mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1
|
mnt=$(hb_prompt_mounted_path "/mnt/backup") || return 1
|
||||||
_borg_repo_ref="$mnt/borgbackup"
|
repo="$mnt/borgbackup"
|
||||||
mkdir -p "$_borg_repo_ref" 2>/dev/null || true
|
mkdir -p "$repo" 2>/dev/null || true
|
||||||
;;
|
;;
|
||||||
remote)
|
remote)
|
||||||
local user host rpath ssh_key
|
local user host rpath
|
||||||
user=$(dialog --backtitle "ProxMenux" --inputbox "$(hb_translate "SSH user:")" \
|
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
|
"$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:")" \
|
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:")" \
|
--inputbox "$(hb_translate "Remote repository path:")" \
|
||||||
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
|
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "/backup/borgbackup" \
|
||||||
3>&1 1>&2 2>&3) || return 1
|
3>&1 1>&2 2>&3) || return 1
|
||||||
if dialog --backtitle "ProxMenux" \
|
|
||||||
--yesno "$(hb_translate "Use a custom SSH key?")" \
|
# SSH key strategy. Three modes:
|
||||||
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W"; then
|
# 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" \
|
ssh_key=$(dialog --backtitle "ProxMenux" \
|
||||||
--fselect "$HOME/.ssh/" 12 70 3>&1 1>&2 2>&3) || return 1
|
--title "$(hb_translate "Select SSH private key file")" \
|
||||||
export BORG_RSH="ssh -i $ssh_key -o StrictHostKeyChecking=accept-new"
|
--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
|
fi
|
||||||
_borg_repo_ref="ssh://$user@$host/$rpath"
|
;;
|
||||||
|
none)
|
||||||
|
ssh_key=""
|
||||||
;;
|
;;
|
||||||
esac
|
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,15 +1426,153 @@ hb_trim_dialog_value() {
|
|||||||
printf '%s' "$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() {
|
hb_prompt_mounted_path() {
|
||||||
local default_path="${1:-/mnt/backup}"
|
local default_path="${1:-/mnt/backup}"
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (( ${#menu[@]} == 0 )); then
|
||||||
|
# No USB at all — single inputbox fallback (no menu, less confusing)
|
||||||
local out
|
local out
|
||||||
|
|
||||||
out=$(dialog --backtitle "ProxMenux" \
|
out=$(dialog --backtitle "ProxMenux" \
|
||||||
--title "$(hb_translate "Mounted disk path")" \
|
--title "$(hb_translate "External disk for backup")" \
|
||||||
--inputbox "$(hb_translate "Path where the external disk is mounted:")" \
|
--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
|
"$HB_UI_INPUT_H" "$HB_UI_INPUT_W" "$default_path" 3>&1 1>&2 2>&3) || return 1
|
||||||
|
|
||||||
out=$(hb_trim_dialog_value "$out")
|
out=$(hb_trim_dialog_value "$out")
|
||||||
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
|
[[ -n "$out" && -d "$out" ]] || { msg_error "$(hb_translate "Path does not exist.")"; return 1; }
|
||||||
if ! mountpoint -q "$out" 2>/dev/null; then
|
if ! mountpoint -q "$out" 2>/dev/null; then
|
||||||
@@ -963,6 +1581,92 @@ hb_prompt_mounted_path() {
|
|||||||
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
|
"$HB_UI_YESNO_H" "$HB_UI_YESNO_W" || return 1
|
||||||
fi
|
fi
|
||||||
echo "$out"
|
echo "$out"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
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() {
|
hb_prompt_dest_dir() {
|
||||||
@@ -1451,8 +2155,13 @@ hb_show_compat_report() {
|
|||||||
title="$(hb_translate "Compatibility check — OK")"
|
title="$(hb_translate "Compatibility check — OK")"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 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" \
|
dialog --backtitle "ProxMenux" --title "$title" \
|
||||||
--textbox "$tmpfile" 22 86 || true
|
--textbox "$tmpfile" 22 86 || true
|
||||||
|
fi
|
||||||
rm -f "$tmpfile"
|
rm -f "$tmpfile"
|
||||||
|
|
||||||
# FAIL means at least one check is a real risk for system integrity
|
# FAIL means at least one check is a real risk for system integrity
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
# Configuration ============================================
|
# Configuration ============================================
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
BACKUP_DIR="/var/backups/proxmenux"
|
BACKUP_DIR="/var/backups/proxmenux"
|
||||||
|
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||||
|
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||||
|
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||||
|
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"
|
||||||
APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"
|
APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C"
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
|
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
|
|||||||
@@ -32,7 +32,6 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
|
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
|
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
|
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
|
|||||||
@@ -40,13 +40,11 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
CONFIG_FILE="$BASE_DIR/config.json"
|
CONFIG_FILE="$BASE_DIR/config.json"
|
||||||
CACHE_FILE="$BASE_DIR/cache.json"
|
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||||
BETA_VERSION_FILE="$BASE_DIR/beta_version.txt"
|
BETA_VERSION_FILE="$BASE_DIR/beta_version.txt"
|
||||||
INSTALL_DIR="/usr/local/bin"
|
INSTALL_DIR="/usr/local/bin"
|
||||||
MENU_SCRIPT="menu"
|
MENU_SCRIPT="menu"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
BACKTITLE="ProxMenux Configuration"
|
BACKTITLE="ProxMenux Configuration"
|
||||||
|
|
||||||
REPO_MAIN="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
REPO_MAIN="https://raw.githubusercontent.com/MacRimi/ProxMenux/main"
|
||||||
@@ -114,27 +112,10 @@ uninstall_proxmenux_monitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
detect_installation_type() {
|
detect_installation_type() {
|
||||||
local has_venv=false
|
# The Translation/Normal split is gone after the googletrans removal.
|
||||||
local has_language=false
|
# All installs are multilingual via pre-built lang/*.json. Keeping the
|
||||||
|
# function name + a fixed value so callers don't have to change.
|
||||||
# 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"
|
echo "normal"
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
check_monitor_status() {
|
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 "$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"
|
[ -f "$LOCAL_VERSION_FILE" ] && info_message+="✓ version.txt → $LOCAL_VERSION_FILE\n" || info_message+="✗ version.txt\n"
|
||||||
|
|
||||||
# Show translation-specific files
|
# Language section: always present now that translations are static
|
||||||
if [ "$install_type" = "translation" ]; then
|
# JSON lookups. Show the configured language and the lang/ directory
|
||||||
[ -f "$CACHE_FILE" ] && info_message+="✓ cache.json → $CACHE_FILE\n" || info_message+="✗ cache.json\n"
|
# so the operator can verify the cache is in place.
|
||||||
|
current_language=$(jq -r '.language // "en"' "$CONFIG_FILE" 2>/dev/null)
|
||||||
info_message+="\n$(translate "Virtual Environment:")\n"
|
[[ -z "$current_language" || "$current_language" == "null" ]] && current_language="en"
|
||||||
if [ -d "$VENV_PATH" ] && [ -f "$VENV_PATH/bin/activate" ]; then
|
info_message+="\n$(translate "Current language:")\n${current_language}\n"
|
||||||
info_message+="✓ $(translate "Installed") → $VENV_PATH\n"
|
if [ -d "$BASE_DIR/lang" ]; then
|
||||||
[ -f "$VENV_PATH/bin/pip" ] && info_message+="✓ pip: $(translate "Installed") → $VENV_PATH/bin/pip\n" || info_message+="✗ pip: $(translate "Not installed")\n"
|
info_message+="✓ $(translate "Translation files:") $BASE_DIR/lang/\n"
|
||||||
else
|
else
|
||||||
info_message+="✗ $(translate "Virtual Environment"): $(translate "Not installed")\n"
|
info_message+="✗ $(translate "Translation files:") $(translate "missing")\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"
|
|
||||||
else
|
|
||||||
info_message+="\n$(translate "Language:")\nEnglish (Fixed)\n"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Display information in a scrollable text box
|
# Display information in a scrollable text box
|
||||||
@@ -786,37 +760,23 @@ uninstall_proxmenu() {
|
|||||||
|
|
||||||
local deps_to_remove=""
|
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" \
|
deps_to_remove=$(dialog --clear --backtitle "ProxMenux Configuration" \
|
||||||
--title "Remove Dependencies" \
|
--title "Remove Dependencies" \
|
||||||
--checklist "Select dependencies to remove:" 12 60 2 \
|
--checklist "Select dependencies to remove:" 12 60 2 \
|
||||||
"dialog" "Interactive dialog boxes" OFF \
|
"dialog" "Interactive dialog boxes" OFF \
|
||||||
"jq" "JSON processor" OFF \
|
"jq" "JSON processor" OFF \
|
||||||
3>&1 1>&2 2>&3)
|
3>&1 1>&2 2>&3)
|
||||||
fi
|
|
||||||
|
|
||||||
# Perform uninstallation with progress bar
|
# Perform uninstallation with progress bar
|
||||||
(
|
(
|
||||||
echo "10" ; echo "Removing ProxMenu files..."
|
echo "10" ; echo "Removing ProxMenu files..."
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
# Remove googletrans and virtual environment if exists
|
# Purge the legacy googletrans virtualenv if it was left over from
|
||||||
if [ -f "$VENV_PATH/bin/activate" ]; then
|
# a pre-static-translations install. Cheap idempotent check.
|
||||||
echo "30" ; echo "Removing googletrans and virtual environment..."
|
if [ -d "/opt/googletrans-env" ]; then
|
||||||
source "$VENV_PATH/bin/activate"
|
echo "30" ; echo "Removing legacy googletrans virtualenv..."
|
||||||
pip uninstall -y googletrans >/dev/null 2>&1
|
rm -rf "/opt/googletrans-env"
|
||||||
deactivate
|
|
||||||
rm -rf "$VENV_PATH"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "50" ; echo "Removing ProxMenu files..."
|
echo "50" ; echo "Removing ProxMenu files..."
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ MENU_REPO="$LOCAL_SCRIPTS/menus"
|
|||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
UTILS_FILE="$LOCAL_SCRIPTS/utils.sh"
|
||||||
[[ ! -f "$UTILS_FILE" ]] && UTILS_FILE="$BASE_DIR/utils.sh"
|
[[ ! -f "$UTILS_FILE" ]] && UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
|
|
||||||
# Source utilities and required scripts
|
# Source utilities and required scripts
|
||||||
if [[ -f "$UTILS_FILE" ]]; then
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
UTILS_FILE="$BASE_DIR/utils.sh"
|
UTILS_FILE="$BASE_DIR/utils.sh"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
|
|
||||||
|
|
||||||
if ! command -v dialog &>/dev/null; then
|
if ! command -v dialog &>/dev/null; then
|
||||||
@@ -23,47 +22,11 @@ if ! command -v dialog &>/dev/null; then
|
|||||||
fi
|
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
|
if [[ -f "$UTILS_FILE" ]]; then
|
||||||
source "$UTILS_FILE"
|
source "$UTILS_FILE"
|
||||||
|
|||||||
@@ -38,16 +38,11 @@ LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"
|
|||||||
INSTALL_DIR="/usr/local/bin"
|
INSTALL_DIR="/usr/local/bin"
|
||||||
BASE_DIR="/usr/local/share/proxmenux"
|
BASE_DIR="/usr/local/share/proxmenux"
|
||||||
CONFIG_FILE="$BASE_DIR/config.json"
|
CONFIG_FILE="$BASE_DIR/config.json"
|
||||||
CACHE_FILE="$BASE_DIR/cache.json"
|
LANG_DIR="$BASE_DIR/lang"
|
||||||
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
LOCAL_VERSION_FILE="$BASE_DIR/version.txt"
|
||||||
MENU_SCRIPT="menu"
|
MENU_SCRIPT="menu"
|
||||||
VENV_PATH="/opt/googletrans-env"
|
|
||||||
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
COMPONENTS_STATUS_FILE="$BASE_DIR/components_status.json"
|
||||||
|
|
||||||
|
|
||||||
# Translation context
|
|
||||||
TRANSLATION_CONTEXT="Context: Technical message for Proxmox and IT. Translate:"
|
|
||||||
|
|
||||||
# Color and style definitions
|
# Color and style definitions
|
||||||
NEON_PURPLE_BLUE="\033[38;5;99m"
|
NEON_PURPLE_BLUE="\033[38;5;99m"
|
||||||
WHITE="\033[38;5;15m"
|
WHITE="\033[38;5;15m"
|
||||||
@@ -216,9 +211,10 @@ msg_error() {
|
|||||||
# Initialize cache
|
# Initialize cache
|
||||||
initialize_cache() {
|
initialize_cache() {
|
||||||
if [[ "$LANGUAGE" != "en" ]]; then
|
if [[ "$LANGUAGE" != "en" ]]; then
|
||||||
if [ ! -f "$CACHE_FILE" ]; then
|
mkdir -p "$LANG_DIR"
|
||||||
mkdir -p "$(dirname "$CACHE_FILE")"
|
local lang_file="$LANG_DIR/${LANGUAGE}.json"
|
||||||
echo "{}" > "$CACHE_FILE"
|
if [ ! -f "$lang_file" ]; then
|
||||||
|
echo "{}" > "$lang_file"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -241,78 +237,37 @@ load_language() {
|
|||||||
|
|
||||||
|
|
||||||
translate() {
|
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 text="$1"
|
||||||
local dest_lang="$LANGUAGE"
|
local dest_lang="$LANGUAGE"
|
||||||
|
|
||||||
|
|
||||||
if [ "$dest_lang" = "en" ]; then
|
if [ "$dest_lang" = "en" ]; then
|
||||||
echo "$text"
|
echo "$text"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -s "$CACHE_FILE" ] || ! jq -e . "$CACHE_FILE" > /dev/null 2>&1; then
|
local lang_file="$LANG_DIR/${dest_lang}.json"
|
||||||
echo "{}" > "$CACHE_FILE"
|
if [ -s "$lang_file" ] && command -v jq >/dev/null 2>&1; then
|
||||||
fi
|
local cached
|
||||||
|
cached=$(jq -r --arg text "$text" '.[$text] // empty' "$lang_file" 2>/dev/null)
|
||||||
local cached_translation=$(jq -r --arg text "$text" --arg lang "$dest_lang" '.[$text][$lang] // .[$text]["notranslate"] // empty' "$CACHE_FILE")
|
if [ -n "$cached" ]; then
|
||||||
if [ -n "$cached_translation" ]; then
|
echo "$cached"
|
||||||
echo "$cached_translation"
|
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ! -d "$VENV_PATH" ]; then
|
|
||||||
echo "$text"
|
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"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$translated"
|
|
||||||
else
|
|
||||||
local error=$(echo "$translation_result" | jq -r '.error')
|
|
||||||
echo "$text"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,14 +12,16 @@ import CopyableCode from "@/components/CopyableCode"
|
|||||||
|
|
||||||
// Resolve which CHANGELOG.md to read for the given locale. The canonical
|
// 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
|
// English file lives at the repo root (so GitHub displays it as-is and
|
||||||
// existing RSS / external consumers don't break). Localized versions
|
// existing RSS / external consumers don't break). Localized versions sit
|
||||||
// sit under <repo>/lang/<locale>/CHANGELOG.md. Falls back to English if
|
// under <repo>/web/data/changelog/<locale>.md — separate from the
|
||||||
// the localized file doesn't exist yet — so a partially-translated
|
// /lang/ directory which is now reserved for runtime translation JSON
|
||||||
// changelog still renders (in EN) instead of 404'ing.
|
// 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 {
|
function resolveChangelogPath(locale: string): string {
|
||||||
const repoRoot = path.join(process.cwd(), "..")
|
const repoRoot = path.join(process.cwd(), "..")
|
||||||
if (locale && locale !== "en") {
|
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
|
if (fs.existsSync(localized)) return localized
|
||||||
}
|
}
|
||||||
return path.join(repoRoot, "CHANGELOG.md")
|
return path.join(repoRoot, "CHANGELOG.md")
|
||||||
|
|||||||
Reference in New Issue
Block a user