diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py
index 796cd962..4f240643 100644
--- a/.github/scripts/run_offline_qualified.py
+++ b/.github/scripts/run_offline_qualified.py
@@ -13,6 +13,9 @@ import sys
import tempfile
PYTHON_FILES = (
+ "AppImage/scripts/tests/test_pbs_i18n.py",
+ "AppImage/scripts/tests/test_monitor_links_i18n.py",
+ "AppImage/scripts/tests/test_monitor_links.py",
"AppImage/scripts/tests/test_local_app_icon_isolation.py",
"AppImage/scripts/tests/test_local_app_icons.py",
"AppImage/scripts/tests/test_docker_app_discovery.py",
@@ -29,6 +32,9 @@ PYTHON_FILES = (
)
NODE_FILES = (
"tests/test_oci_update_cancellation.cjs",
+ "tests/test_host_backup_messages.cjs",
+ "tests/test_pbs_i18n.cjs",
+ "tests/test_monitor_links_i18n.cjs",
"tests/lxc_updates/test_local_app_icons_ui.cjs",
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
diff --git a/.github/scripts/tests/test_pbs_cli_localization.py b/.github/scripts/tests/test_pbs_cli_localization.py
new file mode 100644
index 00000000..bda1064b
--- /dev/null
+++ b/.github/scripts/tests/test_pbs_cli_localization.py
@@ -0,0 +1,250 @@
+"""PBS CLI language and no-effect boundaries using real functions, isolated files."""
+import contextlib
+import copy
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[3]
+
+
+def function(path, name):
+ text = (ROOT/path).read_text()
+ start = text.index(name+'() {')
+ return text[start:text.index('\n}\n', start)+3]
+
+
+def repository(components, suite='trixie'):
+ return dict(Types=['deb'], URIs=['https://credential.invalid/private'], Suites=[suite],
+ Components=components.split(), Enabled=True)
+
+
+class RepositoryCliTests(unittest.TestCase):
+ def run_policy(self, case, language='sk', mode='plan', malformed=None, product='pbs'):
+ spec = importlib.util.spec_from_file_location('policy_fixture', ROOT/'scripts/global/repository_policy.py')
+ module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
+ state = dict(files=[dict(path='/etc/apt/sources.list.d/pbs.sources', repositories=[repository('pbs-enterprise')]),
+ dict(path='/etc/apt/sources.list.d/debian.sources', repositories=[repository('main')])],
+ errors=[], digest='fixture', **{'standard-repos':[{'handle':'no-subscription'}]})
+ status='notfound'; calls=[]
+ if case=='inventory': state['errors']=['PRIVATE RAW DIAGNOSTIC']
+ if case=='status': status='unknown'
+ if case=='suite': state['files'][0]['repositories'][0]['Suites']=['bookworm']
+ if case=='active': status='active'; state['files']=state['files'][1:]
+ if case=='debian': state['files']=state['files'][:1]
+ if case=='mixed': state['files'][0]['repositories'][0]['Components'].append('main')
+ if case=='handle': state['standard-repos']=[]
+ if case=='preserve': state['files'][0]['repositories'][0]['Components']=['pbs-no-subscription']
+ original=copy.deepcopy(state)
+ def run(args):
+ calls.append(args)
+ if args[0]=='proxmox-backup-manager': return json.dumps(dict(status=status, serverid='PRIVATE SERVER ID'))
+ if 'create' in args:
+ state['files'][0]['repositories'][0]['Enabled']=False
+ if case=='partial': state['errors']=['PRIVATE RAW DIAGNOSTIC']
+ return ''
+ if 'set' in args:
+ state['files'].append(dict(path='/etc/apt/sources.list.d/public.sources',repositories=[repository('pbs-no-subscription')]))
+ return ''
+ return json.dumps(state)
+ with tempfile.TemporaryDirectory(prefix='pbs-policy-i18n-') as folder:
+ module.BASE=Path(folder)
+ (module.BASE/'lang').mkdir()
+ (module.BASE/'config.json').write_text(json.dumps({'language':language}))
+ catalog=(ROOT/'lang/sk.json').read_text() if malformed is None else malformed
+ (module.BASE/'lang/sk.json').write_text(catalog)
+ stdout,stderr=io.StringIO(),io.StringIO()
+ suite='sid' if case=='unsupported' else 'trixie'
+ with patch.object(module,'run',side_effect=run), patch.object(module.sys,'argv',['repository_policy.py',mode,suite,product]), contextlib.redirect_stdout(stdout),contextlib.redirect_stderr(stderr):
+ result=module.main()
+ return result,stdout.getvalue(),stderr.getvalue(),calls,state,original
+
+ def test_pbs_errors_are_localized_without_changing_decisions_or_exposing_raw_data(self):
+ expectations={'inventory':'Zoznam repozitárov APT', 'status':'Stav predplatného',
+ 'suite':'iné vydanie', 'active':'aktívne predplatné', 'debian':'základný repozitár Debianu',
+ 'mixed':'spoločný záznam APT', 'handle':'no-subscription', 'unsupported':'Nepodporované vydanie',
+ 'partial':'stav repozitárov je neznámy'}
+ for case,expected in expectations.items():
+ with self.subTest(case=case):
+ result,out,error,calls,state,original=self.run_policy(case,mode='apply' if case=='partial' else 'plan')
+ self.assertEqual(result,1); self.assertEqual(out,''); self.assertIn(expected,error)
+ self.assertNotIn('PRIVATE',error); self.assertNotIn('credential.invalid',error)
+ writes=[c for c in calls if 'create' in c or 'set' in c]
+ if case=='partial':
+ self.assertEqual(len(writes),1)
+ self.assertFalse(state['files'][0]['repositories'][0]['Enabled'])
+ self.assertIn('Automatický návrat',error)
+ else:
+ self.assertEqual(writes,[]); self.assertEqual(state,original)
+ if case=='unsupported': self.assertEqual(calls,[])
+
+ def test_english_error_and_stdout_tokens_remain_compatible(self):
+ en=self.run_policy('unsupported',language='en')
+ self.assertEqual(en[2],'Unsupported Proxmox suite; no repository changed.\n')
+ pve=self.run_policy('unsupported',product='pve')
+ self.assertEqual(pve[2],en[2])
+ for mode,expected in [('plan','offer\n'),('apply','changed\n')]:
+ result,out,error,*_=self.run_policy('offer',mode=mode)
+ self.assertEqual((result,out,error),(0,expected,''))
+ result,out,error,calls,*_=self.run_policy('preserve',mode='apply')
+ self.assertEqual((result,out,error),(0,'preserve\n',''))
+ self.assertFalse(any(c[0]=='proxmox-backup-manager' or 'set' in c or 'create' in c for c in calls))
+
+ def test_bad_or_missing_translation_falls_back_to_original_english(self):
+ for catalog in ('{}','not json','[]',json.dumps({'Unsupported Proxmox suite; no repository changed.':42}),
+ json.dumps({'Unsupported Proxmox suite; no repository changed.':'{unknown}'})):
+ with self.subTest(catalog=catalog):
+ result,out,error,calls,*_=self.run_policy('unsupported',malformed=catalog)
+ self.assertEqual(result,1);self.assertEqual(out,'');self.assertEqual(calls,[])
+ self.assertEqual(error,'Unsupported Proxmox suite; no repository changed.\n')
+
+ def test_translation_cannot_drop_actual_repository_details(self):
+ spec=importlib.util.spec_from_file_location('policy_format_fixture',ROOT/'scripts/global/repository_policy.py')
+ module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
+ with tempfile.TemporaryDirectory(prefix='pbs-policy-format-') as folder:
+ module.BASE=Path(folder);(module.BASE/'lang').mkdir()
+ (module.BASE/'config.json').write_text('{"language":"sk"}')
+ error=module.PolicyError('Inspect {ui}; no repository changed.',ui='Administration > Repositories')
+ for invalid in ('Skontrolujte repozitáre.','{ui.__class__}','{other}','{ui:.0}','{ui!r}'):
+ (module.BASE/'lang/sk.json').write_text(json.dumps({error.template:invalid}))
+ self.assertEqual(module.cli_error(error,'pbs'),str(error))
+ for invalid in ('not json','[]','{"language":"../../private"}'):
+ (module.BASE/'config.json').write_text(invalid)
+ self.assertEqual(module.cli_error(error,'pbs'),str(error))
+
+
+UPDATE_HARNESS=r'''
+BASE_DIR="$FIXTURE"
+LANG_DIR="$REPO/lang"
+SCRIPT_TITLE='fixture'
+TAB='' BGN='' CL='' GN='' BL=''
+mkdir -p "$FIXTURE/log" "$FIXTURE/tmp" "$FIXTURE/boot" "$FIXTURE/run"
+: > "$FIXTURE/calls"
+record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
+pmx_journal_context() { :; }
+pmx_is_pbs() { [[ "$CASE" != pve ]]; }
+pmx_platform_field() { [[ "$1" != supported ]] && printf '%s' "$SUITE" || printf true; }
+ensure_pbs_repositories() { record repository-plan; return 0; }
+dpkg-query() { [[ "$SUITE" == bookworm ]] && printf 3.4.0 || printf 4.0.1; }
+apt-cache() { record apt-cache "$@"; printf ' Candidate: 4.0.2\n'; }
+df() { printf 'Filesystem 1K-blocks Used Available Use%% Mounted\nfixture 10000 1000 %s 10%% /\n' "$([[ "$CASE" == disk ]] && echo 512000 || echo 4096000)"; }
+apt-get() { record apt-get "$@"; [[ "$CASE" != refresh ]]; }
+apt() {
+ record apt "$@"
+ if [[ "$*" == 'list --upgradable' ]]; then
+ printf 'Listing...\n'
+ [[ "$CASE" == current ]] || printf 'fixture/trixie 1.2 amd64 [upgradable from: 1.1]\n'
+ return 0
+ fi
+ [[ "$CASE" != failure ]]
+}
+fixture_env() { "$@"; }
+APT_ENV='fixture_env'
+pmx_format_upgradable() { cat; }
+pmx_record_execution() { record journal "$1"; }
+pmx_dpkg_changes_since() { :; }
+msg_error() { printf '%s\n' "$*"; }
+msg_ok() { printf '%s\n' "$*"; }
+msg_info2() { printf '%s\n' "$*"; }
+msg_warn() { printf '%s\n' "$*"; }
+msg_title() { :; }
+clear() { :; }
+show_proxmenux_logo() { :; }
+whiptail() { printf '%s\n' "$*" >> "$FIXTURE/dialog"; [[ "$CASE" != cancel ]]; }
+uname() { printf old-kernel; }
+touch "$FIXTURE/boot/vmlinuz-new-kernel"
+'''
+
+
+class UpdateCliTests(unittest.TestCase):
+ def run_update(self,case,language='sk',suite='trixie'):
+ with tempfile.TemporaryDirectory(prefix='pbs-update-i18n-') as folder:
+ body=function('scripts/global/update-pbs.sh','update_pbs')
+ for old,new in [('/var/log/',folder+'/log/'),('/tmp/proxmenux_screen_capture_',folder+'/tmp/proxmenux_screen_capture_'),('/var/run/',folder+'/run/'),('/boot/',folder+'/boot/')]: body=body.replace(old,new)
+ script=UPDATE_HARNESS+function('scripts/utils.sh','translate')+body+'\nupdate_pbs\n'
+ result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
+ env={**os.environ,'REPO':str(ROOT),'FIXTURE':folder,'CASE':case,'LANGUAGE':language,'SUITE':suite})
+ files={str(p.relative_to(folder)):p.read_text() for p in Path(folder).rglob('*') if p.is_file()}
+ return result,files
+
+ def test_pve_guard_cannot_reach_repository_or_apt_calls(self):
+ result,files=self.run_update('pve')
+ self.assertEqual(result.returncode,1,result.stderr)
+ self.assertEqual(files['calls'],'')
+ self.assertFalse(any(name.startswith('log/') for name in files))
+
+ def test_disk_and_upgrade_errors_keep_their_actual_parameters(self):
+ result,files=self.run_update('disk')
+ self.assertEqual(result.returncode,1,result.stderr)
+ self.assertIn('Nedostatok miesta na disku. Dostupné:',result.stdout)
+ self.assertIn('500MB',result.stdout)
+ self.assertNotIn('apt',files['calls'])
+ result,files=self.run_update('failure')
+ self.assertEqual(result.returncode,1,result.stderr)
+ self.assertIn('Aktualizácia systému zlyhala. Skontrolujte denník:',result.stdout)
+ self.assertIn('/log/proxmox-update-',result.stdout)
+ self.assertNotIn('autoremove',files['calls'])
+
+ def test_update_status_cancel_refresh_error_and_reboot_notice(self):
+ for suite in ('bookworm','trixie'):
+ for case in ('current','cancel','refresh','success'):
+ with self.subTest(suite=suite,case=case):
+ result,files=self.run_update(case,suite=suite)
+ self.assertEqual(result.returncode,1 if case=='refresh' else 0,result.stderr)
+ if case!='success': self.assertNotIn('full-upgrade',files['calls'])
+ if case=='success': self.assertIn('reštartujte server',result.stdout)
+ if case in ('current','cancel'): self.assertIn('Aktuálna verzia PBS',files['dialog'])
+
+
+class DialogTests(unittest.TestCase):
+ def test_automated_confirmation_and_banner_cancel_do_not_run_actions(self):
+ cases=[('scripts/menus/menu_post_install.sh','confirm_automated_script',
+ ['repozitár no-subscription','upozornenie na predplatné','pamäť','jadro','sieť']),
+ ('scripts/post_install/auto_post_install.sh','remove_subscription_banner',
+ ['Proxmox Backup Server – odstránenie upozornenia na predplatné'])]
+ for path,name,expected in cases:
+ with tempfile.TemporaryDirectory(prefix='pbs-dialog-') as folder:
+ script=function('scripts/utils.sh','translate')+function(path,name)+r'''
+PMX_ON_PBS=true
+LOCAL_SCRIPTS='/fixture/scripts'
+clear() { :; }; sleep() { :; }; stop_spinner() { :; }
+msg_warn() { printf '%s\n' "$*"; }
+bash() { printf forbidden >> "$CAPTURE/actions"; }
+register_tool() { printf forbidden >> "$CAPTURE/actions"; }
+dialog() { printf '%s\n' "$*" >> "$CAPTURE/dialog"; return 1; }
+whiptail() { dialog "$@"; }
+'''+name+'\n'
+ result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
+ env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'CAPTURE':folder})
+ self.assertEqual(result.returncode,1 if name=='remove_subscription_banner' else 0,result.stderr)
+ dialog=(Path(folder)/'dialog').read_text()
+ for text in expected:self.assertIn(text,dialog)
+ self.assertFalse((Path(folder)/'actions').exists())
+ if name=='remove_subscription_banner':self.assertIn('bolo zrušené používateľom',result.stdout)
+
+ def test_community_labels_preserve_dispatch_names_and_translate_display(self):
+ script=function('scripts/utils.sh','translate')+function('scripts/menus/menu_post_install.sh','post_install_label')+'\npost_install_label "$1"\n'
+ for text,expected in [('Proxmox Backup Server Post Install','nastavenie po inštalácii'),
+ ('Proxmox Backup Server Microcode','mikrokód procesora')]:
+ result=subprocess.run(['bash','-c',script,'bash',text],capture_output=True,text=True,timeout=15,
+ env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang')})
+ self.assertEqual(result.returncode,0,result.stderr);self.assertIn(expected,result.stdout)
+
+ def test_failed_apt_hook_check_removes_only_fixture_hook_with_localized_warning(self):
+ with tempfile.TemporaryDirectory(prefix='pbs-hook-') as folder:
+ script=function('scripts/utils.sh','translate')+function('scripts/global/remove-banner-pve-v3.sh','create_apt_hook')+r'''
+apt-config() { return 1; }
+msg_warn() { printf '%s\n' "$*"; }
+create_apt_hook
+'''
+ result=subprocess.run(['bash','-c',script],capture_output=True,text=True,timeout=15,
+ env={**os.environ,'LANGUAGE':'sk','LANG_DIR':str(ROOT/'lang'),'APT_HOOK':folder+'/hook'})
+ self.assertEqual(result.returncode,0,result.stderr)
+ self.assertIn('Nesprávna syntax hooku APT',result.stdout)
+ self.assertFalse((Path(folder)/'hook').exists())
diff --git a/.github/scripts/tests/test_zram_localization.py b/.github/scripts/tests/test_zram_localization.py
new file mode 100644
index 00000000..bf9bc768
--- /dev/null
+++ b/.github/scripts/tests/test_zram_localization.py
@@ -0,0 +1,168 @@
+"""Render the real ZRAM CLI paths in isolated files with host effects stubbed."""
+import json
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[3]
+DETAILS = {
+ "version": ("Cannot determine installed systemd-zram-generator version", "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator"),
+ "mismatch": ("Existing ZRAM profile is active but does not match its configuration", "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii"),
+ "stop": ("Could not stop existing dev-zram0.swap", "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap"),
+ "directory": ("Could not create ZRAM generator directory", "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM"),
+ "config": ("Could not write ZRAM generator configuration", "Nepodarilo sa zapísať konfiguráciu generátora ZRAM"),
+ "marker": ("Could not write ZRAM package ownership state", "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux"),
+ "start": ("Could not activate dev-zram0.swap", "Nepodarilo sa aktivovať jednotku dev-zram0.swap"),
+ "verify": ("Active ZRAM algorithm, capacity or swap sources did not match the profile", "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu"),
+}
+REFUSAL = "Another ZRAM generator configuration exists; refusing to overwrite it:"
+REFUSAL_SK = "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:"
+
+
+def function(path, name):
+ text = path.read_text()
+ start = text.index(name + "() {")
+ return text[start:text.index("\n}\n", start) + 3]
+
+
+HARNESS = r'''
+set -u
+BASE_DIR="$FIXTURE/state"
+LANG_DIR="$REPO/lang"
+mkdir -p "$BASE_DIR" "$FIXTURE/etc/systemd/zram-generator.conf.d" "$FIXTURE/proc"
+CONFIG="$FIXTURE/etc/systemd/zram-generator.conf.d/90-proxmenux.conf"
+MARKER="$BASE_DIR/zram-swap.state"
+printf 'Filename\tType\tSize\tUsed\tPriority\n' > "$FIXTURE/proc/swaps"
+printf '/swapfile none swap sw 0 0\n' > "$FIXTURE/etc/fstab"
+: > "$FIXTURE/calls"
+record() { printf '%s\n' "$*" >> "$FIXTURE/calls"; }
+pmx_journal_context() { :; }
+msg_error() { printf '%s\n' "$*"; }
+msg_success() { printf '%s\n' "$*"; }
+register_tool() { record register "$@"; }
+pmx_install_pkg() { record package-install; return 1; }
+apt-get() { record apt "$@"; return 1; }
+dpkg-query() {
+ record dpkg-query
+ case "$*" in
+ *'${Status}'*) printf 'install ok installed\n';;
+ *'${Version}'*) [[ "$CASE" == version ]] || printf '1.2.0-1\n';;
+ esac
+}
+dpkg() { return 0; }
+install() { record directory; [[ "$CASE" != directory ]]; }
+pmx_write_file() {
+ record write "$1"
+ if [[ "$CASE" == config && "$1" == "$CONFIG" ]] ||
+ [[ "$CASE" == marker && "$1" == "$MARKER" ]]; then
+ cat >/dev/null
+ return 1
+ fi
+ cat > "$1"
+}
+pmx_remove_file() { record remove "$1"; rm -f -- "$1"; }
+pmx_record_uninstall() { record record-uninstall; }
+swapoff() { record swapoff; return 0; }
+systemctl() {
+ record systemctl "$@"
+ [[ "$CASE" != start || "$1" != start ]] &&
+ [[ "$CASE" != stop || "$1" != stop ]]
+}
+source "$REPO/scripts/post_install/zram_profile_helpers.sh"
+zram_profile_verify_active() { [[ "$CASE" != verify && "$CASE" != mismatch ]]; }
+if [[ "$CASE" == mismatch || "$CASE" == stop ]]; then
+ printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
+ [[ "$CASE" != mismatch ]] || zram_render_generator_config true > "$CONFIG"
+ printf 'installed_by_proxmenux=false\n' > "$MARKER"
+ printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$FIXTURE/proc/swaps"
+fi
+if [[ "$CASE" == foreign ]]; then
+ printf '[zram1]\nzram-size = ram / 5\n' > "$FIXTURE/etc/systemd/zram-generator.conf"
+fi
+if [[ "$CASE" == unreadable ]]; then
+ zram_foreign_generator_config() { return 2; }
+fi
+'''
+
+
+class ZramLocalizationTests(unittest.TestCase):
+ def test_missing_optional_translation_uses_original_english(self):
+ with tempfile.TemporaryDirectory(prefix="zram-fallback-") as folder:
+ (Path(folder) / 'xx.json').write_text('{}')
+ script = function(ROOT / "scripts/utils.sh", "translate") + '\ntranslate "$1"\n'
+ result = subprocess.run(["bash", "-c", script, "bash", DETAILS['version'][0]],
+ text=True, capture_output=True, timeout=15,
+ env={**os.environ, 'LANGUAGE': 'xx', 'LANG_DIR': folder})
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout.strip(), DETAILS['version'][0])
+
+ def run_case(self, case, language="sk"):
+ with tempfile.TemporaryDirectory(prefix="zram-i18n-") as folder:
+ directory = Path(folder)
+ installer = function(ROOT / "scripts/post_install/customizable_post_install.sh", "configure_zram_swap")
+ installer = installer.replace("/etc/systemd/zram-generator.conf.d/90-proxmenux.conf", folder + "/etc/systemd/zram-generator.conf.d/90-proxmenux.conf")
+ installer = installer.replace("/proc/swaps", folder + "/proc/swaps").replace("/etc/fstab", folder + "/etc/fstab")
+ installer = installer.replace('zram_foreign_generator_config "$config"', 'zram_foreign_generator_config "$config" "$FIXTURE"')
+ installer = installer.replace('zram_list_foreign_systemd_zram_units)', 'zram_list_foreign_systemd_zram_units "$FIXTURE")')
+ script = HARNESS + function(ROOT / "scripts/utils.sh", "translate") + installer + "\nconfigure_zram_swap\n"
+ result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
+ env={**os.environ, "REPO": str(ROOT), "FIXTURE": folder, "CASE": case, "LANGUAGE": language}, timeout=15)
+ calls = (directory / "calls").read_text().splitlines()
+ files = {str(p.relative_to(directory)): p.read_text() for p in directory.rglob("*") if p.is_file()}
+ return result, calls, files
+
+ def test_all_activation_details_are_localized_and_english_is_preserved(self):
+ for case, (english, slovak) in DETAILS.items():
+ for language, expected in (("sk", slovak), ("en", english)):
+ with self.subTest(case=case, language=language):
+ result, calls, files = self.run_case(case, language)
+ self.assertEqual(result.returncode, 1, result.stderr)
+ self.assertIn(expected, result.stdout)
+ self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
+ if case in ("version", "mismatch"):
+ self.assertTrue(all(call == "dpkg-query" for call in calls), calls)
+ elif case == "stop":
+ self.assertEqual(files["state/zram-swap.state"], "installed_by_proxmenux=false\n")
+ self.assertIn("# ProxMenux ZRAM profile", files["etc/systemd/zram-generator.conf.d/90-proxmenux.conf"])
+ else:
+ self.assertNotIn("state/zram-swap.state", files)
+ self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
+ self.assertFalse(any(call.startswith(("apt ", "package-install", "register ")) for call in calls), calls)
+
+ def test_foreign_generator_is_refused_in_both_languages_without_any_effect(self):
+ for language, expected in (("sk", REFUSAL_SK), ("en", REFUSAL)):
+ result, calls, files = self.run_case("foreign", language)
+ self.assertEqual(result.returncode, 1, result.stderr)
+ self.assertIn(expected, result.stdout)
+ self.assertIn("/etc/systemd/zram-generator.conf", result.stdout)
+ self.assertEqual(calls, [])
+ self.assertEqual(files["etc/systemd/zram-generator.conf"], "[zram1]\nzram-size = ram / 5\n")
+ self.assertNotIn("state/zram-swap.state", files)
+ self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
+ self.assertEqual(files["etc/fstab"], "/swapfile none swap sw 0 0\n")
+ self.assertEqual(files["proc/swaps"], "Filename\tType\tSize\tUsed\tPriority\n")
+
+ def test_failed_inspection_is_not_mistaken_for_no_conflict(self):
+ result, calls, files = self.run_case("unreadable")
+ self.assertEqual(result.returncode, 1, result.stderr)
+ self.assertIn("Nie je možné skontrolovať existujúcu konfiguráciu generátora ZRAM", result.stdout)
+ self.assertEqual(calls, [])
+ self.assertNotIn("state/zram-swap.state", files)
+ self.assertNotIn("etc/systemd/zram-generator.conf.d/90-proxmenux.conf", files)
+
+ def test_uninstall_label_and_cancel_do_not_call_uninstall(self):
+ for language, label in (("sk", "Profil swapu ZRAM"), ("en", "ZRAM swap profile")):
+ with tempfile.TemporaryDirectory(prefix="zram-menu-") as folder:
+ tools = Path(folder) / "tools.json"
+ tools.write_text('{"zram_swap":true}')
+ script = function(ROOT / "scripts/utils.sh", "translate")
+ script += function(ROOT / "scripts/post_install/uninstall-tools.sh", "show_uninstall_menu")
+ script += '\nensure_tools_json() { :; }; migrate_installed_tools() { :; }\nuninstall_zram_swap() { printf forbidden; return 1; }\ndialog() { printf "%s\\n" "$@" > "$MENU_CAPTURE"; return 1; }\nshow_uninstall_menu\n'
+ result = subprocess.run(["bash", "-c", script], text=True, capture_output=True,
+ env={**os.environ, "LANGUAGE": language, "LANG_DIR": str(ROOT / "lang"), "TOOLS_JSON": str(tools), "MENU_CAPTURE": folder + "/dialog.txt"}, timeout=15)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn(label, (Path(folder) / "dialog.txt").read_text())
+ self.assertNotIn("forbidden", result.stdout + result.stderr)
+ self.assertEqual(json.loads(tools.read_text()), {"zram_swap": True})
diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml
index 1993c320..53880af5 100644
--- a/.github/workflows/test-offline-qualified.yml
+++ b/.github/workflows/test-offline-qualified.yml
@@ -16,6 +16,7 @@ on:
- 'AppImage/scripts/tests/test_local_app_icons.py'
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
+ - 'AppImage/scripts/tests/test_pbs_i18n.py'
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
- 'AppImage/app/**'
- 'AppImage/components/**'
@@ -41,6 +42,7 @@ on:
- 'AppImage/scripts/tests/test_local_app_icons.py'
- 'AppImage/scripts/tests/test_local_app_icon_isolation.py'
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
+ - 'AppImage/scripts/tests/test_pbs_i18n.py'
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
- 'AppImage/app/**'
- 'AppImage/components/**'
diff --git a/.github/workflows/test-pbs.yml b/.github/workflows/test-pbs.yml
index 2dbc71b0..84a23736 100644
--- a/.github/workflows/test-pbs.yml
+++ b/.github/workflows/test-pbs.yml
@@ -6,6 +6,7 @@ on:
- '.github/workflows/test-pbs.yml'
- 'AppImage/scripts/*pbs*.py'
- 'AppImage/scripts/tests/test_pbs.py'
+ - 'AppImage/scripts/tests/test_pbs_i18n.py'
- 'AppImage/scripts/health_monitor.py'
- 'AppImage/scripts/auth_manager.py'
- 'AppImage/scripts/build_appimage.sh'
@@ -51,6 +52,7 @@ jobs:
- name: Platform, real TLS, APIs, health and AppImage packaging
run: |
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
+ python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs_i18n.py -v
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
bash -n "$script"
diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx
index 07e10b94..30434ff7 100644
--- a/AppImage/components/host-backup.tsx
+++ b/AppImage/components/host-backup.tsx
@@ -48,6 +48,7 @@ import {
import { ScriptTerminalModal } from "./script-terminal-modal"
import { RestoreProgressCard } from "./restore-progress-card"
import { fetchApi, getApiUrl } from "../lib/api-config"
+import { hostBackupErrorText } from "../lib/host-backup-messages"
import { fetchTerminalTicket } from "../lib/terminal-ws"
import { formatStorage, formatBytes, cancelButtonClass } from "../lib/utils"
import { getStorageUsageColor } from "../lib/storage-usage-color"
@@ -525,7 +526,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
await mutateInfo()
closeUpload()
} catch (e) {
- setErr(e instanceof Error ? e.message : String(e))
+ setErr(hostBackupErrorText(e, t))
setBusy(false)
}
}
@@ -542,7 +543,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
await mutateInfo()
closeDelete()
} catch (e) {
- setErr(e instanceof Error ? e.message : String(e))
+ setErr(hostBackupErrorText(e, t))
setBusy(false)
}
}
@@ -765,7 +766,7 @@ function KeyfileActionsBar({
setPass1("")
setPass2("")
} catch (e) {
- setErr(e instanceof Error ? e.message : String(e))
+ setErr(hostBackupErrorText(e, t))
} finally {
setBusy(false)
}
@@ -956,7 +957,7 @@ export function HostBackup() {
mutateJobs()
setJobToDelete(null)
} catch (e) {
- setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` }))
+ setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${hostBackupErrorText(e, t)}` }))
} finally {
setBusyJobId(null)
}
@@ -1334,7 +1335,7 @@ export function HostBackup() {
{t("backup.archives.remoteQueryWarning")}
{remoteArchivesResp.errors.map((e, i) => (
- {e.backend}/{e.repo_name}: {e.error}
+ {e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
))}
@@ -1761,7 +1762,7 @@ function InspectModal({
setImportFile(null)
setImportPath("")
} catch (e) {
- setImportError(e instanceof Error ? e.message : String(e))
+ setImportError(hostBackupErrorText(e, t))
} finally {
setImporting(false)
}
@@ -1812,7 +1813,7 @@ function InspectModal({
: undefined,
})
} catch (e) {
- setRestoreError(e instanceof Error ? e.message : String(e))
+ setRestoreError(hostBackupErrorText(e, t))
} finally {
setRestorePreparing(false)
}
@@ -1841,7 +1842,7 @@ function InspectModal({
a.click()
document.body.removeChild(a)
} catch (e) {
- setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
+ setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
} finally {
// The download itself runs in the browser's network stack;
// we just initiated it. Clear the spinner immediately.
@@ -1914,7 +1915,7 @@ function InspectModal({
document.body.removeChild(a)
setExportTask(null)
} catch (e) {
- setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
+ setError(t("backup.errors.downloadFailed", { error: hostBackupErrorText(e, t) }))
} finally {
setDownloading(false)
}
@@ -1962,7 +1963,7 @@ function InspectModal({
setShowDeleteArchiveConfirm(false)
if (onDeleted) onDeleted(); else onClose()
} catch (e) {
- setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) }))
+ setError(t("backup.errors.deleteFailed", { error: hostBackupErrorText(e, t) }))
} finally {
setDeletingArchive(false)
}
@@ -1989,7 +1990,7 @@ function InspectModal({
) as NonNullable
setInstructions(res)
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setFetchingInstructions(false)
}
@@ -2021,7 +2022,7 @@ function InspectModal({
)
setReport(res)
} catch (e: any) {
- setError(e?.message || t("backup.errors.preflightFailed"))
+ setError(hostBackupErrorText(e, t))
} finally {
setRunning(false)
}
@@ -2164,7 +2165,7 @@ function InspectModal({
— {exportTask.message}
{exportTask.state === "failed" && exportTask.error && (
- {exportTask.error}
+ {hostBackupErrorText(exportTask.error, t)}
)}
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}
@@ -2706,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
{c.id}
- {c.message}
+ {hostBackupErrorText(c.message, t)}
)
@@ -3326,9 +3327,9 @@ function CreateJobDialog({
} catch (e) {
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
const detail = err.body?.tool_output
- ? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
- : err.message
- setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
+ ? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
+ : hostBackupErrorText(err, t)
+ setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
setPbsImportBusy(false)
setSubmitting(false)
return
@@ -3391,7 +3392,7 @@ function CreateJobDialog({
})
onCreated()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setSubmitting(false)
}
@@ -3764,7 +3765,7 @@ function CreateJobDialog({
>
) : (
- {t("backup.validation.invalidLabel")} {calendarPreview.error}
+ {t("backup.validation.invalidLabel")} {hostBackupErrorText(calendarPreview.error, t)}
)
) : (
@@ -4696,9 +4697,9 @@ function ManualBackupDialog({
} catch (e) {
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
const detail = err.body?.tool_output
- ? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
- : err.message
- setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
+ ? `${hostBackupErrorText(err, t)}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
+ : hostBackupErrorText(err, t)
+ setError(t("backup.errors.encryptionSetupFailed", { error: detail || hostBackupErrorText(e, t) }))
setPbsImportBusy(false)
setSubmitting(false)
return
@@ -4738,7 +4739,7 @@ function ManualBackupDialog({
)
onLaunched(resp.job_id)
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setSubmitting(false)
}
@@ -5542,7 +5543,7 @@ function DestinationsSection({
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
onChanged()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -5554,7 +5555,7 @@ function DestinationsSection({
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
onChanged()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -5571,7 +5572,7 @@ function DestinationsSection({
)
onChanged()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -5599,7 +5600,7 @@ function DestinationsSection({
})
onChanged()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -6042,7 +6043,7 @@ function DestinationRow({
{capacity?.error && (
- {t("backup.destinations.capacityUnavailable", { error: capacity.error })}
+ {t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}
)}
{!capacity && (
@@ -6341,7 +6342,7 @@ function AddDestinationDialog({
)
setGeneratedKey(resp)
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setGeneratingKey(false)
}
@@ -6405,7 +6406,7 @@ function AddDestinationDialog({
}
onSaved(savedRepo)
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setSubmitting(false)
}
@@ -6756,7 +6757,7 @@ function UsbPicker({
mutate()
if (resp?.mountpoint) onPick(resp.mountpoint)
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -6784,7 +6785,7 @@ function UsbPicker({
setFormatTyped("")
if (resp?.mountpoint) onPick(resp.mountpoint)
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -7001,7 +7002,7 @@ function ExtraPathsSection() {
setNewPath("")
mutate()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setSubmitting(false)
}
@@ -7014,7 +7015,7 @@ function ExtraPathsSection() {
await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" })
mutate()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyPath(null)
}
@@ -7180,7 +7181,7 @@ function UsbDrivesSection() {
})
mutate()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -7198,7 +7199,7 @@ function UsbDrivesSection() {
})
mutate()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -7222,7 +7223,7 @@ function UsbDrivesSection() {
setFormatTarget(null)
setFormatTyped("")
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusyKey(null)
}
@@ -7508,7 +7509,7 @@ function JobDetailModal({
setRunning(false)
setRunBaseline(null)
setRunBaselineLogPath(null)
- setActionError(t("backup.errors.runFailed", { error: e instanceof Error ? e.message : String(e) }))
+ setActionError(t("backup.errors.runFailed", { error: hostBackupErrorText(e, t) }))
}
}
@@ -7522,7 +7523,7 @@ function JobDetailModal({
await refetch()
onChanged()
} catch (e) {
- setActionError(t("backup.errors.toggleFailed", { error: e instanceof Error ? e.message : String(e) }))
+ setActionError(t("backup.errors.toggleFailed", { error: hostBackupErrorText(e, t) }))
} finally {
setBusy("")
}
@@ -8155,7 +8156,7 @@ function PbsKeyfileRecoveryDialog({
})
onRecovered()
} catch (e) {
- setError(e instanceof Error ? e.message : String(e))
+ setError(hostBackupErrorText(e, t))
} finally {
setBusy(false)
}
@@ -8342,7 +8343,7 @@ function ArchiveContentsModal({
body: JSON.stringify(body),
})
.then((r: any) => setData(r))
- .catch((e) => setError(e instanceof Error ? e.message : String(e)))
+ .catch((e) => setError(hostBackupErrorText(e, t)))
.finally(() => setLoading(false))
}, [open, source, repo_name, snapshot, path])
@@ -8571,7 +8572,7 @@ function PlanSummary({ plan }: { plan: any }) {
{t("backup.plan.blockers")}
{blockers.map((b, i) => (
- - {typeof b === "string" ? b : (b.message || JSON.stringify(b))}
+ - {hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}
))}
@@ -8581,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
{t("backup.plan.warnings")}
{warnings.map((w, i) => (
- - {typeof w === "string" ? w : (w.message || JSON.stringify(w))}
+ - {hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}
))}
@@ -8611,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
{e.name || e.storage || `entry ${i}`}
{e.type &&
{e.type}}
- {e.message &&
{e.message}
}
+ {e.message &&
{hostBackupErrorText(e.message, t)}
}
)
diff --git a/AppImage/components/monitor-switcher.tsx b/AppImage/components/monitor-switcher.tsx
index 89f8d249..7fb88eb9 100644
--- a/AppImage/components/monitor-switcher.tsx
+++ b/AppImage/components/monitor-switcher.tsx
@@ -14,6 +14,7 @@ import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
} from "./ui/dropdown-menu"
import { fetchApi } from "../lib/api-config"
+import { monitorLinkError } from "../lib/monitor-link-errors"
import { useT } from "../lib/i18n/provider"
interface MonitorLink {
@@ -185,8 +186,9 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
await refreshCache("/api/monitor-links")
setOfferOrigin(false)
} catch (err) {
- await refreshCache("/api/monitor-links")
- setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
+ setAddError(monitorLinkError(err, t))
+ try { await refreshCache("/api/monitor-links") }
+ catch { /* SWR shows the load error; keep the localized mutation error. */ }
} finally { setAdding(false) }
}
const notNow = () => {
@@ -260,7 +262,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
{manageOpen && }
{origin &&