mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge remote-tracking branch 'origin/develop' into HEAD
# Conflicts: # .github/scripts/run_offline_qualified.py # lang/sk.json
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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})
|
||||
@@ -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/**'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
<div className="font-medium">{t("backup.archives.remoteQueryWarning")}</div>
|
||||
{remoteArchivesResp.errors.map((e, i) => (
|
||||
<div key={i} className="font-mono break-all">
|
||||
{e.backend}/{e.repo_name}: {e.error}
|
||||
{e.backend}/{e.repo_name}: {hostBackupErrorText(e.error, t)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -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<typeof instructions>
|
||||
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({
|
||||
<span className="text-muted-foreground">— {exportTask.message}</span>
|
||||
</div>
|
||||
{exportTask.state === "failed" && exportTask.error && (
|
||||
<div className="text-red-500 mt-1">{exportTask.error}</div>
|
||||
<div className="text-red-500 mt-1">{hostBackupErrorText(exportTask.error, t)}</div>
|
||||
)}
|
||||
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
|
||||
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
|
||||
@@ -2706,7 +2707,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
|
||||
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={`font-mono ${color}`}>{c.id}</span>
|
||||
<span className="text-muted-foreground ml-2">{c.message}</span>
|
||||
<span className="text-muted-foreground ml-2">{hostBackupErrorText(c.message, t)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -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({
|
||||
</>
|
||||
) : (
|
||||
<div className="text-red-500">
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {calendarPreview.error}
|
||||
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {hostBackupErrorText(calendarPreview.error, t)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@@ -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 && (
|
||||
<p className="mt-3 text-[11px] text-muted-foreground italic">
|
||||
{t("backup.destinations.capacityUnavailable", { error: capacity.error })}
|
||||
{t("backup.destinations.capacityUnavailable", { error: hostBackupErrorText(capacity.error, t) })}
|
||||
</p>
|
||||
)}
|
||||
{!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 }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.blockers")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{blockers.map((b, i) => (
|
||||
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
|
||||
<li key={i} className="text-red-400">{hostBackupErrorText(typeof b === "string" ? b : (b.message || JSON.stringify(b)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8581,7 +8582,7 @@ function PlanSummary({ plan }: { plan: any }) {
|
||||
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">{t("backup.plan.warnings")}</div>
|
||||
<ul className="space-y-0.5 ml-4 list-disc">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
|
||||
<li key={i} className="text-amber-400">{hostBackupErrorText(typeof w === "string" ? w : (w.message || JSON.stringify(w)), t)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -8611,7 +8612,7 @@ function StorageSection({ storage }: { storage: any }) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
|
||||
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
|
||||
{e.message && <div className="text-muted-foreground text-[11px]">{hostBackupErrorText(e.message, t)}</div>}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
</DropdownMenu>
|
||||
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={open => { if (!open) notNow() }}>
|
||||
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogContent className="grid-cols-1 max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.knownTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.knownBody", { name: origin.name })}</DialogDescription>
|
||||
@@ -290,7 +292,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
|
||||
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const t = useT()
|
||||
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const { data, mutate, error: loadError, isLoading } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
|
||||
const [addresses, setAddresses] = useState<Record<string, string>>({})
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
@@ -304,14 +306,14 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
await mutate()
|
||||
await refreshCache("/api/monitor-links")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
setError(monitorLinkError(err, t))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const send = (path: string, method: string, body?: unknown) =>
|
||||
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
||||
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background"
|
||||
onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||
@@ -320,7 +322,7 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
|
||||
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
|
||||
@@ -350,6 +352,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{loadError && <p role="alert" className="text-sm text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data?.links.map(link => {
|
||||
const own = link.manual || link.custom_url
|
||||
@@ -378,8 +382,8 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
|
||||
</div>
|
||||
: isEditing
|
||||
? <div className="flex gap-2">
|
||||
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
? <div className="grid grid-cols-2 gap-2 sm:flex">
|
||||
<Input className="col-span-2 min-w-0 sm:flex-1" value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
|
||||
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||
<Button size="sm" className="h-10 bg-blue-600 text-white hover:bg-blue-700 disabled:bg-blue-600/50 disabled:text-white disabled:opacity-100"
|
||||
|
||||
@@ -4,7 +4,8 @@ import { CalendarClock, Database } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
|
||||
export interface PBSDatastore {
|
||||
name: string
|
||||
@@ -13,6 +14,7 @@ export interface PBSDatastore {
|
||||
used?: number | null
|
||||
avail?: number | null
|
||||
error?: string
|
||||
error_code?: string
|
||||
"backend-type"?: string
|
||||
"estimated-full-date"?: number
|
||||
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
|
||||
@@ -42,7 +44,7 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-500">{store.error || t("pbs.capacityUnavailable")}</p>
|
||||
<PBSProblemMessage className="text-xs text-amber-500" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
</div>
|
||||
}
|
||||
const tone = usageTone(percent)
|
||||
@@ -62,10 +64,11 @@ export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatast
|
||||
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
|
||||
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const percent = datastorePercent(store)
|
||||
const tone = percent == null ? null : usageTone(percent)
|
||||
const fullDate = store["estimated-full-date"]
|
||||
const type = store["backend-type"] || "datastore"
|
||||
const type = store["backend-type"] || t("pbs.datastoreType")
|
||||
return <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||
@@ -99,13 +102,13 @@ export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
|
||||
<PBSProblemMessage className="text-sm text-muted-foreground" problem={{ code: store.error_code }} raw={store.error} fallback="pbs.capacityUnavailable" />
|
||||
)}
|
||||
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
|
||||
{fullDate !== undefined && <span className="flex items-center">
|
||||
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}</span>
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString(language) : t("pbs.estimatedFullUnknown")}</span>
|
||||
</span>}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
@@ -14,8 +14,10 @@ import { Label } from "./ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic, pbsResultText, pbsServiceState, pbsTaskType, formatPBSDuration } from "../lib/pbs-messages"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useT, useI18n } from "../lib/i18n/provider"
|
||||
|
||||
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
|
||||
interface Detected { name: string; url: string; fingerprint: string }
|
||||
@@ -26,6 +28,7 @@ interface PBSStatus {
|
||||
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
|
||||
datastores: PBSDatastore[] | null; tasks: Task[] | null;
|
||||
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
|
||||
error_details?: Record<string, { code: string; params?: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "", username: "", password: "" }
|
||||
@@ -46,9 +49,10 @@ const TASK_ICONS: Record<string, typeof Activity> = {
|
||||
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
|
||||
}
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
|
||||
// PBS reports "unknown", "OK", "WARNINGS: n" or an error; no state is still running.
|
||||
function taskState(task: Task): keyof typeof badgeTone {
|
||||
if (!task.status) return task.endtime ? "muted" : "running"
|
||||
if (task.status === "unknown") return "muted"
|
||||
if (task.status === "OK") return "ok"
|
||||
if (task.status.startsWith("WARNINGS")) return "warn"
|
||||
return "error"
|
||||
@@ -56,27 +60,19 @@ function taskState(task: Task): keyof typeof badgeTone {
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text of the last run.
|
||||
function jobState(state?: string): keyof typeof badgeTone {
|
||||
if (!state) return "muted"
|
||||
if (!state || state === "unknown") return "muted"
|
||||
if (state.toLowerCase() === "ok") return "ok"
|
||||
if (state.toLowerCase().startsWith("warn")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "—"
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
|
||||
}
|
||||
|
||||
// `localOnly` is the PBS host's own Monitor: no connection picker, the
|
||||
// header Refresh already reloads the page data. Its tabs show one `view` each.
|
||||
export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: boolean; view?: "all" | "datastores" | "tasks" } = {}) {
|
||||
const showStores = view !== "tasks"
|
||||
const showTasks = view !== "datastores"
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
|
||||
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
|
||||
const [selected, setSelected] = useState("")
|
||||
@@ -85,7 +81,8 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
const [method, setMethod] = useState<Method>("password")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [errorFallback, setErrorFallback] = useState("pbs.saveFailed")
|
||||
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
|
||||
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
|
||||
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
|
||||
@@ -102,7 +99,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setErrorFallback("pbs.saveFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let saved = false
|
||||
try {
|
||||
// A password is sent once to create a read-only token; only the token is kept.
|
||||
const { username, password, ...connection } = form
|
||||
@@ -113,22 +112,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
: await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
|
||||
...connection, id: form.id || undefined, token_secret: form.token_secret || undefined,
|
||||
}) })
|
||||
saved = true
|
||||
await reloadConnections()
|
||||
setSelected(result.id)
|
||||
setForm(emptyForm)
|
||||
setManage(false)
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
} catch (err) { setError(saved ? { body: { code: "pbs_saved_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
|
||||
setErrorFallback("pbs.removeFailed")
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
let removed = false
|
||||
try {
|
||||
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
|
||||
removed = true
|
||||
setForm(emptyForm)
|
||||
await reloadConnections()
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
} catch (err) { setError(removed ? { body: { code: "pbs_removed_refresh_failed" }, message: pbsDiagnostic(err) } : err) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const now = Date.now() / 1000
|
||||
@@ -150,8 +153,9 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
|
||||
</Button>
|
||||
</div>}
|
||||
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
|
||||
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
|
||||
{(connectionError || statusError) && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={connectionError || statusError} raw={pbsDiagnostic(connectionError || statusError)} />
|
||||
</div>}
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
|
||||
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
|
||||
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
|
||||
@@ -171,7 +175,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{!localOnly && <Dialog open={manage} onOpenChange={open => { if (!busy) setManage(open) }}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogContent className="grid-cols-1 max-w-2xl max-h-[85vh] overflow-y-auto bg-accent [&_input]:bg-background [&_[role=combobox]]:bg-background">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("pbs.manage")}</DialogTitle>
|
||||
<DialogDescription>{method === "password" && !form.id ? t("pbs.passwordHelp") : t("pbs.permissionsHelp")}</DialogDescription>
|
||||
@@ -201,19 +205,26 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</Button>)}
|
||||
</div>
|
||||
</div>}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{(["name", "url", ...(method === "password" && !form.id ? ["username", "password"] : ["token_id", "token_secret"]), "fingerprint"] as const).map(field => {
|
||||
const secret = field === "token_secret" || field === "password"
|
||||
return <div key={field} className="space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
|
||||
const help = field === "url" ? "pbs.urlHelp" : field === "username" ? "pbs.usernameHelp"
|
||||
: field === "token_id" ? "pbs.tokenIdHelp" : field === "token_secret"
|
||||
? form.id ? "pbs.tokenSecretKeepHelp" : "pbs.tokenSecretHelp" : undefined
|
||||
return <div key={field} className="min-w-0 space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`} className="block leading-5">{t(`pbs.${field}`)}</Label>
|
||||
<Input id={`pbs-${field}`} disabled={busy} type={secret ? "password" : field === "url" ? "url" : "text"}
|
||||
aria-describedby={help ? `pbs-${field}-help` : undefined}
|
||||
autoComplete={secret ? "new-password" : "off"} placeholder={field === "username" ? "root@pam" : undefined}
|
||||
value={form[field as keyof typeof form]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||
{help && <p id={`pbs-${field}-help`} className="text-xs text-muted-foreground">{t(help)}</p>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
{!!error && <div role="alert" className="text-sm text-red-500">
|
||||
<PBSProblemMessage problem={error} raw={pbsDiagnostic(error)} fallback={errorFallback} />
|
||||
</div>}
|
||||
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{form.id && <Button variant="outline" disabled={busy} className="mr-auto text-red-500 hover:text-red-500" onClick={remove}>
|
||||
@@ -230,14 +241,17 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</Dialog>}
|
||||
{data && <>
|
||||
{!localOnly && <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString(language) })}</span>
|
||||
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
|
||||
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
|
||||
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {pbsServiceState(state, t)}</Badge>)}
|
||||
</div>}
|
||||
{Object.entries(data.errors).filter(([section]) => section === "datastores" ? showStores : showTasks)
|
||||
.map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
.map(([section, detail]) => <div key={section} role="alert" className="text-sm text-amber-500">
|
||||
<p className="font-medium">{t(`pbs.${["datastores", "tasks", "sync", "verify", "prune"].includes(section) ? section : "title"}`)}</p>
|
||||
<PBSProblemMessage problem={data.error_details?.[section]} raw={detail} />
|
||||
</div>)}
|
||||
|
||||
{showStores && <Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
|
||||
@@ -268,14 +282,14 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
return <tr key={row.upid} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-foreground">{row.worker_type}</span>
|
||||
<span className="text-foreground" title={row.worker_type}>{pbsTaskType(row.worker_type, t)}</span>
|
||||
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
|
||||
</span></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatDuration(end - row.starttime) : "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={`${badgeTone[state]} max-w-[16rem] truncate`} title={row.status}>
|
||||
{state === "running" ? t("pbs.running") : row.status || "—"}
|
||||
</Badge></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString(language) : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatPBSDuration(end - row.starttime, t) : "—"}</td>
|
||||
<td className="py-2">
|
||||
<PBSResultBadge status={row.status} running={state === "running"} tone={badgeTone[state]} />
|
||||
</td>
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
@@ -299,7 +313,7 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
|
||||
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={badgeTone[jobState(row["last-run-state"])]}>{row["last-run-state"] || "—"}</Badge></td>
|
||||
<td className="py-2"><PBSResultBadge status={row["last-run-state"]} tone={badgeTone[jobState(row["last-run-state"])]} /></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
})()}
|
||||
@@ -309,3 +323,15 @@ export function PBSPanel({ localOnly = false, view = "all" }: { localOnly?: bool
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function PBSResultBadge({ status, running = false, tone }: { status?: string; running?: boolean; tone: string }) {
|
||||
const t = useT()
|
||||
const badge = <Badge variant="outline" className={`${tone} max-w-[16rem] truncate`}>
|
||||
{running ? t("pbs.running") : pbsResultText(status, t)}
|
||||
</Badge>
|
||||
if (running || !status || status === "unknown" || /^ok$/i.test(status) || /^WARNINGS:\s*\d+\s*$/i.test(status)) return badge
|
||||
return <details className="max-w-xs">
|
||||
<summary className="cursor-pointer" aria-label={t("pbs.technicalDetails")}>{badge}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words text-xs text-muted-foreground">{status}</pre>
|
||||
</details>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { pbsErrorText, pbsIsKnownError } from "../lib/pbs-messages"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// Known application messages are already explained in the active language.
|
||||
// Preserve unrecognized external diagnostics as escaped technical details.
|
||||
export function PBSProblemMessage({ problem, raw, fallback, className = "" }: {
|
||||
problem?: unknown; raw?: string; fallback?: string; className?: string
|
||||
}) {
|
||||
const t = useT()
|
||||
return <div className={`min-w-0 space-y-1 ${className}`}>
|
||||
<p>{pbsErrorText(problem, t, fallback)}</p>
|
||||
{raw && !pbsIsKnownError(problem) && <details className="text-xs">
|
||||
<summary className="cursor-pointer">{t("pbs.technicalDetails")}</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words font-mono">{raw}</pre>
|
||||
</details>}
|
||||
</div>
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
|
||||
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { PBSProblemMessage } from "./pbs-problem-message"
|
||||
import { pbsDiagnostic } from "../lib/pbs-messages"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
@@ -187,14 +189,20 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
|
||||
}
|
||||
|
||||
interface PBSOverviewStatus {
|
||||
datastores: PBSDatastore[] | null
|
||||
errors?: Record<string, string>
|
||||
error_details?: Record<string, { code: string }>
|
||||
}
|
||||
|
||||
export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const isPbs = product === "pbs"
|
||||
// On Proxmox Backup Server the datastores take the place of guests and PVE storage.
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>(
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<PBSOverviewStatus>(
|
||||
isPbs ? "/api/pbs/status?server=local" : null,
|
||||
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
|
||||
(url: string) => fetchApi<PBSOverviewStatus>(url),
|
||||
{ refreshInterval: 60000, revalidateOnFocus: false },
|
||||
)
|
||||
const datastores = pbsStatus?.datastores ?? null
|
||||
@@ -545,13 +553,15 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!datastores && !pbsError ? (
|
||||
{!pbsStatus && !pbsError ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : !datastores ? (
|
||||
<p className="text-sm text-amber-500">{t("pbs.loadFailed")}</p>
|
||||
<div role="alert" className="text-sm text-amber-500">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
|
||||
) : (
|
||||
@@ -762,8 +772,12 @@ export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" }
|
||||
{isPbs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
|
||||
{!datastores ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}</div>
|
||||
{!pbsStatus && !pbsError ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.loading")}</div>
|
||||
) : !datastores ? (
|
||||
<div role="alert" className="py-4 text-amber-500 text-sm">
|
||||
<PBSProblemMessage problem={pbsError || pbsStatus?.error_details?.datastores} raw={pbsDiagnostic(pbsError) || pbsStatus?.errors?.datastores} />
|
||||
</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
|
||||
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// Legacy HostBackup API messages are an English wire contract shared with CLI callers.
|
||||
// Match complete, source-verified messages only; never derive a translation key from input.
|
||||
// This presentation-only adapter keeps unknown tool diagnostics and parameter text intact.
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const fixed = new Map<string, string>([
|
||||
["path must be absolute", "backup.apiErrors.absolutePath"],
|
||||
["this path is already configured", "backup.apiErrors.pathConfigured"],
|
||||
["path is required", "backup.apiErrors.pathRequired"],
|
||||
["path query parameter is required", "backup.apiErrors.pathQuery"],
|
||||
["path not found", "backup.apiErrors.pathNotFound"],
|
||||
["invalid job id", "backup.apiErrors.invalidJobId"],
|
||||
["job not found", "backup.apiErrors.jobNotFound"],
|
||||
["runner script not installed", "backup.apiErrors.runnerMissing"],
|
||||
["no restore state to dismiss", "backup.apiErrors.noRestore"],
|
||||
["invalid file name", "backup.apiErrors.invalidFile"],
|
||||
["not found", "backup.apiErrors.notFound"],
|
||||
["log path outside allowed directory", "backup.apiErrors.logOutside"],
|
||||
["targets must be a list", "backup.apiErrors.targetsList"],
|
||||
["empty expression", "backup.apiErrors.emptyExpression"],
|
||||
["invalid id (use letters, digits, _ or -)", "backup.apiErrors.invalidId"],
|
||||
["backend must be pbs, local or borg", "backup.apiErrors.backend"],
|
||||
["backend must be pbs or borg", "backup.apiErrors.remoteBackend"],
|
||||
["backend must be 'pbs' or 'borg'", "backup.apiErrors.remoteBackendQuoted"],
|
||||
["profile_mode must be default or custom", "backup.apiErrors.profileMode"],
|
||||
["borg cannot be attached (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttached"],
|
||||
["borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)", "backup.apiErrors.borgAttachedLong"],
|
||||
["pve_parent_job_id and pve_storage are required for attached", "backup.apiErrors.parentRequired"],
|
||||
["paths must be a list of absolute paths", "backup.apiErrors.absolutePaths"],
|
||||
["custom profile needs at least one path", "backup.apiErrors.customPath"],
|
||||
["could not resolve default profile paths", "backup.apiErrors.defaultPaths"],
|
||||
["retention must be an object", "backup.apiErrors.retentionObject"],
|
||||
["pbs_repository is required", "backup.apiErrors.pbsRepository"],
|
||||
["pbs_encrypt_mode must be 'none', 'new' or 'existing'", "backup.apiErrors.pbsEncryption"],
|
||||
["failed to create PBS encryption keyfile", "backup.apiErrors.pbsKeyFailed"],
|
||||
["failed to create PBS encryption keyfile (is proxmox-backup-client installed?)", "backup.apiErrors.pbsClientMissing"],
|
||||
["borg_repo is required", "backup.apiErrors.borgRepository"],
|
||||
["borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.borgEncryption"],
|
||||
["encrypt_mode must be one of: none, repokey, keyfile, authenticated", "backup.apiErrors.encryption"],
|
||||
["borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)", "backup.apiErrors.borgPassphrase"],
|
||||
["passphrase is required when encryption is enabled", "backup.apiErrors.passphrase"],
|
||||
["password is required", "backup.apiErrors.password"],
|
||||
["confirm_device must match device exactly (typo / safety check)", "backup.apiErrors.deviceConfirmation"],
|
||||
["invalid device path", "backup.apiErrors.invalidDevice"],
|
||||
["absolute mountpoint required", "backup.apiErrors.absoluteMountpoint"],
|
||||
["archive layout not recognized — no rootfs/ found", "backup.apiErrors.archiveLayout"],
|
||||
["archive not found", "backup.apiErrors.archiveMissing"],
|
||||
["archive path is not a regular file", "backup.apiErrors.archiveNotFile"],
|
||||
["invalid name", "backup.apiErrors.invalidName"],
|
||||
["invalid staging_path", "backup.apiErrors.invalidStaging"],
|
||||
["job name cannot be changed (delete + recreate to rename)", "backup.apiErrors.jobRename"],
|
||||
["key_path must be absolute", "backup.apiErrors.absoluteKey"],
|
||||
["mode must be \"local\" or \"ssh\"", "backup.apiErrors.mode"],
|
||||
["name is required (letters, digits, _ or -)", "backup.apiErrors.nameRequired"],
|
||||
["no log available for this job yet", "backup.apiErrors.noLog"],
|
||||
["no manual PBS configs file", "backup.apiErrors.noPbsConfig"],
|
||||
["output file missing", "backup.apiErrors.outputMissing"],
|
||||
["parser output was not valid JSON", "backup.apiErrors.parserJson"],
|
||||
["run_restore output was not valid JSON", "backup.apiErrors.restoreJson"],
|
||||
["repo is required", "backup.apiErrors.repoRequired"],
|
||||
["repo_name and snapshot are required", "backup.apiErrors.repoSnapshot"],
|
||||
["repo_name and snapshot are required for borg", "backup.apiErrors.borgRepoSnapshot"],
|
||||
["repo_name and snapshot are required for pbs", "backup.apiErrors.pbsRepoSnapshot"],
|
||||
["repo_name is required", "backup.apiErrors.repoName"],
|
||||
["restore tooling not installed on this host", "backup.apiErrors.restoreMissing"],
|
||||
["server and datastore are required", "backup.apiErrors.serverDatastore"],
|
||||
["snapshot is required", "backup.apiErrors.snapshotRequired"],
|
||||
["source must be pbs, borg, or local", "backup.apiErrors.source"],
|
||||
["ssh_port must be an integer between 1 and 65535", "backup.apiErrors.sshPort"],
|
||||
["ssh_user, ssh_host and ssh_remote_path are required for ssh mode", "backup.apiErrors.sshRequired"],
|
||||
["target has no repository path", "backup.apiErrors.repositoryPath"],
|
||||
["task not found or expired", "backup.apiErrors.taskExpired"],
|
||||
["invalid expression", "backup.apiErrors.invalidExpression"],
|
||||
["no report emitted", "backup.apiErrors.noReport"],
|
||||
["parse_manifest exited non-zero", "backup.apiErrors.parseManifestFailed"],
|
||||
])
|
||||
|
||||
const parameterized: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[new RegExp("^job already exists: ([\\s\\S]+)$"), "backup.apiErrors.jobExists"],
|
||||
[new RegExp("^path does not exist on this host: ([\\s\\S]+)$"), "backup.apiErrors.pathMissing"],
|
||||
[new RegExp("^device does not exist: ([\\s\\S]+)$"), "backup.apiErrors.deviceMissing"],
|
||||
[new RegExp("^not a mountpoint: ([\\s\\S]+)$"), "backup.apiErrors.mountpoint"],
|
||||
[new RegExp("^local archive not found: ([\\s\\S]+)$"), "backup.apiErrors.localArchiveMissing"],
|
||||
[new RegExp("^Borg destination '([\\s\\S]+)' not found$"), "backup.apiErrors.borgDestination"],
|
||||
[new RegExp("^PBS destination '([\\s\\S]+)' not found$"), "backup.apiErrors.pbsDestination"],
|
||||
[new RegExp("^no password available for PBS repo '([\\s\\S]+)'$"), "backup.apiErrors.pbsPassword"],
|
||||
[new RegExp("^Borg repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.borgRepoMissing"],
|
||||
[new RegExp("^PBS repo \"([\\s\\S]+)\" not configured$"), "backup.apiErrors.pbsRepoMissing"],
|
||||
[new RegExp("^Borg target not found: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetMissing"],
|
||||
[new RegExp("^PBS repo not found: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetMissing"],
|
||||
[new RegExp("^PVE vzdump job not found: ([\\s\\S]+)$"), "backup.apiErrors.pveJobMissing"],
|
||||
[new RegExp("^cannot persist state: ([\\s\\S]+)$"), "backup.apiErrors.statePersist"],
|
||||
[new RegExp("^cannot prepare ssh dir: ([\\s\\S]+)$"), "backup.apiErrors.sshDirectory"],
|
||||
[new RegExp("^cannot read log: ([\\s\\S]+)$"), "backup.apiErrors.logRead"],
|
||||
[new RegExp("^could not update \\.env: ([\\s\\S]+)$"), "backup.apiErrors.envUpdate"],
|
||||
[new RegExp("^failed to persist PBS config: ([\\s\\S]+)$"), "backup.apiErrors.pbsConfigWrite"],
|
||||
[new RegExp("^failed to persist: ([\\s\\S]+)$"), "backup.apiErrors.persist"],
|
||||
[new RegExp("^failed to remove archive: ([\\s\\S]+)$"), "backup.apiErrors.archiveRemove"],
|
||||
[new RegExp("^failed to start: ([\\s\\S]+)$"), "backup.apiErrors.start"],
|
||||
[new RegExp("^failed to update borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsUpdate"],
|
||||
[new RegExp("^failed to update pbs-manual-configs\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.pbsTargetsUpdate"],
|
||||
[new RegExp("^failed to update systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsUpdate"],
|
||||
[new RegExp("^failed to write borg-targets\\.txt: ([\\s\\S]+)$"), "backup.apiErrors.borgTargetsWrite"],
|
||||
[new RegExp("^failed to write job files: ([\\s\\S]+)$"), "backup.apiErrors.jobWrite"],
|
||||
[new RegExp("^failed to write local-target\\.conf: ([\\s\\S]+)$"), "backup.apiErrors.localTargetWrite"],
|
||||
[new RegExp("^failed to write systemd units: ([\\s\\S]+)$"), "backup.apiErrors.unitsWrite"],
|
||||
[new RegExp("^format failed: ([\\s\\S]+)$"), "backup.apiErrors.format"],
|
||||
[new RegExp("^invalid on_calendar: ([\\s\\S]+)$"), "backup.apiErrors.calendar"],
|
||||
[new RegExp("^mount failed: ([\\s\\S]+)$"), "backup.apiErrors.mount"],
|
||||
[new RegExp("^parser invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.parserInvocation"],
|
||||
[new RegExp("^preflight invocation failed: ([\\s\\S]+)$"), "backup.apiErrors.preflightInvocation"],
|
||||
[new RegExp("^public key not found after generation: ([\\s\\S]+)$"), "backup.apiErrors.publicKeyMissing"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be an integer$"), "backup.apiErrors.retentionInteger"],
|
||||
[new RegExp("^retention\\.([\\s\\S]+) must be non-negative$"), "backup.apiErrors.retentionNegative"],
|
||||
[new RegExp("^ssh-keygen failed: ([\\s\\S]+)$"), "backup.apiErrors.keygen"],
|
||||
[new RegExp("^systemctl failed: ([\\s\\S]+)$"), "backup.apiErrors.systemctl"],
|
||||
[new RegExp("^umount failed: ([\\s\\S]+)$"), "backup.apiErrors.unmount"],
|
||||
[new RegExp("^task is in state \"([\\s\\S]+)\", not completed$"), "backup.apiErrors.taskIncomplete"],
|
||||
[new RegExp("^unknown kind: ([\\s\\S]+)$"), "backup.apiErrors.unknownKind"],
|
||||
[new RegExp("^unknown mode \"([\\s\\S]+)\"$"), "backup.apiErrors.unknownMode"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default and cannot be removed$"), "backup.apiErrors.defaultRemove"],
|
||||
[new RegExp("^([\\s\\S]+) is the built-in default; no need to add it$"), "backup.apiErrors.defaultAdd"],
|
||||
[new RegExp("^pbs_encrypt_mode='existing' but no keyfile is installed at ([\\s\\S]+) — import one first with POST /api/host-backups/pbs-encryption/import$"), "backup.apiErrors.keyImport"],
|
||||
[new RegExp("^PVE job storage mismatch \\(job uses ([\\s\\S]+), payload says ([\\s\\S]+)\\)$"), "backup.apiErrors.storageMismatch"],
|
||||
]
|
||||
|
||||
export function hostBackupErrorText(problem: unknown, t: Translate): string {
|
||||
const message = typeof problem === "string" ? problem
|
||||
: problem && typeof problem === "object" && "message" in problem && typeof problem.message === "string"
|
||||
? problem.message : ""
|
||||
if (!message) return t("backup.apiErrors.requestFailed")
|
||||
const key = fixed.get(message)
|
||||
if (key) return t(key)
|
||||
// Keep unusually large tool output verbatim, without running pattern matching over it.
|
||||
if (message.length > 16384) return message
|
||||
for (const [pattern, patternKey] of parameterized) {
|
||||
const match = pattern.exec(message)
|
||||
if (match) return t(patternKey, { value: match[1], other: match[2] ?? "" })
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
monitor_link_address_required: "addressRequired",
|
||||
monitor_link_invalid_address: "invalidAddress",
|
||||
monitor_link_address_credentials: "addressCredentials",
|
||||
monitor_link_invalid_port: "invalidPort",
|
||||
monitor_link_invalid_request: "invalidRequest",
|
||||
monitor_link_duplicate: "duplicate",
|
||||
monitor_link_not_found: "notFound",
|
||||
monitor_link_detected_remove: "detectedRemove",
|
||||
}
|
||||
|
||||
// fetchApi preserves the structured response on Error.body. Never expose raw
|
||||
// server/network messages or treat arbitrary error codes as translation keys.
|
||||
export function monitorLinkError(error: unknown, t: Translate): string {
|
||||
const fallback = () => t("monitorLinks.saveFailed")
|
||||
if (!error || typeof error !== "object" || !("body" in error)) return fallback()
|
||||
const body = error.body
|
||||
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return fallback()
|
||||
if (body.code === "monitor_link_limit") {
|
||||
const params = "params" in body ? body.params : null
|
||||
const count = params && typeof params === "object" && "count" in params ? params.count : null
|
||||
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
? t("monitorLinks.errors.limit", { count }) : fallback()
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code)
|
||||
? t(`monitorLinks.errors.${ERROR_KEYS[body.code]}`) : fallback()
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string
|
||||
|
||||
const ERROR_KEYS: Record<string, string> = {
|
||||
pbs_config_read: "pbs.errors.configRead", pbs_invalid_config: "pbs.errors.invalidConfig",
|
||||
pbs_invalid_field: "pbs.errors.invalidField", pbs_invalid_url: "pbs.errors.invalidUrl",
|
||||
pbs_name_required: "pbs.errors.nameRequired", pbs_token_id_format: "pbs.errors.tokenIdFormat",
|
||||
pbs_token_secret: "pbs.errors.tokenSecret", pbs_invalid_token_id: "pbs.errors.invalidTokenId",
|
||||
pbs_invalid_fingerprint: "pbs.errors.invalidFingerprint", pbs_fingerprint_mismatch: "pbs.errors.fingerprintMismatch",
|
||||
pbs_token_rejected: "pbs.errors.tokenRejected", pbs_invalid_response: "pbs.errors.invalidResponse",
|
||||
pbs_invalid_json: "pbs.errors.invalidJson", pbs_connect_failed: "pbs.errors.connectFailed",
|
||||
pbs_invalid_id: "pbs.errors.invalidId", pbs_invalid_datastores: "pbs.errors.invalidDatastores",
|
||||
pbs_username: "pbs.errors.username", pbs_password: "pbs.errors.password",
|
||||
pbs_login_rejected: "pbs.errors.loginRejected", pbs_invalid_login: "pbs.errors.invalidLogin",
|
||||
pbs_token_create_denied: "pbs.errors.tokenCreateDenied", pbs_invalid_token: "pbs.errors.invalidToken",
|
||||
pbs_audit_role_denied: "pbs.errors.auditRoleDenied", pbs_not_found: "pbs.errors.notFound",
|
||||
pbs_read_failed: "pbs.errors.readFailed", pbs_invalid_list: "pbs.errors.invalidList",
|
||||
pbs_local_unavailable: "pbs.errors.localUnavailable", pbs_auth_required: "pbs.errors.authRequired",
|
||||
pbs_save_failed: "pbs.saveFailed", pbs_remove_failed: "pbs.removeFailed",
|
||||
pbs_saved_refresh_failed: "pbs.savedRefreshFailed", pbs_removed_refresh_failed: "pbs.removedRefreshFailed",
|
||||
pbs_datastore_offline: "pbs.errors.datastoreOffline", pbs_datastore_unavailable: "pbs.errors.datastoreUnavailable",
|
||||
}
|
||||
|
||||
// Accept structured codes, never arbitrary error text as a translation key.
|
||||
function pbsErrorTranslation(error: unknown): { key: string; params?: { count: number } } | null {
|
||||
const body = error && typeof error === "object" && "body" in error ? error.body : error
|
||||
if (!body || typeof body !== "object" || !("code" in body) || typeof body.code !== "string") return null
|
||||
if (body.code === "pbs_connection_limit") {
|
||||
const params = "params" in body ? body.params : null
|
||||
const count = params && typeof params === "object" && "count" in params ? params.count : null
|
||||
return typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
? { key: "pbs.errors.connectionLimit", params: { count } } : null
|
||||
}
|
||||
return Object.prototype.hasOwnProperty.call(ERROR_KEYS, body.code) ? { key: ERROR_KEYS[body.code] } : null
|
||||
}
|
||||
|
||||
export function pbsIsKnownError(error: unknown): boolean {
|
||||
return pbsErrorTranslation(error) !== null
|
||||
}
|
||||
|
||||
export function pbsErrorText(error: unknown, t: Translate, fallback = "pbs.loadFailed"): string {
|
||||
const translated = pbsErrorTranslation(error)
|
||||
return translated ? t(translated.key, translated.params) : t(fallback)
|
||||
}
|
||||
|
||||
export function pbsDiagnostic(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined
|
||||
const body = "body" in error ? error.body : null
|
||||
if (body && typeof body === "object" && "error" in body && typeof body.error === "string") return body.error
|
||||
return "message" in error && typeof error.message === "string" ? error.message : undefined
|
||||
}
|
||||
|
||||
const TASK_TYPES: Record<string, string> = {
|
||||
backup: "backup", reader: "restore", verify: "verify", verificationjob: "verify",
|
||||
verify_group: "verifyGroup", verify_snapshot: "verifySnapshot", prune: "prune", prunejob: "prune",
|
||||
garbage_collection: "garbageCollection", sync: "sync", syncjob: "sync", aptupdate: "updateCheck",
|
||||
termproxy: "terminal", dircreate: "createDatastore", "create-datastore": "createDatastore",
|
||||
}
|
||||
export function pbsTaskType(type: string | undefined, t: Translate): string {
|
||||
return type && Object.prototype.hasOwnProperty.call(TASK_TYPES, type)
|
||||
? t(`pbs.taskTypes.${TASK_TYPES[type]}`) : t("pbs.taskTypes.other", { type: type || "—" })
|
||||
}
|
||||
|
||||
export function pbsResultText(status: string | undefined, t: Translate): string {
|
||||
if (!status || status === "unknown") return t("pbs.states.unknown")
|
||||
if (status.toLowerCase() === "ok") return t("pbs.states.ok")
|
||||
const warnings = /^WARNINGS:\s*(\d+)\s*$/i.exec(status)
|
||||
if (warnings && Number.isSafeInteger(Number(warnings[1]))) return t("pbs.states.warnings", { count: Number(warnings[1]) })
|
||||
return /^warn/i.test(status) ? t("pbs.states.warning") : t("pbs.states.error")
|
||||
}
|
||||
|
||||
export function pbsServiceState(state: string, t: Translate): string {
|
||||
const known = ["active", "inactive", "failed", "activating", "deactivating", "unknown"]
|
||||
return t(`pbs.serviceStates.${known.includes(state) ? state : "unknown"}`)
|
||||
}
|
||||
|
||||
export function formatPBSDuration(seconds: number, t: Translate): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "—"
|
||||
const unit = (key: string, count: number) => t(`pbs.durationUnits.${key}`, { count })
|
||||
if (seconds < 60) return unit("seconds", Math.round(seconds))
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${unit("minutes", minutes)} ${unit("seconds", Math.round(seconds % 60))}`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours < 24 ? `${unit("hours", hours)} ${unit("minutes", minutes % 60)}`
|
||||
: `${unit("days", Math.floor(hours / 24))} ${unit("hours", hours % 24)}`
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
"signInWith": "Sign in with",
|
||||
"methodPassword": "User and password",
|
||||
"methodToken": "API token",
|
||||
"username": "User (user@realm)",
|
||||
"username": "User",
|
||||
"password": "Password",
|
||||
"passwordHelp": "The password is used once to create a read-only API token (Audit role) for this Monitor. Only that token is saved, never the password. Enable Monitor authentication first.",
|
||||
"cancel": "Cancel",
|
||||
@@ -27,10 +27,15 @@
|
||||
"editConnection": "Connection to edit",
|
||||
"add": "Add a new connection",
|
||||
"name": "Name",
|
||||
"url": "HTTPS URL (include :8007 for the native PBS port)",
|
||||
"token_id": "API token ID (user@realm!token)",
|
||||
"token_secret": "API token secret (leave empty to keep the stored secret)",
|
||||
"fingerprint": "SHA256 certificate fingerprint (optional)",
|
||||
"url": "HTTPS address",
|
||||
"urlHelp": "Include :8007 for the default PBS port, for example https://pbs.example:8007.",
|
||||
"usernameHelp": "Use user@realm, for example root@pam.",
|
||||
"tokenIdHelp": "Use user@realm!token, for example monitor@pbs!proxmenux.",
|
||||
"tokenSecretHelp": "Enter the secret of this API token.",
|
||||
"tokenSecretKeepHelp": "Leave empty to keep the saved token secret.",
|
||||
"token_id": "API token ID",
|
||||
"token_secret": "API token secret",
|
||||
"fingerprint": "SHA256 fingerprint (optional)",
|
||||
"tlsHelp": "Leave the fingerprint empty for normal CA and hostname verification. For a self-signed certificate, copy its SHA256 fingerprint from the PBS console or another trusted channel. The pin is checked before sending credentials.",
|
||||
"saved": "PBS connection verified and saved.",
|
||||
"saveFailed": "Could not save the PBS connection.",
|
||||
@@ -72,7 +77,55 @@
|
||||
"estimatedFull": "Estimated full",
|
||||
"estimatedFullUnknown": "Not enough data yet",
|
||||
"duration": "Duration",
|
||||
"noJobs": "No sync, verification or prune schedules are configured."
|
||||
"noJobs": "No sync, verification or prune schedules are configured.",
|
||||
"removeFailed": "Could not remove the PBS connection.",
|
||||
"savedRefreshFailed": "The PBS connection was saved, but the connection list could not be refreshed. Reload the page before trying another change.",
|
||||
"removedRefreshFailed": "The PBS connection was removed, but the connection list could not be refreshed. Reload the page.",
|
||||
"technicalDetails": "Technical details",
|
||||
"datastoreType": "Datastore",
|
||||
"errors": {
|
||||
"configRead": "Could not read the saved PBS connections. Check the configuration file and permissions.",
|
||||
"invalidConfig": "The PBS connection settings are not valid. Check the form and try again.",
|
||||
"invalidField": "A connection field is not valid or is too long.",
|
||||
"invalidUrl": "Use an HTTPS PBS address without embedded credentials, query parameters or a fragment.",
|
||||
"nameRequired": "Enter a server name of at most 128 characters.",
|
||||
"tokenIdFormat": "Enter the PBS API token ID in user@realm!token format.",
|
||||
"tokenSecret": "Enter a valid API token secret without spaces or control characters.",
|
||||
"invalidTokenId": "The API token ID must contain only ASCII characters.",
|
||||
"invalidFingerprint": "Enter the certificate's SHA256 fingerprint: 64 hexadecimal characters, optionally separated by colons.",
|
||||
"fingerprintMismatch": "The PBS certificate does not match the fingerprint. No credentials were sent.",
|
||||
"tokenRejected": "PBS rejected the API token or its permissions. Check the ACLs of both the user and the token.",
|
||||
"invalidResponse": "PBS returned an unexpected response. Redirects are not followed.",
|
||||
"invalidJson": "PBS returned an invalid JSON document.",
|
||||
"connectFailed": "Could not connect to PBS over verified HTTPS. Check the address, certificate and service.",
|
||||
"invalidId": "The PBS connection ID is not valid. Reload the connection list.",
|
||||
"invalidDatastores": "PBS returned an invalid datastore list.",
|
||||
"connectionLimit": "At most {count} PBS connections may be configured.",
|
||||
"username": "Enter the PBS user in user@realm format, for example root@pam.",
|
||||
"password": "Enter the PBS user's password.",
|
||||
"loginRejected": "PBS rejected the username or password.",
|
||||
"invalidLogin": "PBS returned an invalid sign-in response.",
|
||||
"tokenCreateDenied": "This PBS user cannot create API tokens.",
|
||||
"invalidToken": "PBS returned an invalid API token.",
|
||||
"auditRoleDenied": "This PBS user cannot grant the Audit role. Sign in as root@pam or another administrator.",
|
||||
"notFound": "The PBS connection was not found. Reload the connection list.",
|
||||
"readFailed": "Could not read PBS data. Check its service and permissions.",
|
||||
"invalidList": "PBS returned an invalid data list.",
|
||||
"localUnavailable": "PBS is not available on this host.",
|
||||
"authRequired": "Enable local authentication in Monitor before managing remote PBS connections.",
|
||||
"datastoreOffline": "The datastore is offline, in maintenance mode or its backing device is not mounted.",
|
||||
"datastoreUnavailable": "The datastore filesystem is not available."
|
||||
},
|
||||
"taskTypes": {
|
||||
"backup": "Backup", "restore": "Restore", "verify": "Verification",
|
||||
"verifyGroup": "Backup group verification", "verifySnapshot": "Snapshot verification",
|
||||
"prune": "Prune", "garbageCollection": "Garbage collection", "sync": "Synchronization",
|
||||
"updateCheck": "Check for updates", "terminal": "Terminal", "createDatastore": "Create datastore",
|
||||
"other": "Other task ({type})"
|
||||
},
|
||||
"states": { "ok": "OK", "unknown": "Unknown", "warnings": "Warnings: {count}", "warning": "Warning", "error": "Error" },
|
||||
"serviceStates": { "active": "Active", "inactive": "Inactive", "failed": "Failed", "activating": "Starting", "deactivating": "Stopping", "unknown": "Unknown" },
|
||||
"durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" }
|
||||
},
|
||||
"app": {
|
||||
"title": "ProxMenux Monitor",
|
||||
@@ -4317,6 +4370,124 @@
|
||||
"unrecoverable": "Without the key, encrypted backups cannot be restored.",
|
||||
"usesSharedKeyfileAt": "Uses shared keyfile at"
|
||||
},
|
||||
"apiErrors": {
|
||||
"requestFailed": "The request failed.",
|
||||
"absolutePath": "path must be absolute",
|
||||
"pathConfigured": "this path is already configured",
|
||||
"pathRequired": "path is required",
|
||||
"pathQuery": "path query parameter is required",
|
||||
"pathNotFound": "path not found",
|
||||
"invalidJobId": "invalid job id",
|
||||
"jobNotFound": "job not found",
|
||||
"runnerMissing": "runner script not installed",
|
||||
"noRestore": "no restore state to dismiss",
|
||||
"invalidFile": "invalid file name",
|
||||
"notFound": "not found",
|
||||
"logOutside": "log path outside allowed directory",
|
||||
"targetsList": "targets must be a list",
|
||||
"emptyExpression": "empty expression",
|
||||
"invalidId": "invalid id (use letters, digits, _ or -)",
|
||||
"backend": "backend must be pbs, local or borg",
|
||||
"remoteBackend": "backend must be pbs or borg",
|
||||
"remoteBackendQuoted": "backend must be 'pbs' or 'borg'",
|
||||
"profileMode": "profile_mode must be default or custom",
|
||||
"borgAttached": "borg cannot be attached (PVE vzdump only writes to pbs/local storages)",
|
||||
"borgAttachedLong": "borg is not a valid backend for attached jobs (PVE vzdump only writes to pbs/local storages)",
|
||||
"parentRequired": "pve_parent_job_id and pve_storage are required for attached",
|
||||
"absolutePaths": "paths must be a list of absolute paths",
|
||||
"customPath": "custom profile needs at least one path",
|
||||
"defaultPaths": "could not resolve default profile paths",
|
||||
"retentionObject": "retention must be an object",
|
||||
"pbsRepository": "pbs_repository is required",
|
||||
"pbsEncryption": "pbs_encrypt_mode must be 'none', 'new' or 'existing'",
|
||||
"pbsKeyFailed": "failed to create PBS encryption keyfile",
|
||||
"pbsClientMissing": "failed to create PBS encryption keyfile (is proxmox-backup-client installed?)",
|
||||
"borgRepository": "borg_repo is required",
|
||||
"borgEncryption": "borg_encrypt_mode must be one of: none, repokey, keyfile, authenticated",
|
||||
"encryption": "encrypt_mode must be one of: none, repokey, keyfile, authenticated",
|
||||
"borgPassphrase": "borg_passphrase is required when encryption is enabled (save it on the destination or pass one in the request)",
|
||||
"passphrase": "passphrase is required when encryption is enabled",
|
||||
"password": "password is required",
|
||||
"deviceConfirmation": "confirm_device must match device exactly (typo / safety check)",
|
||||
"invalidDevice": "invalid device path",
|
||||
"absoluteMountpoint": "absolute mountpoint required",
|
||||
"archiveLayout": "archive layout not recognized — no rootfs/ found",
|
||||
"archiveMissing": "archive not found",
|
||||
"archiveNotFile": "archive path is not a regular file",
|
||||
"invalidName": "invalid name",
|
||||
"invalidStaging": "invalid staging_path",
|
||||
"jobRename": "job name cannot be changed (delete + recreate to rename)",
|
||||
"absoluteKey": "key_path must be absolute",
|
||||
"mode": "mode must be \"local\" or \"ssh\"",
|
||||
"nameRequired": "name is required (letters, digits, _ or -)",
|
||||
"noLog": "no log available for this job yet",
|
||||
"noPbsConfig": "no manual PBS configs file",
|
||||
"outputMissing": "output file missing",
|
||||
"parserJson": "parser output was not valid JSON",
|
||||
"restoreJson": "run_restore output was not valid JSON",
|
||||
"repoRequired": "repo is required",
|
||||
"repoSnapshot": "repo_name and snapshot are required",
|
||||
"borgRepoSnapshot": "repo_name and snapshot are required for borg",
|
||||
"pbsRepoSnapshot": "repo_name and snapshot are required for pbs",
|
||||
"repoName": "repo_name is required",
|
||||
"restoreMissing": "restore tooling not installed on this host",
|
||||
"serverDatastore": "server and datastore are required",
|
||||
"snapshotRequired": "snapshot is required",
|
||||
"source": "source must be pbs, borg, or local",
|
||||
"sshPort": "ssh_port must be an integer between 1 and 65535",
|
||||
"sshRequired": "ssh_user, ssh_host and ssh_remote_path are required for ssh mode",
|
||||
"repositoryPath": "target has no repository path",
|
||||
"taskExpired": "task not found or expired",
|
||||
"invalidExpression": "invalid expression",
|
||||
"noReport": "no report emitted",
|
||||
"parseManifestFailed": "parse_manifest exited non-zero",
|
||||
"jobExists": "job already exists: {value}",
|
||||
"pathMissing": "path does not exist on this host: {value}",
|
||||
"deviceMissing": "device does not exist: {value}",
|
||||
"mountpoint": "not a mountpoint: {value}",
|
||||
"localArchiveMissing": "local archive not found: {value}",
|
||||
"borgDestination": "Borg destination '{value}' not found",
|
||||
"pbsDestination": "PBS destination '{value}' not found",
|
||||
"pbsPassword": "no password available for PBS repo '{value}'",
|
||||
"borgRepoMissing": "Borg repo \"{value}\" not configured",
|
||||
"pbsRepoMissing": "PBS repo \"{value}\" not configured",
|
||||
"borgTargetMissing": "Borg target not found: {value}",
|
||||
"pbsTargetMissing": "PBS repo not found: {value}",
|
||||
"pveJobMissing": "PVE vzdump job not found: {value}",
|
||||
"statePersist": "cannot persist state: {value}",
|
||||
"sshDirectory": "cannot prepare ssh dir: {value}",
|
||||
"logRead": "cannot read log: {value}",
|
||||
"envUpdate": "could not update .env: {value}",
|
||||
"pbsConfigWrite": "failed to persist PBS config: {value}",
|
||||
"persist": "failed to persist: {value}",
|
||||
"archiveRemove": "failed to remove archive: {value}",
|
||||
"start": "failed to start: {value}",
|
||||
"borgTargetsUpdate": "failed to update borg-targets.txt: {value}",
|
||||
"pbsTargetsUpdate": "failed to update pbs-manual-configs.txt: {value}",
|
||||
"unitsUpdate": "failed to update systemd units: {value}",
|
||||
"borgTargetsWrite": "failed to write borg-targets.txt: {value}",
|
||||
"jobWrite": "failed to write job files: {value}",
|
||||
"localTargetWrite": "failed to write local-target.conf: {value}",
|
||||
"unitsWrite": "failed to write systemd units: {value}",
|
||||
"format": "format failed: {value}",
|
||||
"calendar": "invalid on_calendar: {value}",
|
||||
"mount": "mount failed: {value}",
|
||||
"parserInvocation": "parser invocation failed: {value}",
|
||||
"preflightInvocation": "preflight invocation failed: {value}",
|
||||
"publicKeyMissing": "public key not found after generation: {value}",
|
||||
"retentionInteger": "retention.{value} must be an integer",
|
||||
"retentionNegative": "retention.{value} must be non-negative",
|
||||
"keygen": "ssh-keygen failed: {value}",
|
||||
"systemctl": "systemctl failed: {value}",
|
||||
"unmount": "umount failed: {value}",
|
||||
"taskIncomplete": "task is in state \"{value}\", not completed",
|
||||
"unknownKind": "unknown kind: {value}",
|
||||
"unknownMode": "unknown mode \"{value}\"",
|
||||
"defaultRemove": "{value} is the built-in default and cannot be removed",
|
||||
"defaultAdd": "{value} is the built-in default; no need to add it",
|
||||
"keyImport": "No PBS keyfile is installed at {value}. Import the keyfile first.",
|
||||
"storageMismatch": "PVE job storage mismatch (job uses {value}, payload says {other})"
|
||||
},
|
||||
"errors": {
|
||||
"backendNoStagingPath": "Backend did not return a staging path.",
|
||||
"deleteFailed": "Delete failed: {error}",
|
||||
@@ -6619,6 +6790,17 @@
|
||||
"addSelected": "Add selected",
|
||||
"pbsWebInterface": "PBS web interface",
|
||||
"changeAddress": "Change address",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"errors": {
|
||||
"addressRequired": "Enter the address of the Monitor.",
|
||||
"invalidAddress": "The address must start with http:// or https:// and name a host.",
|
||||
"addressCredentials": "The address must not contain a user or password.",
|
||||
"invalidPort": "The port of the address is not valid.",
|
||||
"invalidRequest": "The request is not valid. Reload the page and try again.",
|
||||
"limit": "At most {count} nodes can be added.",
|
||||
"duplicate": "This address is already in the list.",
|
||||
"notFound": "The node was not found. Reload the list and try again.",
|
||||
"detectedRemove": "Only nodes added by hand can be removed. Detected nodes can be hidden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"notAvailable": "Nedostupné",
|
||||
"serverOffline": "Server je offline",
|
||||
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard"
|
||||
"descriptionPbs": "Prehľad systému Proxmox Backup Server"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "Nedostupné",
|
||||
@@ -4180,7 +4180,7 @@
|
||||
"unmountTitle": "Odpojiť tento cieľ",
|
||||
"whereIsBorgRepo": "Kde je Borg repozitár?",
|
||||
"backupsKeptMessage": "Existujúce zálohy sa neodstránia.",
|
||||
"localAttachHelpMessage": "Najprv pripojte jednotky USB.Ak chcete použiť cestu k úložisku PVE + /dump, nechajte pole prázdne (záložný: /var/lib/vz/dump).",
|
||||
"localAttachHelpMessage": "Najprv pripoj USB disk. Ak pole necháš prázdne, použije sa cesta úložiska PVE doplnená o /dump; náhradná cesta je /var/lib/vz/dump.",
|
||||
"sshAuthorizedKeyHelp": "Pred pridaním do ~/.ssh/authorized_keys pre {user} na vzdialenom hostiteľovi skontrolujte riadok nižšie.",
|
||||
"sshKeyHelpMessage": "Cesta súkromného kľúča na tomto hostiteľovi.Použite {action}, potom skontrolujte a pridajte zobrazený riadok verejného kľúča do ~/.ssh/authorized_keys vzdialeného používateľa.",
|
||||
"sshKeySetupHelp": "Vytvorí kľúč na zadanej lokálnej ceste, ak žiadny neexistuje;inak prečíta svoj verejný kľúč.Na vzdialenom hostiteľovi nie je nainštalovaný žiadny kľúč.",
|
||||
@@ -4224,6 +4224,124 @@
|
||||
"usesSharedKeyfileAt": "Používa spoločný súbor s kľúčom v",
|
||||
"pbsHelp": "Monitor používa {keyfile} s {option} na šifrované zálohy a obnovy PBS.Súbor kľúča je uložený v režime {mode}.Uschovajte si bezpečnú kópiu mimo tohto hostiteľa."
|
||||
},
|
||||
"apiErrors": {
|
||||
"requestFailed": "Požiadavka zlyhala.",
|
||||
"absolutePath": "Cesta musí začínať znakom /.",
|
||||
"pathConfigured": "Táto cesta už je nastavená.",
|
||||
"pathRequired": "Zadaj cestu.",
|
||||
"pathQuery": "V požiadavke chýba cesta.",
|
||||
"pathNotFound": "Cesta sa nenašla.",
|
||||
"invalidJobId": "Neplatný identifikátor úlohy.",
|
||||
"jobNotFound": "Úloha sa nenašla.",
|
||||
"runnerMissing": "Na serveri chýba skript na spustenie zálohy.",
|
||||
"noRestore": "Nie je tu žiadny záznam obnovy na zatvorenie.",
|
||||
"invalidFile": "Neplatný názov súboru.",
|
||||
"notFound": "Požadovaný záznam sa nenašiel.",
|
||||
"logOutside": "Cesta k záznamu je mimo povoleného priečinka.",
|
||||
"targetsList": "Ciele musia byť zadané ako zoznam.",
|
||||
"emptyExpression": "Zadaj časový plán.",
|
||||
"invalidId": "V identifikátore použi iba písmená, číslice, _ alebo -.",
|
||||
"backend": "Vyber cieľ PBS, miestny archív alebo Borg.",
|
||||
"remoteBackend": "Vyber cieľ PBS alebo Borg.",
|
||||
"remoteBackendQuoted": "Vyber cieľ PBS alebo Borg.",
|
||||
"profileMode": "Vyber predvolený alebo vlastný profil.",
|
||||
"borgAttached": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.",
|
||||
"borgAttachedLong": "Borg sa nedá pripojiť k úlohe PVE; vzdump zapisuje iba na PBS alebo miestne úložisko.",
|
||||
"parentRequired": "Vyber nadradenú úlohu PVE aj jej úložisko.",
|
||||
"absolutePaths": "Zadaj zoznam ciest, ktoré začínajú znakom /.",
|
||||
"customPath": "Do vlastného profilu pridaj aspoň jednu cestu.",
|
||||
"defaultPaths": "Cesty predvoleného profilu sa nepodarilo zistiť.",
|
||||
"retentionObject": "Pravidlá uchovávania záloh majú neplatný formát.",
|
||||
"pbsRepository": "Zadaj repozitár PBS.",
|
||||
"pbsEncryption": "Vyber režim šifrovania PBS: bez šifrovania, nový kľúč alebo existujúci kľúč.",
|
||||
"pbsKeyFailed": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť.",
|
||||
"pbsClientMissing": "Súbor so šifrovacím kľúčom PBS sa nepodarilo vytvoriť. Skontroluj, či je nainštalovaný proxmox-backup-client.",
|
||||
"borgRepository": "Zadaj repozitár Borg.",
|
||||
"borgEncryption": "Vyber podporovaný režim šifrovania Borg: none, repokey, keyfile alebo authenticated.",
|
||||
"encryption": "Vyber podporovaný režim šifrovania: none, repokey, keyfile alebo authenticated.",
|
||||
"borgPassphrase": "Šifrovaný repozitár Borg potrebuje heslo. Ulož ho pri cieli alebo ho zadaj v požiadavke.",
|
||||
"passphrase": "Pri zapnutom šifrovaní zadaj heslo.",
|
||||
"password": "Zadaj heslo.",
|
||||
"deviceConfirmation": "Na potvrdenie zadaj presný názov zariadenia.",
|
||||
"invalidDevice": "Neplatná cesta k zariadeniu.",
|
||||
"absoluteMountpoint": "Prípojný bod musí byť cesta začínajúca znakom /.",
|
||||
"archiveLayout": "Archív nemá očakávanú štruktúru: chýba priečinok rootfs/.",
|
||||
"archiveMissing": "Archív sa nenašiel.",
|
||||
"archiveNotFile": "Cesta k archívu neoznačuje bežný súbor.",
|
||||
"invalidName": "Neplatný názov.",
|
||||
"invalidStaging": "Neplatná cesta k dočasným súborom.",
|
||||
"jobRename": "Názov existujúcej úlohy sa nedá zmeniť. Ak ju chceš premenovať, odstráň ju a vytvor novú.",
|
||||
"absoluteKey": "Cesta k súboru s kľúčom musí začínať znakom /.",
|
||||
"mode": "Vyber miestny cieľ alebo pripojenie cez SSH.",
|
||||
"nameRequired": "Zadaj názov; použi iba písmená, číslice, _ alebo -.",
|
||||
"noLog": "Pre túto úlohu zatiaľ nie je dostupný záznam.",
|
||||
"noPbsConfig": "Súbor s ručne nastavenými cieľmi PBS sa nenašiel.",
|
||||
"outputMissing": "Chýba výstupný súbor.",
|
||||
"parserJson": "Výstup spracovania archívu nemá platný formát JSON.",
|
||||
"restoreJson": "Výstup prípravy obnovy nemá platný formát JSON.",
|
||||
"repoRequired": "Zadaj repozitár.",
|
||||
"repoSnapshot": "Vyber repozitár aj zálohu.",
|
||||
"borgRepoSnapshot": "Pre Borg vyber repozitár aj zálohu.",
|
||||
"pbsRepoSnapshot": "Pre PBS vyber repozitár aj zálohu.",
|
||||
"repoName": "Zadaj názov repozitára.",
|
||||
"restoreMissing": "Na tomto serveri chýbajú nástroje na obnovu.",
|
||||
"serverDatastore": "Zadaj server aj dátové úložisko.",
|
||||
"snapshotRequired": "Vyber zálohu.",
|
||||
"source": "Vyber zdroj PBS, Borg alebo miestny archív.",
|
||||
"sshPort": "Port SSH musí byť celé číslo od 1 do 65535.",
|
||||
"sshRequired": "Pre SSH zadaj používateľa, hostiteľa a vzdialenú cestu.",
|
||||
"repositoryPath": "Cieľ nemá nastavenú cestu k repozitáru.",
|
||||
"taskExpired": "Úloha sa nenašla alebo už vypršala.",
|
||||
"invalidExpression": "Neplatný časový plán.",
|
||||
"noReport": "Kontrola nevrátila žiadnu správu.",
|
||||
"parseManifestFailed": "Spracovanie manifestu skončilo chybou.",
|
||||
"jobExists": "Úloha s názvom {value} už existuje.",
|
||||
"pathMissing": "Na tomto serveri sa nenašla cesta: {value}",
|
||||
"deviceMissing": "Zariadenie neexistuje: {value}",
|
||||
"mountpoint": "Táto cesta nie je prípojný bod: {value}",
|
||||
"localArchiveMissing": "Miestny archív sa nenašiel: {value}",
|
||||
"borgDestination": "Cieľ Borg „{value}“ sa nenašiel.",
|
||||
"pbsDestination": "Cieľ PBS „{value}“ sa nenašiel.",
|
||||
"pbsPassword": "Pre repozitár PBS „{value}“ nie je dostupné heslo.",
|
||||
"borgRepoMissing": "Repozitár Borg „{value}“ nie je nastavený.",
|
||||
"pbsRepoMissing": "Repozitár PBS „{value}“ nie je nastavený.",
|
||||
"borgTargetMissing": "Cieľ Borg sa nenašiel: {value}",
|
||||
"pbsTargetMissing": "Repozitár PBS sa nenašiel: {value}",
|
||||
"pveJobMissing": "Úloha zálohovania PVE sa nenašla: {value}",
|
||||
"statePersist": "Stav sa nepodarilo uložiť: {value}",
|
||||
"sshDirectory": "Priečinok SSH sa nepodarilo pripraviť: {value}",
|
||||
"logRead": "Záznam sa nepodarilo prečítať: {value}",
|
||||
"envUpdate": "Súbor .env sa nepodarilo aktualizovať: {value}",
|
||||
"pbsConfigWrite": "Nastavenia PBS sa nepodarilo uložiť: {value}",
|
||||
"persist": "Údaje sa nepodarilo uložiť: {value}",
|
||||
"archiveRemove": "Archív sa nepodarilo odstrániť: {value}",
|
||||
"start": "Úlohu sa nepodarilo spustiť: {value}",
|
||||
"borgTargetsUpdate": "Súbor borg-targets.txt sa nepodarilo aktualizovať: {value}",
|
||||
"pbsTargetsUpdate": "Súbor pbs-manual-configs.txt sa nepodarilo aktualizovať: {value}",
|
||||
"unitsUpdate": "Jednotky systemd sa nepodarilo aktualizovať: {value}",
|
||||
"borgTargetsWrite": "Súbor borg-targets.txt sa nepodarilo uložiť: {value}",
|
||||
"jobWrite": "Súbory úlohy sa nepodarilo uložiť: {value}",
|
||||
"localTargetWrite": "Súbor local-target.conf sa nepodarilo uložiť: {value}",
|
||||
"unitsWrite": "Jednotky systemd sa nepodarilo uložiť: {value}",
|
||||
"format": "Formátovanie zlyhalo: {value}",
|
||||
"calendar": "Neplatný časový plán: {value}",
|
||||
"mount": "Disk sa nepodarilo pripojiť: {value}",
|
||||
"parserInvocation": "Spracovanie archívu sa nepodarilo spustiť: {value}",
|
||||
"preflightInvocation": "Kontrolu pred obnovou sa nepodarilo spustiť: {value}",
|
||||
"publicKeyMissing": "Po vytvorení kľúča sa nenašiel verejný kľúč: {value}",
|
||||
"retentionInteger": "Pravidlo uchovávania „{value}“ musí byť celé číslo.",
|
||||
"retentionNegative": "Pravidlo uchovávania „{value}“ nesmie byť záporné.",
|
||||
"keygen": "Vytvorenie kľúča SSH zlyhalo: {value}",
|
||||
"systemctl": "Správa služby cez systemctl zlyhala: {value}",
|
||||
"unmount": "Disk sa nepodarilo odpojiť: {value}",
|
||||
"taskIncomplete": "Úloha ešte nie je dokončená. Jej stav: {value}",
|
||||
"unknownKind": "Neznámy typ: {value}",
|
||||
"unknownMode": "Neznámy režim: {value}",
|
||||
"defaultRemove": "Cesta {value} je predvolená a nedá sa odstrániť.",
|
||||
"defaultAdd": "Cesta {value} je predvolená; nemusíš ju pridávať.",
|
||||
"keyImport": "V {value} nie je uložený súbor s kľúčom PBS. Najprv ho importuj.",
|
||||
"storageMismatch": "Úložisko sa nezhoduje: úloha PVE používa {value}, požiadavka uvádza {other}."
|
||||
},
|
||||
"errors": {
|
||||
"backendNoStagingPath": "Cieľ nevrátil cestu k dočasným súborom.",
|
||||
"deleteFailed": "Mazanie zlyhalo: {error}",
|
||||
@@ -4809,7 +4927,7 @@
|
||||
"keepAll": "Ponechať všetky zálohy",
|
||||
"description": "Predvolená politika uchovávania záloh uložená v Proxmoxe. Zálohovacie úlohy s vlastnou politikou majú pred týmto predvoleným nastavením prednosť.",
|
||||
"refresh": "Obnoviť",
|
||||
"saved": "Politika uchovávania záloh úložiska bola uložená. Prerezávanie sa nevykonalo.",
|
||||
"saved": "Pravidlá uchovávania záloh na úložisku boli uložené. Žiadne zálohy sa teraz neodstránili.",
|
||||
"choosePolicy": "Vyberte Ponechať všetky alebo aspoň jeden počet uchovávaných záloh väčší ako nula.",
|
||||
"saveFailed": "Nepodarilo sa uložiť politiku uchovávania záloh úložiska. Ak sa konfigurácia zmenila, obnovte zoznam.",
|
||||
"loadFailed": "Nepodarilo sa načítať politiku uchovávania záloh úložiska.",
|
||||
@@ -5644,7 +5762,7 @@
|
||||
"nextStep": {
|
||||
"missing": "Nastavte si držanie na týchto pracovných miestach, alebo na sklade, na ktorý píšu. Proxmox berie nastavenie úlohy ako prvý, potom úložisko je, potom uzol predvolený.",
|
||||
"notDeclared": "Tieto práce uchovávajú všetky kópie, ktoré robia. To je zámerná voľba pre niektoré destinácie, ale potvrďte, že cieľ má priestor na rast.",
|
||||
"onServer": "Tieto úlohy píšu na záložný server, ktorý ich prerezáva podľa vlastných pravidiel. Z tohto uzla sa nedá čítať to, čo drží, takže skontrolujte, či tam nie je."
|
||||
"onServer": "Tieto úlohy ukladajú zálohy na zálohovací server. Ten odstraňuje staré zálohy podľa vlastných pravidiel. Z tohto uzla sa nedá zistiť, ktoré zálohy uchováva; skontroluj ich priamo na zálohovacom serveri."
|
||||
},
|
||||
"explanation": "O tom, koľko výtlačkov sa uchováva a ako dlho sa uchováva. Bez nej sa cieľ naplní, kým neprestane prijímať nové kópie, a záloha, ktorú nemožno napísať, je tá, o ktorej sa dozviete v deň, keď ju potrebujete."
|
||||
},
|
||||
@@ -7302,35 +7420,40 @@
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "Server PBS",
|
||||
"chooseServer": "Vybrať server PBS",
|
||||
"local": "Miestny PBS",
|
||||
"local": "Lokálny PBS",
|
||||
"thisNode": "PBS na tomto uzle",
|
||||
"updatedAt": "Aktualizované o {time}",
|
||||
"servicesActive": "Služby sú aktívne",
|
||||
"detectedTitle": "PBS zistené v úložisku Proxmox",
|
||||
"detectedHelp": "Tieto Proxmox Backup Servery sú nastavené ako úložisko záloh. Pripojte každý z nich API tokenom len na čítanie, aby ste tu videli jeho datastory, úlohy a joby.",
|
||||
"detectedTitle": "PBS zistené v nastaveniach úložísk Proxmoxu",
|
||||
"detectedHelp": "Tieto servery Proxmox Backup Server sú nastavené ako úložiská záloh. Pripoj ich pomocou API tokenu iba na čítanie a zobrazia sa tu ich dátové úložiská, úlohy a plány údržby.",
|
||||
"connect": "Pripojiť",
|
||||
"tasksTab": "Úlohy",
|
||||
"signInWith": "Prihlásiť sa pomocou",
|
||||
"methodPassword": "Používateľ a heslo",
|
||||
"methodToken": "API token",
|
||||
"username": "Používateľ (pouzivatel@realm)",
|
||||
"username": "Používateľ",
|
||||
"password": "Heslo",
|
||||
"passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu len na čítanie (rola Audit) pre tento Monitor. Uloží sa iba tento token, nikdy nie heslo. Najprv zapnite overovanie v Monitore.",
|
||||
"passwordHelp": "Heslo sa použije iba raz na vytvorenie API tokenu iba na čítanie s rolou Audit pre tento Monitor. Uloží sa iba token, heslo sa neukladá. Najprv zapni overovanie v Monitore.",
|
||||
"cancel": "Zrušiť",
|
||||
"refresh": "Obnoviť",
|
||||
"manage": "Spravovať pripojenia PBS",
|
||||
"loadFailed": "Nepodarilo sa načítať stav PBS.",
|
||||
"loading": "Načítavajú sa údaje PBS…",
|
||||
"noServer": "Pridajte pripojenie PBS na zobrazenie dátových úložísk, úloh a plánov.",
|
||||
"permissionsHelp": "Pred uložením prihlasovacích údajov povoľte overovanie v Monitore. Použite vyhradený API token PBS s rolou Audit na / v ACL používateľa aj tokenu, so zapnutým dedením. Tento panel údaje PBS iba číta.",
|
||||
"noServer": "Pridaj pripojenie PBS a zobrazia sa jeho dátové úložiská, úlohy a plány údržby.",
|
||||
"permissionsHelp": "Pred uložením prihlasovacích údajov zapni overovanie v Monitore. Použi vyhradený API token PBS. Používateľ aj token musia mať v zozname prístupových oprávnení (ACL) rolu Audit na ceste / so zapnutým dedením. Tento panel údaje PBS iba číta.",
|
||||
"editConnection": "Pripojenie na úpravu",
|
||||
"add": "Pridať nové pripojenie",
|
||||
"name": "Názov",
|
||||
"url": "HTTPS URL (pre pôvodný port PBS uveďte :8007)",
|
||||
"token_id": "ID API tokenu (user@realm!token)",
|
||||
"token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať uložený kľúč)",
|
||||
"fingerprint": "Odtlačok SHA256 certifikátu (voliteľný)",
|
||||
"tlsHelp": "Pre bežné overenie certifikačnej autority a názvu hostiteľa ponechajte odtlačok prázdny. Pri certifikáte s vlastným podpisom skopírujte jeho odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného kanála. Odtlačok sa kontroluje pred odoslaním prihlasovacích údajov.",
|
||||
"url": "Adresa HTTPS",
|
||||
"urlHelp": "Štandardný port PBS je 8007, napríklad https://pbs.example:8007.",
|
||||
"usernameHelp": "Použi meno a overovaciu oblasť oddelené znakom @, napríklad root@pam.",
|
||||
"tokenIdHelp": "Použi formát meno@oblasť!token, napríklad monitor@pbs!proxmenux.",
|
||||
"tokenSecretHelp": "Zadaj tajný kľúč k tomuto API tokenu.",
|
||||
"tokenSecretKeepHelp": "Prázdne pole zachová uložený tajný kľúč tokenu.",
|
||||
"token_id": "ID API tokenu",
|
||||
"token_secret": "Tajný kľúč API tokenu",
|
||||
"fingerprint": "Odtlačok SHA256 (voliteľný)",
|
||||
"tlsHelp": "Pri bežnom overovaní certifikačnej autority a názvu hostiteľa nechaj odtlačok prázdny. Pri certifikáte s vlastným podpisom skopíruj odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného zdroja. Kontroluje sa ešte pred odoslaním prihlasovacích údajov.",
|
||||
"saved": "Pripojenie PBS bolo overené a uložené.",
|
||||
"saveFailed": "Nepodarilo sa uložiť pripojenie PBS.",
|
||||
"testing": "Overuje sa pripojenie…",
|
||||
@@ -7342,9 +7465,9 @@
|
||||
"total": "Kapacita súborového systému",
|
||||
"used": "Použité",
|
||||
"free": "Dostupné",
|
||||
"noDatastores": "Žiadne dostupné dátové úložiská. Skontrolujte konfiguráciu úložísk a oprávnenia používateľa a tokenu.",
|
||||
"noDatastores": "Nie sú dostupné žiadne dátové úložiská. Skontroluj ich nastavenia a oprávnenia používateľa aj tokenu.",
|
||||
"tasks": "Nedávne úlohy",
|
||||
"noTasks": "Neboli vrátené žiadne nedávne úlohy.",
|
||||
"noTasks": "PBS nevrátil žiadne nedávne úlohy.",
|
||||
"task": "Úloha",
|
||||
"started": "Spustené",
|
||||
"status": "Stav",
|
||||
@@ -7354,7 +7477,7 @@
|
||||
"schedule": "Plán",
|
||||
"sync": "Synchronizácia",
|
||||
"verify": "Overenie",
|
||||
"prune": "Prerezávanie",
|
||||
"prune": "Odstraňovanie starých záloh",
|
||||
"overview": "Prehľad",
|
||||
"logs": "Systémové záznamy",
|
||||
"terminal": "Terminál",
|
||||
@@ -7365,51 +7488,110 @@
|
||||
"memory": "Pamäť",
|
||||
"uptime": "Čas prevádzky",
|
||||
"capacityUnavailable": "Kapacita nie je k dispozícii.",
|
||||
"datastoresUnavailable": "{count} nie je k dispozícii",
|
||||
"datastoresUnavailable": "Nedostupné úložiská: {count}",
|
||||
"datastoresOnline": "Všetky dostupné",
|
||||
"moreDatastores": "+{count} ďalších dátových úložísk",
|
||||
"estimatedFull": "Odhadovaný plný",
|
||||
"estimatedFullUnknown": "Zatiaľ nie je dostatok údajov",
|
||||
"estimatedFull": "Odhadované zaplnenie",
|
||||
"estimatedFullUnknown": "Zatiaľ sa nedá odhadnúť",
|
||||
"duration": "Trvanie",
|
||||
"noJobs": "Nie sú nakonfigurované žiadne plány synchronizácie, overovania ani odstraňovania."
|
||||
"noJobs": "Nie sú nastavené žiadne plány synchronizácie, overovania ani odstraňovania starých záloh.",
|
||||
"removeFailed": "Nepodarilo sa odstrániť pripojenie PBS.",
|
||||
"savedRefreshFailed": "Pripojenie PBS sa uložilo, ale nepodarilo sa obnoviť zoznam pripojení. Pred ďalšou zmenou obnov stránku.",
|
||||
"removedRefreshFailed": "Pripojenie PBS sa odstránilo, ale nepodarilo sa obnoviť zoznam pripojení. Obnov stránku.",
|
||||
"technicalDetails": "Technické podrobnosti",
|
||||
"datastoreType": "Dátové úložisko",
|
||||
"errors": {
|
||||
"configRead": "Nepodarilo sa načítať uložené pripojenia PBS. Skontroluj konfiguračný súbor a oprávnenia.",
|
||||
"invalidConfig": "Nastavenia pripojenia PBS nie sú platné. Skontroluj formulár a skús to znova.",
|
||||
"invalidField": "Niektoré pole pripojenia nie je platné alebo je príliš dlhé.",
|
||||
"invalidUrl": "Použi adresu HTTPS bez vložených prihlasovacích údajov, parametrov za otáznikom a časti za znakom #.",
|
||||
"nameRequired": "Zadaj názov servera s najviac 128 znakmi.",
|
||||
"tokenIdFormat": "Zadaj ID API tokenu PBS vo formáte user@realm!token.",
|
||||
"tokenSecret": "Zadaj platný tajný kľúč API tokenu bez medzier a riadiacich znakov.",
|
||||
"invalidTokenId": "ID API tokenu môže obsahovať iba znaky ASCII.",
|
||||
"invalidFingerprint": "Zadaj odtlačok SHA256 certifikátu: 64 šestnástkových znakov, prípadne oddelených dvojbodkami.",
|
||||
"fingerprintMismatch": "Certifikát PBS sa nezhoduje s odtlačkom. Prihlasovacie údaje sa neodoslali.",
|
||||
"tokenRejected": "PBS odmietol API token alebo jeho oprávnenia. Skontroluj zoznam prístupových oprávnení (ACL) používateľa aj tokenu.",
|
||||
"invalidResponse": "PBS vrátil neočakávanú odpoveď. Presmerovania sa nenasledujú.",
|
||||
"invalidJson": "PBS vrátil neplatný dokument JSON.",
|
||||
"connectFailed": "Nepodarilo sa pripojiť k PBS cez overené HTTPS. Skontroluj adresu, certifikát a službu.",
|
||||
"invalidId": "ID pripojenia PBS nie je platné. Obnov zoznam pripojení.",
|
||||
"invalidDatastores": "PBS vrátil neplatný zoznam dátových úložísk.",
|
||||
"connectionLimit": "Môžeš nastaviť najviac {count} pripojení PBS.",
|
||||
"username": "Zadaj používateľa PBS vo formáte user@realm, napríklad root@pam.",
|
||||
"password": "Zadaj heslo používateľa PBS.",
|
||||
"loginRejected": "PBS odmietol používateľské meno alebo heslo.",
|
||||
"invalidLogin": "PBS vrátil neplatnú odpoveď na prihlásenie.",
|
||||
"tokenCreateDenied": "Tento používateľ PBS nemôže vytvárať API tokeny.",
|
||||
"invalidToken": "PBS vrátil neplatný API token.",
|
||||
"auditRoleDenied": "Tento používateľ PBS nemôže prideliť rolu Audit. Prihlás sa ako root@pam alebo iný správca.",
|
||||
"notFound": "Pripojenie PBS sa nenašlo. Obnov zoznam pripojení.",
|
||||
"readFailed": "Nepodarilo sa načítať údaje PBS. Skontroluj službu a oprávnenia.",
|
||||
"invalidList": "PBS vrátil neplatný zoznam údajov.",
|
||||
"localUnavailable": "PBS na tomto hostiteľovi nie je dostupný.",
|
||||
"authRequired": "Pred správou vzdialených pripojení PBS zapni lokálne overovanie v Monitore.",
|
||||
"datastoreOffline": "Dátové úložisko je odpojené, v režime údržby alebo jeho zariadenie nie je pripojené.",
|
||||
"datastoreUnavailable": "Súborový systém dátového úložiska nie je dostupný."
|
||||
},
|
||||
"taskTypes": {
|
||||
"backup": "Zálohovanie", "restore": "Obnova", "verify": "Overovanie",
|
||||
"verifyGroup": "Overovanie skupiny záloh", "verifySnapshot": "Overovanie snímky",
|
||||
"prune": "Odstraňovanie starých záloh", "garbageCollection": "Čistenie nepoužívaných dát", "sync": "Synchronizácia",
|
||||
"updateCheck": "Kontrola aktualizácií", "terminal": "Terminál", "createDatastore": "Vytvorenie dátového úložiska",
|
||||
"other": "Iná úloha ({type})"
|
||||
},
|
||||
"states": { "ok": "V poriadku", "unknown": "Neznámy", "warnings": "Upozornenia: {count}", "warning": "Upozornenie", "error": "Chyba" },
|
||||
"serviceStates": { "active": "Aktívna", "inactive": "Neaktívna", "failed": "Zlyhala", "activating": "Spúšťa sa", "deactivating": "Zastavuje sa", "unknown": "Neznámy stav" },
|
||||
"durationUnits": { "seconds": "{count} s", "minutes": "{count} min", "hours": "{count} h", "days": "{count} d" }
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"cluster": "Klaster",
|
||||
"standalone": "Samostatné uzly",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Tento uzol",
|
||||
"checking": "Kontrolujú sa uzly…",
|
||||
"loadFailed": "Nepodarilo sa načítať zoznam uzlov.",
|
||||
"empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.",
|
||||
"empty": "Zatiaľ tu nie sú ďalšie uzly. Pridaj uzol a jeho Monitor otvoríš priamo odtiaľto.",
|
||||
"noResponse": "Žiadna odpoveď",
|
||||
"pbsInterface": "rozhranie PBS",
|
||||
"openNewTab": "Otvoriť na novej karte",
|
||||
"manage": "Pridať alebo spravovať uzly…",
|
||||
"dialogTitle": "Prepojené uzly",
|
||||
"dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.",
|
||||
"dialogDescription": "Uzly klastra a nastavené servery Proxmox Backup Server sa zisťujú automaticky. Do Monitora každého uzla sa prihlasuješ samostatne.",
|
||||
"addTitle": "Pridať uzol",
|
||||
"name": "Meno",
|
||||
"address": "Adresa monitora",
|
||||
"name": "Názov uzla",
|
||||
"address": "Adresa Monitora",
|
||||
"type": "Typ",
|
||||
"add": "Pridať",
|
||||
"save": "Uložiť",
|
||||
"remove": "Odstrániť",
|
||||
"visible": "Viditeľné",
|
||||
"visible": "Zobraziť",
|
||||
"autoAddress": "Automaticky",
|
||||
"detected": "Zistené",
|
||||
"added": "Pridané",
|
||||
"detected": "Zistený",
|
||||
"added": "Pridaný",
|
||||
"saveFailed": "Nepodarilo sa uložiť zoznam uzlov.",
|
||||
"backTo": "Späť na {name}",
|
||||
"cameFromTitle": "Pridať uzol monitora?",
|
||||
"cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.",
|
||||
"cameFromTitle": "Pridať uzol Monitora?",
|
||||
"cameFromBody": "Pridaj {name} medzi prepojené uzly tohto Monitora.",
|
||||
"notNow": "Teraz nie",
|
||||
"knownTitle": "Pridať prepojené uzly?",
|
||||
"knownBody": "{name} prepája tieto monitory. Vyberte tie, ktoré chcete pridať do tohto monitora.",
|
||||
"knownBody": "Uzol {name} má prepojené tieto uzly. Vyber tie, ktoré chceš pridať aj sem.",
|
||||
"addSelected": "Pridať vybrané",
|
||||
"pbsWebInterface": "Webové rozhranie PBS",
|
||||
"changeAddress": "Zmeniť adresu",
|
||||
"cancel": "Zrušiť"
|
||||
"cancel": "Zrušiť",
|
||||
"errors": {
|
||||
"addressRequired": "Zadaj adresu Monitora.",
|
||||
"invalidAddress": "Adresa musí začínať na http:// alebo https:// a obsahovať názov hostiteľa alebo IP adresu.",
|
||||
"addressCredentials": "Adresa nesmie obsahovať používateľské meno ani heslo.",
|
||||
"invalidPort": "Port v adrese nie je platný. Zadaj číslo od 1 do 65535.",
|
||||
"invalidRequest": "Požiadavka nie je platná. Obnov stránku a skús to znova.",
|
||||
"limit": "Môžeš pridať najviac {count} uzlov.",
|
||||
"duplicate": "Táto adresa už je v zozname.",
|
||||
"notFound": "Uzol sa nenašiel. Obnov zoznam a skús to znova.",
|
||||
"detectedRemove": "Odstrániť môžeš iba ručne pridané uzly. Automaticky zistené uzly môžeš skryť."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ from jwt_middleware import require_auth, require_admin_scope
|
||||
monitor_links_bp = Blueprint("monitor_links", __name__)
|
||||
|
||||
|
||||
def link_error(exc):
|
||||
payload = {"error": str(exc), "code": exc.code}
|
||||
if exc.params:
|
||||
payload["params"] = exc.params
|
||||
return jsonify(payload), 400
|
||||
|
||||
|
||||
@monitor_links_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -27,9 +34,9 @@ def add_link():
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True, key=key)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
|
||||
@@ -40,9 +47,9 @@ def update_link(key):
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
|
||||
@@ -53,6 +60,6 @@ def delete_link(key):
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return link_error(exc)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
return jsonify(error="Could not save the node list.", code="monitor_links_save_failed"), 500
|
||||
|
||||
@@ -8,6 +8,10 @@ from jwt_middleware import require_auth, require_admin_scope
|
||||
pbs_bp = Blueprint("pbs", __name__)
|
||||
|
||||
|
||||
def pbs_error(exc, status):
|
||||
return jsonify(error=str(exc), **exc.problem()), status
|
||||
|
||||
|
||||
@pbs_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -27,7 +31,7 @@ def servers():
|
||||
servers = pbs.public_servers()
|
||||
return jsonify(servers=servers, local_available=pbs.local_available(), detected=_detected(servers))
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 500
|
||||
return pbs_error(exc, 500)
|
||||
|
||||
|
||||
def _detected(servers):
|
||||
@@ -57,42 +61,42 @@ def _protected_configuration():
|
||||
@require_admin_scope
|
||||
def save_server():
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 409
|
||||
try:
|
||||
identifier = pbs.save_server(request.get_json(silent=True))
|
||||
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return pbs_error(exc, 400)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save PBS connection."), 500
|
||||
return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/servers/login", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def connect_with_password():
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials.", code="pbs_auth_required"), 409
|
||||
try:
|
||||
identifier = pbs.connect_with_password(request.get_json(silent=True))
|
||||
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return pbs_error(exc, 400)
|
||||
except OSError:
|
||||
return jsonify(error="Could not save PBS connection."), 500
|
||||
return jsonify(error="Could not save PBS connection.", code="pbs_save_failed"), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/servers/<identifier>", methods=["DELETE"])
|
||||
@require_admin_scope
|
||||
def delete_server(identifier):
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before changing PBS connections."), 409
|
||||
return jsonify(error="Enable local authentication before changing PBS connections.", code="pbs_auth_required"), 409
|
||||
try:
|
||||
pbs.delete_server(identifier)
|
||||
return jsonify(success=True)
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return pbs_error(exc, 400)
|
||||
except OSError:
|
||||
return jsonify(error="Could not remove PBS connection."), 500
|
||||
return jsonify(error="Could not remove PBS connection.", code="pbs_remove_failed"), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/status")
|
||||
@@ -101,4 +105,4 @@ def status():
|
||||
try:
|
||||
return jsonify(pbs.get_status(request.args.get("server", "local")))
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 502
|
||||
return pbs_error(exc, 502)
|
||||
|
||||
@@ -39,7 +39,10 @@ _refresh_lock = threading.Lock()
|
||||
|
||||
|
||||
class LinkError(ValueError):
|
||||
pass
|
||||
def __init__(self, message, *, code="monitor_links_save_failed", params=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.params = params
|
||||
|
||||
|
||||
def _load():
|
||||
@@ -63,19 +66,19 @@ def _save(data):
|
||||
def normalize_url(value):
|
||||
"""Return the origin of an http(s) address, or raise LinkError."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise LinkError("Enter the address of the Monitor.")
|
||||
raise LinkError("Enter the address of the Monitor.", code="monitor_link_address_required")
|
||||
text = value.strip()
|
||||
if "://" not in text:
|
||||
text = "https://" + text
|
||||
parsed = urllib.parse.urlsplit(text)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise LinkError("The address must start with http:// or https:// and name a host.")
|
||||
raise LinkError("The address must start with http:// or https:// and name a host.", code="monitor_link_invalid_address")
|
||||
if parsed.username or parsed.password:
|
||||
raise LinkError("The address must not contain a user or password.")
|
||||
raise LinkError("The address must not contain a user or password.", code="monitor_link_address_credentials")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise LinkError("The port of the address is not valid.") from exc
|
||||
raise LinkError("The port of the address is not valid.", code="monitor_link_invalid_port") from exc
|
||||
host = parsed.hostname
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
@@ -301,16 +304,16 @@ def start_prewarmer():
|
||||
|
||||
def add_link(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
|
||||
name = str(payload.get("name") or "").strip()[:64]
|
||||
url = normalize_url(payload.get("url"))
|
||||
product = payload.get("product") if payload.get("product") in PRODUCTS else "pve"
|
||||
with _lock:
|
||||
data = _load()
|
||||
if len(data["manual"]) >= MAX_MANUAL:
|
||||
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.")
|
||||
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.", code="monitor_link_limit", params={"count": MAX_MANUAL})
|
||||
if any(row.get("url") == url for row in data["manual"]):
|
||||
raise LinkError("This address is already in the list.")
|
||||
raise LinkError("This address is already in the list.", code="monitor_link_duplicate")
|
||||
identifier = secrets.token_hex(6)
|
||||
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
|
||||
"url": url, "product": product})
|
||||
@@ -320,13 +323,13 @@ def add_link(payload):
|
||||
|
||||
def update_link(key, payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
raise LinkError("Invalid request.", code="monitor_link_invalid_request")
|
||||
with _lock:
|
||||
data = _load()
|
||||
if key.startswith("manual:"):
|
||||
row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None)
|
||||
if row is None:
|
||||
raise LinkError("Node not found.")
|
||||
raise LinkError("Node not found.", code="monitor_link_not_found")
|
||||
if "name" in payload:
|
||||
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
|
||||
if payload.get("url"):
|
||||
@@ -345,7 +348,7 @@ def update_link(key, payload):
|
||||
if not override:
|
||||
data["overrides"].pop(key, None)
|
||||
else:
|
||||
raise LinkError("Node not found.")
|
||||
raise LinkError("Node not found.", code="monitor_link_not_found")
|
||||
_save(data)
|
||||
|
||||
|
||||
@@ -354,6 +357,6 @@ def delete_link(key):
|
||||
data = _load()
|
||||
remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key]
|
||||
if len(remaining) == len(data["manual"]):
|
||||
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.")
|
||||
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.", code="monitor_link_detected_remove")
|
||||
data["manual"] = remaining
|
||||
_save(data)
|
||||
|
||||
@@ -39,7 +39,13 @@ MAX_RESPONSE = 4 * 1024 * 1024
|
||||
|
||||
|
||||
class PBSError(ValueError):
|
||||
pass
|
||||
def __init__(self, message, *, code="pbs_unknown", params=None):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.params = params
|
||||
|
||||
def problem(self):
|
||||
return {"code": self.code, **({"params": self.params} if self.params else {})}
|
||||
|
||||
|
||||
def get_platform():
|
||||
@@ -87,7 +93,7 @@ def _load_servers():
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, ValueError) as exc:
|
||||
raise PBSError("Could not read the PBS server configuration.") from exc
|
||||
raise PBSError("Could not read the PBS server configuration.", code="pbs_config_read") from exc
|
||||
|
||||
|
||||
def _save_servers(servers):
|
||||
@@ -113,11 +119,11 @@ def public_servers():
|
||||
|
||||
def _validate_server(data, previous=None, fields=("name", "url", "token_id", "token_secret", "fingerprint")):
|
||||
if not isinstance(data, dict):
|
||||
raise PBSError("Invalid PBS server configuration.")
|
||||
raise PBSError("Invalid PBS server configuration.", code="pbs_invalid_config")
|
||||
previous = previous or {}
|
||||
row = {field: data.get(field, previous.get(field, "")) for field in fields}
|
||||
if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
|
||||
raise PBSError("Invalid PBS connection field.")
|
||||
raise PBSError("Invalid PBS connection field.", code="pbs_invalid_field")
|
||||
row["name"] = row["name"].strip()
|
||||
row["url"] = row["url"].strip().rstrip("/")
|
||||
try:
|
||||
@@ -127,26 +133,26 @@ def _validate_server(data, previous=None, fields=("name", "url", "token_id", "to
|
||||
or parts.query or parts.fragment):
|
||||
raise ValueError()
|
||||
except ValueError as exc:
|
||||
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from exc
|
||||
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.", code="pbs_invalid_url") from exc
|
||||
if not row["name"] or len(row["name"]) > 128:
|
||||
raise PBSError("A server name of at most 128 characters is required.")
|
||||
raise PBSError("A server name of at most 128 characters is required.", code="pbs_name_required")
|
||||
if "token_id" not in row:
|
||||
return _validated_fingerprint(row)
|
||||
row["token_id"] = row["token_id"].strip()
|
||||
if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
|
||||
raise PBSError("Enter a PBS API token ID in user@realm!token format.")
|
||||
raise PBSError("Enter a PBS API token ID in user@realm!token format.", code="pbs_token_id_format")
|
||||
if (not row["token_secret"] or not row["token_secret"].isascii()
|
||||
or any(char.isspace() or ord(char) < 32 for char in row["token_secret"])):
|
||||
raise PBSError("A valid API token secret is required.")
|
||||
raise PBSError("A valid API token secret is required.", code="pbs_token_secret")
|
||||
if not row["token_id"].isascii():
|
||||
raise PBSError("Invalid API token ID.")
|
||||
raise PBSError("Invalid API token ID.", code="pbs_invalid_token_id")
|
||||
return _validated_fingerprint(row)
|
||||
|
||||
|
||||
def _validated_fingerprint(row):
|
||||
fingerprint = row["fingerprint"].strip().lower().replace(":", "")
|
||||
if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint):
|
||||
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).")
|
||||
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).", code="pbs_invalid_fingerprint")
|
||||
row["fingerprint"] = fingerprint
|
||||
return row
|
||||
|
||||
@@ -158,7 +164,8 @@ def _remote_get(server, endpoint):
|
||||
|
||||
|
||||
def _remote_request(server, method, endpoint, headers, form=None,
|
||||
rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs."):
|
||||
rejected="PBS rejected the API token or its permissions. Check both the user and token ACLs.",
|
||||
*, rejected_code="pbs_token_rejected"):
|
||||
"""One request to PBS; credentials leave only after the certificate is checked."""
|
||||
parts = urlsplit(server["url"])
|
||||
fingerprint = server.get("fingerprint")
|
||||
@@ -175,7 +182,7 @@ def _remote_request(server, method, endpoint, headers, form=None,
|
||||
if fingerprint:
|
||||
actual = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).hexdigest()
|
||||
if not hmac.compare_digest(actual, fingerprint):
|
||||
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.")
|
||||
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.", code="pbs_fingerprint_mismatch")
|
||||
path = parts.path.rstrip("/") + "/api2/json" + endpoint
|
||||
body = urlencode(form) if form is not None else None
|
||||
request_headers = {"Accept": "application/json", **headers}
|
||||
@@ -185,17 +192,17 @@ def _remote_request(server, method, endpoint, headers, form=None,
|
||||
response = connection.getresponse()
|
||||
content = response.read(MAX_RESPONSE + 1)
|
||||
if response.status in (401, 403):
|
||||
raise PBSError(rejected)
|
||||
raise PBSError(rejected, code=rejected_code)
|
||||
if response.status != 200 or len(content) > MAX_RESPONSE:
|
||||
raise PBSError("PBS returned an invalid response. Redirects are not followed.")
|
||||
raise PBSError("PBS returned an invalid response. Redirects are not followed.", code="pbs_invalid_response")
|
||||
document = json.loads(content)
|
||||
if not isinstance(document, dict) or "data" not in document:
|
||||
raise PBSError("PBS returned an invalid JSON document.")
|
||||
raise PBSError("PBS returned an invalid JSON document.", code="pbs_invalid_json")
|
||||
return document["data"]
|
||||
except PBSError:
|
||||
raise
|
||||
except (OSError, http.client.HTTPException, ValueError) as exc:
|
||||
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.") from exc
|
||||
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.", code="pbs_connect_failed") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@@ -203,19 +210,19 @@ def _remote_request(server, method, endpoint, headers, form=None,
|
||||
def save_server(data):
|
||||
identifier = data.get("id") if isinstance(data, dict) else None
|
||||
if identifier is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(identifier)):
|
||||
raise PBSError("Invalid PBS server ID.")
|
||||
raise PBSError("Invalid PBS server ID.", code="pbs_invalid_id")
|
||||
with _lock:
|
||||
previous = next((row for row in _load_servers() if row["id"] == identifier), None)
|
||||
row = _validate_server(data, previous)
|
||||
# /version may be public; validate credentials on the protected status API.
|
||||
usage = _remote_get(row, "/status/datastore-usage")
|
||||
if not isinstance(usage, list):
|
||||
raise PBSError("PBS returned an invalid datastore list.")
|
||||
raise PBSError("PBS returned an invalid datastore list.", code="pbs_invalid_datastores")
|
||||
row.update(id=identifier or "pbs-" + secrets.token_hex(6), revision=secrets.token_hex(16))
|
||||
with _lock:
|
||||
servers = _load_servers()
|
||||
if previous is None and len(servers) >= 32:
|
||||
raise PBSError("At most 32 PBS connections may be configured.")
|
||||
raise PBSError("At most 32 PBS connections may be configured.", code="pbs_connection_limit", params={"count": 32})
|
||||
servers = [existing for existing in servers if existing["id"] != row["id"]]
|
||||
_save_servers([*servers, row])
|
||||
return row["id"]
|
||||
@@ -228,27 +235,27 @@ def connect_with_password(data):
|
||||
username = data.get("username") if isinstance(data, dict) else None
|
||||
password = data.get("password") if isinstance(data, dict) else None
|
||||
if not isinstance(username, str) or not re.fullmatch(r"[^\s:!@]+@[^\s:!@]+", username.strip()):
|
||||
raise PBSError("Enter the PBS user as user@realm, for example root@pam.")
|
||||
raise PBSError("Enter the PBS user as user@realm, for example root@pam.", code="pbs_username")
|
||||
if not isinstance(password, str) or not password or len(password) > 4096:
|
||||
raise PBSError("Enter the password of the PBS user.")
|
||||
raise PBSError("Enter the password of the PBS user.", code="pbs_password")
|
||||
ticket = _remote_request(endpoint, "POST", "/access/ticket", {},
|
||||
{"username": username.strip(), "password": password},
|
||||
rejected="PBS rejected the user name or password.")
|
||||
rejected="PBS rejected the user name or password.", rejected_code="pbs_login_rejected")
|
||||
if not isinstance(ticket, dict) or not ticket.get("ticket") or not ticket.get("CSRFPreventionToken"):
|
||||
raise PBSError("PBS returned an invalid sign-in response.")
|
||||
raise PBSError("PBS returned an invalid sign-in response.", code="pbs_invalid_login")
|
||||
session = {"Cookie": "PBSAuthCookie=" + quote(ticket["ticket"], safe=""),
|
||||
"CSRFPreventionToken": ticket["CSRFPreventionToken"]}
|
||||
user = str(ticket.get("username") or username.strip())
|
||||
name = "proxmenux-" + secrets.token_hex(4)
|
||||
token_path = f"/access/users/{quote(user, safe='')}/token/{name}"
|
||||
created = _remote_request(endpoint, "POST", token_path, session, {"comment": "ProxMenux Monitor, read-only"},
|
||||
rejected="This PBS user cannot create API tokens.")
|
||||
rejected="This PBS user cannot create API tokens.", rejected_code="pbs_token_create_denied")
|
||||
if not isinstance(created, dict) or not created.get("tokenid") or not created.get("value"):
|
||||
raise PBSError("PBS returned an invalid API token.")
|
||||
raise PBSError("PBS returned an invalid API token.", code="pbs_invalid_token")
|
||||
try:
|
||||
_remote_request(endpoint, "PUT", "/access/acl", session,
|
||||
{"path": "/", "role": "Audit", "auth-id": created["tokenid"], "propagate": "1"},
|
||||
rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.")
|
||||
rejected="This PBS user cannot grant the Audit role. Sign in with root@pam or an administrator.", rejected_code="pbs_audit_role_denied")
|
||||
return save_server({**endpoint, "token_id": created["tokenid"], "token_secret": created["value"]})
|
||||
except PBSError:
|
||||
try:
|
||||
@@ -263,7 +270,7 @@ def delete_server(identifier):
|
||||
servers = _load_servers()
|
||||
remaining = [row for row in servers if row["id"] != identifier]
|
||||
if len(remaining) == len(servers):
|
||||
raise PBSError("PBS server not found.")
|
||||
raise PBSError("PBS server not found.", code="pbs_not_found")
|
||||
_save_servers(remaining)
|
||||
|
||||
|
||||
@@ -276,7 +283,7 @@ def _cli(*arguments):
|
||||
raise ValueError()
|
||||
return data
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as exc:
|
||||
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.") from exc
|
||||
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.", code="pbs_read_failed") from exc
|
||||
|
||||
|
||||
def _local_api(path):
|
||||
@@ -290,7 +297,7 @@ def _local_api(path):
|
||||
raise ValueError()
|
||||
return data
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as exc:
|
||||
raise PBSError("Could not read PBS " + path + ". Check its service and permissions.") from exc
|
||||
raise PBSError("Could not read PBS " + path + ". Check its service and permissions.", code="pbs_read_failed") from exc
|
||||
|
||||
|
||||
def _local_datastores():
|
||||
@@ -305,12 +312,14 @@ def _local_datastores():
|
||||
inactive = store.get("backing-device") and not (path and os.path.ismount(path))
|
||||
if not path or inactive or store.get("maintenance-mode"):
|
||||
row["error"] = "Datastore is offline, in maintenance or has no mounted backing device."
|
||||
row["error_code"] = "pbs_datastore_offline"
|
||||
else:
|
||||
try:
|
||||
capacity = shutil.disk_usage(path)
|
||||
row.update(total=capacity.total, used=capacity.used, avail=capacity.free)
|
||||
except OSError:
|
||||
row["error"] = "Datastore filesystem is not available."
|
||||
row["error_code"] = "pbs_datastore_unavailable"
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
@@ -331,10 +340,10 @@ def get_status(identifier):
|
||||
cached = _status_cache.get(identifier)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
return cached[1]
|
||||
errors = {}
|
||||
errors, error_details = {}, {}
|
||||
if identifier == "local":
|
||||
if not local_available():
|
||||
raise PBSError("The local host is not a PBS server.")
|
||||
raise PBSError("The local host is not a PBS server.", code="pbs_local_unavailable")
|
||||
name, version = get_platform().get("hostname") or socket.gethostname(), _local_version()
|
||||
services = _local_services()
|
||||
operations = {"datastores": _local_datastores,
|
||||
@@ -346,7 +355,7 @@ def get_status(identifier):
|
||||
with _lock:
|
||||
server = next((row for row in _load_servers() if row["id"] == identifier), None)
|
||||
if not server:
|
||||
raise PBSError("PBS server not found.")
|
||||
raise PBSError("PBS server not found.", code="pbs_not_found")
|
||||
metadata = _remote_get(server, "/version")
|
||||
name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None
|
||||
services = {}
|
||||
@@ -354,18 +363,19 @@ def get_status(identifier):
|
||||
"sync": "/admin/sync", "verify": "/admin/verify", "prune": "/admin/prune"}
|
||||
operations = {key: (lambda endpoint=path: _remote_get(server, endpoint)) for key, path in endpoints.items()}
|
||||
result = {"server": identifier, "name": name, "version": version, "services": services,
|
||||
"fetched_at": int(time.time()), "errors": errors}
|
||||
"fetched_at": int(time.time()), "errors": errors, "error_details": error_details}
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
futures = {key: executor.submit(operation) for key, operation in operations.items()}
|
||||
for key, future in futures.items():
|
||||
try:
|
||||
data = future.result()
|
||||
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
|
||||
raise PBSError("PBS returned an invalid list.")
|
||||
raise PBSError("PBS returned an invalid list.", code="pbs_invalid_list")
|
||||
result[key] = [{**row, "name": row.get("store", row.get("name"))} for row in data] if key == "datastores" else data
|
||||
except PBSError as exc:
|
||||
result[key] = None # Unknown/forbidden is never presented as zero.
|
||||
errors[key] = str(exc)
|
||||
error_details[key] = exc.problem()
|
||||
with _lock:
|
||||
if len(_status_cache) >= 33:
|
||||
_status_cache.clear()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Exercise the shipped routes and persistence; localization is additive."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from flask import Flask
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
import monitor_links as links
|
||||
|
||||
|
||||
class MonitorLinksI18nTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.path = Path(temporary.name) / "links.json"
|
||||
self.previous = {"manual": [{"id": "saved", "name": "Saved", "url": "https://saved.test", "product": "pve"}], "overrides": {}}
|
||||
self.path.write_text(json.dumps(self.previous))
|
||||
for patch in [mock.patch.object(links, "LINKS_FILE", self.path),
|
||||
mock.patch.object(links, "refresh"), mock.patch.object(links, "_probe")]:
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
auth_provider = types.SimpleNamespace(
|
||||
load_auth_config=lambda: {"enabled": True},
|
||||
verify_token=lambda token: "fixture" if token in ("admin", "reader") else None,
|
||||
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only")
|
||||
if token in ("admin", "reader") else (None, None))
|
||||
middleware_spec = importlib.util.spec_from_file_location("fixture_jwt_middleware", ROOT / "AppImage/scripts/jwt_middleware.py")
|
||||
middleware = importlib.util.module_from_spec(middleware_spec)
|
||||
# Execute the shipped decorators; only credential verification is faked.
|
||||
with mock.patch.dict(sys.modules, {"auth_manager": auth_provider}):
|
||||
middleware_spec.loader.exec_module(middleware)
|
||||
spec = importlib.util.spec_from_file_location("fixture_monitor_links_routes", ROOT / "AppImage/scripts/flask_monitor_links_routes.py")
|
||||
routes = importlib.util.module_from_spec(spec)
|
||||
# Import isolation keeps the real JWT module and other suites untouched.
|
||||
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}):
|
||||
spec.loader.exec_module(routes)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(routes.monitor_links_bp)
|
||||
self.client = app.test_client()
|
||||
|
||||
def send(self, method, path="", payload=None, role="admin"):
|
||||
return self.client.open("/api/monitor-links" + path, method=method,
|
||||
json=payload, headers={"Authorization": "Bearer " + role} if role else {})
|
||||
|
||||
def rejected(self, method, path, payload, message, code, params=None):
|
||||
before = self.path.read_bytes()
|
||||
with mock.patch.object(links, "_save") as save:
|
||||
response = self.send(method, path, payload)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
expected = {"error": message, "code": code}
|
||||
if params:
|
||||
expected["params"] = params
|
||||
self.assertEqual(response.json, expected)
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-store")
|
||||
save.assert_not_called()
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_validation_errors_preserve_english_status_and_have_stable_codes_without_effects(self):
|
||||
cases = [
|
||||
("POST", "", None, "Invalid request.", "monitor_link_invalid_request"),
|
||||
("PATCH", "/manual:saved", [], "Invalid request.", "monitor_link_invalid_request"),
|
||||
("POST", "", {"url": " "}, "Enter the address of the Monitor.", "monitor_link_address_required"),
|
||||
("POST", "", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
|
||||
("POST", "", {"url": "https://user:pass@example.test"}, "The address must not contain a user or password.", "monitor_link_address_credentials"),
|
||||
("PATCH", "/manual:saved", {"name": "Unsaved", "url": "https://example.test:99999"}, "The port of the address is not valid.", "monitor_link_invalid_port"),
|
||||
("PATCH", "/cluster:node", {"url": "ftp://example.test"}, "The address must start with http:// or https:// and name a host.", "monitor_link_invalid_address"),
|
||||
("POST", "", {"url": "https://saved.test/path"}, "This address is already in the list.", "monitor_link_duplicate"),
|
||||
("PATCH", "/manual:absent", {"url": "https://new.test"}, "Node not found.", "monitor_link_not_found"),
|
||||
("PATCH", "/other:node", {}, "Node not found.", "monitor_link_not_found"),
|
||||
("DELETE", "/cluster:node", None, "Only nodes added by hand can be removed; detected ones can be hidden.", "monitor_link_detected_remove"),
|
||||
]
|
||||
for case in cases:
|
||||
with self.subTest(case=case):
|
||||
self.rejected(*case)
|
||||
|
||||
def test_limit_uses_actual_count_and_preserves_previous_file(self):
|
||||
with mock.patch.object(links, "MAX_MANUAL", 1):
|
||||
self.rejected("POST", "", {"url": "https://new.test"}, "At most 1 nodes can be added.",
|
||||
"monitor_link_limit", {"count": 1})
|
||||
|
||||
def test_write_failure_is_localizable_without_leaking_filesystem_details(self):
|
||||
for method, path, payload in [("POST", "", {"url": "https://new.test"}),
|
||||
("PATCH", "/manual:saved", {"name": "Unsaved"}),
|
||||
("DELETE", "/manual:saved", None)]:
|
||||
with self.subTest(method=method), mock.patch.object(links.os, "replace", side_effect=OSError("private path details")):
|
||||
before = self.path.read_bytes()
|
||||
response = self.send(method, path, payload)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
self.assertEqual(response.json, {"error": "Could not save the node list.", "code": "monitor_links_save_failed"})
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
|
||||
def test_successful_crud_and_detected_hide_reset_keep_existing_contract(self):
|
||||
response = self.send("POST", payload={"name": "New", "url": "new.test:8008/path", "product": "pbs"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(set(response.json), {"success", "key"})
|
||||
key = response.json["key"]
|
||||
self.assertTrue(key.startswith("manual:"))
|
||||
row = links._load()["manual"][-1]
|
||||
self.assertEqual((row["url"], row["product"]), ("https://new.test:8008", "pbs"))
|
||||
self.assertEqual(self.send("PATCH", "/" + key, {"url": "http://changed.test"}).json, {"success": True})
|
||||
self.assertEqual(links._load()["manual"][-1]["url"], "http://changed.test")
|
||||
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "https://override.test", "hidden": True}).json, {"success": True})
|
||||
self.assertEqual(self.send("PATCH", "/cluster:node", {"url": "", "hidden": False}).json, {"success": True})
|
||||
self.assertEqual(links._load()["overrides"], {"cluster:node": {"hidden": False}})
|
||||
self.assertEqual(self.send("DELETE", "/" + key).json, {"success": True})
|
||||
self.assertEqual(links._load()["manual"], self.previous["manual"])
|
||||
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(links.refresh.call_count, 5)
|
||||
links._probe.assert_not_called()
|
||||
|
||||
def test_existing_read_and_admin_decorators_still_guard_routes_before_effects(self):
|
||||
with mock.patch.object(links, "cached_links", return_value={"self": {}, "links": []}) as cached, \
|
||||
mock.patch.object(links, "_save") as save:
|
||||
for method, path in [("POST", ""), ("PATCH", "/manual:saved"), ("DELETE", "/manual:saved")]:
|
||||
for role, status in [(None, 401), ("reader", 403)]:
|
||||
self.assertEqual(self.send(method, path, {"url": "https://new.test"}, role).status_code, status)
|
||||
self.assertEqual(self.send("GET", role=None).status_code, 401)
|
||||
self.assertEqual(self.send("GET", "?all=1", role="reader").status_code, 200)
|
||||
cached.assert_called_once_with(include_hidden=True)
|
||||
save.assert_not_called()
|
||||
links.refresh.assert_not_called()
|
||||
links._probe.assert_not_called()
|
||||
self.assertEqual(json.loads(self.path.read_text()), self.previous)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -205,7 +205,7 @@ class PBSManagerTests(unittest.TestCase):
|
||||
|
||||
def test_password_sign_in_keeps_only_a_read_only_token(self):
|
||||
calls = []
|
||||
def request(server, method, endpoint, headers, form=None, rejected=""):
|
||||
def request(server, method, endpoint, headers, form=None, rejected="", **kwargs):
|
||||
calls.append((method, endpoint, form))
|
||||
if endpoint == "/access/ticket":
|
||||
return {"ticket": "PBS:root@pam:ticket", "CSRFPreventionToken": "csrf", "username": "root@pam"}
|
||||
@@ -227,7 +227,7 @@ class PBSManagerTests(unittest.TestCase):
|
||||
|
||||
def test_password_sign_in_removes_its_token_when_the_role_cannot_be_granted(self):
|
||||
calls = []
|
||||
def request(server, method, endpoint, headers, form=None, rejected=""):
|
||||
def request(server, method, endpoint, headers, form=None, rejected="", **kwargs):
|
||||
calls.append((method, endpoint))
|
||||
if endpoint == "/access/ticket":
|
||||
return {"ticket": "t", "CSRFPreventionToken": "c"}
|
||||
@@ -351,15 +351,17 @@ class TLSTests(unittest.TestCase):
|
||||
|
||||
def test_bad_pin_and_untrusted_ca_send_no_http_credentials(self):
|
||||
for fingerprint in ('0' * 64, ''):
|
||||
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError):
|
||||
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError) as caught:
|
||||
pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage')
|
||||
self.assertEqual(caught.exception.code, "pbs_fingerprint_mismatch" if fingerprint else "pbs_connect_failed")
|
||||
self.assertEqual(self.requests, [])
|
||||
|
||||
def test_bad_credentials_and_redirects_are_rejected(self):
|
||||
for status in (401, 403, 302):
|
||||
type(self).response_status = status
|
||||
with self.subTest(status=status), self.assertRaises(pbs.PBSError):
|
||||
with self.subTest(status=status), self.assertRaises(pbs.PBSError) as caught:
|
||||
pbs._remote_get(self.connection, '/status/datastore-usage')
|
||||
self.assertEqual(caught.exception.code, "pbs_token_rejected" if status in (401, 403) else "pbs_invalid_response")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""PBS localization metadata must preserve routes, security and persistence."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from flask import Flask
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
import auth_manager
|
||||
import pbs_manager as pbs
|
||||
REAL_REMOTE_REQUEST = pbs._remote_request
|
||||
|
||||
|
||||
class PBSI18nTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp = tempfile.TemporaryDirectory(); self.addCleanup(temp.cleanup)
|
||||
self.root = Path(temp.name)
|
||||
for patch in [mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json"),
|
||||
mock.patch.object(pbs, "_remote_get", return_value=[]),
|
||||
mock.patch.object(pbs, "_remote_request"),
|
||||
mock.patch.object(pbs, "local_available", return_value=False)]:
|
||||
patch.start(); self.addCleanup(patch.stop)
|
||||
(self.root / "auth.json").write_text(json.dumps({"enabled": True, "password_hash": "fixture"}))
|
||||
self.server = {"name": "Fixture", "url": "https://pbs.invalid:8007", "token_id": "audit@pbs!monitor", "token_secret": "fake-fixture-secret", "fingerprint": ""}
|
||||
self.id = pbs.save_server(self.server)
|
||||
pbs._remote_get.reset_mock(); pbs._status_cache.clear()
|
||||
auth = types.SimpleNamespace(load_auth_config=auth_manager.load_auth_config, verify_token=lambda _: "fixture",
|
||||
verify_token_full=lambda token: ("fixture", "full_admin" if token == "admin" else "read_only"))
|
||||
spec = importlib.util.spec_from_file_location("fixture_pbs_jwt", ROOT / "AppImage/scripts/jwt_middleware.py")
|
||||
middleware = importlib.util.module_from_spec(spec)
|
||||
with mock.patch.dict(sys.modules, {"auth_manager": auth}): spec.loader.exec_module(middleware)
|
||||
spec = importlib.util.spec_from_file_location("fixture_pbs_routes", ROOT / "AppImage/scripts/flask_pbs_routes.py")
|
||||
routes = importlib.util.module_from_spec(spec)
|
||||
with mock.patch.dict(sys.modules, {"jwt_middleware": middleware}): spec.loader.exec_module(routes)
|
||||
app = Flask(__name__); app.register_blueprint(routes.pbs_bp); self.client = app.test_client()
|
||||
|
||||
def send(self, method="POST", path="/api/pbs/servers", data=None, role="admin"):
|
||||
return self.client.open(path, method=method, json=data, headers={"Authorization": "Bearer " + role} if role else {})
|
||||
|
||||
def reject(self, data, message, code, path="/api/pbs/servers"):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
with mock.patch.object(pbs, "_save_servers") as save:
|
||||
response = self.send(path=path, data=data)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.json, {"error": message, "code": code})
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-store")
|
||||
save.assert_not_called(); pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_invalid_input_is_localizable_before_any_remote_or_save_effect(self):
|
||||
cases = [
|
||||
(None, "Invalid PBS server configuration.", "pbs_invalid_config"),
|
||||
({**self.server, "name": []}, "Invalid PBS connection field.", "pbs_invalid_field"),
|
||||
({**self.server, "url": "http://pbs.invalid"}, "Use an HTTPS PBS URL without embedded credentials, query or fragment.", "pbs_invalid_url"),
|
||||
({**self.server, "name": ""}, "A server name of at most 128 characters is required.", "pbs_name_required"),
|
||||
({**self.server, "token_id": "bad"}, "Enter a PBS API token ID in user@realm!token format.", "pbs_token_id_format"),
|
||||
({**self.server, "token_secret": "bad\nsecret"}, "A valid API token secret is required.", "pbs_token_secret"),
|
||||
({**self.server, "token_id": "človek@pbs!monitor"}, "Invalid API token ID.", "pbs_invalid_token_id"),
|
||||
({**self.server, "fingerprint": "invalid"}, "The certificate fingerprint must be SHA256 (64 hex characters).", "pbs_invalid_fingerprint"),
|
||||
({**self.server, "id": "../../bad"}, "Invalid PBS server ID.", "pbs_invalid_id"),
|
||||
]
|
||||
for data, message, code in cases:
|
||||
with self.subTest(code=code): self.reject(data, message, code)
|
||||
for data, message, code in [
|
||||
({**self.server, "username": "missing-realm", "password": "fake"}, "Enter the PBS user as user@realm, for example root@pam.", "pbs_username"),
|
||||
({**self.server, "username": "root@pam", "password": ""}, "Enter the password of the PBS user.", "pbs_password"),
|
||||
]:
|
||||
with self.subTest(code=code): self.reject(data, message, code, "/api/pbs/servers/login")
|
||||
|
||||
def test_authentication_and_scope_guards_precede_all_mutations(self):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
save_patch = mock.patch.object(pbs, "_save_servers")
|
||||
save = save_patch.start(); self.addCleanup(save_patch.stop)
|
||||
for path, method in [("/api/pbs/servers", "POST"), ("/api/pbs/servers/login", "POST"), ("/api/pbs/servers/" + self.id, "DELETE")]:
|
||||
for role, status in [(None, 401), ("reader", 403)]:
|
||||
self.assertEqual(self.send(method, path, self.server, role).status_code, status)
|
||||
(self.root / "auth.json").write_text(json.dumps({"enabled": False}))
|
||||
for path in ("/api/pbs/servers", "/api/pbs/servers/login"):
|
||||
response = self.send(path=path, data=self.server)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json, {"error": "Enable local authentication before saving remote PBS credentials.", "code": "pbs_auth_required"})
|
||||
response = self.send("DELETE", "/api/pbs/servers/" + self.id)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json["code"], "pbs_auth_required")
|
||||
pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
|
||||
save.assert_not_called()
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_corrupt_configuration_missing_server_and_local_unavailable_keep_status(self):
|
||||
response = self.send("DELETE", "/api/pbs/servers/absent")
|
||||
self.assertEqual((response.status_code, response.json), (400, {"error": "PBS server not found.", "code": "pbs_not_found"}))
|
||||
for identifier, code in [("absent", "pbs_not_found"), ("local", "pbs_local_unavailable")]:
|
||||
response = self.send("GET", "/api/pbs/status?server=" + identifier)
|
||||
self.assertEqual(response.status_code, 502); self.assertEqual(response.json["code"], code)
|
||||
(self.root / "pbs_servers.json").write_text("invalid json")
|
||||
response = self.send("GET", "/api/pbs/servers")
|
||||
self.assertEqual((response.status_code, response.json), (500, {"error": "Could not read the PBS server configuration.", "code": "pbs_config_read"}))
|
||||
pbs._remote_get.assert_not_called(); pbs._remote_request.assert_not_called()
|
||||
|
||||
def test_save_delete_failures_leave_previous_bytes_and_do_not_leak_details(self):
|
||||
for path, method, data, code, message in [
|
||||
("/api/pbs/servers", "POST", {**self.server, "id": self.id}, "pbs_save_failed", "Could not save PBS connection."),
|
||||
("/api/pbs/servers/" + self.id, "DELETE", None, "pbs_remove_failed", "Could not remove PBS connection."),
|
||||
]:
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
with self.subTest(method=method), mock.patch.object(pbs.os, "replace", side_effect=OSError("private filesystem detail")):
|
||||
response = self.send(method, path, data)
|
||||
self.assertEqual((response.status_code, response.json), (500, {"error": message, "code": code}))
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_partial_status_preserves_legacy_error_null_and_cache_with_additive_metadata(self):
|
||||
def remote(server, endpoint):
|
||||
if endpoint == "/version": return {"version": "4.2.5"}
|
||||
if endpoint == "/admin/verify": raise pbs.PBSError("Fixture access denied", code="pbs_token_rejected")
|
||||
if endpoint == "/admin/prune": return {"invalid": "list"}
|
||||
return []
|
||||
with mock.patch.object(pbs, "_remote_get", side_effect=remote) as remote_call:
|
||||
result = self.send("GET", "/api/pbs/status?server=" + self.id)
|
||||
count = remote_call.call_count
|
||||
self.assertEqual(self.send("GET", "/api/pbs/status?server=" + self.id).json, result.json)
|
||||
self.assertEqual(remote_call.call_count, count)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertIsNone(result.json["verify"]); self.assertIsNone(result.json["prune"])
|
||||
self.assertEqual(result.json["sync"], [])
|
||||
self.assertEqual(result.json["errors"]["verify"], "Fixture access denied")
|
||||
self.assertEqual(result.json["error_details"]["verify"], {"code": "pbs_token_rejected"})
|
||||
self.assertEqual(result.json["error_details"]["prune"], {"code": "pbs_invalid_list"})
|
||||
|
||||
def test_local_datastore_errors_and_command_errors_keep_legacy_messages(self):
|
||||
with mock.patch.object(pbs, "_cli", return_value=[{"name": "offline", "path": "/fixture", "backing-device": "fixture"}]), \
|
||||
mock.patch.object(pbs.os.path, "ismount", return_value=False), mock.patch.object(pbs.shutil, "disk_usage") as usage:
|
||||
store = pbs._local_datastores()[0]
|
||||
self.assertEqual(store["error"], "Datastore is offline, in maintenance or has no mounted backing device.")
|
||||
self.assertEqual(store["error_code"], "pbs_datastore_offline"); usage.assert_not_called(); self.assertIsNone(store["total"])
|
||||
with mock.patch.object(pbs.subprocess, "run", side_effect=OSError("fixture")):
|
||||
for fn, args in [(pbs._cli, ("task", "list")), (pbs._local_api, ("/admin/sync",))]:
|
||||
with self.subTest(args=args), self.assertRaises(pbs.PBSError) as caught: fn(*args)
|
||||
self.assertEqual(caught.exception.code, "pbs_read_failed")
|
||||
self.assertIn("Check its service and permissions.", str(caught.exception))
|
||||
|
||||
def test_remote_failure_codes_follow_the_actual_request_and_keep_english(self):
|
||||
cases = [
|
||||
(401, b'{}', "pbs_token_rejected", "PBS rejected the API token or its permissions. Check both the user and token ACLs."),
|
||||
(302, b'{}', "pbs_invalid_response", "PBS returned an invalid response. Redirects are not followed."),
|
||||
(200, b'{}', "pbs_invalid_json", "PBS returned an invalid JSON document."),
|
||||
(200, b'invalid-json', "pbs_connect_failed", "Could not connect to PBS over verified HTTPS. Check the address, certificate and service."),
|
||||
]
|
||||
for status, body, code, message in cases:
|
||||
connection = mock.Mock(); connection.getresponse.return_value.status = status
|
||||
connection.getresponse.return_value.read.return_value = body
|
||||
with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), \
|
||||
self.assertRaises(pbs.PBSError) as caught:
|
||||
REAL_REMOTE_REQUEST(self.server, "GET", "/version", {})
|
||||
self.assertEqual((str(caught.exception), caught.exception.problem()), (message, {"code": code}))
|
||||
connection.close.assert_called_once()
|
||||
for code in ("pbs_login_rejected", "pbs_token_create_denied", "pbs_audit_role_denied"):
|
||||
connection = mock.Mock(); connection.getresponse.return_value.status = 403
|
||||
connection.getresponse.return_value.read.return_value = b'{}'
|
||||
with self.subTest(code=code), mock.patch.object(pbs.http.client, "HTTPSConnection", return_value=connection), self.assertRaises(pbs.PBSError) as caught:
|
||||
REAL_REMOTE_REQUEST(self.server, "POST", "/access/ticket", {}, rejected="Original context", rejected_code=code)
|
||||
self.assertEqual((str(caught.exception), caught.exception.code), ("Original context", code))
|
||||
|
||||
def test_connection_limit_and_invalid_datastore_response_do_not_save(self):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
with mock.patch.object(pbs, "_save_servers") as save, mock.patch.object(pbs, "_load_servers", return_value=[{"id": str(i)} for i in range(32)]):
|
||||
response = self.send(data=self.server)
|
||||
self.assertEqual(response.json, {"error": "At most 32 PBS connections may be configured.", "code": "pbs_connection_limit", "params": {"count": 32}})
|
||||
self.assertEqual(response.status_code, 400); save.assert_not_called()
|
||||
pbs._remote_get.return_value = {}
|
||||
response = self.send(data=self.server)
|
||||
self.assertEqual(response.json["code"], "pbs_invalid_datastores")
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
|
||||
def test_login_failures_keep_saved_state_and_cleanup_a_created_token(self):
|
||||
before = (self.root / "pbs_servers.json").read_bytes()
|
||||
login = {"name": "Fixture", "url": "https://pbs.invalid", "username": "root@pam", "password": "fake"}
|
||||
for stage, code in [("ticket", "pbs_invalid_login"), ("token", "pbs_invalid_token"), ("acl", "pbs_audit_role_denied")]:
|
||||
calls = []
|
||||
def request(server, method, endpoint, headers, form=None, rejected="", rejected_code="pbs_token_rejected"):
|
||||
calls.append((method, endpoint))
|
||||
if endpoint == "/access/ticket": return None if stage == "ticket" else {"ticket": "fake", "CSRFPreventionToken": "fake"}
|
||||
if method == "POST": return None if stage == "token" else {"tokenid": "audit@pbs!fixture", "value": "fake"}
|
||||
if method == "PUT": raise pbs.PBSError(rejected, code=rejected_code)
|
||||
pbs._remote_request.side_effect = request
|
||||
response = self.send(path="/api/pbs/servers/login", data=login)
|
||||
self.assertEqual(response.status_code, 400); self.assertEqual(response.json["code"], code)
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), before)
|
||||
self.assertEqual(sum(method == "DELETE" for method, _ in calls), 1 if stage == "acl" else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
+128
-98
@@ -1,72 +1,7 @@
|
||||
{
|
||||
"'auto'/'local', or root@IP for development": "'auto'/'local' alebo root@IP pri vývoji",
|
||||
"Add the host firewall rule of this container; one option for each confirmed rule": "Pridať pravidlo firewallu hostiteľa pre tento kontajner; jedna voľba pre každé potvrdené pravidlo",
|
||||
"Another OCI operation is using the instance registry.": "Register inštancií používa iná operácia OCI.",
|
||||
"Blockers": "Prekážky",
|
||||
"Bulk generation requires GITHUB_TOKEN to obtain an immutable revision per application": "Hromadné generovanie vyžaduje GITHUB_TOKEN na získanie nemennej revízie každej aplikácie",
|
||||
"CT {vmid} ({name}) could not be kept running; it is left stopped": "CT {vmid} ({name}) sa nepodarilo udržať v chode; zostáva zastavený",
|
||||
"CT {vmid} ({name}) stopped on its own; starting it again (attempt {attempt})": "CT {vmid} ({name}) sa sám zastavil; spúšťa sa znova (pokus {attempt})",
|
||||
"CT {vmid}: DHCP client of {name} (IPv{version}) started again": "CT {vmid}: klient DHCP rozhrania {name} (IPv{version}) bol znova spustený",
|
||||
"Configure and install an application": "Nastaviť a nainštalovať aplikáciu",
|
||||
"Could not reach {url} after 3 attempts: {reason}": "Adresa {url} nie je dostupná ani po 3 pokusoch: {reason}",
|
||||
"Emit numeric progress for the OCI menu": "Vypisovať číselný priebeh pre ponuku OCI",
|
||||
"Enable a mount on an installed Rclone OCI": "Nastaviť pripojenie v nainštalovanom OCI kontajneri Rclone",
|
||||
"Generated: {generated}; failed: {failed}; family: {provider}": "Vygenerované: {generated}; neúspešné: {failed}; rodina: {provider}",
|
||||
"GitHub returned HTTP 403 for {url}. Set GITHUB_TOKEN to raise the API rate limit.": "GitHub vrátil HTTP 403 pre {url}. Nastavením GITHUB_TOKEN zvýšite limit požiadaviek API.",
|
||||
"GitHub returned HTTP {status} for {url}.": "GitHub vrátil HTTP {status} pre {url}.",
|
||||
"GitHub returned a truncated tree for the CasaOS catalogue": "GitHub vrátil neúplný strom súborov katalógu CasaOS",
|
||||
"Host directories are not reverted by the backup (confirmed beforehand)": "Adresáre hostiteľa sa zo zálohy nevracajú do pôvodného stavu (vopred potvrdené)",
|
||||
"Icons resolved: {resolved}; cleared: {cleared}; unchanged: {unchanged}; with a theme variant: {themed}": "Priradené ikony: {resolved}; vymazané: {cleared}; nezmenené: {unchanged}; s variantom témy: {themed}",
|
||||
"Index updated: {count} candidate applications": "Index bol aktualizovaný: {count} kandidátnych aplikácií",
|
||||
"Install? [y/N]:": "Nainštalovať? [y/N]:",
|
||||
"Installation blocked:": "Inštalácia je zablokovaná:",
|
||||
"Installation cancelled.": "Inštalácia bola zrušená.",
|
||||
"Internal installer operation; not legacy adoption": "Interná operácia inštalátora; neslúži na prevzatie existujúceho kontajnera",
|
||||
"Jellyfin configuration already applied:": "Nastavenia Jellyfin už boli použité:",
|
||||
"Jellyfin configuration updated:": "Nastavenia Jellyfin boli aktualizované:",
|
||||
"Keep the backup taken before the update in this Proxmox storage": "Ponechať zálohu vytvorenú pred aktualizáciou v tomto úložisku Proxmox",
|
||||
"List applications": "Vypísať aplikácie",
|
||||
"No Compose files were found in the CasaOS catalogue": "V katalógu CasaOS sa nenašli žiadne súbory Compose",
|
||||
"Only the applications that need no question: the ones the cluster keeps a record of": "Iba aplikácie bez potreby otázok: tie, ktorých záznam uchováva klaster",
|
||||
"Open the page of an application in the menu": "Otvoriť stránku aplikácie v ponuke",
|
||||
"ProxMenux OCI manager Apps (Technology Preview)": "ProxMenux – správca OCI aplikácií (Technology Preview)",
|
||||
"Rclone remote": "Vzdialené úložisko Rclone",
|
||||
"Refresh the index from the image sources": "Obnoviť index zo zdrojov obrazov",
|
||||
"Regenerate the catalog templates": "Znova vygenerovať šablóny katalógu",
|
||||
"Regenerate the template of one application": "Znova vygenerovať šablónu jednej aplikácie",
|
||||
"Register again the OCI applications restored from a backup": "Znova zaregistrovať OCI aplikácie obnovené zo zálohy",
|
||||
"Resolve catalog icons against the jsdelivr icon sets": "Priradiť ikony katalógu zo súprav ikon jsdelivr",
|
||||
"Scheduled run: no questions; stops where a person has to decide": "Plánovaný beh bez otázok; zastaví sa pri potrebe rozhodnutia používateľa",
|
||||
"Show the summary of a template": "Zobraziť súhrn šablóny",
|
||||
"The ProxMenux application catalogue is not a list": "Katalóg aplikácií ProxMenux nemá formát zoznamu",
|
||||
"The README has no recognisable docker-compose section": "README neobsahuje rozpoznateľnú sekciu docker-compose",
|
||||
"The application '{app_id}' does not come from LinuxServer": "Aplikácia '{app_id}' nepochádza z LinuxServer",
|
||||
"The block found does not look like a Docker Compose file": "Nájdený blok nevyzerá ako súbor Docker Compose",
|
||||
"The docker-compose block is not closed": "Blok docker-compose nie je ukončený",
|
||||
"The docker-compose section contains no code block": "Sekcia docker-compose neobsahuje blok kódu",
|
||||
"The main Compose service cannot be identified unambiguously": "Hlavnú službu v súbore Compose nemožno jednoznačne určiť",
|
||||
"Unsupported host architecture:": "Nepodporovaná architektúra hostiteľa:",
|
||||
"Update, modify or recreate one installed OCI instance": "Aktualizovať, upraviť alebo znova vytvoriť jednu nainštalovanú inštanciu OCI",
|
||||
"Volumes": "Dátové zväzky",
|
||||
"clone source mount tree": "klonovanie zdrojového stromu pripojenia",
|
||||
"enter container namespace": "vstup do menného priestoru kontajnera",
|
||||
"installable": "možno nainštalovať",
|
||||
"main container of the application": "hlavný kontajner aplikácie",
|
||||
"make cloned mount tree read-only": "nastavenie klonovaného stromu pripojenia iba na čítanie",
|
||||
"none": "žiadne",
|
||||
"not generated": "nevygenerované",
|
||||
"pending adaptation": "čaká na prispôsobenie",
|
||||
"print the estimated unpacked size in MiB (0 when unknown)": "vypísať odhad rozbalenej veľkosti v MiB (0, ak nie je známa)",
|
||||
"publish mount tree": "sprístupnenie stromu pripojenia",
|
||||
"python3-jsonschema is required to generate templates": "Na generovanie šablón je potrebný python3-jsonschema",
|
||||
"removed unused image archive: {path} ({size} bytes)": "odstránený nepoužívaný archív obrazu: {path} ({size} bajtov)",
|
||||
"return to host namespace": "návrat do menného priestoru hostiteľa",
|
||||
"the GPU did not answer in time": "GPU neodpovedalo včas",
|
||||
"Application data": "Údaje aplikácie",
|
||||
"Consume/export": "Priečinky consume a export",
|
||||
"Library": "Knižnica",
|
||||
"# Alternative if you prefer screen": "# Alternatíva, ak preferujete screen",
|
||||
"# Recommended: avoids disconnection during upgrade": "# Odporúčané: zabráni odpojeniu počas aktualizácie",
|
||||
"'auto'/'local', or root@IP for development": "'auto'/'local' alebo root@IP pri vývoji",
|
||||
"(Checked entries will be removed. Uncheck to keep in VM.)": "(Označené položky sa odstránia. Zrušte označenie, ak ich chcete ponechať vo VM.)",
|
||||
"(Import is selected by default — required for disk image imports)": "(Import je vybraný predvolene - je potrebný na import diskových obrazov)",
|
||||
"(Only the host directory is modified. Nothing inside the container is changed.": "(Mení sa iba priečinok na hostovi. Vo vnútri kontajnera sa nemení nič.",
|
||||
@@ -154,7 +89,7 @@
|
||||
"A multi-container application is not recreated: its containers are updated together.": "Aplikácia s viacerými kontajnermi sa nevytvorí znova: jej kontajnery sa aktualizujú spoločne.",
|
||||
"A new ProxMenux version is available:": "Je dostupná nová verzia ProxMenux:",
|
||||
"A new kernel is staged for the next boot:": "Na najbližší štart je pripravený nový kernel:",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "Je nainštalované novšie jadro. Aby ste ho mohli použiť, reštartujte ho pred ďalším oknom zálohy.",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "Je nainštalované novšie jadro. Pred ďalším plánovaným zálohovaním reštartujte server, aby sa použilo.",
|
||||
"A newer version is available:": "Je dostupná novšia verzia:",
|
||||
"A pending operation exists for": "Nedokončená operácia existuje pre",
|
||||
"A pending stack assembly already exists; it is not overwritten": "Už existuje nedokončené zostavenie aplikácie; neprepíše sa",
|
||||
@@ -229,6 +164,7 @@
|
||||
"APT configured to skip additional languages": "APT je nastavené tak, aby nesťahovalo ďalšie jazyky",
|
||||
"APT configured to skip downloading additional languages": "APT je nastavené tak, aby nesťahovalo ďalšie jazyky",
|
||||
"APT configured to use IPv4": "APT je nastavené na používanie IPv4",
|
||||
"APT hook syntax issue, removing...": "Nesprávna syntax hooku APT; hook sa odstráni...",
|
||||
"APT index update failed after switching to no-subscription. See log:": "Aktualizácia APT indexu zlyhala aj po prepnutí na no-subscription. Pozrite záznam:",
|
||||
"APT index update failed. See log:": "Aktualizácia APT indexu zlyhala. Pozrite záznam:",
|
||||
"APT indexes updated for Trixie": "APT indexy boli aktualizované pre Trixie",
|
||||
@@ -276,6 +212,7 @@
|
||||
"Activate ProxMenux Monitor": "Zapnúť ProxMenux Monitor",
|
||||
"Active": "Aktívne",
|
||||
"Active Connections": "Aktívne pripojenia",
|
||||
"Active ZRAM algorithm, capacity or swap sources did not match the profile": "Algoritmus kompresie, veľkosť alebo zdroje swapu aktívnej ZRAM nezodpovedajú profilu",
|
||||
"Active exports:": "Aktívne exporty:",
|
||||
"Active session:": "Aktívna relácia:",
|
||||
"Actual device path on the host": "Skutočná cesta k zariadeniu na hostiteľovi",
|
||||
@@ -329,6 +266,7 @@
|
||||
"Add or remove extra paths and devices": "Upraviť pripojené cesty a zariadenia",
|
||||
"Add physical disk to VM via": "Pridať fyzický disk do VM cez",
|
||||
"Add share block in /etc/samba/smb.conf:": "Pridať blok zdieľania do /etc/samba/smb.conf:",
|
||||
"Add the host firewall rule of this container; one option for each confirmed rule": "Pridať pravidlo firewallu hostiteľa pre tento kontajner; jedna voľba pre každé potvrdené pravidlo",
|
||||
"Add unprivileged flag to container configuration:": "Pridať príznak neprivilegovaného kontajnera do nastavenia:",
|
||||
"Add virtual disk": "Pridať virtuálny disk",
|
||||
"Add: Mount Host Directory into LXC": "Pridať: pripojiť priečinok hosta do LXC",
|
||||
@@ -473,12 +411,13 @@
|
||||
"Analyzing selected disks...": "Analyzujem vybrané disky...",
|
||||
"Analyzing system for available PCIe storage devices...": "Analyzujem systém a hľadám dostupné PCIe úložiskové zariadenia...",
|
||||
"Another OCI operation is using the instance registry": "Register inštancií používa iná operácia OCI",
|
||||
"Another OCI operation is using the instance registry.": "Register inštancií používa iná operácia OCI.",
|
||||
"Another OCI operation is using the instance registry. Wait for it to finish and open this menu again; no container is modified.": "Register inštancií používa iná operácia OCI. Počkajte na jej dokončenie a znova otvorte túto ponuku; žiadny kontajner sa nezmení.",
|
||||
"Another OCI operation is using the instance registry. Wait for it to finish.": "Register OCI aplikácií používa iná operácia. Počkajte na jej dokončenie.",
|
||||
"Another OCI operation is using the registry. This operation was not started.": "Register používa iná OCI operácia. Táto operácia sa nespustila.",
|
||||
"Another ProxMenux OCI operation is running. Try again when it finishes.": "Prebieha ďalšia operácia ProxMenux OCI. Skúste to znova, keď to skončí.",
|
||||
"Another ZRAM configuration exists or could not be inspected; systemd-zram-generator was retained": "Existuje iná konfigurácia ZRAM alebo ju nebolo možné skontrolovať; systemd-zram-generator bol zachovaný",
|
||||
"Another ZRAM generator configuration exists; refusing to overwrite it:": "Existuje iná konfigurácia generátora ZRAM; refusing to overwrite it:",
|
||||
"Another ZRAM generator configuration exists; refusing to overwrite it:": "Existuje iná konfigurácia generátora ZRAM; neprepíše sa:",
|
||||
"Another ZRAM swap setup exists; refusing to replace it:": "Existuje ďalšie nastavenie výmeny ZRAM; odmietnutie jeho výmeny:",
|
||||
"Another device of the network already answers on this address:": "Na tejto adrese už odpovedá iné zariadenie v sieti:",
|
||||
"Another instance uses": "Iná inštancia už používa",
|
||||
@@ -487,6 +426,7 @@
|
||||
"Another registry account operation is running. Try again when it finishes.": "Prebieha iná operácia registrového účtu. Skúste to znova, keď to skončí.",
|
||||
"Another stack operation is pending": "Iná operácia celej aplikácie ešte nie je dokončená",
|
||||
"Application": "Aplikácia",
|
||||
"Application data": "Údaje aplikácie",
|
||||
"Application registered on this host:": "Aplikácia registrovaná na tomto hostiteľovi:",
|
||||
"Application responding:": "Aplikácia odpovedá:",
|
||||
"Application responding; checking its stability...": "Aplikácia reaguje; kontrola jej stability...",
|
||||
@@ -694,6 +634,7 @@
|
||||
"Bandwidth limit configured": "Limit priepustnosti je nastavený",
|
||||
"Bandwidth test (iperf3)": "Test priepustnosti (iperf3)",
|
||||
"Bandwidth test completed successfully": "Test priepustnosti bol úspešne dokončený",
|
||||
"Banner removal cancelled by user.": "Odstránenie upozornenia na predplatné bolo zrušené používateľom.",
|
||||
"Base VM created with ID": "Základná VM bola vytvorená s ID",
|
||||
"Base VMID": "Základné VMID",
|
||||
"Base VMID (empty = next free block)": "Základné VMID (prázdne = ďalší voľný blok)",
|
||||
@@ -723,6 +664,7 @@
|
||||
"Blocked GPU ID for VM Mode": "Blokované ID GPU pre VM režim",
|
||||
"Blocked device(s)": "Blokované zariadenia",
|
||||
"Blocked device(s):": "Blokované zariadenia:",
|
||||
"Blockers": "Prekážky",
|
||||
"BookStack web interface": "Webové rozhranie BookStack",
|
||||
"Bookstack is a free and open source Wiki designed for creating beautiful documentation. Featuring a simple, but powerful WYSIWYG editor it allows for teams to create detailed and useful documentation with ease.": "Bookstack je bezplatný a open source Wiki navrhnutý pre vytvorenie krásnej dokumentácie. Vďaka jednoduchému, ale výkonnému WYSIWYG editoru umožňuje tímom jednoducho vytvárať podrobnú a užitočnú dokumentáciu.",
|
||||
"Boot Disk": "Bootovací disk",
|
||||
@@ -757,6 +699,7 @@
|
||||
"Build and install the gasket and apex kernel modules (DKMS)": "Zostaví a nainštaluje kernel moduly gasket a apex (DKMS)",
|
||||
"Build dependencies installed.": "Závislosti na zostavenie boli nainštalované.",
|
||||
"Build your personal knowledge base with TriliumNext Notes": "Vytvorte si osobnú znalostnú databázu s TriliumNext Notes",
|
||||
"Bulk generation requires GITHUB_TOKEN to obtain an immutable revision per application": "Hromadné generovanie vyžaduje GITHUB_TOKEN na získanie nemennej revízie každej aplikácie",
|
||||
"Business & ERP": "Biznis a ERP",
|
||||
"CHANGES APPLIED SUCCESSFULLY": "ZMENY BOLI ÚSPEŠNE POUŽITÉ",
|
||||
"CIFS Client Tools: AVAILABLE": "Nástroje CIFS klienta: DOSTUPNÉ",
|
||||
@@ -786,7 +729,11 @@
|
||||
"CRITICAL: The selected disk is referenced by a RUNNING VM or CT.": "KRITICKÉ: vybraný disk používa BEŽIACA VM alebo CT.",
|
||||
"CT": "CT",
|
||||
"CT started successfully.": "CT bol úspešne spustený.",
|
||||
"CT {vmid} ({name}) could not be kept running; it is left stopped": "CT {vmid} ({name}) sa nepodarilo udržať v chode; zostáva zastavený",
|
||||
"CT {vmid} ({name}) stopped on its own; starting it again (attempt {attempt})": "CT {vmid} ({name}) sa sám zastavil; spúšťa sa znova (pokus {attempt})",
|
||||
"CT {vmid} is a host monitor and had a rule in the host firewall. Allow TCP port {port} from {subnet} through the firewall of this host? Existing firewall rules are not changed.": "CT {vmid} je hostiteľský monitor a mal pravidlo v hostiteľskom firewalle. Povoliť TCP port {port} z {subnet} cez bránu firewall tohto hostiteľa? Existujúce pravidlá brány firewall sa nemenia.",
|
||||
"CT {vmid} is one of the {count} containers of {application}.": "CT {vmid} patrí do aplikácie {application} (počet kontajnerov: {count}).",
|
||||
"CT {vmid}: DHCP client of {name} (IPv{version}) started again": "CT {vmid}: klient DHCP rozhrania {name} (IPv{version}) bol znova spustený",
|
||||
"CUDA requires a working NVIDIA driver": "CUDA vyžaduje funkčný ovládač NVIDIA",
|
||||
"CUDA requires the NVIDIA Container Toolkit on the host": "CUDA vyžaduje NVIDIA Container Toolkit na hostiteľovi",
|
||||
"Calculate all kinds of statistics from your (local) Emby or Jellyfin server": "Vypočítajte všetky druhy štatistík z vášho (miestneho) servera Emby alebo Jellyfin",
|
||||
@@ -803,6 +750,7 @@
|
||||
"Cannot continue": "Nedá sa pokračovať",
|
||||
"Cannot create:": "Nedá sa vytvoriť:",
|
||||
"Cannot detect filesystem on": "Nedá sa zistiť súborový systém na",
|
||||
"Cannot determine installed systemd-zram-generator version": "Nepodarilo sa zistiť verziu nainštalovaného balíka systemd-zram-generator",
|
||||
"Cannot find": "Nedá sa nájsť",
|
||||
"Cannot identify the vendor of the device:": "Nepodarilo sa určiť výrobcu zariadenia:",
|
||||
"Cannot inspect current swap configuration": "Nie je možné skontrolovať aktuálnu konfiguráciu výmeny",
|
||||
@@ -1083,8 +1031,11 @@
|
||||
"Component:": "Súčasť:",
|
||||
"Components to uninstall (manual for now):": "Súčasti na odinštalovanie (zatiaľ ručne):",
|
||||
"Compose capabilities validated in the LXC user namespace:": "Oprávnenia capabilities zo súboru Compose boli overené v používateľskom mennom priestore LXC:",
|
||||
"Compose environment must be a list or a mapping.": "Položka environment v súbore Compose musí byť zoznam alebo mapa kľúčov a hodnôt.",
|
||||
"Compose file of the application": "Súbor Compose aplikácie",
|
||||
"Compose file of this host": "súboru Compose z tohto hostiteľa",
|
||||
"Compose ports must be a list.": "Položka ports v súbore Compose musí byť zoznam.",
|
||||
"Compose volumes must be a list.": "Položka volumes v súbore Compose musí byť zoznam.",
|
||||
"Compressed size:": "Veľkosť po kompresii:",
|
||||
"Compressing": "Komprimujem",
|
||||
"Compression": "Kompresia",
|
||||
@@ -1123,6 +1074,7 @@
|
||||
"Configure Proxmox APT repositories": "Nastaviť APT repozitáre Proxmoxu",
|
||||
"Configure Samba Client in LXC (only privileged)": "Nastaviť Samba klienta v LXC (iba privilegovaný)",
|
||||
"Configure Samba Server in LXC (only privileged)": "Nastaviť Samba server v LXC (iba privilegovaný)",
|
||||
"Configure and install an application": "Nastaviť a nainštalovať aplikáciu",
|
||||
"Configure as guest (no authentication)": "Nastaviť ako hosť (bez prihlásenia)",
|
||||
"Configure backup destinations": "Nastaviť ciele záloh",
|
||||
"Configure backup destinations (PBS, Borg, local)": "Nastaviť ciele záloh (PBS, Borg, lokálne)",
|
||||
@@ -1227,6 +1179,7 @@
|
||||
"Consider security implications for production environments": "V produkčnom prostredí zvážte bezpečnostné dôsledky",
|
||||
"Consider visiting the repository and supporting the project.": "Zvážte návštevu repozitára a podporu projektu.",
|
||||
"Console log:": "Konzolový log:",
|
||||
"Consume/export": "Priečinky consume a export",
|
||||
"Container": "Kontajner",
|
||||
"Container — LXC root directories": "Kontajner — root priečinky LXC",
|
||||
"Container ID": "ID kontajnera",
|
||||
@@ -1379,6 +1332,7 @@
|
||||
"Core packages": "Základné balíky",
|
||||
"Cores": "Počet jadier",
|
||||
"Corrupted gzip layer": "Poškodená vrstva gzip",
|
||||
"Could not activate dev-zram0.swap": "Nepodarilo sa aktivovať jednotku dev-zram0.swap",
|
||||
"Could not add": "Nepodarilo sa pridať",
|
||||
"Could not add disk": "Disk sa nepodarilo pridať",
|
||||
"Could not add the confirmed host firewall rule": "Potvrdené pravidlo firewallu hostiteľa sa nepodarilo pridať",
|
||||
@@ -1395,12 +1349,14 @@
|
||||
"Could not change VM virtual display to vga: std": "Virtuálne zobrazenie VM sa nepodarilo zmeniť na vga: std",
|
||||
"Could not check the NVIDIA GPU": "Nepodarilo sa skontrolovať GPU NVIDIA",
|
||||
"Could not clone any gasket-driver repository. Check your internet connection and": "Nepodarilo sa naklonovať žiadny repozitár gasket-driver. Skontrolujte internetové pripojenie a",
|
||||
"Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {ui} before retrying; no automatic rollback was attempted.": "Nepodarilo sa dokončiť alebo overiť zmenu repozitárov; zmeny môžu byť čiastočné a stav repozitárov je neznámy. Pred ďalším pokusom skontrolujte {ui}. Automatický návrat do pôvodného stavu sa nevykonal.",
|
||||
"Could not complete private network release:": "Nepodarilo sa dokončiť uvoľnenie súkromnej siete:",
|
||||
"Could not configure IOMMU kernel parameters automatically. Configure manually and reboot.": "Parametre kernelu pre IOMMU sa nepodarilo nastaviť automaticky. Nastavte ich ručne a reštartujte.",
|
||||
"Could not convert the OCI rootfs to privileged": "Nepodarilo sa previesť OCI rootfs na privilegované",
|
||||
"Could not copy the PVE keyfile into place. Check permissions on:": "PVE keyfile sa nepodarilo skopírovať na správne miesto. Skontrolujte oprávnenia na:",
|
||||
"Could not copy the keyfile into place.": "Keyfile sa nepodarilo skopírovať na správne miesto.",
|
||||
"Could not copy the keyfile into place. Check permissions on:": "Keyfile sa nepodarilo skopírovať na správne miesto. Skontrolujte oprávnenia na:",
|
||||
"Could not create ZRAM generator directory": "Nepodarilo sa vytvoriť adresár pre konfiguráciu generátora ZRAM",
|
||||
"Could not create a temporary directory for the NVIDIA Container Toolkit.": "Nepodarilo sa vytvoriť dočasný adresár pre NVIDIA Container Toolkit.",
|
||||
"Could not create converter directory:": "Priečinok pre konvertor sa nepodarilo vytvoriť:",
|
||||
"Could not create destination directory:": "Cieľový priečinok sa nepodarilo vytvoriť:",
|
||||
@@ -1457,6 +1413,7 @@
|
||||
"Could not push the key. Check the password and that": "Kľúč sa nepodarilo odoslať. Skontrolujte heslo a to, že",
|
||||
"Could not query the image registry": "Nepodarilo sa získať údaje z registra obrazov",
|
||||
"Could not reach {registry}. Check its address, DNS and network access.": "Nepodarilo sa dosiahnuť {registry}. Skontrolujte jeho adresu, DNS a prístup k sieti.",
|
||||
"Could not reach {url} after 3 attempts: {reason}": "Adresa {url} nie je dostupná ani po 3 pokusoch: {reason}",
|
||||
"Could not read SMART data from": "SMART dáta sa nepodarilo prečítať z",
|
||||
"Could not read VM configuration.": "Nastavenie VM sa nepodarilo prečítať.",
|
||||
"Could not read the CUDA compute capability": "Nepodarilo sa prečítať výpočtovú schopnosť CUDA",
|
||||
@@ -1489,6 +1446,7 @@
|
||||
"Could not stage pending restore. Nothing new was scheduled.": "Nepodarilo sa pripraviť čakajúce obnovenie. Nič nové nebolo naplánované.",
|
||||
"Could not stop LXC": "LXC sa nepodarilo zastaviť",
|
||||
"Could not stop ZRAM swap; profile configuration remains": "Nepodarilo sa zastaviť výmenu ZRAM; konfigurácia profilu zostáva zachovaná",
|
||||
"Could not stop existing dev-zram0.swap": "Nepodarilo sa zastaviť existujúcu jednotku dev-zram0.swap",
|
||||
"Could not translate the Compose command/entrypoint": "Nepodarilo sa preložiť príkaz/bod vstupu",
|
||||
"Could not unload nouveau module (may be in use). The blacklist will take effect after reboot. Installation will continue but a reboot will be required.": "Modul nouveau sa nepodarilo uvoľniť (možno sa práve používa). Blacklist sa prejaví po reštarte. Inštalácia bude pokračovať, ale reštart bude potrebný.",
|
||||
"Could not unmount": "Nepodarilo sa odpojiť",
|
||||
@@ -1497,6 +1455,8 @@
|
||||
"Could not unmount — disk may be busy. Removing fstab entry anyway.": "Nepodarilo sa odpojiť - disk sa možno používa. Záznam z fstab aj tak odstraňujem.",
|
||||
"Could not update config file.": "Konfiguračný súbor sa nepodarilo aktualizovať.",
|
||||
"Could not verify removal of the managed host firewall rule.": "Nepodarilo sa overiť odstránenie spravovaného pravidla firewallu hostiteľa.",
|
||||
"Could not write ZRAM generator configuration": "Nepodarilo sa zapísať konfiguráciu generátora ZRAM",
|
||||
"Could not write ZRAM package ownership state": "Nepodarilo sa zapísať údaj o tom, či balík ZRAM nainštaloval ProxMenux",
|
||||
"Could not write to:": "Nepodarilo sa zapísať do:",
|
||||
"Crafty Controller default login": "Crafty Controller predvolené prihlásenie",
|
||||
"Create Directory": "Vytvoriť priečinok",
|
||||
@@ -2035,6 +1995,7 @@
|
||||
"Emby organizes video, music, live TV, and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices. This container is packaged as a standalone emby Media Server.": "Emby usporadúva videá, hudbu, živú televíziu a fotografie z osobných knižníc a streamuje ich do inteligentných televízorov, prehrávačov a mobilných zariadení. Tento kontajner obsahuje samostatný Emby Media Server.",
|
||||
"Emergency Proxmox System Repair": "Núdzová oprava systému Proxmox",
|
||||
"Emergency recovery:": "Núdzová obnova:",
|
||||
"Emit numeric progress for the OCI menu": "Vypisovať číselný priebeh pre ponuku OCI",
|
||||
"Empowering the smart home": "Posilnenie inteligentného domova",
|
||||
"Empty": "Prázdny",
|
||||
"Empty exec service check": "Prázdna kontrola exec služby",
|
||||
@@ -2057,6 +2018,7 @@
|
||||
"Enable ZFS autotrim (SSD/NVMe pools)": "Zapnúť ZFS autotrim (SSD/NVMe pooly)",
|
||||
"Enable ZRAM swap profile": "Povoliť výmenný profil ZRAM",
|
||||
"Enable a mount on an existing Rclone OCI container": "Nastaviť pripojenie na existujúcom OCI kontajneri Rclone",
|
||||
"Enable a mount on an installed Rclone OCI": "Nastaviť pripojenie v nainštalovanom OCI kontajneri Rclone",
|
||||
"Enable an optical drive for MakeMKV": "Sprístupniť optickú mechaniku pre MakeMKV",
|
||||
"Enable auto-sync if /var/log exceeds 90% of its size?": "Zapnúť automatickú synchronizáciu, keď /var/log presiahne 90 % svojej veľkosti?",
|
||||
"Enable fast reboots": "Zapnúť rýchle reštarty",
|
||||
@@ -2210,6 +2172,7 @@
|
||||
"Enterprise repository present": "Enterprise repozitár existuje",
|
||||
"Enterprise repository returned 401 Unauthorized (no valid subscription). Switch to the no-subscription repository and retry?": "Enterprise repozitár vrátil 401 Unauthorized (žiadne platné predplatné). Prepnúť na no-subscription repozitár a skúsiť znova?",
|
||||
"Enterprise repository unauthorized and fallback declined by user": "Enterprise repozitár nie je autorizovaný a používateľ odmietol náhradné riešenie",
|
||||
"Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.": "Enterprise má spoločný záznam APT s ďalšími komponentmi. Rozdeľte ho v rozhraní repozitárov Proxmoxu; žiadny repozitár sa nezmenil.",
|
||||
"Entropy generation optimization removed": "Optimalizácia generovania entropie bola odstránená",
|
||||
"Environment entry without an explicit value": "Položka premennej prostredia bez výslovne zadanej hodnoty",
|
||||
"Environment override for an unknown service:": "Prepísanie premenných prostredia pre neznámu službu:",
|
||||
@@ -2263,6 +2226,7 @@
|
||||
"Execution engine for Index-TTS": "Výpočtový engine pre Index-TTS",
|
||||
"Existing Groups": "Existujúce skupiny",
|
||||
"Existing TLS certificate reused:": "Existujúce osvedčenie TLS opätovne použité:",
|
||||
"Existing ZRAM profile is active but does not match its configuration": "Existujúci profil ZRAM je aktívny, ale nezodpovedá svojej konfigurácii",
|
||||
"Existing file, re-downloading...": "Súbor už existuje, sťahujem ho znova...",
|
||||
"Existing filesystem:": "Existujúci súborový systém:",
|
||||
"Existing hostpci entries detected — they will be reused": "Zistené existujúce hostpci položky - použijú sa znova",
|
||||
@@ -2694,6 +2658,7 @@
|
||||
"Generated Paperless administrator": "Vygenerovaný účet správcu Paperless",
|
||||
"Generated Tandoor administrator": "Vygenerovaný účet správcu Tandoor",
|
||||
"Generated administrator login": "Prihlásenie generovaného správcu",
|
||||
"Generated: {generated}; failed: {failed}; family: {provider}": "Vygenerované: {generated}; neúspešné: {failed}; rodina: {provider}",
|
||||
"Generating OVF descriptor...": "Vytváram OVF popisovač...",
|
||||
"Generating dkms.conf...": "Vytváram dkms.conf...",
|
||||
"Generating manifest...": "Vytváram manifest...",
|
||||
@@ -2705,6 +2670,9 @@
|
||||
"Get the container's storage information:": "Zistiť informácie o úložisku kontajnera:",
|
||||
"Get up and running with large language models locally": "Jednoduché lokálne spúšťanie veľkých jazykových modelov",
|
||||
"Git installed": "Git nainštalovaný",
|
||||
"GitHub returned HTTP 403 for {url}. Set GITHUB_TOKEN to raise the API rate limit.": "GitHub vrátil HTTP 403 pre {url}. Nastavením GITHUB_TOKEN zvýšite limit požiadaviek API.",
|
||||
"GitHub returned HTTP {status} for {url}.": "GitHub vrátil HTTP {status} pre {url}.",
|
||||
"GitHub returned a truncated tree for the CasaOS catalogue": "GitHub vrátil neúplný strom súborov katalógu CasaOS",
|
||||
"GitQlient is a multi-platform Git client originally forked from QGit. Nowadays it goes beyond of just a fork and adds a lot of new functionality.": "GitQlient je multiplatformový klient Git, ktorý vznikol ako odnož QGit. Dnes prináša množstvo vlastných nových funkcií.",
|
||||
"Github Desktop is an open source Electron-based GitHub app. It is written in TypeScript and uses React.": "GitHub Desktop je aplikácia GitHub s otvoreným zdrojovým kódom založená na Electrone. Je napísaná v TypeScripte a používa React.",
|
||||
"Global settings and SSH jail configured": "Globálne nastavenia a SSH jail sú nastavené",
|
||||
@@ -2852,6 +2820,7 @@
|
||||
"Host device node": "Uzol hostiteľského zariadenia",
|
||||
"Host directories are not included in the backup and are not reverted by a recovery.": "Hostiteľské adresáre nie sú zahrnuté do zálohy a nie sú vrátené zotavením.",
|
||||
"Host directories are not included in the backups and are not reverted by a recovery.": "Hostiteľské adresáre nie sú zahrnuté v zálohách a nie sú vrátené zotavením.",
|
||||
"Host directories are not reverted by the backup (confirmed beforehand)": "Adresáre hostiteľa sa zo zálohy nevracajú do pôvodného stavu (vopred potvrdené)",
|
||||
"Host directories cannot be included in vzdump": "Hostiteľské adresáre nemožno zahrnúť do vzdump",
|
||||
"Host directories cannot be part of the vzdump backup": "Adresáre hostiteľa nemožno zahrnúť do zálohy vzdump",
|
||||
"Host directories that are kept, with their content:": "Hostiteľské adresáre, ktoré sú uchovávané, s ich obsahom:",
|
||||
@@ -2875,6 +2844,7 @@
|
||||
"Host fstab NFS mounts (not registered with pvesm):": "NFS mounty v host fstab (nie sú registrované cez pvesm):",
|
||||
"Host fstab NFS mounts:": "NFS mounty v host fstab:",
|
||||
"Host fstab mounts (not registered as Proxmox storage):": "Mounty v host fstab (nie sú zaregistrované ako úložisko Proxmoxu):",
|
||||
"Host has an active subscription but no active {short} repository. Configure its Enterprise source in {ui}; no repository changed.": "Server má aktívne predplatné, ale nemá aktívny repozitár {short}. Nastavte zdroj Enterprise v {ui}; žiadny repozitár sa nezmenil.",
|
||||
"Host identity (hostname, hosts)": "Identita hosta (hostname, hosts)",
|
||||
"Host kernel module loaded:": "Načítaný modul jadra hostiteľa:",
|
||||
"Host monitor configured: shared PID and network namespaces, LXCFS disabled in this container": "Konfigurovaný monitor hostiteľa: zdieľané PID a sieťové priestory, LXCFS vypnuté v tomto kontajneri",
|
||||
@@ -2957,6 +2927,7 @@
|
||||
"ISO image — installation images": "ISO obraz — inštalačné obrazy",
|
||||
"ISO image downloaded": "ISO obraz bol stiahnutý",
|
||||
"ISO not found to mount on device": "ISO sa nenašlo na pripojenie k zariadeniu",
|
||||
"Icons resolved: {resolved}; cleared: {cleared}; unchanged: {unchanged}; with a theme variant: {themed}": "Priradené ikony: {resolved}; vymazané: {cleared}; nezmenené: {unchanged}; s variantom témy: {themed}",
|
||||
"Identical:": "Zhodné:",
|
||||
"Identify candidate disk (never use system disk):": "Nájsť vhodný disk (nikdy nepoužívajte systémový disk):",
|
||||
"Identify persistent disk paths": "Zistiť trvalé cesty k diskom",
|
||||
@@ -2964,6 +2935,7 @@
|
||||
"If Code 43 error appears, KVM hiding is already configured.": "Ak sa objaví chyba Code 43, KVM skrytie už je nastavené.",
|
||||
"If GUI does not load:": "Ak sa GUI nenačíta:",
|
||||
"If ZFS errors occur:": "Ak sa objavia chyby ZFS:",
|
||||
"If a newer application image is available, the container is stopped and backed up, and the backup integrity is verified. The container is then updated using the new image.\n\nIf the update fails after the container was changed, ProxMenux attempts to restore the previous installation from the backup. If the operation remains incomplete, select the application in OCI management and choose \"Recover the previous installation\".\n\nHost directories mounted in the container are not restored by the container backup.": "Ak je dostupný novší obraz aplikácie, kontajner sa zastaví, vytvorí sa jeho záloha a overí sa jej integrita. Potom sa kontajner aktualizuje pomocou nového obrazu.\n\nAk aktualizácia po zmene kontajnera zlyhá, ProxMenux sa pokúsi obnoviť predchádzajúcu inštaláciu zo zálohy. Ak operácia zostane nedokončená, v správe OCI vyberte danú aplikáciu a možnosť „Obnoviť predchádzajúcu inštaláciu“.\n\nPripojené adresáre hostiteľa sa zo zálohy kontajnera neobnovujú.",
|
||||
"If booting in EFI mode with root on LVM: install GRUB for EFI": "Ak štartujete v EFI režime s root na LVM: nainštalujte GRUB pre EFI",
|
||||
"If config issues occur:": "Ak nastanú problémy s nastavením:",
|
||||
"If container won't start:": "Ak sa kontajner nespustí:",
|
||||
@@ -3106,6 +3078,7 @@
|
||||
"Independent LXC applications installed": "Inštalované nezávislé aplikácie LXC",
|
||||
"Independent LXCs: no main container or hookscript. Each one keeps its own Start with Proxmox setting.": "Samostatné kontajnery LXC bez hlavného kontajnera a skriptu hookscript. Každý si zachová vlastné nastavenie spúšťania spolu s Proxmoxom.",
|
||||
"Independent applications, without a main container.": "Nezávislé aplikácie bez hlavného kontajnera.",
|
||||
"Index updated: {count} candidate applications": "Index bol aktualizovaný: {count} kandidátnych aplikácií",
|
||||
"Indexers and quality profiles still need to be configured.": "Indexery a profily kvality ešte treba nastaviť.",
|
||||
"Inherited retention:": "Prevzaté uchovávanie:",
|
||||
"Inherited schedule:": "Prevzatý plán:",
|
||||
@@ -3176,7 +3149,10 @@
|
||||
"Install with: apt-get install open-iscsi": "Nainštalujte cez: apt-get install open-iscsi",
|
||||
"Install/Update Coral TPU on Host": "Nainštalovať/aktualizovať Coral TPU na serveri",
|
||||
"Install/Update NVIDIA Drivers (Host + LXC)": "Nainštalovať/aktualizovať NVIDIA ovládače (server + LXC)",
|
||||
"Install? [y/N]:": "Nainštalovať? [y/N]:",
|
||||
"Installation Complete": "Inštalácia dokončená",
|
||||
"Installation blocked:": "Inštalácia je zablokovaná:",
|
||||
"Installation cancelled.": "Inštalácia bola zrušená.",
|
||||
"Installation completed": "Inštalácia dokončená",
|
||||
"Installation completed.": "Inštalácia je dokončená.",
|
||||
"Installation completed. Please reboot the server manually as soon as possible.": "Inštalácia je dokončená. Čo najskôr ručne reštartujte server.",
|
||||
@@ -3256,6 +3232,7 @@
|
||||
"Installing zfs-auto-snapshot package...": "Inštalujem balík zfs-auto-snapshot...",
|
||||
"Installs essential packages if missing": "Nainštaluje chýbajúce základné balíky",
|
||||
"Insufficient Disk Space": "Nedostatok miesta na disku",
|
||||
"Insufficient disk space. Available:": "Nedostatok miesta na disku. Dostupné:",
|
||||
"Insufficient disk space. Skipping LXC": "Nedostatok miesta na disku. LXC preskakujem",
|
||||
"Insufficient memory. NVIDIA install aborted.": "Nedostatok pamäte. Inštalácia NVIDIA bola prerušená.",
|
||||
"Insufficient memory. Skipping LXC": "Nedostatok pamäte. LXC preskakujem",
|
||||
@@ -3291,12 +3268,15 @@
|
||||
"Interfaces to Remove": "Rozhrania na odstránenie",
|
||||
"Internal error: NVIDIA installer path is empty or file not found.": "Vnútorná chyba: cesta k NVIDIA inštalátoru je prázdna alebo sa súbor nenašiel.",
|
||||
"Internal error: missing arguments in pmx_prepare_host_shared_dir": "Vnútorná chyba: chýbajú argumenty v pmx_prepare_host_shared_dir",
|
||||
"Internal installer operation; not legacy adoption": "Interná operácia inštalátora; neslúži na prevzatie existujúceho kontajnera",
|
||||
"Internal subnet of the tunnel (change it only if it clashes)": "Interná podsieť tunela (zmeňte ju iba pri konflikte)",
|
||||
"Interrupted operation": "Prerušená operácia",
|
||||
"Interrupted operation:": "Prerušená operácia:",
|
||||
"Interrupted stack operation found": "Našla sa prerušená operácia celej aplikácie",
|
||||
"Invalid 'proxmox-ve' candidate (not 9.x or none). Please verify your repository configuration and network, then retry.": "Neplatný kandidát 'proxmox-ve' (nie je 9.x alebo chýba). Skontrolujte nastavenie repozitárov a sieť, potom skúste znova.",
|
||||
"Invalid ALLOWED_HOSTS value": "Neplatná hodnota ALLOWED_HOSTS",
|
||||
"Invalid Compose port:": "Neplatný port v súbore Compose:",
|
||||
"Invalid Compose volume path:": "Neplatná cesta dátového úložiska v súbore Compose:",
|
||||
"Invalid Home Assistant OS check timeout": "Neplatný časový limit kontroly Home Assistant OS",
|
||||
"Invalid ID": "Neplatné ID",
|
||||
"Invalid IPv4 address": "Neplatná adresa IPv4",
|
||||
@@ -3332,6 +3312,7 @@
|
||||
"Invalid action": "Neplatná akcia",
|
||||
"Invalid administrator email": "Neplatný administrátorský mail",
|
||||
"Invalid administrator user name": "Neplatné meno správcu",
|
||||
"Invalid application identifier:": "Neplatný identifikátor aplikácie:",
|
||||
"Invalid base VMID": "Neplatné základné VMID",
|
||||
"Invalid bridge": "Neplatný sieťový most (bridge)",
|
||||
"Invalid check package:": "Neplatný kontrolný balík:",
|
||||
@@ -3463,7 +3444,9 @@
|
||||
"JSON output for scripts": "JSON výstup pre skripty",
|
||||
"Jackett works as a proxy server: it translates queries from apps (Sonarr, SickRage, CouchPotato, Mylar, etc) into tracker-site-specific http queries, parses the html response, then sends results back to the requesting software. This allows for getting recent uploads (like RSS) and performing searches. Jackett is a single repository of maintained indexer scraping & translation logic - removing the burden from other apps.": "Jackett funguje ako proxy server: dotazy z aplikácií Sonarr, SickRage, CouchPotato, Mylar a ďalších prevádza na HTTP dotazy pre jednotlivé trackery, spracuje ich HTML odpovede a výsledky vráti aplikácii. Umožňuje vyhľadávať aj získavať najnovšie príspevky cez RSS. Zdieľaná logika získavania a prevodu údajov z indexerov odbremeňuje ostatné aplikácie.",
|
||||
"Jellyfin WebUI": "Jellyfin WebUI",
|
||||
"Jellyfin configuration already applied:": "Nastavenia Jellyfin už boli použité:",
|
||||
"Jellyfin configuration applied:": "Použitá konfigurácia Jellyfin:",
|
||||
"Jellyfin configuration updated:": "Nastavenia Jellyfin boli aktualizované:",
|
||||
"Jellyfin did not create encoding.xml before the timeout": "Jellyfin nevytvoril encoding.xml pred uplynutím časového limitu",
|
||||
"Jellyfin has not created encoding.xml in any declared path": "Jellyfin nevytvoril encoding.xml v žiadnej deklarovanej ceste",
|
||||
"Jellyseerr is a free and open source software application for managing requests for your media library.": "Jellyseerr je bezplatná a open source softvérová aplikácia pre správu žiadostí o mediálnu knižnicu.",
|
||||
@@ -3506,6 +3489,7 @@
|
||||
"Keep current version (N)": "Ponechať aktuálnu verziu (N)",
|
||||
"Keep current version (N) if modified": "Ponechať aktuálnu verziu (N), ak bola upravená",
|
||||
"Keep in source VM(s) + disable onboot + add to target VM": "Ponechať v pôvodných VM + vypnúť onboot + pridať do cieľovej VM",
|
||||
"Keep the backup taken before the update in this Proxmox storage": "Ponechať zálohu vytvorenú pred aktualizáciou v tomto úložisku Proxmox",
|
||||
"Keeping GPU in source VM config": "Ponechávam GPU v nastavení zdrojovej VM",
|
||||
"Keeping the settings changed in Proxmox:": "Udržiavanie nastavenia v Proxmox:",
|
||||
"Kept sharedfiles group (has regular users assigned).": "Skupina sharedfiles zostala zachovaná (má priradených bežných používateľov).",
|
||||
@@ -3591,6 +3575,7 @@
|
||||
"Let's review your current network configuration.": "Pozrime si aktuálne nastavenie siete.",
|
||||
"Liberate your videos and unleash infinite possibilities.": "Uvoľnite svoje videá a uvoľnite nekonečné možnosti.",
|
||||
"Libraries: /data/media/movies, /data/media/series and /data/media/music. Select them in the media server.": "Knižnice: /data/media/movies, /data/media/series a /data/media/music. Vyberte ich v mediálnom serveri.",
|
||||
"Library": "Knižnica",
|
||||
"Library size in GB": "Veľkosť knižnice v GB",
|
||||
"LibreDB Studio": "LibreDB Studio",
|
||||
"LibreOffice is a free and powerful office suite, and a successor to OpenOffice.org (commonly known as OpenOffice). Its clean interface and feature-rich tools help you unleash your creativity and enhance your productivity.": "LibreOffice je bezplatný a výkonný kancelársky balík a nástupca OpenOffice.org, známeho ako OpenOffice. Prehľadné rozhranie a bohaté nástroje podporujú tvorivosť a zvyšujú produktivitu.",
|
||||
@@ -3619,6 +3604,7 @@
|
||||
"List all containers to identify the privileged one:": "Zobraziť všetky kontajnery a nájsť privilegovaný:",
|
||||
"List all containers to identify the unprivileged one:": "Zobraziť všetky kontajnery a nájsť neprivilegovaný:",
|
||||
"List all virtual machines": "Zobraziť všetky VM",
|
||||
"List applications": "Vypísať aplikácie",
|
||||
"List backups in a specific storage": "Zobraziť zálohy v konkrétnom úložisku",
|
||||
"List block devices and partitions": "Zobraziť blokové zariadenia a oddiely",
|
||||
"List configured storages:": "Zobraziť nastavené úložiská:",
|
||||
@@ -4152,6 +4138,7 @@
|
||||
"No Changes Needed": "Nie sú potrebné žiadne zmeny",
|
||||
"No Cleanup Needed": "Čistenie nie je potrebné",
|
||||
"No Compose file was given": "Nebol zadaný žiadny súbor Compose",
|
||||
"No Compose files were found in the CasaOS catalogue": "V katalógu CasaOS sa nenašli žiadne súbory Compose",
|
||||
"No Controller/NVMe selected for now.": "Zatiaľ nie je vybraný žiadny Controller/NVMe.",
|
||||
"No Coral Detected": "Coral sa nenašiel",
|
||||
"No Coral TPU device was found on this host (neither PCIe/M.2 nor USB).": "Na tomto hoste sa nenašlo žiadne Coral TPU zariadenie (ani PCIe/M.2, ani USB).",
|
||||
@@ -4245,10 +4232,11 @@
|
||||
"No accessible Samba servers found.": "Nenašli sa dostupné Samba servery.",
|
||||
"No accessible exports found.": "Nenašli sa žiadne dostupné exporty.",
|
||||
"No accessible shares found.": "Nenašli sa dostupné zdieľania.",
|
||||
"No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.": "Pre toto vydanie nie je aktívny základný repozitár Debianu; pred pokračovaním ho nastavte v rozhraní repozitárov Proxmoxu.",
|
||||
"No active Proxmox storage accepts OCI template archives.": "Žiadne aktívne úložisko Proxmox neakceptuje archívy šablón OCI.",
|
||||
"No active exports or showmount not available": "Nie sú aktívne exporty alebo príkaz showmount nie je dostupný",
|
||||
"No active session": "Žiadna aktívna relácia",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PBS.Neinteraktívny režim nemôže zmeniť zdroje APT;nakonfigurujte ich v Administrácia > Repozitáre.",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Server nemá aktívne predplatné ani použiteľný repozitár PBS. Neinteraktívny režim nemôže meniť zdroje APT; nastavte ich v Administration > Repositories.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PVE. Neinteraktívny režim nemôže zmeniť zdroje APT; nakonfigurujte ich v časti Uzol > Aktualizácie > Úložiská.",
|
||||
"No additional GPU can be added.": "Nie je možné pridať ďalšiu GPU.",
|
||||
"No additional device needs to be added.": "Netreba pridávať žiadne ďalšie zariadenie.",
|
||||
@@ -4397,6 +4385,7 @@
|
||||
"No supported NVIDIA versions found for this GPU": "Pre tento GPU sa nenašli žiadne podporované verzie NVIDIA",
|
||||
"No sysfs ROM entry — trying ACPI VFCT table...": "Chýba sysfs ROM položka - skúšam ACPI VFCT tabuľku...",
|
||||
"No target configured.": "Nie je nastavený žiadny cieľ.",
|
||||
"No template is available for '{app_id}'": "Pre aplikáciu '{app_id}' nie je dostupná šablóna",
|
||||
"No test currently running": "Aktuálne nebeží žiadny test",
|
||||
"No unambiguous loader download found in release metadata.": "V metadátach vydania sa nenašlo žiadne jednoznačné stiahnutie zavádzača.",
|
||||
"No uninstaller found for:": "Nenašiel sa odinštalátor pre:",
|
||||
@@ -4540,6 +4529,7 @@
|
||||
"Only one service at a time can be installed this way.": "Týmto spôsobom sa dá naraz nainštalovať iba jedna služba.",
|
||||
"Only removes storage definition, not remote data.": "Odstráni iba definíciu úložiska, nie vzdialené dáta.",
|
||||
"Only run this if you used LVM (step 6b):": "Spustite iba vtedy, ak ste použili LVM (krok 6b):",
|
||||
"Only the applications that need no question: the ones the cluster keeps a record of": "Iba aplikácie bez potreby otázok: tie, ktorých záznam uchováva klaster",
|
||||
"Only the host backup hook is removed — PVE vzdump jobs targeting this storage stay intact.": "Odstráni sa iba hook zálohy hosta - PVE vzdump úlohy smerujúce na toto úložisko zostanú zachované.",
|
||||
"Only the image reference, with no Compose file": "Len odkaz na obraz bez súboru Compose",
|
||||
"Only the update of the image runs unattended.": "Bezobslužne sa vykonáva iba aktualizácia obrazu.",
|
||||
@@ -4553,6 +4543,7 @@
|
||||
"Open the VM console and wait for the loader to boot": "Otvorte konzolu VM a počkajte, kým sa spustí loader",
|
||||
"Open the dashboard from this host on port 8008 to create a new admin account.": "Otvorte dashboard z tohto servera na porte 8008 a vytvorte nový admin účet.",
|
||||
"Open the dashboard to create a new admin account:": "Otvorte dashboard a vytvorte nový admin účet:",
|
||||
"Open the page of an application in the menu": "Otvoriť stránku aplikácie v ponuke",
|
||||
"Open-source AI-powered coding assistant": "Programátorský asistent využívajúci AI s otvoreným zdrojovým kódom",
|
||||
"Open-source UI for building and debugging multi-agent and RAG applications": "Rozhranie s otvoreným zdrojovým kódom na tvorbu a ladenie aplikácií s viacerými agentmi a RAG",
|
||||
"Open-source self-hosted SQL IDE.": "Vývojové prostredie SQL s otvoreným zdrojovým kódom na vlastnom serveri.",
|
||||
@@ -4952,6 +4943,7 @@
|
||||
"ProxMenux Monitor protection": "ochranou ProxMenux Monitoru",
|
||||
"ProxMenux Monitor unit repaired and restarted": "Jednotka ProxMenux Monitor bola opravená a reštartovaná",
|
||||
"ProxMenux Monitor → Backups tab (live progress card with estimated time, logs, rollback delta)": "ProxMenux Monitor → karta Backups (živý priebeh s odhadom času, záznamami a rozdielom rollbacku)",
|
||||
"ProxMenux OCI manager Apps (Technology Preview)": "ProxMenux – správca OCI aplikácií (Technology Preview)",
|
||||
"ProxMenux attaches directories, not single files, so this image cannot be installed yet.": "ProxMenux pripája adresáre, nie jednotlivé súbory, takže tento obrázok ešte nie je možné nainštalovať.",
|
||||
"ProxMenux can apply open permissions on this NFS directory from the host so the container can read and write:": "ProxMenux môže na hostovi nastaviť otvorené oprávnenia pre tento NFS priečinok, aby kontajner mohol čítať aj zapisovať:",
|
||||
"ProxMenux can remount it with open permissions so any LXC can read and write.": "ProxMenux ho môže znovu pripojiť s otvorenými oprávneniami, aby každý LXC mohol čítať aj zapisovať.",
|
||||
@@ -4971,9 +4963,13 @@
|
||||
"Proxmology logo applied": "Logo Proxmology bolo použité",
|
||||
"Proxmox 9 system update allready": "Systém Proxmox 9 je už aktualizovaný",
|
||||
"Proxmox APT repositories configured": "APT repozitáre Proxmoxu sú nastavené",
|
||||
"Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {ui}; no repository changed.": "Zoznam repozitárov APT Proxmoxu je neplatný alebo obsahuje chyby načítania. Opravte zdroje v {ui}; žiadny repozitár sa nezmenil.",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Ciele Proxmox Backup Servera (PBS)",
|
||||
"Proxmox Backup Server APT repositories configured": "APT repozitáre Proxmox Backup Server sú nastavené",
|
||||
"Proxmox Backup Server Microcode": "Proxmox Backup Server – mikrokód procesora",
|
||||
"Proxmox Backup Server Post Install": "Proxmox Backup Server – nastavenie po inštalácii",
|
||||
"Proxmox Backup Server Subscription Banner Removal": "Proxmox Backup Server – odstránenie upozornenia na predplatné",
|
||||
"Proxmox Backup Server Update": "Aktualizácia Proxmox Backup Server",
|
||||
"Proxmox Backup Server update completed.": "Aktualizácia Proxmox Backup Server bola dokončená.",
|
||||
"Proxmox CIFS Storage Status:": "Stav CIFS úložiska v Proxmoxe:",
|
||||
@@ -4995,6 +4991,7 @@
|
||||
"Proxmox iSCSI Storage Status:": "Stav iSCSI úložiska v Proxmoxe:",
|
||||
"Proxmox jail configured": "Proxmox jail nastavený",
|
||||
"Proxmox local storages:": "Lokálne úložiská Proxmoxu:",
|
||||
"Proxmox no-subscription standard repository handle unavailable; no repository changed.": "Štandardný repozitár Proxmoxu no-subscription nie je dostupný; žiadny repozitár sa nezmenil.",
|
||||
"Proxmox repository": "Proxmox úložisko",
|
||||
"Proxmox repository OK (candidate is 9.x)": "Proxmox repozitár je v poriadku (kandidát je 9.x)",
|
||||
"Proxmox repository configuration completed": "Nastavenie repozitárov Proxmoxu je dokončené",
|
||||
@@ -5062,6 +5059,7 @@
|
||||
"Rclone mount needs a privileged LXC with FUSE access. The container is dedicated to Rclone and its web UI must not be exposed to untrusted networks.": "Pripojenie Rclone vyžaduje privilegovaný kontajner LXC s prístupom k FUSE. Kontajner je vyhradený pre Rclone a jeho webové rozhranie nesmie byť prístupné z nedôveryhodných sietí.",
|
||||
"Rclone mount requires a privileged LXC with FUSE access. Use this profile only on a trusted node and network.": "Pripojenie cez Rclone vyžaduje privilegovaný kontajner LXC s prístupom k FUSE. Tento profil používajte iba na dôveryhodnom uzle a v dôveryhodnej sieti.",
|
||||
"Rclone mount requires a privileged container": "Pripojenie Rclone vyžaduje privilegovaný kontajner",
|
||||
"Rclone remote": "Vzdialené úložisko Rclone",
|
||||
"Re-enter the BORG REPOKEY passphrase to confirm:": "Zadajte BORG REPOKEY frázu znova na potvrdenie:",
|
||||
"Re-running pre-check after repairs...": "Spúšťam predbežnú kontrolu znova po opravách...",
|
||||
"Reachable": "Dostupné",
|
||||
@@ -5167,15 +5165,19 @@
|
||||
"Recreation prepared": "Pripravené rekreácie",
|
||||
"Refresh APT index and verify repositories:": "Obnovte APT index a overte repozitáre:",
|
||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Obnoviť indexy nakonfigurovaných repozitárov APT a skontrolovať aktualizáciu balíkov PBS? Súbory repozitárov sa zachovajú.",
|
||||
"Refresh the index from the image sources": "Obnoviť index zo zdrojov obrazov",
|
||||
"Refresh token of the connector, from the Twingate admin console": "Obnovovací token konektora z administrátorskej konzoly Twingate",
|
||||
"Refresh your browser (Ctrl+Shift+R) to see changes": "Obnovte prehliadač (Ctrl+Shift+R), aby ste videli zmeny",
|
||||
"Refresh your browser to see changes (server restart may be required)": "Obnovte stránku v prehliadači, aby ste videli zmeny (môže byť potrebný reštart servera)",
|
||||
"Refreshing apt cache...": "Obnovujem apt cache...",
|
||||
"Refreshing the NVIDIA runtime...": "Obnovenie prevádzky NVIDIA...",
|
||||
"Refusing an unexpected rootfs path:": "Odmietnutá neočakávaná cesta koreňového súborového systému:",
|
||||
"Regenerate the catalog templates": "Znova vygenerovať šablóny katalógu",
|
||||
"Regenerate the template of one application": "Znova vygenerovať šablónu jednej aplikácie",
|
||||
"Regenerating PVE package cache...": "Znovu vytváram cache PVE balíkov...",
|
||||
"Regenerating boot artifacts for the merged kernel-agnostic changes...": "Znovu vytváram boot súbory pre zlúčené zmeny nezávislé od kernelu...",
|
||||
"Regenerating certificates and restarting services...": "Znovu generujem certifikáty a reštartujem služby...",
|
||||
"Register again the OCI applications restored from a backup": "Znova zaregistrovať OCI aplikácie obnovené zo zálohy",
|
||||
"Register mount path in Proxmox:": "Zaregistrovať cestu pripojenia v Proxmoxe:",
|
||||
"Registering disk as Proxmox storage...": "Registrujem disk ako úložisko Proxmoxu...",
|
||||
"Registering module with DKMS...": "Registrujem modul v DKMS...",
|
||||
@@ -5339,7 +5341,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "Pomocník úložiska nie je k dispozícii. Inštalácia NVIDIA sa zastavila.",
|
||||
"Repository policy returned an unexpected result.": "Politika úložiska vrátila neočakávaný výsledok.",
|
||||
"Repository ready.": "Repozitár je pripravený.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená;zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Administrácia > Repozitáre a zopakujte apt-get update.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Zdroje repozitárov sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Operácia sa zastavila; pôvodné zdroje sa neobnovili. Pred pokračovaním skontrolujte Administration > Repositories a zopakujte apt-get update.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená; zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Node > Updates > Repository a zopakujte apt-get update.",
|
||||
"Repository switch declined; no APT source changed.": "Prepnutie úložiska bolo odmietnuté; žiadny zdroj APT sa nezmenil.",
|
||||
"Repository:": "Repozitár:",
|
||||
@@ -5362,6 +5364,7 @@
|
||||
"Resetting time synchronization...": "Resetujem synchronizáciu času...",
|
||||
"Residual Bookworm entries commented where applicable": "Zostávajúce položky Bookworm boli zakomentované, kde to bolo potrebné",
|
||||
"Resilio-sync (formerly BitTorrent Sync) uses the BitTorrent protocol to sync files and folders between all of your devices. There are both free and paid versions, this container supports both. There is an official sync image but we created this one as it supports user mapping to simplify permissions for volumes.": "Resilio Sync (predtým BitTorrent Sync) používa protokol BitTorrent na synchronizáciu súborov a priečinkov medzi zariadeniami. Kontajner podporuje bezplatnú aj platenú verziu. Oproti oficiálnemu obrazu podporuje mapovanie používateľov, ktoré zjednodušuje oprávnenia dátových zväzkov.",
|
||||
"Resolve catalog icons against the jsdelivr icon sets": "Priradiť ikony katalógu zo súprav ikon jsdelivr",
|
||||
"Resolve package conflicts": "Vyriešiť konflikty balíkov",
|
||||
"Resources": "Zdroje",
|
||||
"Restart Network": "Reštartovať sieť",
|
||||
@@ -5604,6 +5607,7 @@
|
||||
"Scheduled backup job created": "Naplánovaná úloha zálohy bola vytvorená",
|
||||
"Scheduled backup jobs": "Naplánované úlohy záloh",
|
||||
"Scheduled backups and retention policies": "Naplánované zálohy a pravidlá uchovávania",
|
||||
"Scheduled run: no questions; stops where a person has to decide": "Plánovaný beh bez otázok; zastaví sa pri potrebe rozhodnutia používateľa",
|
||||
"Scheduled tasks (cron)": "Naplánované úlohy (cron)",
|
||||
"Scheduler script not found:": "Skript plánovača sa nenašiel:",
|
||||
"ScreenScraper": "ScreenScraper",
|
||||
@@ -5963,6 +5967,7 @@
|
||||
"Show standard exclude patterns": "Zobraziť štandardné vylučovacie vzory",
|
||||
"Show status of all storage pools": "Zobraziť stav všetkých úložísk",
|
||||
"Show the QR code of a peer again with: pct exec <CTID> -- /app/show-peer 1": "Zobraziť QR kód peera znovu s: pct exec <CTID> -- /app/show-peer 1",
|
||||
"Show the summary of a template": "Zobraziť súhrn šablóny",
|
||||
"Show traffic statistics per interface": "Zobraziť štatistiky prenosu podľa rozhrania",
|
||||
"Show vzdump backup configuration": "Zobraziť nastavenie vzdump záloh",
|
||||
"Shows status and type (nfs/cifs/dir/iscsi...).": "Zobrazí stav a typ (nfs/cifs/dir/iscsi...).",
|
||||
@@ -6205,6 +6210,7 @@
|
||||
"Subscription banner removal failed": "Odstránenie bannera odberu zlyhalo",
|
||||
"Subscription banner removed successfully": "Banner predplatného bol úspešne odstránený",
|
||||
"Subscription banner restored successfully (desktop and mobile)": "Hlásenie o predplatnom bolo úspešne obnovené (desktop aj mobil)",
|
||||
"Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.": "Stav predplatného nie je jednoznačne active, notfound alebo expired. Overte ho lokálne príkazom proxmox-backup-manager subscription get; žiadny repozitár sa nezmenil.",
|
||||
"Success": "Úspech",
|
||||
"Successful": "Úspešné",
|
||||
"Supervisor does not confirm healthy and supported yet": "Supervisor zatiaľ nepotvrdzuje zdravý a podporovaný stav",
|
||||
@@ -6253,6 +6259,7 @@
|
||||
"System path mounts are not yet supported": "Pripojenie systémovej cesty ešte nie je podporované",
|
||||
"System reboot required": "Vyžaduje sa reštart systému",
|
||||
"System upgrade completed": "Aktualizácia systému je dokončená",
|
||||
"System upgrade failed. Check log:": "Aktualizácia systému zlyhala. Skontrolujte denník:",
|
||||
"System uptime": "Doba behu systému",
|
||||
"Systemd ulimits set": "systemd ulimits sú nastavené",
|
||||
"TCP BBR configuration created successfully": "Nastavenie TCP BBR bolo úspešne vytvorené",
|
||||
@@ -6316,10 +6323,13 @@
|
||||
"The Bookmark Everything App": "Aplikácia na ukladanie záložiek všetkého druhu",
|
||||
"The Brave browser is a fast, private and secure web browser for PC, Mac and mobile.": "Prehliadač Brave je rýchly, súkromný a bezpečný webový prehliadač pre PC, Mac a mobil.",
|
||||
"The CT already exists:": "CT už existuje:",
|
||||
"The Compose document must be a mapping.": "Dokument Compose musí byť mapa kľúčov a hodnôt.",
|
||||
"The Compose file declares no service": "Súbor Compose neobsahuje žiadnu službu",
|
||||
"The Compose file describes several images:": "Skladací súbor popisuje niekoľko obrázkov:",
|
||||
"The Compose file describes several services:": "Súbor Compose opisuje viacero služieb:",
|
||||
"The Compose file does not contain a Compose document": "Súbor neobsahuje dokument Compose",
|
||||
"The Compose file has no services.": "Súbor Compose neobsahuje žiadne služby.",
|
||||
"The Compose file has no x-casaos metadata.": "Súbor Compose neobsahuje metadáta x-casaos.",
|
||||
"The Compose file is not valid YAML:": "Súbor Compose nie je platný YAML:",
|
||||
"The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "Súbor Compose ponúka voliteľné uvoľnenie obmedzení AppArmor alebo seccomp, ktoré zostane vypnuté, kým ho používateľ nevyberie. Pokračujte iba vtedy, ak obrazu dôverujete a riziko prijímate.",
|
||||
"The Compose value must be text or a list:": "Hodnota Compose musí byť text alebo zoznam:",
|
||||
@@ -6369,9 +6379,11 @@
|
||||
"The PostgreSQL volume needs at least 4 GB": "Dátový zväzok PostgreSQL musí mať najmenej 4 GB",
|
||||
"The PostgreSQL volume needs at least 8 GB": "Dátový zväzok PostgreSQL musí mať najmenej 8 GB",
|
||||
"The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "Kontrola aktualizácií v ProxMenux Monitor nie je dostupná. Spustite alebo aktualizujte ProxMenux Monitor a skúste to znova.",
|
||||
"The ProxMenux application catalogue is not a list": "Katalóg aplikácií ProxMenux nemá formát zoznamu",
|
||||
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "Aktualizácia Proxmox VE 8 sa nedokončila. Prechod na Proxmox VE 9 sa nespustil.",
|
||||
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Chýba zväzok kľúčov archívu Proxmox;Inštalácia Ceph nemôže bezpečne pokračovať",
|
||||
"The Proxmox inventory and the local configurations differ": "Inventár Proxmox nezodpovedá miestnym nastaveniam kontajnerov",
|
||||
"The README has no recognisable docker-compose section": "README neobsahuje rozpoznateľnú sekciu docker-compose",
|
||||
"The ROCm image has no support for the AMD GPU of this host:": "Obraz ROCm nemá podporu pre AMD GPU tohto hostiteľa:",
|
||||
"The ROCm image of this application has no support for the AMD GPU of this host:": "Obraz ROCm tejto aplikácie nemá podporu pre AMD GPU tohto hostiteľa:",
|
||||
"The Rclone configuration needs at least 1 GB": "Nastavenia Rclone vyžadujú aspoň 1 GB",
|
||||
@@ -6408,6 +6420,8 @@
|
||||
"The administrator email is not valid": "E-mailová adresa správcu nie je platná",
|
||||
"The administrator user contains characters that are not allowed": "Užívateľ správcu obsahuje znaky, ktoré nie sú povolené",
|
||||
"The all-in-one AI application.": "Univerzálna aplikácia umelej inteligencie.",
|
||||
"The application '{app_id}' does not come from LinuxServer": "Aplikácia '{app_id}' nepochádza z LinuxServer",
|
||||
"The application '{app_id}' is not in the index": "Aplikácia '{app_id}' nie je v indexe",
|
||||
"The application configuration needs a first start to complete.": "Konfigurácia aplikácie potrebuje prvý štart na dokončenie.",
|
||||
"The application could not be modified:": "Aplikáciu nebolo možné upraviť:",
|
||||
"The application could not be recovered:": "Aplikáciu sa nepodarilo znova zaregistrovať:",
|
||||
@@ -6437,6 +6451,7 @@
|
||||
"The backup was taken on a different PVE or kernel major.minor. These paths will be SKIPPED to keep the boot safe:": "Záloha bola vytvorená na inom PVE alebo inom major.minor kerneli. Tieto cesty sa PRESKOČIA, aby zostal štart bezpečný:",
|
||||
"The bind mount target escapes the rootfs:": "Cieľ pripojenia bind smeruje mimo koreňového súborového systému:",
|
||||
"The block device does not exist:": "Blokové zariadenie neexistuje:",
|
||||
"The block found does not look like a Docker Compose file": "Nájdený blok nevyzerá ako súbor Docker Compose",
|
||||
"The build could not be checked beforehand; continuing without that check.": "Zostavenie nebolo možné vopred skontrolovať;pokračovanie bez tejto kontroly.",
|
||||
"The cached image does not match the current digest": "Comment",
|
||||
"The cached image is damaged; it will be downloaded again.": "Obraz vo vyrovnávacej pamäti je poškodený; stiahne sa znova.",
|
||||
@@ -6521,6 +6536,8 @@
|
||||
"The disk size cannot be reproduced": "Veľkosť disku nie je možné reprodukovať",
|
||||
"The disk usage of the container could not be read": "Použitie disku kontajnera nebolo možné prečítať",
|
||||
"The disks of the failed attempt could not be removed and stay in the temporary container:": "Disky neúspešného pokusu sa nepodarilo odstrániť a zostávajú v dočasnom kontajneri:",
|
||||
"The docker-compose block is not closed": "Blok docker-compose nie je ukončený",
|
||||
"The docker-compose section contains no code block": "Sekcia docker-compose neobsahuje blok kódu",
|
||||
"The domain must resolve to the public address of this network before the certificate can be issued.": "Pred vydaním certifikátu musí doména smerovať na verejnú adresu tejto siete.",
|
||||
"The download client still needs to be configured.": "Klienta na sťahovanie ešte treba nastaviť.",
|
||||
"The download stopped progressing; cancelling this attempt.": "Sťahovanie prestalo napredovať; tento pokus sa ruší.",
|
||||
@@ -6554,6 +6571,7 @@
|
||||
"The fstab entry will still be removed; reboot or manual umount needed.": "Záznam vo fstab sa aj tak odstráni; bude potrebný reštart alebo ručné odpojenie.",
|
||||
"The gateway must be another usable address in the same subnet.": "Vstupnou bránou musí byť ďalšia použiteľná adresa v tej istej podsiete.",
|
||||
"The gateway should appear in your Tailscale admin console shortly.": "Brána by sa čoskoro mala objaviť v Tailscale administrácii.",
|
||||
"The generated template does not match the schema:": "Vygenerovaná šablóna nezodpovedá schéme:",
|
||||
"The guest access test did not succeed.": "Test prístupu hosťa nebol úspešný.",
|
||||
"The healthcheck cannot run without an IP address": "Kontrola zdravotného stavu nemôže prebiehať bez IP adresy",
|
||||
"The host NVIDIA driver is not responding correctly": "Ovládač NVIDIA na serveri nereaguje správne",
|
||||
@@ -6631,6 +6649,8 @@
|
||||
"The long test runs directly on the disk hardware.": "Dlhý test beží priamo na hardvéri disku.",
|
||||
"The machine learning container is rebuilt with the image of the new choice. Its model cache, the library and the database are kept. The whole application is stopped and updated, as in an update. If the operation fails after a container was changed, the previous containers and the previous choice are restored. An operation that stops halfway is recovered from the OCI management menu.": "Kontajner strojového učenia sa znova vytvorí z obrazu pre zvolený CPU alebo GPU. Vyrovnávacia pamäť modelov, knižnica a databáza sa zachovajú. Celá aplikácia sa zastaví a aktualizuje rovnakým postupom ako pri bežnej aktualizácii. Ak operácia po zmene kontajnera zlyhá, ProxMenux sa pokúsi obnoviť predchádzajúce kontajnery aj pôvodnú voľbu CPU/GPU. Ak operácia zostane nedokončená, obnovte ju z ponuky správy OCI.",
|
||||
"The machine learning container is rebuilt with the image of the new choice. Its model cache, the library and the database are kept. The whole application is stopped and updated, as in an update; if anything fails, the previous containers and the previous choice are restored.": "Kontajner strojového učenia je prestavaný s obrazom novej voľby. Uchováva sa jej vyrovnávacia pamäť modelu, knižnica a databáza. Celá aplikácia sa zastaví a aktualizuje ako pri aktualizácii; ak čokoľvek zlyhá, obnovia sa predchádzajúce kontajnery a predchádzajúca voľba.",
|
||||
"The main Compose service cannot be identified unambiguously": "Hlavnú službu v súbore Compose nemožno jednoznačne určiť",
|
||||
"The main Compose service has no image.": "Hlavná služba v súbore Compose nemá zadaný žiadny obraz.",
|
||||
"The main container of this application is not on this host. Restore it too.": "Hlavný kontajner tejto aplikácie nie je na tomto hostiteľovi. Obnovte ho tiež.",
|
||||
"The main member must stop first and start last": "Hlavný kontajner sa musí zastaviť ako prvý a spustiť ako posledný",
|
||||
"The main member of the stack is missing": "Chýba hlavný kontajner aplikácie",
|
||||
@@ -6944,6 +6964,7 @@
|
||||
"This guarantees that device nodes are available before applying LXC GPU config.": "Tým sa zabezpečí, že device nodes budú dostupné ešte pred použitím GPU nastavenia pre LXC.",
|
||||
"This host architecture is not supported by the OCI image importer.": "Importér obrazov OCI nepodporuje architektúru tohto hostiteľa.",
|
||||
"This host has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PVE source will be disabled. Enterprise Ceph sources, if present, will be disabled without choosing a replacement Ceph channel; configure Ceph separately if needed.": "Tento hostiteľ nemá žiadne aktívne predplatné, prepnúť na úložisko bez predplatného? Neprístupný zdroj Enterprise PVE bude zakázaný. Enterprise Ceph zdroje, ak sú prítomné, budú deaktivované bez výberu náhradného Ceph kanála; v prípade potreby nakonfigurujte Ceph samostatne.",
|
||||
"This host runs {name} on {suite}, but these {name} repositories are for another suite: {details}. Disable or remove them in {ui} and run the update again; no repository changed.": "Na serveri beží {name} na vydaní {suite}, ale tieto repozitáre {name} sú pre iné vydanie: {details}. Vypnite alebo odstráňte ich v {ui} a spustite aktualizáciu znova; žiadny repozitár sa nezmenil.",
|
||||
"This image cannot be installed as it is described:": "Tento obraz nemožno nainštalovať podľa zadanej konfigurácie:",
|
||||
"This image has no compatible Linux image for this host ({architecture}).": "Tento obraz nemá variant Linuxu kompatibilný s týmto hostiteľom ({architecture}).",
|
||||
"This image requires the host module": "Tento obraz vyžaduje modul hostiteľa",
|
||||
@@ -7223,6 +7244,7 @@
|
||||
"Unsupported OCI digest:": "Nepodporovaný identifikátor digest OCI:",
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Nepodporovaný profil OCI-LXC AppArmor:",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Nepodporovaný profil OCI-LXC seccomp:",
|
||||
"Unsupported Proxmox suite; no repository changed.": "Nepodporované vydanie Proxmoxu; žiadny repozitár sa nezmenil.",
|
||||
"Unsupported Terminal": "Nepodporovaný terminál",
|
||||
"Unsupported architecture for Figurine:": "Nepodporovaná architektúra pre Figurine:",
|
||||
"Unsupported architecture:": "Nepodporovaná architektúra:",
|
||||
@@ -7234,6 +7256,7 @@
|
||||
"Unsupported device type:": "Nepodporovaný typ zariadenia:",
|
||||
"Unsupported dynamic NVIDIA capabilities:": "Nepodporované dynamické schopnosti NVIDIA:",
|
||||
"Unsupported format. Only .ova and .ovf files are supported.": "Nepodporovaný formát. Podporované sú iba .ova a .ovf súbory.",
|
||||
"Unsupported host architecture:": "Nepodporovaná architektúra hostiteľa:",
|
||||
"Unsupported kept setting:": "Nepodporované uchované nastavenie:",
|
||||
"Unsupported media storage mode:": "Nepodporovaný režim úložiska médií:",
|
||||
"Unsupported mount options": "Nepodporované možnosti pripojenia",
|
||||
@@ -7289,6 +7312,7 @@
|
||||
"Update system to latest PVE 8.4+ (if not done already):": "Aktualizujte systém na najnovšie PVE 8.4+ (ak to ešte nie je hotové):",
|
||||
"Update the image with the saved configuration": "Aktualizovať obraz s uloženými nastaveniami",
|
||||
"Update the whole stack?": "Aktualizovať celú aplikáciu?",
|
||||
"Update, modify or recreate one installed OCI instance": "Aktualizovať, upraviť alebo znova vytvoriť jednu nainštalovanú inštanciu OCI",
|
||||
"Updated": "Aktualizované",
|
||||
"Updated sharedfiles group to GID: 101000": "Skupina sharedfiles bola aktualizovaná na GID: 101000",
|
||||
"Updated stack checked": "Aktualizovaná aplikácia je skontrolovaná",
|
||||
@@ -7323,7 +7347,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Asistent aktualizácie: Proxmox VE 8 → 9 (Trixie)",
|
||||
"Upgrade canceled by user": "Aktualizácia zrušená používateľom",
|
||||
"Upgrade one node at a time": "Aktualizujte vždy iba jeden uzol naraz",
|
||||
"Upgrade the system, offering the": "Aktualizujte systém a ponúknite",
|
||||
"Upgrade the system, offering the": "Aktualizácia systému a prípadný prechod na",
|
||||
"Upgrade to Proxmox VE 9": "Aktualizovať na Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "Aktualizujem NVIDIA nástroje (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Nahrať šifrovanú kópiu kľúča do PBS, aby ste ju vedeli obnoviť na preinštalovanom hostovi iba pomocou frázy?",
|
||||
@@ -7544,6 +7568,7 @@
|
||||
"Volume configuration cancelled": "Nastavenie dátového úložiska bolo zrušené",
|
||||
"Volume options are not yet supported": "Voľby dátových zväzkov zatiaľ nie sú podporované",
|
||||
"Volume size in GB": "Veľkosť dátového disku v GB",
|
||||
"Volumes": "Dátové zväzky",
|
||||
"Volumes attached": "Dátové zväzky sú pripojené",
|
||||
"Volumes prepared for the first start:": "Dátové zväzky pripravené na prvé spustenie:",
|
||||
"Volumes shared between services are not yet supported": "Dátové zväzky zdieľané medzi službami zatiaľ nie sú podporované",
|
||||
@@ -7744,6 +7769,7 @@
|
||||
"ZRAM profile removed, but package purge failed": "Profil ZRAM bol odstránený, ale vymazanie balíka zlyhalo",
|
||||
"ZRAM still holds pages; configuration was preserved": "ZRAM stále drží stránky; konfigurácia bola zachovaná",
|
||||
"ZRAM still holds pages; profile remains active": "ZRAM stále drží stránky; profil zostáva aktívny",
|
||||
"ZRAM swap profile": "Profil swapu ZRAM",
|
||||
"ZRAM swap profile enabled": "Swap profil ZRAM povolený",
|
||||
"ZRAM swap profile removed": "Výmenný profil ZRAM bol odstránený",
|
||||
"ZSTD - fast, good ratio (recommended)": "ZSTD - rýchly, dobrý pomer (odporúčané)",
|
||||
@@ -7810,6 +7836,7 @@
|
||||
"chmod 1777 + setfacl o::rwx (applied on the NFS share from this host)": "chmod 1777 + setfacl o::rwx (použité na NFS zdieľaní z tohto hosta)",
|
||||
"chmod failed — NFS server may be restricting changes from root": "chmod zlyhal - NFS server môže obmedzovať zmeny od roota",
|
||||
"chown/chmod failed — likely unprivileged CT against host bind mount. Falling back to ACL.": "chown/chmod zlyhalo - pravdepodobne ide o neprivilegovaný CT nad bind mountom z hosta. Skúšam náhradné riešenie cez ACL.",
|
||||
"clone source mount tree": "klonovanie zdrojového stromu pripojenia",
|
||||
"containers": "kontajnery",
|
||||
"containers of": "nádob",
|
||||
"content:": "obsah:",
|
||||
@@ -7849,6 +7876,7 @@
|
||||
"driver:": "ovládač:",
|
||||
"e.g.": "napr.",
|
||||
"empty = generate": "prázdne = generovať",
|
||||
"enter container namespace": "vstup do menného priestoru kontajnera",
|
||||
"exFAT (portable: Windows/Linux/macOS)": "exFAT (prenosné: Windows/Linux/macOS)",
|
||||
"exFAT tools installed successfully.": "Nástroje exFAT boli úspešne nainštalované.",
|
||||
"experimental on this GPU": "experimentálne na tomto GPU",
|
||||
@@ -7928,6 +7956,7 @@
|
||||
"individual template is blocked": "individuálna šablóna je zablokovaná",
|
||||
"initramfs updated": "initramfs aktualizovaný",
|
||||
"initramfs updated.": "initramfs bol aktualizovaný.",
|
||||
"installable": "možno nainštalovať",
|
||||
"installed": "nainštalovaný",
|
||||
"installed but command not immediately available": "nainštalované, ale príkaz nie je hneď dostupný",
|
||||
"installed correctly and available": "nainštalované správne a dostupné",
|
||||
@@ -7970,9 +7999,12 @@
|
||||
"is one of the": "je jedným z",
|
||||
"is referenced in the following stopped VM(s)/CT(s):": "je uvedený v týchto zastavených VM/CT:",
|
||||
"it asks for the network of the host; the container gets its own address instead": "žiada o sieť hostiteľa; kontajner dostane namiesto toho svoju vlastnú adresu",
|
||||
"it asks for two different memory limits": "požaduje dva rozdielne limity pamäte",
|
||||
"it could not be started:": "nedalo sa spustiť:",
|
||||
"it describes more than one image": "definuje viac než jeden obraz",
|
||||
"it is locked or its disk could not be mounted": "je uzamknutý alebo sa nepodarilo pripojiť jeho disk",
|
||||
"it publishes no other architecture": "nepublikuje žiadnu inú architektúru",
|
||||
"it uses Compose configs, which have no equivalent here": "používa nastavenia Compose configs, ktoré tu nemajú ekvivalent",
|
||||
"it uses an NVIDIA GPU and this host has no working NVIDIA driver and Container Toolkit. Install them and run the recovery again.": "používa GPU NVIDIA, ale na tomto serveri nie je funkčný ovládač NVIDIA alebo NVIDIA Container Toolkit. Nainštalujte chýbajúce súčasti a registráciu zopakujte.",
|
||||
"it uses the Compose option": "používa možnosť Compose",
|
||||
"its NVIDIA runtime could not be rebuilt for the driver of this host; the container will not start until it is installed again:": "jeho runtime NVIDIA nebolo možné prebudovať pre ovládač tohto hostiteľa; kontajner sa nespustí, kým nebude znova nainštalovaný:",
|
||||
@@ -7980,10 +8012,13 @@
|
||||
"its Rclone mount is published by a host script that a backup does not include. Enable the mount again from the Rclone entry of the catalog before starting it.": "jeho pripojenie Rclone je publikované hostiteľským skriptom, ktorý záloha neobsahuje. Pred spustením znova povoľte pripojenie v položke Rclone v katalógu.",
|
||||
"its backup was made before ProxMenux kept the record inside the container": "jeho záloha bola vykonaná skôr, ako ProxMenux uchovával záznam v kontajneri",
|
||||
"its configuration could not be read": "nepodarilo sa prečítať jeho konfiguráciu",
|
||||
"its health check is not written in a way that can be read": "kontrola stavu je zadaná v nepodporovanom formáte",
|
||||
"its hookscript does not exist on this host:": "jeho hookscript na tomto hostiteľovi neexistuje:",
|
||||
"its host firewall rule was not added:": "pravidlo brány firewall hostiteľa nebolo pridané:",
|
||||
"its include exists with other content:": "jeho include existuje s iným obsahom:",
|
||||
"its record is missing and it was not found among the restored containers": "jeho záznam chýba a medzi obnovenými kontajnermi sa nenašiel",
|
||||
"its shared memory size is not written in a way that can be read": "veľkosť zdieľanej pamäte je zadaná v nepodporovanom formáte",
|
||||
"its stop timeout is not written in a way that can be read": "časový limit zastavenia je zadaný v nepodporovanom formáte",
|
||||
"journald MaxLevelStore is adequate for auth logging": "journald MaxLevelStore je vhodný na logovanie prihlásení",
|
||||
"journald drop-in created: /etc/systemd/journald.conf.d/proxmenux-loglevel.conf": "journald drop-in vytvorený: /etc/systemd/journald.conf.d/proxmenux-loglevel.conf",
|
||||
"journald log level restored": "Úroveň logovania journald bola obnovená",
|
||||
@@ -7999,6 +8034,7 @@
|
||||
"keep-weekly (0 disables)": "keep-weekly (0 vypne)",
|
||||
"keep-yearly (0 disables)": "keep-yearly (0 vypne)",
|
||||
"keeping current settings": "ponechávam aktuálne nastavenie",
|
||||
"kernel": "jadro",
|
||||
"kexec configured successfully. Use the command: reboot-quick": "kexec je úspešne nastavený. Použite príkaz: reboot-quick",
|
||||
"kexec-pve service enabled": "Služba kexec-pve je zapnutá",
|
||||
"kexec-pve service file created": "Súbor služby kexec-pve bol vytvorený",
|
||||
@@ -8011,12 +8047,15 @@
|
||||
"layers": "vrstvy",
|
||||
"legacy .link file(s) to the ProxMenux-managed format": "staršie .link súbory do formátu spravovaného ProxMenuxom",
|
||||
"log2ram completely removed from system": "log2ram bol úplne odstránený zo systému",
|
||||
"main container of the application": "hlavný kontajner aplikácie",
|
||||
"make cloned mount tree read-only": "nastavenie klonovaného stromu pripojenia iba na čítanie",
|
||||
"manually inside the container before starting it.": "ručne vo vnútri kontajnera pred jeho spustením.",
|
||||
"manually inside the container.": "ručne vo vnútri kontajnera.",
|
||||
"maximum performance": "maximálny výkon",
|
||||
"may be closed — trying discovery anyway...": "môže byť zatvorený - aj tak skúšam vyhľadanie...",
|
||||
"melonDS aims at providing fast and accurate Nintendo DS emulation.": "melonDS sa zameriava na rýchlu a presnú emuláciu Nintendo DS.",
|
||||
"members:": "členovia:",
|
||||
"memory": "pamäť",
|
||||
"minimum": "minimum",
|
||||
"missing": "chýba",
|
||||
"mkfs.btrfs not found. Install btrfs-progs and retry.": "mkfs.btrfs sa nenašiel. Nainštalujte btrfs-progs a skúste to znova.",
|
||||
@@ -8031,6 +8070,7 @@
|
||||
"needs a relaxed AppArmor or seccomp profile": "potrebuje uvoľnený AppArmor alebo seccomp profil",
|
||||
"needs stack review": "vyžaduje kontrolu zostavy",
|
||||
"needs these external services, already running:": "vyžaduje tieto už spustené externé služby:",
|
||||
"network": "sieť",
|
||||
"never": "nikdy",
|
||||
"next free": "ďalšie voľné",
|
||||
"next free block": "ďalší voľný blok",
|
||||
@@ -8041,11 +8081,14 @@
|
||||
"no declarative value": "žiadna deklaratívna hodnota",
|
||||
"no passphrase": "bez frázy",
|
||||
"no password": "bez hesla",
|
||||
"no-subscription repository": "repozitár no-subscription",
|
||||
"no_root_squash": "no_root_squash",
|
||||
"non-ProxMenux .tar archive(s) in this path": "non-ProxMenux .tar archívy v tejto ceste",
|
||||
"none": "žiadne",
|
||||
"not available yet": "zatiaľ nedostupné",
|
||||
"not changed": "bez zmeny",
|
||||
"not found.": "sa nenašiel.",
|
||||
"not generated": "nevygenerované",
|
||||
"not installed": "nie je nainštalované",
|
||||
"not reliable on this hardware due to the following limitations": "na tomto hardvéri nespoľahlivý pre tieto obmedzenia",
|
||||
"nouveau driver has been blacklisted.": "Ovládač nouveau bol pridaný na blacklist.",
|
||||
@@ -8085,6 +8128,7 @@
|
||||
"pct exec authorization failed": "Autorizácia cez pct exec zlyhala",
|
||||
"pct push failed. Check log:": "pct push zlyhalo. Skontrolujte záznam:",
|
||||
"pending (reboot required to enumerate full group)": "čaká (na zistenie celej skupiny je potrebný reštart)",
|
||||
"pending adaptation": "čaká na prispôsobenie",
|
||||
"phpMyAdmin is installed with arbitrary server connections enabled: the login page has a Server field where the address of the MySQL or MariaDB server is entered, together with its user and password.": "phpMyAdmin je nainštalovaný so zapnutými ľubovoľnými pripojeniami servera: prihlasovacia stránka má pole servera, kde je zadaná adresa servera MySQL alebo MariaDB, spolu s jeho užívateľom a heslom.",
|
||||
"pigz configuration completed": "Nastavenie pigz je dokončené",
|
||||
"pigz enabled in vzdump configuration": "pigz je zapnutý v nastavení vzdump",
|
||||
@@ -8094,10 +8138,12 @@
|
||||
"playit.gg has to claim this agent before it forwards anything. The agent prints a one-time claim link on the container console and keeps it there until the link is opened.": "Pred presmerovaním prevádzky treba agenta priradiť k účtu playit.gg. Agent vypíše na konzole kontajnera jednorazový odkaz na priradenie a ponechá ho tam, kým ho neotvoríte.",
|
||||
"port": "port",
|
||||
"portmapper/rpcbind has been disabled": "portmapper/rpcbind bol zakázaný",
|
||||
"print the estimated unpacked size in MiB (0 when unknown)": "vypísať odhad rozbalenej veľkosti v MiB (0, ak nie je známa)",
|
||||
"private network assigned automatically": "súkromná sieť pridelená automaticky",
|
||||
"privileged LXC": "privilegovaný kontajner LXC",
|
||||
"proxmox-backup-client reported:": "proxmox-backup-client oznámil:",
|
||||
"proxmox-boot-tool refreshed": "proxmox-boot-tool bol obnovený",
|
||||
"publish mount tree": "sprístupnenie stromu pripojenia",
|
||||
"pve-enterprise.list update skipped (no change)": "Aktualizácia pve-enterprise.list preskočená (bez zmeny)",
|
||||
"pve-enterprise.list updated to Trixie": "pve-enterprise.list aktualizovaný na Trixie",
|
||||
"pvecm command not found (might not be in a cluster)": "Príkaz pvecm sa nenašiel (možno nejde o klaster)",
|
||||
@@ -8110,6 +8156,7 @@
|
||||
"pvesm status failed": "pvesm status zlyhal",
|
||||
"pyLoad is a Free and Open Source download manager written in Python and designed to be extremely lightweight, easily extensible and fully manageable via web.": "pyLoad je bezplatný správca sťahovania s otvoreným zdrojovým kódom napísaný v Pythone. Je nenáročný, ľahko rozšíriteľný a plne ovládateľný cez web.",
|
||||
"pyLoad web interface": "webové rozhranie pyLoad",
|
||||
"python3-jsonschema is required to generate templates": "Na generovanie šablón je potrebný python3-jsonschema",
|
||||
"qBittorrent WebUI password (user: admin)": "qBittorrent WebUI heslo (používateľ: admin)",
|
||||
"qBittorrent already has a configuration; it is not overwritten": "qBittorrent už má konfiguráciu; nie je prepísaná",
|
||||
"qBittorrent configured": "Konfigurovaný qBittorrent",
|
||||
@@ -8131,12 +8178,14 @@
|
||||
"removed from Proxmox": "odstránené z Proxmoxu",
|
||||
"removed in Proxmox": "odstránené v Proxmox",
|
||||
"removed successfully from Proxmox.": "úspešne odstránené z Proxmoxu.",
|
||||
"removed unused image archive: {path} ({size} bytes)": "odstránený nepoužívaný archív obrazu: {path} ({size} bajtov)",
|
||||
"requires running the official installer, which restarts the Monitor service.": "vyžaduje spustenie oficiálneho inštalátora, ktorý reštartuje službu Monitor.",
|
||||
"requires the package": "vyžaduje balík",
|
||||
"restarted successfully": "úspešne reštartované",
|
||||
"restoring /etc/network would lose connectivity": "obnova /etc/network by prerušila pripojenie",
|
||||
"restoring on:": "obnova na:",
|
||||
"retries": "pokusov",
|
||||
"return to host namespace": "návrat do menného priestoru hostiteľa",
|
||||
"reverse proxy": "reverse proxy",
|
||||
"root and real users only": "iba root a skutoční používatelia",
|
||||
"rpcbind could not be disabled completely": "rpcbind nebolo možné úplne vypnúť",
|
||||
@@ -8181,6 +8230,7 @@
|
||||
"storage yet.": "úložisko zatiaľ.",
|
||||
"storage(s) from backup exist on target": "úložiská zo zálohy existujú na cieli",
|
||||
"storage.": "úložisko.",
|
||||
"subscription banner": "upozornenie na predplatné",
|
||||
"successfully formatted with": "úspešne naformátované ako",
|
||||
"suggested:": "navrhované:",
|
||||
"switch_gpu_mode.sh was not found.": "switch_gpu_mode.sh sa nenašiel.",
|
||||
@@ -8189,18 +8239,22 @@
|
||||
"systemctl restart networking failed:": "systemctl restart networking zlyhal:",
|
||||
"systemd OnCalendar expression": "systemd OnCalendar výraz",
|
||||
"the API key was not generated on the first start": "kľúč API nebol vytvorený pri prvom štarte",
|
||||
"the GPU did not answer in time": "GPU neodpovedalo včas",
|
||||
"the NVIDIA hook of the container is not available:": "spúšťací skript NVIDIA pre tento kontajner nie je dostupný:",
|
||||
"the bridge does not exist on this host:": "sieťový most (bridge) na tomto hostiteľovi neexistuje:",
|
||||
"the bridge exists on this host with another network:": "sieťový most (bridge) existuje na tomto hostiteľovi s inou sieťou:",
|
||||
"the container has its own address, so the port Docker published on the host is not needed": "kontajner má vlastnú adresu, takže port zverejnený Dockerom na hostiteľovi nie je potrebný",
|
||||
"the containers do not agree on the private network of": "kontajnery sa nezhodujú na súkromnej sieti",
|
||||
"the device does not exist on this host:": "zariadenie na tomto hostiteľovi neexistuje:",
|
||||
"the devices it asks for are not written in a way that can be read": "požadované zariadenia sú zadané v nepodporovanom formáte",
|
||||
"the host directory does not exist:": "hostiteľský adresár neexistuje:",
|
||||
"the host firewall rule of the installation is not added again; allow its port in the firewall of this host if you use it.": "pravidlo hostiteľského firewallu inštalácie sa znova nepridá; povoľte jeho port vo firewalle tohto hostiteľa, ak ho používate.",
|
||||
"the memory limit it asks for cannot be applied": "požadovaný limit pamäte nemožno uplatniť",
|
||||
"the minimum number of characters is": "minimálny počet znakov je",
|
||||
"the qBittorrent schema is not available": "schéma qBittorrent nie je k dispozícii",
|
||||
"the record it carries does not belong to this container": "záznam, ktorý nesie, nepatrí do tohto kontajnera",
|
||||
"the record of the application does not list this container": "v zázname aplikácie nie je uvedený tento kontajner",
|
||||
"the resource limits it asks for cannot be applied": "požadované limity systémových prostriedkov nemožno uplatniť",
|
||||
"the settings of its include are not in the record:": "nastavenia jeho include nie sú v zázname:",
|
||||
"the start order of its dependencies is not in the record": "poradie spúšťania jeho závislostí nie je v zázname",
|
||||
"this configuration needs the device": "táto konfigurácia potrebuje zariadenie",
|
||||
@@ -8253,6 +8307,7 @@
|
||||
"with Proxmox": "s Proxmox",
|
||||
"with prefix, e.g.": "s predponou, napr.",
|
||||
"with the password you provided.": "s heslom, ktoré ste zadali.",
|
||||
"x-casaos.main must name an existing service.": "Položka x-casaos.main musí odkazovať na existujúcu službu.",
|
||||
"xemu is a free and open-source application that emulates the original Microsoft Xbox game console, enabling people to play their original Xbox games on Windows, macOS, and Linux systems.": "xemu je bezplatná a open-source aplikácia, ktorá napodobňuje originálnu konzolu Microsoft Xbox, ktorá umožňuje ľuďom hrať svoje pôvodné Xbox hry na systémoch Windows, macOS a Linux.",
|
||||
"xfs — Proxmox dir storage (large files and VMs)": "xfs — Proxmox dir úložisko (veľké súbory a VM)",
|
||||
"xfs — better for large files": "xfs - lepšie pre veľké súbory",
|
||||
@@ -8268,6 +8323,7 @@
|
||||
"zfs-auto-snapshot removed (existing snapshots preserved on the pool)": "zfs-auto-snapshot bol odstránený (existujúce snapshoty v poole zostali zachované)",
|
||||
"zpool command not found. Install zfsutils-linux and retry.": "Príkaz zpool sa nenašiel. Nainštalujte zfsutils-linux a skúste to znova.",
|
||||
"zpool not found. Install zfsutils-linux and retry.": "zpool sa nenašiel. Nainštalujte zfsutils-linux a skúste to znova.",
|
||||
"{application} runs in {count} containers.": "Počet kontajnerov aplikácie {application}: {count}.",
|
||||
"{count} saved account.": "{count} uložený účet.",
|
||||
"{count} saved accounts.": "{count} uložené účty.",
|
||||
"{path} already exists and belongs to another user of the host, so {app} cannot write to it.\n\nProxMenux can add access rules (ACL) to this directory, to everything inside it and to what is created there later, for the users and groups {app} runs as on the host (IDs {ids}). The owner of the files does not change and whatever reads or writes them today keeps doing so; the owner of the directory also gets access to the files {app} creates.\n\nGrant {app} access to this directory?": "{path} už existuje a patrí inému používateľovi hostiteľa, takže {app} naň nemôže zapisovať.\n\nProxMenux môže pridať pravidlá prístupu (ACL) k tomuto adresáru, ku všetkému v ňom a k tomu, čo sa v ňom vytvorí neskôr, pre používateľov a skupiny, pod ktorými {app} beží na hostiteľovi (ID {ids}). Vlastník súborov sa nemení a čokoľvek, čo ich dnes číta alebo zapisuje, to robí aj naďalej; vlastník adresára tiež získa prístup k súborom, ktoré {app} vytvorí.\n\nUdeliť {app} prístup k tomuto adresáru?",
|
||||
@@ -8309,31 +8365,5 @@
|
||||
"⚠ Disk data will NOT be erased.": "⚠ Dáta na disku sa NEVYMAŽÚ.",
|
||||
"⚠ Disk will be unmounted and removed from /etc/fstab.": "⚠ Disk sa odpojí a odstráni z /etc/fstab.",
|
||||
"⚠ The /etc/fstab entry will be removed.": "⚠ Záznam v /etc/fstab bude odstránený.",
|
||||
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený.",
|
||||
"it describes more than one image": "definuje viac než jeden obraz",
|
||||
"it uses Compose configs, which have no equivalent here": "používa nastavenia Compose configs, ktoré tu nemajú ekvivalent",
|
||||
"the devices it asks for are not written in a way that can be read": "požadované zariadenia sú zadané v nepodporovanom formáte",
|
||||
"its health check is not written in a way that can be read": "kontrola stavu je zadaná v nepodporovanom formáte",
|
||||
"its stop timeout is not written in a way that can be read": "časový limit zastavenia je zadaný v nepodporovanom formáte",
|
||||
"its shared memory size is not written in a way that can be read": "veľkosť zdieľanej pamäte je zadaná v nepodporovanom formáte",
|
||||
"the resource limits it asks for cannot be applied": "požadované limity systémových prostriedkov nemožno uplatniť",
|
||||
"the memory limit it asks for cannot be applied": "požadovaný limit pamäte nemožno uplatniť",
|
||||
"it asks for two different memory limits": "požaduje dva rozdielne limity pamäte",
|
||||
"Compose environment must be a list or a mapping.": "Položka environment v súbore Compose musí byť zoznam alebo mapa kľúčov a hodnôt.",
|
||||
"Compose volumes must be a list.": "Položka volumes v súbore Compose musí byť zoznam.",
|
||||
"Compose ports must be a list.": "Položka ports v súbore Compose musí byť zoznam.",
|
||||
"The Compose file has no services.": "Súbor Compose neobsahuje žiadne služby.",
|
||||
"The Compose document must be a mapping.": "Dokument Compose musí byť mapa kľúčov a hodnôt.",
|
||||
"The Compose file has no x-casaos metadata.": "Súbor Compose neobsahuje metadáta x-casaos.",
|
||||
"x-casaos.main must name an existing service.": "Položka x-casaos.main musí odkazovať na existujúcu službu.",
|
||||
"The main Compose service has no image.": "Hlavná služba v súbore Compose nemá zadaný žiadny obraz.",
|
||||
"Invalid Compose volume path:": "Neplatná cesta dátového úložiska v súbore Compose:",
|
||||
"Invalid Compose port:": "Neplatný port v súbore Compose:",
|
||||
"Invalid application identifier:": "Neplatný identifikátor aplikácie:",
|
||||
"No template is available for '{app_id}'": "Pre aplikáciu '{app_id}' nie je dostupná šablóna",
|
||||
"The application '{app_id}' is not in the index": "Aplikácia '{app_id}' nie je v indexe",
|
||||
"The generated template does not match the schema:": "Vygenerovaná šablóna nezodpovedá schéme:",
|
||||
"If a newer application image is available, the container is stopped and backed up, and the backup integrity is verified. The container is then updated using the new image.\n\nIf the update fails after the container was changed, ProxMenux attempts to restore the previous installation from the backup. If the operation remains incomplete, select the application in OCI management and choose \"Recover the previous installation\".\n\nHost directories mounted in the container are not restored by the container backup.": "Ak je dostupný novší obraz aplikácie, kontajner sa zastaví, vytvorí sa jeho záloha a overí sa jej integrita. Potom sa kontajner aktualizuje pomocou nového obrazu.\n\nAk aktualizácia po zmene kontajnera zlyhá, ProxMenux sa pokúsi obnoviť predchádzajúcu inštaláciu zo zálohy. Ak operácia zostane nedokončená, v správe OCI vyberte danú aplikáciu a možnosť „Obnoviť predchádzajúcu inštaláciu“.\n\nPripojené adresáre hostiteľa sa zo zálohy kontajnera neobnovujú.",
|
||||
"{application} runs in {count} containers.": "Počet kontajnerov aplikácie {application}: {count}.",
|
||||
"CT {vmid} is one of the {count} containers of {application}.": "CT {vmid} patrí do aplikácie {application} (počet kontajnerov: {count})."
|
||||
"⚠ The disk will be unmounted.": "⚠ Disk bude odpojený."
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ EOFAPT
|
||||
|
||||
# Verify APT hook syntax
|
||||
apt-config dump >/dev/null 2>&1 || {
|
||||
msg_warn "APT hook syntax issue, removing..."
|
||||
msg_warn "$(translate "APT hook syntax issue, removing...")"
|
||||
rm -f "$APT_HOOK"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ repository API responses: they can contain subscription keys or credentials.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import string
|
||||
import sys
|
||||
|
||||
ENDPOINT = '/nodes/localhost/apt/repositories'
|
||||
@@ -26,6 +28,41 @@ PRODUCTS = {
|
||||
'test': {'pbs-no-subscription', 'pbs-test', 'pbstest'},
|
||||
'ui': 'Administration > Repositories', 'ceph': False},
|
||||
}
|
||||
BASE = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def N_(text):
|
||||
"""Mark a CLI message without changing the policy's English exception."""
|
||||
return text
|
||||
|
||||
|
||||
class PolicyError(ValueError):
|
||||
def __init__(self, template, **params):
|
||||
self.template = template
|
||||
self.params = params
|
||||
super().__init__(template.format(**params))
|
||||
|
||||
|
||||
def cli_error(error, product):
|
||||
"""Localize only PBS CLI diagnostics; policy callers retain English errors."""
|
||||
if product != 'pbs' or not isinstance(error, PolicyError):
|
||||
return str(error)
|
||||
try:
|
||||
language = json.loads((BASE / 'config.json').read_text()).get('language', 'en')
|
||||
if language not in ('sk', 'de', 'es', 'fr', 'it', 'pt', 'sv'):
|
||||
return str(error)
|
||||
catalog = json.loads((BASE / 'lang' / f'{language}.json').read_text())
|
||||
message = catalog.get(error.template)
|
||||
if isinstance(message, str) and message.strip():
|
||||
fields = lambda text: {name for _, name, _, _ in string.Formatter().parse(text) if name is not None}
|
||||
if fields(message) != fields(error.template) or any(
|
||||
spec or conversion for _, _, spec, conversion in string.Formatter().parse(message)
|
||||
):
|
||||
return str(error)
|
||||
return message.format(**error.params)
|
||||
except (OSError, ValueError, KeyError, IndexError, TypeError, AttributeError):
|
||||
pass
|
||||
return str(error)
|
||||
|
||||
|
||||
def run(args):
|
||||
@@ -43,7 +80,7 @@ def subscription_status(product='pve'):
|
||||
except (ValueError, AttributeError):
|
||||
status = None
|
||||
if status not in ('active', 'notfound', 'expired'):
|
||||
raise ValueError('Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.')
|
||||
raise PolicyError(N_('Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.'))
|
||||
return status
|
||||
# pvesubscription's CLI get printer emits sorted "key: value" lines,
|
||||
# including a secret key and server ID. Only parse the exact status line.
|
||||
@@ -110,12 +147,12 @@ def repository_state(suite, product='pve'):
|
||||
entries.append((file['path'], index, row))
|
||||
return data, entries
|
||||
except (KeyError, TypeError, ValueError, IndexError) as exc:
|
||||
raise ValueError(f"Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {p['ui']}; no repository changed.") from exc
|
||||
raise PolicyError(N_('Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {ui}; no repository changed.'), ui=p['ui']) from exc
|
||||
|
||||
|
||||
def evaluate(suite, apply=False, product='pve'):
|
||||
if suite not in ('bookworm', 'trixie'):
|
||||
raise ValueError('Unsupported Proxmox suite; no repository changed.')
|
||||
raise PolicyError(N_('Unsupported Proxmox suite; no repository changed.'))
|
||||
p = PRODUCTS[product]
|
||||
data, entries = repository_state(suite, product)
|
||||
pve = []
|
||||
@@ -139,8 +176,8 @@ def evaluate(suite, apply=False, product='pve'):
|
||||
ceph.append((path, index, row))
|
||||
# Name the stale sources, never their URIs: those can carry credentials.
|
||||
if wrong_pve:
|
||||
raise ValueError(f"This host runs {p['name']} on {suite}, but these {p['name']} repositories are for another suite: "
|
||||
f"{'; '.join(wrong_pve)}. Disable or remove them in {p['ui']} and run the update again; no repository changed.")
|
||||
raise PolicyError(N_('This host runs {name} on {suite}, but these {name} repositories are for another suite: {details}. Disable or remove them in {ui} and run the update again; no repository changed.'),
|
||||
name=p['name'], suite=suite, details='; '.join(wrong_pve), ui=p['ui'])
|
||||
if wrong_ceph:
|
||||
raise ValueError(f"This host runs Proxmox VE on {suite}, but these Enterprise Ceph repositories are for another suite: "
|
||||
f"{'; '.join(wrong_ceph)}. Correct them in Node > Updates > Repositories before switching; no repository changed.")
|
||||
@@ -152,20 +189,20 @@ def evaluate(suite, apply=False, product='pve'):
|
||||
status = subscription_status(product) # fail closed before any write
|
||||
if status == 'active':
|
||||
if not pve:
|
||||
raise ValueError(f"Host has an active subscription but no active {p['short']} repository. Configure its Enterprise source in {p['ui']}; no repository changed.")
|
||||
raise PolicyError(N_('Host has an active subscription but no active {short} repository. Configure its Enterprise source in {ui}; no repository changed.'), short=p['short'], ui=p['ui'])
|
||||
return 'preserve'
|
||||
if not debian:
|
||||
raise ValueError('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.')
|
||||
raise PolicyError(N_('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.'))
|
||||
# No active product source or only inaccessible Enterprise. A mixed stanza
|
||||
# cannot be disabled without also disabling an unrelated component.
|
||||
disable = [item for item in pve if p['enterprise'] in item[2]['Components']] + ceph
|
||||
for _, _, row in disable:
|
||||
expected = {p['enterprise']} if p['enterprise'] in row['Components'] else {'enterprise'}
|
||||
if set(row['Components']) != expected:
|
||||
raise ValueError('Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.')
|
||||
raise PolicyError(N_('Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.'))
|
||||
handles = [r.get('handle') for r in data['standard-repos'] if r.get('handle') == 'no-subscription']
|
||||
if len(handles) != 1:
|
||||
raise ValueError('Proxmox no-subscription standard repository handle unavailable; no repository changed.')
|
||||
raise PolicyError(N_('Proxmox no-subscription standard repository handle unavailable; no repository changed.'))
|
||||
if not apply:
|
||||
return 'offer'
|
||||
# Adopt a refreshed digest only after checking the complete expected
|
||||
@@ -192,11 +229,12 @@ def evaluate(suite, apply=False, product='pve'):
|
||||
for path, index, _ in disable for p, i, row in current):
|
||||
raise ValueError('Could not verify the switched repositories; inspect the Proxmox repository UI before retrying.')
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {p['ui']} before retrying; no automatic rollback was attempted.") from exc
|
||||
raise PolicyError(N_('Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {ui} before retrying; no automatic rollback was attempted.'), ui=p['ui']) from exc
|
||||
return 'changed'
|
||||
|
||||
|
||||
def main():
|
||||
product = 'pve'
|
||||
try:
|
||||
if len(sys.argv) not in (3, 4) or sys.argv[1] not in ('plan', 'apply') \
|
||||
or (len(sys.argv) == 4 and sys.argv[3] not in PRODUCTS):
|
||||
@@ -204,7 +242,7 @@ def main():
|
||||
product = sys.argv[3] if len(sys.argv) == 4 else 'pve'
|
||||
print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply', product=product))
|
||||
except ValueError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
print(cli_error(exc, product), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ update_pbs() {
|
||||
local available_space
|
||||
available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}')
|
||||
if [ "$available_space" -lt 1024 ]; then
|
||||
msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")"
|
||||
msg_error "$(translate "Insufficient disk space. Available:") ${available_space}MB"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -149,7 +149,7 @@ update_pbs() {
|
||||
rm -f "$screen_capture"
|
||||
|
||||
if [ $upgrade_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
msg_error "$(translate "System upgrade failed. Check log:") $log_file"
|
||||
echo "Finished: $(date -Iseconds) — upgrade failed (exit $upgrade_exit_code)" >> "$log_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -63,13 +63,13 @@ confirm_automated_script() {
|
||||
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server"):\n\n"
|
||||
script_info+="• $(translate "Upgrade the system, offering the") \Z4no-subscription repository\Z0 $(translate "when the server has no subscription (you will be asked)")\n"
|
||||
script_info+="• $(translate "Upgrade the system, offering the") \Z4$(translate "no-subscription repository")\Z0 $(translate "when the server has no subscription (you will be asked)")\n"
|
||||
else
|
||||
script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server"):\n\n"
|
||||
script_info+="• $(translate "Configure") \Z4free repositories\Z0 $(translate "and upgrade the system (disables the enterprise repo)")\n"
|
||||
fi
|
||||
script_info+="• $(translate "Optionally remove") \Z4subscription banner\Z0 $(translate "from Proxmox web interface (you will be asked)")\n"
|
||||
script_info+="• $(translate "Optimize") \Z4memory\Z0, \Z4kernel\Z0, $(translate "and") \Z4network\Z0 $(translate "for better performance and stability")\n"
|
||||
script_info+="• $(translate "Optionally remove") \Z4$(translate "subscription banner")\Z0 $(translate "from Proxmox web interface (you will be asked)")\n"
|
||||
script_info+="• $(translate "Optimize") \Z4$(translate "memory")\Z0, \Z4$(translate "kernel")\Z0, $(translate "and") \Z4$(translate "network")\Z0 $(translate "for better performance and stability")\n"
|
||||
script_info+="• $(translate "Install and configure") \Z4Log2RAM\Z0 $(translate "(only on SSD/NVMe) to protect your disk")\n"
|
||||
script_info+="• $(translate "Enable") \Z4ZFS autotrim\Z0 $(translate "on SSD/NVMe pools that support discard")\n"
|
||||
script_info+="• $(translate "Improve log rotation and limit log size to save space and extend disk life")\n"
|
||||
@@ -289,10 +289,10 @@ post_install_label() {
|
||||
echo "Proxmox VE Microcode"
|
||||
;;
|
||||
"Proxmox Backup Server Post Install")
|
||||
echo "Proxmox Backup Server Post Install"
|
||||
translate "Proxmox Backup Server Post Install"
|
||||
;;
|
||||
"Proxmox Backup Server Microcode")
|
||||
echo "Proxmox Backup Server Microcode"
|
||||
translate "Proxmox Backup Server Microcode"
|
||||
;;
|
||||
"Community Scripts")
|
||||
translate "Community Scripts"
|
||||
|
||||
@@ -182,9 +182,9 @@ remove_subscription_banner() {
|
||||
# description: Patch the Proxmox web UI to suppress the subscription dialog and register a successful patch.
|
||||
if [[ "$PMX_ON_PBS" == true ]]; then
|
||||
stop_spinner
|
||||
if ! whiptail --title "Proxmox Backup Server Subscription Banner Removal" \
|
||||
if ! whiptail --title "$(translate "Proxmox Backup Server Subscription Banner Removal")" \
|
||||
--yesno "$(translate "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?")\n\n$(translate "Attention: Removing the subscription banner may cause issues in the web interface after a future update.")\n\n$(translate "If this happens, you can restore the backup from the 'Subscription Banner Removal' option in 'Uninstall optimizations'.")\n\n$(translate "Are you sure you want to continue?")" 14 75; then
|
||||
msg_warn "Banner removal cancelled by user."
|
||||
msg_warn "$(translate "Banner removal cancelled by user.")"
|
||||
return 1
|
||||
fi
|
||||
if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then
|
||||
|
||||
@@ -2574,7 +2574,7 @@ configure_zram_swap() {
|
||||
fi
|
||||
version="$(dpkg-query -W -f='${Version}' systemd-zram-generator 2>/dev/null)"
|
||||
if [[ -z "$version" ]]; then
|
||||
failure="Cannot determine installed systemd-zram-generator version"
|
||||
failure="$(translate "Cannot determine installed systemd-zram-generator version")"
|
||||
else
|
||||
if dpkg --compare-versions "$version" ge '1.2.0'; then
|
||||
supports_params=true
|
||||
@@ -2588,7 +2588,7 @@ configure_zram_swap() {
|
||||
msg_success "$(translate "ZRAM profile is already active")"
|
||||
return 0
|
||||
fi
|
||||
failure="Existing ZRAM profile is active but does not match its configuration"
|
||||
failure="$(translate "Existing ZRAM profile is active but does not match its configuration")"
|
||||
fi
|
||||
if [[ -z "$failure" && "$previous_active" == true ]]; then
|
||||
if ! swapoff /dev/zram0; then
|
||||
@@ -2597,31 +2597,31 @@ configure_zram_swap() {
|
||||
fi
|
||||
state_mutated=true
|
||||
if ! systemctl stop dev-zram0.swap >/dev/null 2>&1; then
|
||||
failure="Could not stop existing dev-zram0.swap"
|
||||
failure="$(translate "Could not stop existing dev-zram0.swap")"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$failure" ]]; then
|
||||
state_mutated=true
|
||||
if ! install -d -m 755 "$(dirname "$config")"; then
|
||||
failure="Could not create ZRAM generator directory"
|
||||
failure="$(translate "Could not create ZRAM generator directory")"
|
||||
elif ! printf '%s\n' "$desired" | pmx_write_file "$config"; then
|
||||
failure="Could not write ZRAM generator configuration"
|
||||
failure="$(translate "Could not write ZRAM generator configuration")"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$failure" ]]; then
|
||||
old_umask="$(umask)"
|
||||
umask 077
|
||||
if ! printf 'installed_by_proxmenux=%s\n' "$package_owned" | pmx_write_file "$marker"; then
|
||||
failure="Could not write ZRAM package ownership state"
|
||||
failure="$(translate "Could not write ZRAM package ownership state")"
|
||||
fi
|
||||
umask "$old_umask"
|
||||
[[ -f "$marker" ]] && chmod 600 "$marker"
|
||||
fi
|
||||
if [[ -z "$failure" ]]; then
|
||||
if ! systemctl daemon-reload || ! systemctl start dev-zram0.swap; then
|
||||
failure="Could not activate dev-zram0.swap"
|
||||
failure="$(translate "Could not activate dev-zram0.swap")"
|
||||
elif ! zram_profile_verify_active "$algorithm"; then
|
||||
failure="Active ZRAM algorithm, capacity or swap sources did not match the profile"
|
||||
failure="$(translate "Active ZRAM algorithm, capacity or swap sources did not match the profile")"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$failure" ]]; then
|
||||
|
||||
@@ -1356,7 +1356,7 @@ show_uninstall_menu() {
|
||||
system_limits) desc="System Limits Increase";;
|
||||
entropy) desc="Entropy Generation (haveged)";;
|
||||
memory_settings) desc="Memory Settings Optimization";;
|
||||
zram_swap) desc="ZRAM swap profile";;
|
||||
zram_swap) desc="$(translate "ZRAM swap profile")";;
|
||||
kernel_panic) desc="Kernel Panic Configuration";;
|
||||
apt_ipv4) desc="APT IPv4 Force";;
|
||||
kexec) desc="kexec for quick reboots";;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Exercise the shipped error adapter and actual HostBackup handlers with a fake transport.
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
|
||||
const { ts, read, compile, translator } = require('./backup_i18n_seam.cjs');
|
||||
const library = { exports: {} };
|
||||
vm.runInNewContext(compile(read('lib/host-backup-messages.ts')), library);
|
||||
const { hostBackupErrorText } = library.exports;
|
||||
// Independently read the real API's literal/f-string contracts without importing
|
||||
// Flask or running any route, host probe, script or persistence operation.
|
||||
const contracts = JSON.parse(require('node:child_process').execFileSync('python3', ['-I', '-B', '-c', `
|
||||
import ast,json,sys
|
||||
tree=ast.parse(open(sys.argv[1]).read())
|
||||
def messages(node):
|
||||
if isinstance(node,ast.Constant) and isinstance(node.value,str): return [node.value]
|
||||
if isinstance(node,ast.JoinedStr): return [''.join(part.value if isinstance(part,ast.Constant) else 'PARAMETER' for part in node.values)]
|
||||
if isinstance(node,ast.BoolOp): return [text for child in node.values for text in messages(child)]
|
||||
return []
|
||||
result=set()
|
||||
for function in tree.body:
|
||||
if isinstance(function,ast.FunctionDef) and function.name.startswith(('api_host_backups_','api_host_restore')):
|
||||
for node in ast.walk(function):
|
||||
if isinstance(node,ast.Dict):
|
||||
for key,value in zip(node.keys,node.values):
|
||||
if isinstance(key,ast.Constant) and key.value=='error': result.update(messages(value))
|
||||
print(json.dumps(sorted(result)))
|
||||
`, 'AppImage/scripts/flask_server.py'], { encoding: 'utf8' }));
|
||||
const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten)
|
||||
: typeof n === 'object' ? [n, ...flatten(n.props?.children)] : [n];
|
||||
const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
|
||||
function fixture(locale) {
|
||||
let cursor = 0, slots = [], tree, failure = 'path must be absolute', mutations = 0;
|
||||
const calls = [], t = translator(locale);
|
||||
const jsx = (type, props) => typeof type === 'function' ? type(props) : { type, props };
|
||||
const hooks = { useState(initial) { const i = cursor++; if (!(i in slots)) slots[i] = initial; return [slots[i], value => { slots[i] = value; }]; } };
|
||||
const requireModule = name => {
|
||||
if (name === 'react') return hooks;
|
||||
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx };
|
||||
if (name === 'swr') return { default: () => ({ data: { paths: [{ path: '/kept' }] }, mutate: () => { mutations++; } }) };
|
||||
if (name.includes('i18n/provider')) return { useT: () => t };
|
||||
if (name.endsWith('host-backup-messages')) return library.exports;
|
||||
if (name.endsWith('api-config')) return { fetchApi: async (url, options) => { calls.push({ url, ...options }); if (failure) throw new Error(failure); return { status: 'ok' }; } };
|
||||
return new Proxy({}, { get: (_, key) => key });
|
||||
};
|
||||
const source = read('components/host-backup.tsx') + '\nexports.ExtraPathsSection = ExtraPathsSection;';
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX } }).outputText,
|
||||
{ exports: module.exports, require: requireModule, Error, console });
|
||||
return { calls, t, render() { cursor = 0; tree = module.exports.ExtraPathsSection(); return tree; }, text: () => text(tree),
|
||||
input: () => flatten(tree).find(n => n.type === 'Input'),
|
||||
add: () => flatten(tree).find(n => n.type === 'Button' && text(n).includes(t('backup.actions.addPath'))),
|
||||
fail: message => { failure = message; }, mutations: () => mutations };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
assert.ok(contracts.length > 100, 'real HostBackup error contracts discovered');
|
||||
for (const message of contracts) {
|
||||
const translated = hostBackupErrorText(message, translator('sk'));
|
||||
assert.notEqual(translated, message, 'application-owned message must be localized: ' + message);
|
||||
assert.ok(!translated.includes('backup.apiErrors.'), 'every mapped key has a real Slovak value');
|
||||
assert.equal(hostBackupErrorText(message, translator('missing')), hostBackupErrorText(message, translator('en')), 'missing locale uses English');
|
||||
}
|
||||
for (const locale of ['en', 'sk', 'missing']) {
|
||||
const t = translator(locale);
|
||||
assert.equal(hostBackupErrorText(new Error('path must be absolute'), t), t('backup.apiErrors.absolutePath'));
|
||||
assert.equal(hostBackupErrorText({ message: 'this path is already configured' }, t), t('backup.apiErrors.pathConfigured'));
|
||||
assert.equal(hostBackupErrorText('job already exists: test_1', t), t('backup.apiErrors.jobExists', { value: 'test_1' }));
|
||||
const raw = 'tool failed: <img src=x onerror=unsafe()> path must be absolute';
|
||||
assert.equal(hostBackupErrorText(raw, t), raw, 'external diagnostics must not be partially matched or discarded');
|
||||
const long = 'path does not exist on this host: ' + 'x'.repeat(20000);
|
||||
assert.equal(hostBackupErrorText(long, t), long, 'large diagnostics preserved without pattern matching');
|
||||
for (const unknown of ['constructor', '__proto__', 'toString', 'future backend message']) assert.equal(hostBackupErrorText(unknown, t), unknown);
|
||||
for (const invalid of [null, undefined, {}, 23]) assert.equal(hostBackupErrorText(invalid, t), t('backup.apiErrors.requestFailed'));
|
||||
const unsafe = '<img src=x onerror=unsafe()>{backup.actions.run}';
|
||||
assert.equal(hostBackupErrorText('path does not exist on this host: ' + unsafe, t), t('backup.apiErrors.pathMissing', { value: unsafe }), 'captured paths remain literal data');
|
||||
const f = fixture(locale); f.render();
|
||||
assert.equal(f.add().props.disabled, true);
|
||||
await f.add().props.onClick(); assert.equal(f.calls.length, 0, 'blank path causes no request');
|
||||
f.input().props.onChange({ target: { value: 'relative-path' } }); f.render(); await f.add().props.onClick(); f.render();
|
||||
assert.equal(f.calls.length, 1); assert.equal(f.calls[0].url, '/api/host-backups/extra-paths');
|
||||
assert.equal(f.calls[0].method, 'POST'); assert.deepEqual(JSON.parse(f.calls[0].body), { path: 'relative-path' });
|
||||
assert.equal(f.input().props.value, 'relative-path'); assert.equal(f.mutations(), 0, 'rejected input preserves state and does not refresh as success');
|
||||
assert.ok(f.text().includes(t('backup.apiErrors.absolutePath'))); assert.ok(f.text().includes('/kept'));
|
||||
f.fail('future backend message'); await f.add().props.onClick(); f.render(); assert.ok(f.text().includes('future backend message'));
|
||||
f.fail(null); await f.add().props.onClick(); f.render(); assert.equal(f.input().props.value, ''); assert.equal(f.mutations(), 1);
|
||||
}
|
||||
// Adapter has no effect on the server's CLI/API/persistence contracts.
|
||||
const host = read('components/host-backup.tsx');
|
||||
const ast = ts.createSourceFile('host-backup.tsx', host, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX), encryptionCatches = [];
|
||||
function visit(node) { if (ts.isCatchClause(node) && node.block.getText(ast).includes('err.body?.tool_output')) encryptionCatches.push(node.block); ts.forEachChild(node, visit); }
|
||||
visit(ast); assert.equal(encryptionCatches.length, 2, 'scheduled and manual backup encryption paths both checked');
|
||||
for (const locale of ['en', 'sk', 'missing']) for (const block of encryptionCatches) {
|
||||
let displayed, effects = 0;
|
||||
const code = ts.transpileModule('function run(e) '+block.getText(ast)+'; run(error);', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
const t = translator(locale), error = Object.assign(new Error('failed to create PBS encryption keyfile'), { body: { tool_output: 'EXTERNAL TOOL DIAGNOSTIC' } });
|
||||
vm.runInNewContext(code, { error, t, hostBackupErrorText, setError: value => { displayed = value; }, setPbsImportBusy: () => {}, setSubmitting: () => {}, onCreated: () => effects++ });
|
||||
assert.ok(displayed.includes(hostBackupErrorText(error, t)) && displayed.includes('EXTERNAL TOOL DIAGNOSTIC'));
|
||||
assert.ok(!displayed.includes('{error}') && !displayed.includes('{detail}'), 'error interpolation must not leave an unresolved placeholder');
|
||||
assert.equal(effects, 0);
|
||||
}
|
||||
assert.ok(!host.includes('e instanceof Error ? e.message : String(e)'), 'all HostBackup catch consumers use the adapter');
|
||||
assert.ok(!host.includes('{e.error}') && !host.includes('{calendarPreview.error}'), 'structured error consumers use the adapter too');
|
||||
console.log('PASS: '+contracts.length+' real API contracts, HostBackup actual extra-path handlers, EN/SK/fallback, 0 calls for blank, rejected input/state, success, literal parameters and unknown diagnostics');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,133 @@
|
||||
// Run the shipped translation adapter, transport and both dialog components.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
||||
}}).outputText;
|
||||
const translations = Object.fromEntries(['en', 'sk'].map(locale => [locale, JSON.parse(fs.readFileSync(`AppImage/messages/${locale}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], translations[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (match, key) => params[key] ?? match);
|
||||
};
|
||||
const adapter = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/monitor-link-errors.ts', 'utf8')), adapter);
|
||||
const {monitorLinkError} = adapter.exports;
|
||||
const codes = {
|
||||
monitor_link_address_required: 'addressRequired', monitor_link_invalid_address: 'invalidAddress',
|
||||
monitor_link_address_credentials: 'addressCredentials', monitor_link_invalid_port: 'invalidPort',
|
||||
monitor_link_invalid_request: 'invalidRequest', monitor_link_duplicate: 'duplicate',
|
||||
monitor_link_not_found: 'notFound', monitor_link_detected_remove: 'detectedRemove',
|
||||
};
|
||||
const failure = (code, params) => Object.assign(new Error('RAW ENGLISH SECRET DETAILS'), {body: {error: 'RAW ENGLISH SECRET DETAILS', code, params}});
|
||||
|
||||
const flatten = tree => !tree || typeof tree === 'boolean' ? [] : Array.isArray(tree) ? tree.flatMap(flatten)
|
||||
: typeof tree === 'object' ? [tree, ...flatten(tree.props?.children)] : [tree];
|
||||
const text = tree => flatten(tree).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
const jsx = (type, props) => ({type, props});
|
||||
const source = compile(fs.readFileSync('AppImage/components/monitor-switcher.tsx', 'utf8') + '\nexport {ManageLinksDialog};');
|
||||
|
||||
function fixture({locale = 'sk', links = [], origin = false, loadError = false, loading = false} = {}) {
|
||||
let slots = [], cursor = 0, effects = [], tree, responseError, refreshFailure = false;
|
||||
const calls = [], refreshes = [], storage = new Map();
|
||||
const candidates = [{u: 'https://other.test', n: 'Other', p: 'pve'}];
|
||||
const carried = Buffer.from(JSON.stringify(candidates)).toString('base64url');
|
||||
const location = {origin: 'https://current.test', hostname: 'current.test', pathname: '/', hash: '',
|
||||
search: origin ? `?pmx_from=https%3A%2F%2Fsource.test&pmx_name=Source&pmx_links=${carried}` : ''};
|
||||
const react = {
|
||||
useState(initial) {const slot = cursor++; if (!(slot in slots)) slots[slot] = typeof initial === 'function' ? initial() : initial;
|
||||
return [slots[slot], value => {slots[slot] = typeof value === 'function' ? value(slots[slot]) : value;}];},
|
||||
useEffect(fn, deps) {const slot = cursor++; const old = slots[slot];
|
||||
if (!old || deps.some((dep, i) => dep !== old[i])) effects.push(fn);
|
||||
slots[slot] = deps;
|
||||
},
|
||||
};
|
||||
const exported = {};
|
||||
vm.runInNewContext(source, {exports: exported, URL, URLSearchParams, escape, unescape,
|
||||
atob: value => Buffer.from(value, 'base64').toString('binary'), btoa: value => Buffer.from(value, 'binary').toString('base64'),
|
||||
window: {location, history: {replaceState() {location.search = ''}}},
|
||||
sessionStorage: {getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value)},
|
||||
require(name) {
|
||||
if (name === 'react') return react;
|
||||
if (name === 'react/jsx-runtime') return {jsx, jsxs: jsx, Fragment: 'Fragment'};
|
||||
if (name === 'swr') return {default: () => ({data: {self: {name: 'Current', product: 'pve'}, links}, error: loadError, isLoading: loading,
|
||||
mutate: async () => refreshes.push('all')}), mutate: async key => {refreshes.push(key); if (refreshFailure) throw new Error('RAW REFRESH ERROR');}};
|
||||
if (name.includes('i18n/provider')) return {useT: () => translate(locale)};
|
||||
if (name.includes('monitor-link-errors')) return {monitorLinkError};
|
||||
if (name.includes('api-config')) return {fetchApi: async (path, options) => {calls.push({path, ...options}); if (responseError) throw responseError; return {success: true};}};
|
||||
return new Proxy({}, {get: (_, key) => key});
|
||||
},
|
||||
});
|
||||
return {
|
||||
render(component = 'ManageLinksDialog') {cursor = 0; effects = []; tree = exported[component]({open: true, onOpenChange() {}, nodeName: 'Current'}); effects.forEach(fn => fn()); return tree;},
|
||||
button(label) {const node = flatten(tree).find(n => n.type === 'Button' && (text(n) === label || n.props['aria-label'] === label)); assert.ok(node, label); return node;},
|
||||
input(id) {const node = flatten(tree).find(n => n.type === 'Input' && (n.props.id === id || n.props['aria-label'] === id)); assert.ok(node, id); return node;},
|
||||
nodes: () => flatten(tree), text: () => text(tree), fail: error => {responseError = error;}, failRefresh: () => {refreshFailure = true;}, calls, refreshes, storage,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
for (const locale of ['en', 'sk']) {
|
||||
const t = translate(locale);
|
||||
for (const [code, key] of Object.entries(codes)) assert.equal(monitorLinkError(failure(code), t), t(`monitorLinks.errors.${key}`));
|
||||
assert.equal(monitorLinkError(failure('monitor_link_limit', {count: 64}), t), t('monitorLinks.errors.limit', {count: 64}));
|
||||
for (const error of [null, undefined, 'raw', new Error('raw'), {body: null}, {body: {}},
|
||||
...['constructor', '__proto__', 'toString', 'unknown', 'monitor_links_save_failed'].map(code => failure(code)),
|
||||
...[undefined, null, '64', 0, -1, 1.5, NaN, Infinity, 2 ** 54].map(count => failure('monitor_link_limit', {count}))]) {
|
||||
assert.equal(monitorLinkError(error, t), t('monitorLinks.saveFailed'));
|
||||
}
|
||||
const f = fixture({locale, links: [{key: 'manual:saved', name: 'Saved', url: 'https://saved.test', manual: true}]});
|
||||
f.render(); f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://unsaved.test'}}); f.render();
|
||||
f.button(t('monitorLinks.cancel')).props.onClick(); f.render();
|
||||
assert.equal(f.calls.length, 0, 'Cancel must not write');
|
||||
f.button(t('monitorLinks.changeAddress')).props.onClick(); f.render();
|
||||
assert.equal(f.input(t('monitorLinks.address')).props.value, 'https://saved.test', 'Cancel discards address draft');
|
||||
f.input(t('monitorLinks.address')).props.onChange({target: {value: 'https://changed.test'}}); f.render();
|
||||
await f.button(t('monitorLinks.save')).props.onClick(); f.render();
|
||||
assert.equal(f.calls[0].method, 'PATCH'); assert.deepEqual(JSON.parse(f.calls[0].body), {url: 'https://changed.test'});
|
||||
assert.equal(f.button(t('monitorLinks.add')).props.disabled, true, 'Blank address cannot be submitted');
|
||||
f.input('monitor-link-url').props.onChange({target: {value: 'ftp://bad.test'}}); f.render();
|
||||
f.fail(failure('monitor_link_invalid_address')); await f.button(t('monitorLinks.add')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.errors.invalidAddress')));
|
||||
assert.equal(f.input('monitor-link-url').props.value, 'ftp://bad.test', 'Failure retains draft');
|
||||
assert.ok(!f.text().includes('RAW ENGLISH'));
|
||||
f.fail(new Error('raw network details')); await f.button(t('monitorLinks.remove')).props.onClick(); f.render();
|
||||
assert.ok(f.text().includes(t('monitorLinks.saveFailed')));
|
||||
const failedRead = fixture({locale, loadError: new Error('raw')}); failedRead.render();
|
||||
assert.ok(failedRead.text().includes(t('monitorLinks.loadFailed')));
|
||||
const pendingRead = fixture({locale, loading: true}); pendingRead.render();
|
||||
assert.ok(pendingRead.text().includes(t('monitorLinks.checking')));
|
||||
|
||||
const declined = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) declined.render('MonitorSwitcher');
|
||||
declined.button(t('monitorLinks.notNow')).props.onClick(); declined.render('MonitorSwitcher');
|
||||
assert.equal(declined.calls.length, 0, 'Not now must not write');
|
||||
const carried = fixture({locale, origin: true});
|
||||
for (let n = 0; n < 3; n++) carried.render('MonitorSwitcher');
|
||||
const boxes = carried.nodes().filter(n => n.type === 'Checkbox'); assert.equal(boxes.length, 2);
|
||||
boxes[1].props.onCheckedChange(false); carried.render('MonitorSwitcher');
|
||||
carried.fail(failure('monitor_link_duplicate'));
|
||||
carried.failRefresh();
|
||||
await carried.button(t('monitorLinks.addSelected')).props.onClick(); carried.render('MonitorSwitcher');
|
||||
assert.equal(carried.calls.length, 1, 'Only selected origin is attempted');
|
||||
assert.equal(JSON.parse(carried.calls[0].body).url, 'https://source.test');
|
||||
assert.ok(carried.text().includes(t('monitorLinks.errors.duplicate')));
|
||||
assert.equal(carried.refreshes.length, 1, 'Failed/partially successful batch refreshes the visible list');
|
||||
}
|
||||
// Real authenticated transport preserves additive fields for the adapter.
|
||||
const api = {exports: {}, process: {env: {}}, fetch: async () => new Response(JSON.stringify({error: 'Original English error', code: 'monitor_link_duplicate'}), {status: 400, headers: {'Content-Type': 'application/json'}})};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/api-config.ts', 'utf8')), api);
|
||||
try {await api.exports.fetchApi('/api/monitor-links', {method: 'POST'}); assert.fail('expected rejection');}
|
||||
catch (error) {assert.equal(error.status, 400); assert.equal(error.message, 'Original English error'); assert.equal(monitorLinkError(error, translate('sk')), translations.sk.monitorLinks.errors.duplicate);}
|
||||
const en = translations.en.monitorLinks, sk = translations.sk.monitorLinks;
|
||||
assert.deepEqual(Object.keys(en.errors).sort(), Object.keys(sk.errors).sort());
|
||||
for (const key of Object.keys(en.errors)) {
|
||||
assert.ok(sk.errors[key]);
|
||||
assert.deepEqual(en.errors[key].match(/\{\w+\}/g), sk.errors[key].match(/\{\w+\}/g), key);
|
||||
}
|
||||
console.log('PASS: EN/SK known/unknown errors, real transport, both dialogs, Cancel/Not now zero writes, draft persistence, selected-only batch, localized loading/failure');
|
||||
}
|
||||
main().catch(error => {console.error(error); process.exitCode = 1;});
|
||||
@@ -0,0 +1,154 @@
|
||||
// Shipped PBS code with only transport, hooks and UI primitives replaced.
|
||||
const assert = require('node:assert/strict'), fs = require('node:fs'), vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const compile = source => ts.transpileModule(source, {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX}}).outputText;
|
||||
const catalogs = Object.fromEntries(['en','sk'].map(l => [l, JSON.parse(fs.readFileSync(`AppImage/messages/${l}/common.json`))]));
|
||||
const translate = locale => (key, params = {}) => {
|
||||
const value = key.split('.').reduce((tree, part) => tree?.[part], catalogs[locale]);
|
||||
assert.equal(typeof value, 'string', key);
|
||||
return value.replace(/\{(\w+)\}/g, (m,k) => params[k] ?? m);
|
||||
};
|
||||
const library = {exports: {}};
|
||||
vm.runInNewContext(compile(fs.readFileSync('AppImage/lib/pbs-messages.ts','utf8')), library);
|
||||
const messages = library.exports;
|
||||
const failure = code => Object.assign(new Error('RAW DIAGNOSTIC'), {body: {error:'RAW DIAGNOSTIC',code}});
|
||||
const flatten = n => !n || typeof n === 'boolean' ? [] : Array.isArray(n) ? n.flatMap(flatten)
|
||||
: typeof n === 'object' ? n.type === 'Dialog' && !n.props.open ? [] : [n,...flatten(n.props?.children)] : [n];
|
||||
const text = n => flatten(n).filter(n => typeof n === 'string' || typeof n === 'number').join('');
|
||||
|
||||
function fixture(locale='sk', options={}) {
|
||||
let cursor=0, slots=[], effects=[], tree, apiError, reloadError;
|
||||
const calls=[], confirmations=[], reloads=[];
|
||||
const servers={local_available:false,servers:[{id:'saved',name:'Saved PBS',url:'https://saved.invalid:8007',token_id:'audit@pbs!monitor',has_token:true,fingerprint:''}],detected:[]};
|
||||
const status={name:'Fixture',version:'4.2.5',fetched_at:1700000000,services:{'proxmox-backup':'inactive'},datastores:[],tasks:[],sync:[],verify:[],prune:[],errors:{}};
|
||||
const t=translate(locale), loaded={};
|
||||
const jsx=(type,props) => typeof type === 'function' ? type(props) : ({type,props});
|
||||
const react={useState(initial){const i=cursor++;if(!(i in slots))slots[i]=typeof initial==='function'?initial():initial;return [slots[i],value=>{slots[i]=typeof value==='function'?value(slots[i]):value;}];},
|
||||
useEffect(fn,deps){const i=cursor++, old=slots[i];if(!old||deps.some((d,j)=>d!==old[j]))effects.push(fn);slots[i]=deps;}};
|
||||
function requireModule(name) {
|
||||
if(name==='react')return react;
|
||||
if(name==='react/jsx-runtime')return {jsx,jsxs:jsx,Fragment:'Fragment'};
|
||||
if(name==='swr')return {default:key=>({data:key?.startsWith('/api/pbs/status')?status:key?servers:undefined,error:options.loadError,
|
||||
isLoading:false,mutate:async()=>{reloads.push(key);if(reloadError)throw reloadError;}})};
|
||||
if(name.includes('i18n/provider'))return {useT:()=>t,useI18n:()=>({language:locale})};
|
||||
if(name.includes('pbs-messages'))return messages;
|
||||
if(name.includes('api-config'))return {fetchApi:async(path,opts)=>{calls.push({path,...opts});if(apiError)throw apiError;return {id:'new',success:true};}};
|
||||
if(name.endsWith('pbs-problem-message'))return load('AppImage/components/pbs-problem-message.tsx');
|
||||
if(name.endsWith('pbs-datastore-usage'))return load('AppImage/components/pbs-datastore-usage.tsx');
|
||||
if(name.includes('lib/utils'))return {formatStorage:n=>String(n)+' GB'};
|
||||
return new Proxy({}, {get:(_,key)=>key});
|
||||
}
|
||||
function load(path) {if(loaded[path])return loaded[path];const e={};loaded[path]=e;
|
||||
vm.runInNewContext(compile(fs.readFileSync(path,'utf8')), {exports:e,require:requireModule,Date,window:{confirm:message=>{confirmations.push(message);return options.confirm===true;}}});return e;}
|
||||
const panel=load('AppImage/components/pbs-panel.tsx').PBSPanel;
|
||||
return {t,servers,status,calls,confirmations,reloads,jsx,requireModule,
|
||||
render(props){cursor=0;effects=[];tree=panel(props);effects.forEach(fn=>fn());return tree;},
|
||||
nodes:()=>flatten(tree),text:()=>text(tree),
|
||||
button(label){const n=flatten(tree).find(n=>n.type==='Button'&&text(n)===label);assert.ok(n,label);return n;},
|
||||
input(field){const n=flatten(tree).find(n=>n.type==='Input'&&n.props.id==='pbs-'+field);assert.ok(n,field);return n;},
|
||||
select(label){const n=flatten(tree).find(n=>n.type==='Select'&&flatten(n).some(n=>n.props?.['aria-label']===label));assert.ok(n,label);return n;},
|
||||
fail:e=>{apiError=e;},failReload:e=>{reloadError=e;},load,
|
||||
};
|
||||
}
|
||||
|
||||
function overview(locale, sourcePath='AppImage/components/system-overview.tsx') {
|
||||
const source=fs.readFileSync(sourcePath,'utf8'), ast=ts.createSourceFile('overview.tsx',source,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX), branches=[];
|
||||
function visit(n){if(ts.isConditionalExpression(n)&&n.condition.getText(ast)==='isPbs'&&n.whenTrue.getText(ast).includes('pbs.datastores'))branches.push(n.whenTrue.getText(ast));ts.forEachChild(n,visit);}visit(ast);
|
||||
assert.equal(branches.length,2,'overview summary and storage details both consume PBS');
|
||||
const f=fixture(locale);
|
||||
for(const branch of branches) {
|
||||
for(const state of ['partial','network','empty','loading']) {
|
||||
const pbsStatus=state==='loading'||state==='network'?undefined:{datastores:state==='empty'?[]:null,errors:{datastores:'RAW DIAGNOSTIC'},error_details:{datastores:{code:'pbs_token_rejected'}}};
|
||||
const context={isPbs:true,pbsStatus,pbsError:state==='network'?failure('pbs_connect_failed'):undefined,datastores:pbsStatus?.datastores,
|
||||
t:f.t,require:f.requireModule,pbsDiagnostic:messages.pbsDiagnostic,PBSProblemMessage:f.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage,
|
||||
DatastoreUsageRow:f.load('AppImage/components/pbs-datastore-usage.tsx').DatastoreUsageRow};
|
||||
for(const name of ['Card','CardHeader','CardTitle','CardContent','Database','Badge'])context[name]=name;
|
||||
vm.runInNewContext(compile('exports.result=('+branch+');'),{...context,exports:context});
|
||||
const rendered=text(context.result);
|
||||
if(state==='partial') {assert.ok(rendered.includes(f.t('pbs.errors.tokenRejected')));assert.ok(!rendered.includes(f.t('pbs.loading')));assert.ok(!flatten(context.result).some(n=>n.props?.className?.includes('animate-pulse')));}
|
||||
if(state==='network')assert.ok(rendered.includes(f.t('pbs.errors.connectFailed')));
|
||||
if(state==='empty')assert.ok(rendered.includes(f.t('pbs.noDatastores')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const backend=fs.readFileSync('AppImage/scripts/pbs_manager.py','utf8')+fs.readFileSync('AppImage/scripts/flask_pbs_routes.py','utf8');
|
||||
const codes=new Set([...backend.matchAll(/(?:code|rejected_code)="(pbs_\w+)"/g)].map(m=>m[1]));
|
||||
for(const locale of ['en','sk']) {
|
||||
const t=translate(locale);
|
||||
for(const code of codes) {
|
||||
if(code==='pbs_unknown')continue;
|
||||
const err=failure(code);err.body.params={count:32};
|
||||
assert.notEqual(messages.pbsErrorText(err,t),t('pbs.loadFailed'),code);
|
||||
assert.ok(!messages.pbsErrorText(err,t).includes('RAW'));
|
||||
}
|
||||
for(const err of [null,undefined,'raw',new Error('raw'),{body:null},{code:'constructor'},{code:'__proto__'},{code:'future'},
|
||||
...[null,0,-1,1.2,'32',Infinity,NaN,2**54].map(count=>({code:'pbs_connection_limit',params:{count}}))]) {
|
||||
assert.equal(messages.pbsErrorText(err,t),t('pbs.loadFailed'));
|
||||
}
|
||||
assert.equal(messages.pbsResultText('OK',t),t('pbs.states.ok'));
|
||||
assert.equal(messages.pbsResultText('WARNINGS: 2',t),t('pbs.states.warnings',{count:2}));
|
||||
assert.equal(messages.pbsResultText(undefined,t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('unknown',t),t('pbs.states.unknown'));
|
||||
assert.equal(messages.pbsResultText('TASK ERROR: raw',t),t('pbs.states.error'));
|
||||
for(const n of [-1,NaN,Infinity])assert.equal(messages.formatPBSDuration(n,t),'—');
|
||||
for(const [n,label] of [[0,'0 s'],[59,'59 s'],[60,'1 min 0 s'],[3599,'59 min 59 s'],[3600,'1 h 0 min'],[86399,'23 h 59 min'],[86400,'1 d 0 h']])assert.equal(messages.formatPBSDuration(n,t),label);
|
||||
assert.equal(messages.pbsTaskType('garbage_collection',t),t('pbs.taskTypes.garbageCollection'));
|
||||
assert.equal(messages.pbsTaskType('future',t),t('pbs.taskTypes.other',{type:'future'}));
|
||||
assert.equal(messages.pbsServiceState('constructor',t),t('pbs.serviceStates.unknown'));
|
||||
assert.equal(t('pbs.url'),locale==='sk'?'Adresa HTTPS':'HTTPS address');
|
||||
const diagnosticFixture=fixture(locale),problem=diagnosticFixture.load('AppImage/components/pbs-problem-message.tsx').PBSProblemMessage;
|
||||
for(const code of codes){
|
||||
if(code==='pbs_unknown')continue;
|
||||
const err=failure(code);err.body.params={count:32};
|
||||
const rendered=problem({problem:err,raw:'RAW DIAGNOSTIC'});
|
||||
assert.ok(!text(rendered).includes('RAW DIAGNOSTIC'),'Known '+code+' must not repeat an English application message');
|
||||
assert.ok(!flatten(rendered).some(n=>n.type==='details'));
|
||||
}
|
||||
for(const code of ['future','__proto__','constructor']){
|
||||
const rendered=problem({problem:failure(code),raw:'RAW <img> DIAGNOSTIC'});
|
||||
assert.ok(text(rendered).includes('RAW <img> DIAGNOSTIC'));assert.ok(flatten(rendered).some(n=>n.type==='details'));
|
||||
}
|
||||
const unknown=fixture(locale);unknown.status.tasks=[{upid:'unknown',endtime:1700000000,status:'unknown'}];unknown.status.sync=[{id:'unknown','last-run-state':'unknown'}];
|
||||
unknown.render();unknown.render();
|
||||
const unknownBadges=unknown.nodes().filter(n=>n.type==='Badge'&&text(n)===t('pbs.states.unknown'));
|
||||
assert.equal(unknownBadges.length,2);assert.ok(unknownBadges.every(n=>!n.props.className.includes('red-')));
|
||||
unknown.status.tasks=[{upid:'warn',endtime:1700000000,status:'WARNINGS: 3'},{upid:'raw',endtime:1700000000,status:'RAW EXTERNAL TASK ERROR'}];unknown.status.sync=[];unknown.render();
|
||||
const taskDetails=unknown.nodes().filter(n=>n.type==='details');assert.equal(taskDetails.length,1);assert.ok(text(taskDetails[0]).includes('RAW EXTERNAL TASK ERROR'));
|
||||
const f=fixture(locale);f.render();f.render();f.button(t('pbs.manage')).props.onClick();f.render();
|
||||
assert.equal(f.input('url').props['aria-describedby'],'pbs-url-help');
|
||||
assert.ok(f.text().includes(t('pbs.urlHelp')));assert.ok(f.text().includes(t('pbs.usernameHelp')));
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();
|
||||
f.input('password').props.onChange({target:{value:'fake-one-time-password'}});f.render();
|
||||
f.button(t('pbs.cancel')).props.onClick();f.render();assert.equal(f.calls.length,0);
|
||||
f.button(t('pbs.manage')).props.onClick();f.render();assert.equal(f.input('password').props.value,'');
|
||||
f.input('name').props.onChange({target:{value:'Draft'}});f.render();f.input('url').props.onChange({target:{value:'http://bad.invalid'}});f.render();
|
||||
f.fail(failure('pbs_invalid_url'));await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.errors.invalidUrl')));assert.equal(f.input('url').props.value,'http://bad.invalid');
|
||||
assert.equal(f.calls[0].path,'/api/pbs/servers/login');assert.ok(!Object.hasOwn(JSON.parse(f.calls[0].body),'token_secret'));
|
||||
f.button(t('pbs.methodToken')).props.onClick();f.render();await f.button(t('pbs.save')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.tokenSecretHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretKeepHelp')));
|
||||
assert.equal(f.input('token_secret').props['aria-describedby'],'pbs-token_secret-help');
|
||||
assert.equal(f.calls[1].path,'/api/pbs/servers');assert.ok(!Object.hasOwn(JSON.parse(f.calls[1].body),'password'));
|
||||
f.select(t('pbs.editConnection')).props.onValueChange('saved');f.render();await f.button(t('pbs.remove')).props.onClick();f.render();
|
||||
assert.ok(f.text().includes(t('pbs.tokenSecretKeepHelp')));assert.ok(!f.text().includes(t('pbs.tokenSecretHelp')));
|
||||
assert.deepEqual(f.confirmations,[t('pbs.removeConfirm')]);assert.equal(f.calls.length,2,'Declined remove has no DELETE');
|
||||
const remove=fixture(locale,{confirm:true});remove.render();remove.render();remove.button(t('pbs.manage')).props.onClick();remove.render();remove.select(t('pbs.editConnection')).props.onValueChange('saved');remove.render();
|
||||
await remove.button(t('pbs.remove')).props.onClick();remove.render();assert.equal(remove.calls.length,1);assert.equal(remove.calls[0].method,'DELETE');
|
||||
const failedRemove=fixture(locale,{confirm:true});failedRemove.render();failedRemove.render();failedRemove.button(t('pbs.manage')).props.onClick();failedRemove.render();failedRemove.select(t('pbs.editConnection')).props.onValueChange('saved');failedRemove.render();failedRemove.fail(new Error('network failure'));
|
||||
await failedRemove.button(t('pbs.remove')).props.onClick();failedRemove.render();assert.ok(failedRemove.text().includes(t('pbs.removeFailed')));assert.equal(failedRemove.calls.length,1);
|
||||
const failedReload=fixture(locale);failedReload.render();failedReload.render();failedReload.button(t('pbs.manage')).props.onClick();failedReload.render();failedReload.failReload(new Error('raw reload'));
|
||||
for(const [field,value] of Object.entries({name:'Valid',url:'https://fixture.invalid',username:'root@pam',password:'fake'})){failedReload.input(field).props.onChange({target:{value}});failedReload.render();}
|
||||
assert.equal(failedReload.button(t('pbs.save')).props.disabled,false);
|
||||
await failedReload.button(t('pbs.save')).props.onClick();failedReload.render();assert.equal(failedReload.calls.length,1,'No automatic mutation retry after refresh fails');assert.ok(failedReload.text().includes(t('pbs.savedRefreshFailed')));
|
||||
const removeReload=fixture(locale,{confirm:true});removeReload.render();removeReload.render();removeReload.button(t('pbs.manage')).props.onClick();removeReload.render();removeReload.select(t('pbs.editConnection')).props.onValueChange('saved');removeReload.render();removeReload.failReload(new Error('network'));
|
||||
await removeReload.button(t('pbs.remove')).props.onClick();removeReload.render();assert.equal(removeReload.calls.length,1);assert.ok(removeReload.text().includes(t('pbs.removedRefreshFailed')));
|
||||
const store=f.load('AppImage/components/pbs-datastore-usage.tsx').PBSDatastoreCard({store:{name:'Offline',total:null,used:null,error:'RAW DIAGNOSTIC',error_code:'pbs_datastore_offline'}});
|
||||
assert.ok(text(store).includes(t('pbs.errors.datastoreOffline')));assert.ok(!text(store).includes('0 GB'));
|
||||
overview(locale);
|
||||
}
|
||||
console.log('PASS: all structured PBS error codes, EN/SK/unknown diagnostics, forms/login/token/remove/cancel, no automatic retry, task/service/duration states and both real overview branches');
|
||||
}
|
||||
main().catch(error=>{console.error(error);process.exitCode=1});
|
||||
module.exports={fixture,overview};
|
||||
Reference in New Issue
Block a user