Merge develop and preserve Slovak Monitor and CLI messages

This commit is contained in:
VAIO73
2026-10-22 04:44:42 +00:00
94 changed files with 11043 additions and 1595 deletions
+9
View File
@@ -15,6 +15,9 @@ import tempfile
PYTHON_FILES = (
"tests/test_post_install_i18n.py",
"tests/test_remaining_cli_i18n.py",
"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",
@@ -33,6 +36,12 @@ NODE_FILES = (
"tests/test_api_import_boundaries.cjs",
"tests/test_api_errors.cjs",
"tests/test_optimization_labels.cjs",
"tests/test_optimization_source.cjs",
"tests/lxc_updates/test_app_editor_errors.cjs",
"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,284 @@
"""Human diagnostics translate; IDs, JSON, error types and forbidden effects do not."""
from contextlib import ExitStack, nullcontext, redirect_stderr, redirect_stdout
import copy
import importlib.util
import io
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import MagicMock, Mock, patch
import urllib.error
sys.path.insert(0, str(Path(__file__).resolve().parent))
import test_oci_complete_ui_sk as fixtures
from proxmenux_oci import casaos, cli, converter, i18n
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.github_source import GitHubSource, SourceError
import oci_image_cache
import oci_dhcp_lease
import oci_rocm_check
import oci_ui
import oci_watchdog
import configure_jellyfin_encoding
class TechnicalMessages(unittest.TestCase):
setUp = fixtures.CompleteUI.setUp
patch = fixtures.CompleteUI.patch
def remote_language(self):
self.patch(oci_ui, '_language', 'sk')
self.patch(oci_ui, '_cache', self.sk)
def test_real_readme_errors_translate_only_at_the_display_boundary(self):
for text, expected in [('# App', 'README neobsahuje'),
('## docker-compose\nplain text', 'Sekcia docker-compose neobsahuje blok kódu'),
('## docker-compose\n```yaml\nservices: {}', 'Blok docker-compose nie je ukončený'),
('## docker-compose\n```yaml\nnot-compose\n```', 'Nájdený blok nevyzerá')]:
with self.subTest(text=text), self.assertRaises(converter.ConversionError) as caught:
converter.extract_compose(text)
raw = str(caught.exception)
self.assertIn(expected, i18n.diagnostic(caught.exception))
self.assertEqual(str(caught.exception), raw)
def test_schema_error_keeps_field_details_and_original_exception(self):
with self.assertRaises(converter.ConversionError) as caught:
self.catalog.validate({})
raw = str(caught.exception)
self.assertTrue(raw.startswith('La plantilla generada no cumple el esquema:'))
self.assertEqual(i18n.diagnostic(caught.exception), 'Vygenerovaná šablóna nezodpovedá schéme:' + raw.split(':', 1)[1])
def test_missing_schema_validator_is_distinct_from_invalid_schema_without_effects(self):
template = {}
read = self.patch(Path, 'read_text', side_effect=AssertionError('schema must not be read without a validator'))
with patch.dict(sys.modules, {'jsonschema': None}):
with self.assertRaises(converter.ConversionError) as caught:
self.catalog.validate(template)
raw = 'python3-jsonschema is required to generate templates'
self.assertEqual(str(caught.exception), raw)
self.assertEqual(i18n.diagnostic(caught.exception), 'Na generovanie šablón je potrebný python3-jsonschema')
self.assertEqual(str(caught.exception), raw)
self.assertIsNone(self.catalog.validate(template, required=False))
read.assert_not_called()
self.assertEqual(template, {})
for effect in self.effects:
effect.assert_not_called()
def test_real_casaos_error_preserves_the_protocol_and_localizes_the_message(self):
with self.assertRaises(converter.ConversionError) as caught:
casaos.parse_casaos_compose('[]')
self.assertEqual(str(caught.exception), 'The CasaOS document is not a Compose mapping')
self.assertEqual(i18n.diagnostic(caught.exception), 'Dokument Compose musí byť mapa kľúčov a hodnôt.')
def test_github_rate_limit_keeps_url_and_token_variable_without_a_network_call(self):
url = 'https://fiction.invalid/catalogue?ref=example'
error = urllib.error.HTTPError(url, 403, 'Forbidden', {'X-RateLimit-Remaining': '0'}, None)
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen', side_effect=error)
with self.assertRaises(SourceError) as caught:
GitHubSource(token='fictional-token')._request(url)
request.assert_called_once()
text = i18n.diagnostic(caught.exception)
self.assertIn('GitHub vrátil HTTP 403', text)
self.assertIn(url, text)
self.assertIn('GITHUB_TOKEN', text)
self.assertNotIn('fictional-token', text)
def test_github_timeout_retains_external_reason_and_three_attempts(self):
request = self.patch(__import__('urllib.request', fromlist=['urlopen']), 'urlopen',
side_effect=urllib.error.URLError('fictional external transport reason'))
self.patch(__import__('time'), 'sleep')
with self.assertRaises(SourceError) as caught:
GitHubSource()._request('https://fiction.invalid/template')
self.assertEqual(request.call_count, 3)
self.assertIn('ani po 3 pokusoch: fictional external transport reason', i18n.diagnostic(caught.exception))
def test_unknown_diagnostics_and_machine_codes_pass_through_unchanged(self):
for value in ['service:db:healthcheck-format', 'external tool: failure at 0x1234',
'Check failed', 'Invalid cache', 'Ambiguous application', 'private-operation-id']:
self.assertEqual(i18n.diagnostic(value), value)
def test_cli_help_preserves_flags_actions_defaults_and_numeric_types(self):
argv = ['manage', '104', '--action', 'recreate', '--unattended', '--min-image-age-days', '7']
slovak = cli.build_parser()
self.assertIn('Vypísať aplikácie', slovak.format_help())
with patch.object(i18n, '_language', 'en'):
english = cli.build_parser()
self.assertIn('List applications', english.format_help())
self.assertEqual(vars(english.parse_args(argv)), vars(slovak.parse_args(argv)))
def test_cli_summary_preserves_image_reference_ports_mount_paths_and_status_code(self):
template = self.catalog.compose('tandoor')
saved = copy.deepcopy(template)
text = cli._template_summary_text(template)
self.assertIn('Obraz: ' + template['container_contract']['image']['reference'], text)
self.assertIn('Dátové zväzky:', text)
self.assertIn('Stav: ' + template['status'], text)
for mount in template['container_contract']['volumes']:
self.assertIn(mount['container_path'], text)
self.assertEqual(template, saved)
def test_install_cli_no_stays_successful_cancellation_without_dispatch(self):
template = self.catalog.compose('jellyfin')
saved = copy.deepcopy(template)
self.patch(cli.Catalog, 'compose', return_value=template)
self.patch(cli, 'build_deployment', return_value={'fiction': True})
self.patch(cli, '_deployment_summary_text', return_value='Fiktívny plán')
answer = self.patch(__import__('builtins'), 'input', return_value='n')
output = io.StringIO()
with redirect_stdout(output): self.assertEqual(cli.main(['install', 'jellyfin']), 0)
self.assertIn('Nainštalovať? [y/N]:', answer.call_args.args[0])
self.assertEqual(output.getvalue().count('Inštalácia bola zrušená.'), 1)
self.assertEqual(template, saved)
def test_blocked_install_remains_error_one_and_never_asks_to_install(self):
template = self.catalog.compose('jellyfin')
template['compatibility'].update(automatic_install_candidate=False, untranslated_blockers=['fiction-blocker-code'])
self.patch(cli.Catalog, 'compose', return_value=template)
answer = self.patch(__import__('builtins'), 'input', side_effect=AssertionError('blocked installation asked a question'))
output = io.StringIO()
with redirect_stdout(io.StringIO()), redirect_stderr(output):
self.assertEqual(cli.main(['install', 'jellyfin']), 1)
self.assertIn('Inštalácia je zablokovaná: fiction-blocker-code', output.getvalue())
answer.assert_not_called()
def test_watchdog_manual_stop_stays_stopped_without_start_or_notice(self):
self.remote_language()
self.patch(oci_watchdog, 'watched', return_value={777: 'Fiction'})
self.patch(oci_watchdog, 'is_running', return_value=False)
self.patch(oci_watchdog, 'is_busy', return_value=False)
self.patch(oci_watchdog, 'read_tasks', return_value=[('vzshutdown', '777', 75)])
start = self.patch(oci_watchdog, 'start', side_effect=AssertionError('manual stop was undone'))
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
state = oci_watchdog.new_state(); state['seen'] = 50
output = io.StringIO()
with redirect_stdout(output): oci_watchdog.look({777: state}, 100)
self.assertTrue(state['settled'])
self.assertEqual(output.getvalue(), '')
start.assert_not_called(); notice.assert_not_called()
def test_watchdog_localization_does_not_change_retry_state_or_notification_payload(self):
self.patch(oci_watchdog, 'watched', return_value={777: 'Fiction'})
self.patch(oci_watchdog, 'is_running', return_value=False)
self.patch(oci_watchdog, 'is_busy', return_value=False)
self.patch(oci_watchdog, 'read_tasks', return_value=[])
start = self.patch(oci_watchdog, 'start', return_value=True)
notice = self.patch(oci_watchdog.oci_operation_notice, 'notify')
outcomes = []
for locale in ('en', 'sk'):
with patch.object(oci_ui, '_language', locale), patch.object(oci_ui, '_cache', self.sk):
state = oci_watchdog.new_state(); state['seen'] = 50
text = io.StringIO()
with redirect_stdout(text): oci_watchdog.look({777: state}, 100)
outcomes.append((copy.deepcopy(state), notice.call_args, start.call_args))
self.assertIn('CT 777 (Fiction)', text.getvalue())
if locale == 'sk': self.assertIn('spúšťa sa znova (pokus 1)', text.getvalue())
self.assertEqual(outcomes[0], outcomes[1])
def test_image_cache_json_contract_and_invalid_command_have_no_mutations(self):
self.remote_language()
self.patch(oci_image_cache.instances, 'locked', side_effect=lambda *a: nullcontext())
self.patch(oci_image_cache, 'archives_of', return_value={})
output = io.StringIO()
with redirect_stdout(output): self.assertEqual(oci_image_cache.main(['list', '777']), 0)
self.assertEqual(json.loads(output.getvalue()), {'archives': [], 'freed': 0})
error = io.StringIO()
with redirect_stderr(error): self.assertEqual(oci_image_cache.main(['invalid']), 2)
self.assertIn('Použitie: oci_image_cache.py [prune | list VMID... | remove VMID...]', error.getvalue())
def test_rocm_timeout_localizes_but_external_gpu_errors_are_preserved(self):
self.remote_language()
self.patch(sys, 'argv', ['oci_rocm_check.py', '777'])
for raw, expected in [('the GPU did not answer in time', 'GPU neodpovedalo včas'),
('external provider exception 0x123', 'external provider exception 0x123')]:
with self.subTest(raw=raw), patch.object(oci_rocm_check, 'check', side_effect=RuntimeError(raw)):
output = io.StringIO()
with redirect_stderr(output): self.assertEqual(oci_rocm_check.main(), 1)
self.assertEqual(output.getvalue().strip(), expected)
def test_jellyfin_completion_distinguishes_changed_from_already_applied(self):
self.remote_language()
self.patch(sys, 'argv', ['configure_jellyfin_encoding.py', '/fiction-root', '{}'])
for changed, expected in [(True, 'Nastavenia Jellyfin boli aktualizované:'),
(False, 'Nastavenia Jellyfin už boli použité:')]:
with self.subTest(changed=changed), patch.object(configure_jellyfin_encoding, 'update_encoding',
return_value=(Path('/fiction-root/config/encoding.xml'), changed)):
output = io.StringIO()
with redirect_stdout(output): configure_jellyfin_encoding.main()
self.assertEqual(output.getvalue().strip(), expected + ' /config/encoding.xml')
def test_dhcp_completion_keeps_interface_and_ip_version_without_repairing_a_guest(self):
self.remote_language()
self.patch(oci_dhcp_lease, 'LOCKS', MagicMock())
self.patch(oci_dhcp_lease.fcntl, 'flock')
repair = self.patch(oci_dhcp_lease, 'repair', return_value=[('eth0', 4)])
output = io.StringIO()
with redirect_stdout(output): oci_dhcp_lease.check(777, 0)
repair.assert_called_once_with(777, 0)
self.assertEqual(output.getvalue().strip(), 'CT 777: klient DHCP rozhrania eth0 (IPv4) bol znova spustený')
class StandalonePublisher(unittest.TestCase):
def test_mount_error_keeps_host_language_after_entering_the_container_root(self):
spec = importlib.util.spec_from_file_location('fiction_namespace_publisher',
fixtures.ROOT / 'oci/remote/rclone_mount_publish.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
inside = False
def chroot(path):
nonlocal inside
inside = True
def read(path, **kwargs):
if inside:
raise FileNotFoundError('Host language catalog is outside the container root')
return ('{"language":"sk"}' if path.name == 'config.json' else
'{"clone source mount tree":"naklonovať strom zdrojového pripojenia"}')
libc = Mock(); libc.setns.return_value = 0; libc.syscall.return_value = -1
with ExitStack() as guards:
for target, name, options in [
(sys, 'argv', {'new': ['publisher.py', '777', '/fiction-source', '/fiction-target', 'ro']}),
(module.platform, 'machine', {'return_value': 'x86_64'}),
(Path, 'read_text', {'autospec': True, 'side_effect': read}),
(module.ctypes, 'CDLL', {'return_value': libc}),
(module.ctypes, 'get_errno', {'return_value': 13}),
(module.os, 'open', {'return_value': 42}),
(module.os, 'close', {}), (module.os, 'fchdir', {}),
(module.os, 'chroot', {'side_effect': chroot}), (module.os, 'chdir', {})]:
guards.enter_context(patch.object(target, name, **options))
with self.assertRaises(OSError) as caught:
module.main()
self.assertTrue(inside)
self.assertEqual(caught.exception.errno, 13)
self.assertIn('naklonovať strom zdrojového pripojenia', str(caught.exception))
self.assertEqual(libc.syscall.call_args.args[0], module.SYSCALLS['x86_64'][0])
def test_copied_helper_has_localized_errors_without_engine_import_or_mount_syscalls(self):
with tempfile.TemporaryDirectory(prefix='oci-publisher-language-') as directory:
root = Path(directory); (root / 'lang').mkdir()
(root / 'config.json').write_text('{"language":"sk"}')
(root / 'lang/sk.json').write_text(json.dumps({'Usage:': 'Použitie:', 'Unsupported host architecture:': 'Nepodporovaná architektúra hostiteľa:'}))
source = fixtures.ROOT / 'oci/remote/rclone_mount_publish.py'
copied = root / 'publisher.py'; copied.write_bytes(source.read_bytes())
spec = importlib.util.spec_from_file_location('fiction_standalone_publisher', copied)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {'oci_ui': None}), patch.dict(__import__('os').environ, {'PMX_BASE_DIR': str(root)}):
spec.loader.exec_module(module)
with patch.object(module.ctypes, 'CDLL', side_effect=AssertionError('mount syscall path entered')) as syscall:
with patch.object(sys, 'argv', ['publisher.py']), redirect_stderr(io.StringIO()) as output:
self.assertEqual(module.main(), 2)
self.assertIn('Použitie: publisher.py PID SOURCE TARGET rw|ro', output.getvalue())
with patch.object(sys, 'argv', ['publisher.py', '1', '/fiction-source', '/fiction-target', 'ro']), \
patch.object(module.platform, 'machine', return_value='fiction-architecture'), redirect_stderr(io.StringIO()) as output:
self.assertEqual(module.main(), 2)
self.assertIn('Nepodporovaná architektúra hostiteľa: fiction-architecture', output.getvalue())
syscall.assert_not_called()
(root / 'config.json').write_text('invalid json')
module._messages.cache_clear()
self.assertEqual(module.translate('Usage:'), 'Usage:')
(root / 'config.json').write_text('{"language":"en"}')
module._messages.cache_clear()
self.assertEqual(module.translate('Usage:'), 'Usage:')
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,148 @@
"""Real catalogue/wizard rendering, with host and installation effects forbidden."""
import copy
from contextlib import ExitStack, redirect_stdout
import io
import json
import os
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(os.environ.get('OCI_I18N_TEST_ROOT', Path(__file__).resolve().parents[3]))
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote'), str(ROOT / 'oci/tests')]
from proxmenux_oci import cli, i18n, installer, stack
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.ui import UserCancelled
from test_advanced_flow_order import RecordingUI, addresses, storages
class CompleteUI(unittest.TestCase):
def setUp(self):
self.scope = ExitStack()
self.addCleanup(self.scope.close)
self.sk = json.loads((ROOT / 'lang/sk.json').read_text())
self.catalog = Catalog(ROOT / 'oci')
self.patch(i18n, '_language', 'sk')
self.patch(i18n, '_cache', self.sk)
self.patch(installer.host, 'storages', side_effect=storages)
self.patch(installer.host, 'bridges', return_value=[{'iface': 'vmbr0', 'cidr': '192.0.2.10/24'}])
self.patch(installer.host, 'timezone', return_value='Europe/Bratislava')
self.patch(installer.host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': False})
self.patch(installer.access, 'ask_addresses', side_effect=addresses)
self.patch(installer.access, 'ask_ipv4', return_value=('dhcp', None))
self.effects = []
for module, name in [(cli, '_install'), (cli, '_rclone_mount'), (cli, 'run_remote_install'),
(installer, 'run_remote_install'), (cli.images, 'offer_removal'),
(subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink')]:
self.effects.append(self.patch(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
self.addCleanup(lambda: [effect.assert_not_called() for effect in self.effects])
def patch(self, obj, name, *args, **kwargs):
return self.scope.enter_context(patch.object(obj, name, *args, **kwargs))
def test_search_no_match_uses_application_wording_and_never_installs(self):
ui = Mock()
ui.ask.return_value = 'fiction-no-such-app'
cli._search(self.catalog, ui, [])
self.assertEqual(ui.ask.call_args.args[0], 'Názov alebo časť popisu aplikácie')
self.assertIn('Žiadna zhoda aplikácií', ui.message.call_args.args[0])
ui.choose.assert_not_called()
def test_immich_detail_translates_service_roles_without_changing_template(self):
template = self.catalog.compose('immich')
before = copy.deepcopy(template)
text = cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template)
self.assertIn('Strojové učenie', text)
self.assertIn('Aplikácia', text)
self.assertIn('Oficiálny obraz', text)
self.assertNotIn('Machine learning', text)
self.assertEqual(template, before)
with patch.object(i18n, '_language', 'en'):
self.assertIn('Machine learning', cli._app_detail_text(self.catalog, self.catalog.find_item('immich'), template))
def test_missing_catalogue_entry_preserves_raw_error_and_localizes_cli(self):
for language, expected in [('sk', "Aplikácia 'fiction-missing' nie je v indexe"),
('en', "The application 'fiction-missing' is not in the index")]:
with self.subTest(language=language), patch.object(i18n, '_language', language):
with self.assertRaisesRegex(ConversionError, "The application 'fiction-missing' is not in the index"):
self.catalog.find_item('fiction-missing')
output = io.StringIO()
with redirect_stdout(io.StringIO()), patch.object(sys, 'stderr', output), patch.object(cli, 'PROJECT_ROOT', ROOT / 'oci'):
self.assertEqual(cli.main(['show', 'fiction-missing']), 1)
self.assertEqual(output.getvalue().strip(), 'ERROR: ' + expected)
def test_missing_template_uses_existing_translation_and_keeps_identifier(self):
self.patch(self.catalog, 'find_item', return_value={'id': 'fiction-missing'})
with self.assertRaises(ConversionError) as caught:
self.catalog.compose('fiction-missing')
self.assertEqual(str(caught.exception), "No template is available for 'fiction-missing'")
self.assertEqual(i18n.diagnostic(caught.exception), "Pre aplikáciu 'fiction-missing' nie je dostupná šablóna")
def test_detail_cancel_and_escape_do_not_enter_install_or_rclone(self):
for app in ('jellyfin', 'rclone', 'immich', 'suite-arr'):
for escape in (False, True):
with self.subTest(app=app, escape=escape):
ui = Mock()
ui.confirm.return_value = False
ui.detail_menu.return_value = None
if escape:
ui.detail_menu.side_effect = UserCancelled()
try:
cli._app_detail(self.catalog, ui, self.catalog.find_item(app))
except UserCancelled:
self.assertTrue(escape)
def test_special_wizard_plans_are_identical_in_english_and_slovak(self):
for app in ('immich', 'nextcloud-stack', 'paperless-ngx', 'tandoor'):
for mode in (installer.DEFAULT_MODE, installer.ADVANCED_MODE):
with self.subTest(app=app, mode=mode):
template = self.catalog.compose(app)
before = copy.deepcopy(template)
plan_sk = installer.build_deployment(template, RecordingUI(), mode)
with patch.object(i18n, '_language', 'en'):
plan_en = installer.build_deployment(template, RecordingUI(), mode)
self.assertEqual(plan_sk, plan_en)
self.assertEqual(template, before)
text = cli._deployment_summary_text(template, plan_sk)
self.assertNotIn('Consume/export:', text)
if app == 'paperless-ngx':
self.assertIn('Priečinky consume a export', text)
def test_romm_optional_credentials_are_localized_but_variable_names_stay(self):
ui = RecordingUI()
ui.confirm = lambda text, default=False, **kwargs: (ui.asked.append(text) or text.startswith('Nastaviť IGDB'))
ui.password = lambda text, **kwargs: (ui.asked.append(text) or 'fiction-secret')
ui.ask = lambda text, default=None, **kwargs: (ui.asked.append(text) or (default if default is not None else 'fiction-value'))
plan = installer.build_deployment(self.catalog.compose('romm'), ui, installer.ADVANCED_MODE)
self.assertTrue(any('Tajný kľúč klienta IGDB' == text for text in ui.asked))
self.assertFalse(any('IGDB Client Secret' == text for text in ui.asked))
server = next(service for service in plan['services'] if service['name'] == 'romm')
self.assertTrue(any(row['name'] == 'IGDB_CLIENT_SECRET' for row in server['deployment']['environment']))
def test_tandoor_warning_keeps_negative_requirement_and_first_run_account(self):
template = self.catalog.compose('tandoor')
warning = i18n.translate(template['proxmox']['security_profile']['warning'])
self.assertIn('nevyžaduje privilegovaný', warning)
credentials = template['first_run']['credentials']
self.assertTrue(any('Vygenerovaný účet správcu Tandoor' == i18n.translate(row['label']) for row in credentials))
def test_documented_commands_paths_and_literals_remain_copyable(self):
fragments = {
'rsnapshot starts with the default configuration, which backs up /data into /.snapshots. Edit /config/rsnapshot.conf inside the container to set your own backup points, snapshot root and retention intervals.': ['/data', '/.snapshots', '/config/rsnapshot.conf'],
'Each /request command needs a backend: add a [[backends]] block in the same file with the url and api_key of your Sonarr, Radarr or Seerr instance, then restart the container.': ['/request', '[[backends]]', 'url', 'api_key'],
'Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>': ['public: false', 'pct exec <CTID> -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add <user>'],
'Certificate provider: empty for Let\'s Encrypt, zerossl for ZeroSSL': ["Let's Encrypt", 'zerossl', 'ZeroSSL'],
'Libraries: /data/media/movies, /data/media/series and /data/media/music. Select them in the media server.': ['/data/media/movies', '/data/media/series', '/data/media/music'],
}
for source, tokens in fragments.items():
with self.subTest(source=source):
for token in tokens:
self.assertIn(token, i18n.translate(source))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,268 @@
"""Exercise the native custom-image and registry UI with the real EN/SK lookup."""
import json
from pathlib import Path
import runpy
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / 'oci/src'))
from proxmenux_oci import custom, i18n, registry_accounts as accounts, registry_auth as auth
from proxmenux_oci.ui import UserCancelled
COMPOSE = 'services:\n app:\n image: ghcr.io/owner/app:latest\n'
class UI:
def __init__(self, choices=(), answers=(), confirms=()):
self.choices = iter(choices)
self.answers = iter(answers)
self.confirms = iter(confirms)
self.seen = []
def choose(self, text, options, default=None, **kwargs):
self.seen.append((text, options, kwargs))
return next(self.choices)
def ask(self, text, default=None, **kwargs):
self.seen.append(text)
return next(self.answers)
def password(self, text):
self.seen.append(text)
raise UserCancelled(text)
def confirm(self, text, default=False, **kwargs):
self.seen.append((text, default))
return next(self.confirms)
def review(self, text, title=None, **kwargs):
self.seen.append((text, title, kwargs))
return False
def message(self, text, title=None):
self.seen.append((text, title))
info = message
class CustomRegistryLocalizationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
(self.base / 'lang').mkdir()
(self.base / 'config.json').write_text('{"language":"sk"}')
(self.base / 'lang/sk.json').write_bytes((ROOT / 'lang/sk.json').read_bytes())
for name, value in [('BASE_DIR', self.base), ('_language', None), ('_cache', None)]:
item = patch.object(i18n, name, value)
item.start()
self.addCleanup(item.stop)
# Any unmocked subprocess, network fetch or installation is a failure.
self.run = self.guard(custom.subprocess, 'run')
self.network = self.guard(custom.urllib.request, 'urlopen')
self.install = self.guard(custom, 'install_template')
def guard(self, module, name):
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
mock = item.start()
self.addCleanup(item.stop)
return mock
def no_effects(self):
self.run.assert_not_called()
self.network.assert_not_called()
self.install.assert_not_called()
def manager(self):
return SimpleNamespace(path=Path('/fixture/auth.json'),
accounts=MagicMock(return_value=[{'registry': 'ghcr.io', 'account': 'owner',
'method': 'Používateľské meno / token', 'status': 'Saved'}]),
login=MagicMock(), logout=MagicMock())
def menu(self, ui, manager):
with patch.object(accounts, 'require_root'):
accounts.registry_accounts_menu(ui, auth=manager)
def test_dynamic_blockers_are_extracted_and_rendered_in_slovak(self):
expected = {
'multi-service-compose': 'definuje viac než jeden obraz',
'native-multi-lxc-orchestrator-not-yet-implemented': 'definuje viac než jeden obraz',
'top-level-configs': 'používa nastavenia Compose configs, ktoré tu nemajú ekvivalent',
'devices-format': 'požadované zariadenia sú zadané v nepodporovanom formáte',
'healthcheck-format': 'kontrola stavu je zadaná v nepodporovanom formáte',
'stop-grace-period-format': 'časový limit zastavenia je zadaný v nepodporovanom formáte',
'shm-size-format': 'veľkosť zdieľanej pamäte je zadaná v nepodporovanom formáte',
'ulimits-format-or-resource': 'požadované limity systémových prostriedkov nemožno uplatniť',
'mem-limit-format-or-below-proxmox-minimum': 'požadovaný limit pamäte nemožno uplatniť',
'mem-limit-deploy-consistency-review': 'požaduje dva rozdielne limity pamäte',
}
extractor = runpy.run_path(str(ROOT / '.github/scripts/build_translation_cache.py'))
extracted = extractor['extract_python_texts']([ROOT / 'oci/src'])
self.assertTrue(set(custom.BLOCKER_TEXTS.values()) <= set(extracted))
for code, message in expected.items():
with self.subTest(code=code):
self.assertEqual(custom.blocker_text('service:app:' + code), message)
self.assertEqual(custom.blocker_text('unrecognized:raw-code'), 'unrecognized:raw-code')
self.no_effects()
def test_cancelled_source_menu_is_translated_without_reading_an_image(self):
ui = UI([None])
with self.assertRaises(UserCancelled) as caught:
custom.explore(ui)
self.assertEqual(str(caught.exception), 'Výber obrazu bol zrušený')
self.assertIn('Obraz, ktorý nie je v katalógu', repr(ui.seen))
self.assertIn('Len odkaz na obraz bez súboru Compose', repr(ui.seen))
self.no_effects()
def test_invalid_compose_inputs_reach_localized_error_before_registry_or_install(self):
cases = [
('services: {}', 'Súbor Compose neobsahuje žiadnu službu'),
('services: [', 'Súbor Compose nie je platný YAML:'),
(COMPOSE + ' environment: 42\n', 'Položka environment'),
(COMPOSE + ' volumes: {}\n', 'Položka volumes'),
(COMPOSE + ' ports: invalid\n', 'Položka ports'),
(COMPOSE + ' ports: [invalid]\n', 'Neplatný port'),
(COMPOSE + ' volumes: [relative:relative]\n', 'Neplatná cesta dátového úložiska'),
(COMPOSE + 'x-casaos:\n main: missing\n', 'Položka x-casaos.main'),
]
for text, expected in cases:
with self.subTest(text=text), patch.object(custom, 'read_definition', return_value=(text, 'fixture')), \
self.assertRaises(custom.ConversionError) as caught:
custom.explore(UI())
self.assertIn(expected, str(caught.exception))
self.no_effects()
def test_converter_raw_contract_and_english_ui_are_preserved(self):
bad = COMPOSE + ' ports: [invalid]\n'
with self.assertRaises(custom.ConversionError) as raw:
custom.template_from_compose(bad)
self.assertEqual(str(raw.exception), "Puerto no valido: 'invalid'")
with patch.object(i18n, '_language', 'en'), \
patch.object(custom, 'read_definition', return_value=(bad, 'fixture')), \
self.assertRaises(custom.ConversionError) as ui_error:
custom.explore(UI())
self.assertEqual(str(ui_error.exception), str(raw.exception))
self.no_effects()
def test_conversion_message_retains_literal_diagnostic_data_and_unknown_fallback(self):
detail = " {'target': '<path>{literal}\\á'}"
message = custom.conversion_message(custom.ConversionError('Ruta de volumen no valida:' + detail))
self.assertEqual(message, 'Neplatná cesta dátového úložiska v súbore Compose:' + detail)
unknown = 'unknown <diagnostic> {literal}'
self.assertEqual(custom.conversion_message(custom.ConversionError(unknown)), unknown)
self.no_effects()
def test_all_legacy_converter_diagnostics_have_slovak_ui_and_unchanged_english(self):
messages = [
'environment must be a list or a mapping', 'volumes must be a list', 'ports must be a list',
'The CasaOS Compose file has no services', 'The CasaOS document is not a Compose mapping',
'The Compose file has no x-casaos metadata', 'x-casaos.main no identifica un servicio valido',
'The main CasaOS service declares no image', 'Ruta de volumen no valida: /data',
'Puerto no valido: invalid', 'Cannot normalise the CasaOS identifier: ???',
'Docker Compose CasaOS no valido: fixture',
]
for text in messages:
with self.subTest(text=text):
self.assertNotEqual(custom.conversion_message(custom.ConversionError(text)), text)
with patch.object(i18n, '_language', 'en'):
self.assertEqual(custom.conversion_message(custom.ConversionError(text)), text)
self.no_effects()
def test_custom_review_cancel_keeps_installation_disabled(self):
ui = UI()
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'vloženého súboru Compose')), \
patch.object(custom, 'registry_report', return_value=(True, 'Dostupný obraz')):
custom.explore(ui)
self.assertIn('Nainštalovať tento obraz?', repr(ui.seen))
self.assertIn('Obraz:', repr(ui.seen))
self.assertIn('ghcr.io/owner/app:latest', repr(ui.seen))
self.no_effects()
def test_unavailable_image_explains_registry_accounts_without_authentication_or_install(self):
ui = UI()
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'fixture')), \
patch.object(custom, 'registry_report', return_value=(False, 'Nedostupný obraz')), \
patch.object(custom, '_registry_refused', return_value=False), \
patch.object(accounts, 'offer_sign_in') as offer:
custom.explore(ui)
self.assertIn('prihláste cez Účty registrov', repr(ui.seen))
offer.assert_not_called()
self.no_effects()
def test_registry_metadata_dialog_uses_image_and_digest_and_retains_values(self):
manager = self.manager()
ui = UI(['account-0', 'inspect', None], ['ghcr.io/owner/app:latest'])
metadata = dict(architecture='amd64', version='<v>{literal}', size=1024, digest='sha256:' + 'a'*64)
with patch.object(accounts, 'inspect_registry_image', return_value=metadata) as inspect:
self.menu(ui, manager)
inspect.assert_called_once_with('ghcr.io/owner/app:latest')
rendered = repr(ui.seen)
for value in ('Prístup k obrazu bol potvrdený.', 'Digest: sha256:', '<v>{literal}', 'Účty registrov'):
self.assertIn(value, rendered)
self.assertNotIn('obráz', rendered)
manager.login.assert_not_called()
manager.logout.assert_not_called()
self.no_effects()
def test_credential_help_retains_permissions_and_no_encryption_claim(self):
manager = self.manager()
ui = UI(['storage', None])
self.menu(ui, manager)
rendered = repr(ui.seen)
for value in ('root', '700', '600', 'base64', 'nie sú šifrované', 'každý uzol klastra', '/fixture/auth.json'):
self.assertIn(value, rendered)
manager.login.assert_not_called()
manager.logout.assert_not_called()
self.no_effects()
def test_cancelled_password_and_declined_logout_do_not_change_accounts(self):
for ui in (UI(['add', None], ['ghcr.io', 'owner']), UI(['account-0', 'logout', None], confirms=[False])):
manager = self.manager()
self.menu(ui, manager)
manager.login.assert_not_called()
manager.logout.assert_not_called()
self.no_effects()
def test_mismatched_registry_is_rejected_before_download(self):
manager = self.manager()
ui = UI(['account-0', 'download', None], ['quay.io/owner/app:latest'])
with patch.object(accounts, 'download_registry_image') as download:
self.menu(ui, manager)
download.assert_not_called()
self.assertIn('Zadajte obraz z tohto registra.', repr(ui.seen))
self.no_effects()
def test_declined_private_image_signin_does_not_even_construct_auth_manager(self):
ui = UI(confirms=[False])
with patch.object(accounts, 'RegistryAuth') as manager, patch.object(accounts, 'require_root') as root:
self.assertFalse(accounts.offer_sign_in(ui, 'ghcr.io/owner/app:latest'))
manager.assert_not_called()
root.assert_not_called()
self.assertIn('Prihlásiť sa teraz?', repr(ui.seen))
self.no_effects()
def test_registry_error_classes_hide_untrusted_remote_data_in_both_languages(self):
cases = [('status code: 429', 'obmedzuje počet požiadaviek'),
('requested access to the resource is denied', 'odmietol prístup'),
('manifest unknown', 'Obraz alebo tag'),
('unsupported manifest', 'metadáta obrazu'),
('docker-credential-helper', 'správu prihlasovacích údajov'),
('unknown failure', 'Obraz sa nepodarilo načítať')]
for language in ('sk', 'en'):
for error, expected in cases:
with self.subTest(language=language, error=error), patch.object(i18n, '_language', language):
message = auth.registry_error('registry.test:5000', error + ' REMOTE_PRIVATE_VALUE')
self.assertNotIn('REMOTE_PRIVATE_VALUE', message)
if language == 'sk':
self.assertIn(expected, message)
if 'credential' not in error:
self.assertIn('registry.test:5000', message)
self.no_effects()
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,176 @@
"""Cancel a real editor through Menu/CLI/Monitor contracts, never apply it."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import sys
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, management as menu, recreation
from proxmenux_oci.ui import UserCancelled
class ModifyCancellationTests(CancellationFixture):
def setUp(self):
super().setUp()
self.record.update({
'installation_id': 'fixture-oci-edit-cancel',
'observed': {'config_sha256': 'fixture-config-hash'},
'template': {'id': 'fixture-app', 'catalog_ui': {'title': 'Fixture'},
'container_contract': {'image': {'reference': 'example/fixture:latest'}}},
'deployment': {'vmid': 110, 'hostname': 'fixture',
'resources': {'cores': 2, 'memory_mb': 512},
'rootfs': {'storage': 'fixture-storage', 'size_gb': 8},
'mounts': [], 'devices': [], 'environment': [
{'name': 'PASSWORD', 'value': 'private-fixture-value', 'sensitive': True}],
'network': {'bridge': 'vmbr-fixture', 'ipv4': 'dhcp'}}})
self.original = copy.deepcopy(self.record)
self.ui.back_enabled = False
self.path_write = self.guard(Path, 'write_text')
self.path_bytes = self.guard(Path, 'write_bytes')
def script(self, choices, answers=(), confirms=(), reviews=()):
self.ui.choose.side_effect = choices
self.ui.ask.side_effect = answers
self.ui.confirm.side_effect = confirms
self.ui.review.side_effect = reviews
def no_effects(self):
super().no_effects()
self.path_write.assert_not_called()
self.path_bytes.assert_not_called()
self.assertFalse(self.ui.back_enabled)
def test_main_cancel_is_distinct_falsy_cancellation(self):
self.script([None])
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row, action='modify'), menu.CANCELLED)
self.assertFalse(menu.CANCELLED)
self.no_effects()
def test_main_discard_without_edits_returns_cancel_code(self):
self.script(['discard'])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.ui.confirm.assert_not_called()
self.no_effects()
def test_real_cli_cancel_returns_code_six_without_extra_console_output(self):
self.script([None])
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
self.assertEqual(output.getvalue(), '')
self.no_effects()
def test_real_cli_discard_after_edits_never_calls_backup_or_lifecycle(self):
self.script(['cores', 'discard'], ['3'], [True])
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 6)
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
self.no_effects()
def test_main_cancel_after_edits_requires_discard_confirmation(self):
self.script(['cores', None], ['3'], [True])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.ui.confirm.assert_called_once_with('Discard unapplied changes?', False)
self.no_effects()
def test_declining_discard_preserves_draft_until_later_main_cancel(self):
self.script(['cores', 'discard', None], ['3'], [False, True])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
last_options = dict(self.ui.choose.call_args.args[1])
self.assertTrue(last_options['cores'].rstrip().endswith('3'))
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
self.assertEqual(self.ui.confirm.call_count, 2)
self.no_effects()
def test_section_cancel_returns_to_editor_before_main_cancel(self):
self.script(['cores', None], [UserCancelled()])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.assertEqual(self.ui.choose.call_count, 2)
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
self.ui.review.assert_not_called()
self.no_effects()
def test_zero_cores_rejected_then_section_and_main_cancel_are_safe(self):
self.script(['cores', None], ['0', UserCancelled()])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.assertIn('Cores: minimum 1', self.ui.message.call_args.args[0])
self.assertIn('No pending changes', self.ui.choose.call_args.args[0])
self.no_effects()
def test_review_no_returns_to_editor_with_draft_then_discard_cancels(self):
self.script(['cores', 'apply', 'discard'], ['3'], [True], [False])
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 6)
self.assertEqual(self.ui.choose.call_count, 3)
self.assertIn('Unapplied changes', self.ui.choose.call_args.args[0])
self.assertNotIn('private-fixture-value', self.ui.review.call_args.args[0])
self.no_effects()
def test_native_menu_returns_to_list_after_editor_cancel(self):
self.script(['110', 'modify', None, None])
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT/'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 4)
self.no_effects()
def test_legacy_editor_callers_still_receive_none_on_cancel(self):
for choice in (None, 'discard'):
with self.subTest(choice=choice):
self.script([choice])
self.assertIsNone(recreation.edit_recreation(self.record, self.ui))
self.no_effects()
def test_unchanged_review_is_not_mislabeled_as_user_cancellation(self):
self.script(['apply'])
self.assertIsNone(recreation.edit_recreation(self.record, self.ui, cancelled_result=menu.CANCELLED))
self.ui.message.assert_called_once_with('Nothing was changed.', 'Modify OCI')
self.no_effects()
def test_real_editor_error_is_not_converted_to_cancellation(self):
self.script(['cores'], [RuntimeError('fixture editor failure')])
with self.assertRaisesRegex(RuntimeError, 'fixture editor failure'):
menu.direct_management(ROOT/'oci', 110, 'modify')
self.no_effects()
def test_real_cli_validation_failure_remains_failure_one(self):
self.script(['cores'], [cli.InstallError('fixture validation failure')])
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()):
self.assertEqual(cli.main(['manage', '110', '--action', 'modify']), 1)
self.no_effects()
def test_unattended_modify_is_rejected_before_opening_the_editor(self):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify', unattended=True), 1)
self.ui.choose.assert_not_called()
self.no_effects()
def test_accepted_proposal_keeps_original_backend_and_private_payload(self):
self.script(['cores', 'apply'], ['3'], reviews=[True])
self.backup.side_effect = None
self.backup.return_value = True
def lifecycle(command, title):
self.assertEqual(command[:3], [sys.executable, str(ROOT/'oci/remote/oci_update_current.py'), '110'])
proposal_file = Path(command[command.index('--proposal')+1])
self.assertEqual(proposal_file.stat().st_mode & 0o777, 0o600)
proposal = json.loads(proposal_file.read_text())
self.assertEqual(proposal['operation'], 'recreate')
self.assertEqual(proposal['candidate']['deployment']['resources']['cores'], 3)
self.assertEqual(proposal['base_config_sha256'], 'fixture-config-hash')
self.assertNotIn('private-fixture-value', ' '.join(command))
return True
self.lifecycle.side_effect = lifecycle
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 0)
self.backup.assert_called_once()
self.lifecycle.assert_called_once()
self.assertEqual(self.record, self.original)
self.write.assert_not_called()
def test_failed_backup_after_review_remains_failure_without_lifecycle(self):
self.script(['cores', 'apply'], ['3'], reviews=[True])
self.backup.side_effect = None
self.backup.return_value = False
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'modify'), 1)
self.lifecycle.assert_not_called()
self.write.assert_not_called()
self.assertEqual(self.record, self.original)
@@ -116,7 +116,7 @@ class RecoveryMessages(unittest.TestCase):
instances.ROOT = Path('/inert')
instances.read = lambda root, vmid: self.primary
self.modules = {'oci_instances': instances, 'oci_stack_replay': ModuleType('oci_stack_replay')}
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path,
self.scope = {'sys': SimpleNamespace(path=[], executable='python3'), 'Path': Path, 'CANCELLED': None,
'translate': lambda text: text, 'images': SimpleNamespace(offer_removal=lambda *args: None),
'_remove': lambda *args: self.fail('remove must not run'),
'_run_lifecycle': lambda command, title: self.calls.append((command, title)) or False}
@@ -0,0 +1,246 @@
"""Pending recovery through real Menu/CLI/Dialog paths; all host effects forbidden."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
import oci_instances as instances
import oci_instance_transaction as transaction
import oci_native_modify as native_modify
import oci_stack_transaction as stack_transaction
import oci_ui
ACTUAL_RECOVER = transaction.recover
class PendingRecoveryFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.record.update(pending_transaction='/fiction-only/transaction.json')
self.row['pending'] = True
self.ui.choose.return_value = 'recover'
self.original = copy.deepcopy(self.record)
self.writes = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
self.processes = [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
self.recovery = self.guard(transaction, 'recover')
self.native_recovery = self.guard(native_modify, 'recover')
self.stack_recovery = self.guard(stack_transaction, 'execute')
def stack(self):
self.row['stack'] = True
self.record['pending_stack_transaction'] = '/fiction-only/stack.json'
self.original = copy.deepcopy(self.record)
def no_effects(self):
super().no_effects()
for mock in [*self.writes, *self.processes, self.recovery, self.native_recovery, self.stack_recovery]:
mock.assert_not_called()
class PendingRecoveryTests(PendingRecoveryFixture):
def test_single_no_is_distinct_falsy_cancellation(self):
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
self.assertFalse(menu.CANCELLED)
self.no_effects()
def test_single_menu_cancel_is_distinct_cancellation(self):
self.ui.choose.return_value = None
self.assertIs(menu.manage_instance(ROOT/'oci', self.ui, self.row), menu.CANCELLED)
self.ui.review.assert_not_called()
self.no_effects()
def test_real_cli_single_no_and_cancel_return_six_without_duplicate_output(self):
for choice in ('recover', None):
with self.subTest(choice=choice), patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
self.ui.choose.return_value = choice
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
self.assertEqual(output.getvalue(), '')
self.no_effects()
def test_real_cli_stack_no_returns_six_before_reading_the_journal(self):
self.stack()
with patch.object(cli, 'Catalog'), patch.object(Path, 'read_text', side_effect=AssertionError('Journal read after No')) as read:
self.assertEqual(cli.main(['manage', '110', '--action', 'update']), 6)
read.assert_not_called()
self.no_effects()
def test_actual_dialog_no_and_escape_cancel_single_and_stack(self):
dialog = native_ui.DialogUI()
for stacked in (False, True):
if stacked:
self.stack()
for code in (1, 255):
with self.subTest(stack=stacked, code=code):
answers = ([] if stacked else [subprocess.CompletedProcess('dialog', 0, stderr='2')])
answers.append(subprocess.CompletedProcess('dialog', code, stderr=''))
with patch.object(native_ui, 'interactive_ui', return_value=dialog), \
patch.object(dialog, '_run', side_effect=answers):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
self.no_effects()
def test_actual_dialog_cancel_and_escape_from_single_operation_menu(self):
dialog = native_ui.DialogUI()
for code in (1, 255):
with self.subTest(code=code), patch.object(native_ui, 'interactive_ui', return_value=dialog), \
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 6)
self.no_effects()
def test_native_menu_returns_to_its_list_after_pending_single_or_stack_no(self):
for stacked in (False, True):
if stacked:
self.stack()
self.ui.choose.reset_mock()
self.ui.choose.side_effect = ['110', None] if stacked else ['110', 'recover', None]
with self.subTest(stack=stacked), patch.object(menu, '_clean_orphans'), \
patch.object(menu, 'offer_recovery'), patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT/'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 2 if stacked else 3)
self.no_effects()
def test_confirmed_single_recovery_and_status_preserve_exact_commands_and_results(self):
self.ui.review.return_value = True
for action in ('recover', 'status'):
for success, code in ((True, 0), (False, 1)):
self.lifecycle.reset_mock()
self.lifecycle.side_effect = None
self.lifecycle.return_value = success
self.ui.choose.return_value = action
with self.subTest(action=action, result=success):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
self.lifecycle.assert_called_once_with(
[sys.executable, str(ROOT/'oci/remote/oci_instance_transaction.py'), action, '110'],
'Recover OCI' if action == 'recover' else 'OCI management')
self.write.assert_not_called(); self.backup.assert_not_called()
self.assertEqual(self.record, self.original)
def test_confirmed_stack_recovery_preserves_command_and_failure_is_not_cancel(self):
self.stack()
self.ui.review.return_value = True
for success, code in ((True, 0), (False, 1)):
self.lifecycle.reset_mock()
self.lifecycle.side_effect = None
self.lifecycle.return_value = success
with self.subTest(result=success), patch.object(Path, 'read_text', return_value=json.dumps({'plan': {'members': [{'vmid': 110}, {'vmid': 111}]}})):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), code)
self.lifecycle.assert_called_once_with(
[sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--recover', '--acknowledge-external-data'],
'Recover OCI stack')
self.write.assert_not_called(); self.backup.assert_not_called()
self.assertEqual(self.record, self.original)
def test_identity_failure_stays_failure_and_does_not_offer_recovery(self):
with patch.object(menu, 'check_selected', return_value={**self.row, 'reason': 'different-installation'}):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update'), 1)
self.ui.choose.assert_not_called(); self.ui.review.assert_not_called()
self.no_effects()
def test_unattended_does_not_turn_pending_menu_into_an_automatic_restore(self):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, 'update', unattended=True), 1)
self.no_effects()
def test_registration_cli_retains_its_separate_entry_point(self):
with patch.object(cli, 'Catalog'), patch.object(menu, 'direct_recovery', return_value=0) as register, \
patch.object(menu, 'direct_management', side_effect=AssertionError('Registration routed to rollback')):
self.assertEqual(cli.main(['recover']), 0)
register.assert_called_once_with(ROOT/'oci')
self.no_effects()
class RecoverySlovakMeaning(PendingRecoveryFixture):
def test_actual_recovery_dialogs_preserve_host_data_and_bounded_recovery_meaning(self):
cache = json.loads((ROOT/'lang/sk.json').read_text())
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
menu.manage_instance(ROOT/'oci', self.ui, self.row)
text = self.ui.review.call_args.args[0]
self.assertIn('Zdieľané hostiteľské adresáre sa nevrátia späť', text)
self.assertIn('po úspešnom vyčistení', text)
self.assertEqual(self.ui.choose.call_args.args[0], 'Prerušená operácia')
self.assertEqual(dict(self.ui.choose.call_args.args[1])['status'], 'Zobraziť stav')
self.stack()
menu.manage_instance(ROOT/'oci', self.ui, self.row)
self.assertEqual(self.ui.review.call_args.args[1], 'Obnoviť OCI aplikáciu s viacerými kontajnermi')
self.assertIn('môžu zlyhať', self.ui.review.call_args.args[0])
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Obnoviť alebo dokončiť operáciu?')
self.no_effects()
class RecoveryGuardMessages(PendingRecoveryFixture):
def setUp(self):
super().setUp()
cache = json.loads((ROOT/'lang/sk.json').read_text())
for name, value in (('_language', 'sk'), ('_cache', cache)):
p = patch.object(oci_ui, name, value)
p.start(); self.addCleanup(p.stop)
self.record.update(installation_id='fiction-installation', transaction_id='fiction-tx')
self.original = copy.deepcopy(self.record)
self.journal = Path(self.record['pending_transaction'])
self.state = {'id': 'fiction-tx', 'vmid': 110, 'phase': 'backup-ready',
'record': {'installation_id': 'fiction-installation'}, 'before_config': 'before',
'backup': None, 'backup_sha256': 'saved-sha'}
def rejected(self, text, *, node='fiction-host', changed_backup=False):
calls = []
def readonly(*args):
calls.append(args)
if args[0] == 'pvesh':
return json.dumps([{'type': 'lxc', 'vmid': 110, 'node': node}]).encode()
if args == ('pct', 'config', '110'):
return b'changed'
raise AssertionError('Unexpected recovery effect: ' + repr(args))
with patch.object(Path, 'read_text', return_value=json.dumps(self.state)), \
patch.object(Path, 'exists', return_value=True), patch.object(Path, 'is_file', return_value=True), \
patch.object(transaction.socket, 'gethostname', return_value='fiction-host'), \
patch.object(transaction.host_mounts, 'verify_sources'), patch.object(transaction.gpu_devices, 'verify'), \
patch.object(transaction, 'owned', return_value=b'fiction config'), \
patch.object(transaction, 'filehash', return_value='changed-sha' if changed_backup else 'saved-sha'), \
patch.object(transaction, 'run', side_effect=readonly), redirect_stdout(io.StringIO()):
# Only reads before the rejecting guard are supplied. Any next
# command or attempt to persist state fails this fixture.
with self.assertRaisesRegex(ValueError, text):
ACTUAL_RECOVER(ROOT/'oci', self.journal)
self.no_effects()
return calls
def test_foreign_transaction_is_rejected_before_probing_the_container(self):
self.state['record']['installation_id'] = 'different-installation'
self.assertEqual(self.rejected('Záznam nepatrí k tejto operácii'), [])
def test_missing_verified_backup_cannot_touch_a_changed_container(self):
self.assertEqual(len(self.rejected('Chýba overená záloha; zmenený kontajner zostáva nedotknutý')), 2)
def test_changed_backup_is_rejected_before_decompression_or_restore(self):
self.state['backup'] = '/fiction-only/backup.zst'
self.assertEqual(len(self.rejected('Záloha bola zmenená; obnova zablokovaná', changed_backup=True)), 2)
def test_guest_on_another_node_is_never_overwritten(self):
self.assertEqual(len(self.rejected('kontajner je na inom uzle; nie je prepísaný', node='another-node')), 1)
def test_incomplete_stack_recovery_state_fails_before_any_adapter_call(self):
adapter = SimpleNamespace(validate=self.recovery, stop=self.native_recovery,
restore=self.stack_recovery)
state = {'phase': 'recovery-failed', 'plan': {'start_order': [110, 111]},
'running': {'110': True}, 'stop_intent': True, 'replacement_intent': False}
before = copy.deepcopy(state)
with self.assertRaisesRegex(ValueError, 'Záznam operácie obsahuje neúplný stav obnovy'):
stack_transaction.recover_state(self.journal, state, adapter)
self.assertEqual(state, before)
self.no_effects()
def test_critical_safety_and_untouched_result_have_the_correct_meaning(self):
cache = json.loads((ROOT/'lang/sk.json').read_text())
self.assertEqual(cache['A host mount is not part of the journal; recovery blocked'],
'Pripojenie adresára hostiteľa nie je uvedené v zázname operácie; obnova je zablokovaná')
self.assertEqual(cache['Recovery completed. The container had not been modified yet.'],
'Obnova dokončená. Kontajner ešte nebol zmenený.')
self.assertEqual(cache['A backup was modified'], 'Záloha bola zmenená')
self.assertEqual(cache['Recovering the previous installation'], 'Obnovovanie predchádzajúcej inštalácie')
self.assertEqual(cache['Recover the previous installation'], 'Obnoviť predchádzajúcu inštaláciu')
self.no_effects()
@@ -0,0 +1,234 @@
"""Restored-app registration through real Menu/CLI/Dialog; no host effects."""
from contextlib import nullcontext, redirect_stdout
import copy
import io
import json
from pathlib import Path
import string
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui
import oci_restore_recovery as restored
import oci_ui
class RegistrationFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.found = [{'vmid':110, 'hostname':'fiction-primary'}]
self.original_found = copy.deepcopy(self.found)
self.ui.confirm.return_value = False
item = patch.object(menu, 'restored_applications', side_effect=lambda project:self.found)
self.pending = item.start();self.addCleanup(item.stop)
self.firewall = self.guard(menu, '_restored_firewall_rules')
self.backend = [self.guard(restored, name) for name in ('recover','register','run')]
self.files = [self.guard(Path, name) for name in ('write_text','write_bytes','unlink')]
self.processes = [self.guard(subprocess, name) for name in ('run','Popen','check_output')]
def members(self):
self.found.append({'vmid':111,'hostname':'fiction-dependent'})
self.original_found = copy.deepcopy(self.found)
def no_effects(self):
super().no_effects()
self.firewall.assert_not_called()
self.safe_backend()
def safe_backend(self):
for item in [*self.backend,*self.files,*self.processes]:item.assert_not_called()
self.assertEqual(self.found,self.original_found)
def accepted(self, answers, completed=True):
self.ui.confirm.side_effect=answers
self.firewall.side_effect=None
self.firewall.return_value=[{'vmid':row['vmid'],'port':61208 if row['vmid']==110 else 19999,
'source':'192.0.2.0/24'} for row in self.found]
self.lifecycle.side_effect=None;self.lifecycle.return_value=completed
self.pending.side_effect=lambda project:[] if completed and self.lifecycle.called else self.found
class RegistrationCancellationTests(RegistrationFixture):
def test_offer_no_is_explicit_falsy_cancellation(self):
self.assertIs(menu.offer_recovery(ROOT/'oci',self.ui),menu.CANCELLED)
self.assertFalse(menu.CANCELLED);self.no_effects()
def test_direct_single_no_finishes_before_a_third_pending_read(self):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
self.assertEqual(self.pending.call_count,2)
self.no_effects()
def test_real_cli_single_no_returns_six_without_output(self):
with patch.object(cli,'Catalog'),redirect_stdout(io.StringIO()) as output:
self.assertEqual(cli.main(['recover']),6)
self.assertEqual(output.getvalue(),'');self.no_effects()
def test_real_cli_multi_container_no_is_not_a_guest_action(self):
self.members()
with patch.object(cli,'Catalog'):
self.assertEqual(cli.main(['recover']),6)
text=self.ui.confirm.call_args.args[0]
self.assertIn('CT 110',text);self.assertIn('CT 111',text)
self.no_effects()
def test_actual_dialog_initial_no_and_escape_for_single_and_multi(self):
dialog=native_ui.DialogUI()
for multi in (False,True):
if multi:self.members()
for code in (1,255):
with self.subTest(multi=multi,code=code),patch.object(native_ui,'interactive_ui',return_value=dialog), \
patch.object(dialog,'_run',return_value=subprocess.CompletedProcess('dialog',code,stderr='')):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
self.no_effects()
def test_native_menu_continues_to_list_after_registration_no(self):
self.ui.choose.return_value=None
with patch.object(menu,'_clean_orphans'),patch.object(menu,'_records_unread',return_value=[]):
menu.interactive_management(ROOT/'oci',self.ui)
self.ui.choose.assert_called_once();self.no_effects()
def test_optional_firewall_and_start_no_registers_without_flags(self):
self.members();self.accepted([True,False,False,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0],
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
self.assertEqual(self.ui.confirm.call_count,4);self.safe_backend()
def test_single_optional_no_preserves_registration_without_start(self):
self.accepted([True,False,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0],
[menu.sys.executable,str(ROOT/'oci/remote/oci_restore_recovery.py'),'recover'])
self.assertEqual(self.ui.confirm.call_count,3);self.safe_backend()
def test_only_one_confirmed_firewall_is_forwarded_and_no_start(self):
self.members();self.accepted([True,False,True,False])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0][-2:],['--host-firewall','111'])
self.assertNotIn('--start',self.lifecycle.call_args.args[0]);self.safe_backend()
def test_explicit_start_yes_retains_flag_but_never_starts_any_guest_here(self):
self.accepted([True,False,True])
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.lifecycle.call_args.args[0][-1],'--start');self.safe_backend()
def test_failed_registration_stays_failure_with_pending_records(self):
self.accepted([True,False,False],completed=False)
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
self.safe_backend()
def test_empty_list_is_success_without_confirmation_or_backend(self):
self.found.clear();self.original_found=[]
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.ui.confirm.assert_not_called();self.ui.message.assert_called_once();self.no_effects()
def test_root_identity_failure_never_probes_restored_apps(self):
with patch.object(menu.os,'geteuid',return_value=1000):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),1)
self.pending.assert_not_called();self.ui.confirm.assert_not_called();self.no_effects()
def test_empty_offer_does_not_cancel_the_native_menu(self):
self.found.clear();self.original_found=[]
self.assertIsNone(menu.offer_recovery(ROOT/'oci',self.ui))
self.ui.confirm.assert_not_called();self.no_effects()
def test_empty_registration_state_is_rendered_in_slovak_without_recovery(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
with patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
self.found.clear();self.original_found=[]
self.assertEqual(menu.direct_recovery(ROOT/'oci'),0)
self.assertEqual(self.ui.message.call_args.args[0],
'Žiadna OCI aplikácia obnovená zo zálohy nečaká na opätovnú registráciu.')
self.no_effects()
def test_initial_single_and_multi_dialog_describes_registration_precheck(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
for multi in (False,True):
if multi:self.members()
with self.subTest(multi=multi),patch.object(i18n,'_language','sk'),patch.object(i18n,'_cache',catalog):
self.assertEqual(menu.direct_recovery(ROOT/'oci'),6)
text=self.ui.confirm.call_args.args[0]
self.assertIn('Pred opätovnou registráciou',text)
self.assertNotIn('Obnova najprv',text)
self.assertIn('aplikácie sa potom nespúšťajú automaticky',text)
self.no_effects()
def test_real_backend_blocker_stays_error_and_uses_registration_wording(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
plan={'title':'Fictional restored application','restored':[{'vmid':110}],
'blockers':['Fictional missing resource'],'notes':[]}
with patch.object(menu.sys,'argv',['fixture','recover','--root','/fiction-only']), \
patch.object(restored.instances,'locked',return_value=nullcontext()), \
patch.object(restored,'prepare',return_value=[plan]), \
patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
patch.object(restored,'msg_error') as error,patch.object(restored,'msg_info2'):
self.assertEqual(restored.main(),1)
error.assert_called_once_with('Túto aplikáciu zatiaľ nemožno znova zaregistrovať:')
self.no_effects()
def test_real_host_resource_check_reports_missing_data_without_creating_anything(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
plan={'restored':[{'vmid':110,'config':'mp0: /fiction-only/data,mp=/data\n',
'copy':{'record':{'deployment':{},'observed':{}}}}], 'blockers':[],'notes':[]}
with patch.object(oci_ui,'_language','sk'),patch.object(oci_ui,'_cache',catalog), \
patch.object(restored.os.path,'isdir',return_value=False), \
patch.object(restored,'firewall_rule',return_value=None):
restored.check_host_resources(plan)
self.assertEqual(len(plan['blockers']),1)
self.assertIn('/fiction-only/data',plan['blockers'][0])
self.assertIn('registráciu zopakujte',plan['blockers'][0])
self.no_effects()
class RegistrationContractTests(unittest.TestCase):
def test_real_monitor_shell_recover_keeps_global_cli_contract_and_exit_six(self):
with tempfile.TemporaryDirectory() as folder:
base=Path(folder);(base/'scripts/oci').mkdir(parents=True)
(base/'utils.sh').write_text('load_language() { :; }\ninitialize_cache() { :; }\n')
(base/'scripts/oci/oci_manager_apps.sh').write_text('printf "%s\\n" "$@"\nexit 6\n')
script=(ROOT/'scripts/oci/manage_instance.sh').read_text()
script=script.replace('/usr/local/share/proxmenux',folder)
import os
result=subprocess.run(['bash','-c',script],capture_output=True,text=True,
env={**os.environ,'ACTION':'recover','VMID':'not-a-vmid'})
self.assertEqual(result.returncode,6);self.assertEqual(result.stdout,'recover\n')
self.assertEqual(result.stderr,'')
def test_curated_slovak_registration_words_and_format_fields(self):
catalog=json.loads((ROOT/'lang/sk.json').read_text())
expected={
'Recover restored OCI applications':'Registrácia',
'No restored OCI application is waiting to be recovered.':'registráciu',
'Recover them now?':'zaregistrovať',
'This application cannot be recovered yet:':'zaregistrovať',
'The application could not be recovered:':'zaregistrovať',
'Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.':'vyberte Nie',
'it is locked or its disk could not be mounted':'uzamknutý',
'The local storage does not accept snippets':'local',
'it uses an NVIDIA GPU and this host has no working NVIDIA driver and Container Toolkit. Install them and run the recovery again.':'NVIDIA Container Toolkit',
'the NVIDIA hook of the container is not available:':'spúšťací skript',
'its Rclone mount is published again on this host at':'v ceste',
'Choose another bridge for the container in Proxmox (Network) and run the recovery again.':'registráciu',
'Mount or create it with its data and run the recovery again.':'registráciu',
'Connect it, or remove it from the container in Proxmox (Resources), and run the recovery again.':'registráciu',
}
fields=lambda text:sorted(f for _,f,_,_ in string.Formatter().parse(text) if f)
for key,word in expected.items():
with self.subTest(key=key):
self.assertIn(word,catalog[key]);self.assertEqual(fields(key),fields(catalog[key]))
def test_registration_web_text_is_distinct_from_previous_installation_recovery(self):
for locale in ('en','sk'):
data=json.loads((ROOT/'AppImage/messages'/locale/'common.json').read_text())['vmLxc']
with self.subTest(locale=locale):
self.assertEqual(data['ociUpdates']['registerRestored'],
'Znova zaregistrovať' if locale=='sk' else 'Register again')
self.assertEqual(data['ociUpdates']['recover'],'Obnoviť' if locale=='sk' else 'Recover')
self.assertIn('zaregistrovať' if locale=='sk' else 'Register again',data['updates']['ociRestoredBody'])
self.assertIn('Zaregistrovať' if locale=='sk' else 'Register',data['ociUpdates']['terminalTitleRecover'])
if __name__=='__main__':unittest.main()
@@ -0,0 +1,235 @@
"""Actual removal UI/guards in SK and EN; real host effects are forbidden."""
from contextlib import nullcontext
import copy
import json
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import i18n, management
from proxmenux_oci.ui import UserCancelled
import oci_instances as instances
import oci_remove as removal
import oci_ui
class RemovalSlovakTests(unittest.TestCase):
def setUp(self):
cache = json.loads((ROOT / 'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in (('_language', 'sk'), ('_cache', cache)):
p = patch.object(module, name, value)
p.start(); self.addCleanup(p.stop)
self.records = {}
self.make_records(2)
self.read = self.start(instances, 'read', side_effect=lambda root, vmid: self.records[vmid])
self.start(management, '_selected_configuration', side_effect=self.config)
self.bridge = self.start(removal, 'bridge_in_use', return_value=False)
self.ui = Mock()
self.ui.review.return_value = False
self.forbidden = []
for module, name in ((subprocess, 'run'), (subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes'), (Path, 'unlink'),
(removal.shutil, 'rmtree'), (instances, 'write'), (instances, 'command'),
(management, '_run_lifecycle'), (removal, 'run'),
(removal, 'remove_host_state'), (removal.oci_console, 'remove_log'),
(removal, 'release_bridge'), (removal, 'remove_owned_host_firewall'),
(removal, 'release_shared_host_files'), (removal.work_backup, 'forget'),
(removal.image_cache, 'prune')):
self.forbidden.append(self.start(module, name, side_effect=AssertionError('Forbidden effect: ' + name)))
def start(self, module, name, **kwargs):
p = patch.object(module, name, **kwargs)
result = p.start(); self.addCleanup(p.stop)
return result
def make_records(self, count):
self.records.clear()
for offset in range(count):
vmid = 999990 + offset
self.records[vmid] = {'vmid': vmid, 'installation_id': 'fixture-' + str(vmid),
'template': {'catalog_ui': {'title': 'Fixture App'}},
'deployment': {'mounts': [{'type': 'host-bind', 'source': '/fixture/saved',
'container_path': '/saved'}]}}
if offset:
self.records[vmid]['stack_member'] = {'primary_vmid': 999990}
if count > 1:
self.records[999990]['stack'] = {'members': [{'vmid': vmid} for vmid in self.records],
'deployment': {'network': {'private_bridge': 'vmbr999', 'private_subnet': '10.77.99.0/24'}}}
self.records[999990]['deployment']['host_firewall'] = {'port': 8080}
self.original = copy.deepcopy(self.records)
def config(self, vmid):
return (f'hostname: fixture-{vmid}\nrootfs: fixture:vm-{vmid}-disk-0,size=8G\n'
f'mp0: fixture:vm-{vmid}-disk-1,mp=/data,size=16G\n'
'mp1: /fixture/shared,mp=/shared\n').encode()
def tearDown(self):
self.assertEqual(self.records, self.original)
for effect in self.forbidden:
effect.assert_not_called()
def test_single_summary_separates_deleted_disks_and_retained_host_paths(self):
self.make_records(1)
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Kontajnery určené na odstránenie:', text)
self.assertIn('Údaje kontajnera určené na vymazanie:', text)
self.assertIn('/data (16G)', text)
kept = text.split('Cesty hostiteľa nájdené')[1]
self.assertIn('/fixture/shared', kept); self.assertIn('/fixture/saved', kept)
self.assertNotIn('/fixture/shared', text.split('Cesty hostiteľa nájdené')[0])
self.assertNotIn('súkromnej siete:', text)
def test_primary_summary_counts_are_grammatical_for_two_three_and_five(self):
for count in (2, 3, 5):
with self.subTest(count=count):
self.make_records(count)
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn(f'Počet kontajnerov aplikácie Fixture App: {count}.', text)
self.assertIn('Všetky sú určené na odstránenie.', text)
for vmid in self.records:
self.assertIn(f'CT {vmid}', text)
def test_member_summary_warns_that_entire_application_is_selected(self):
text = management._removal_summary(ROOT / 'oci', 999991)
self.assertIn('CT 999991 patrí do aplikácie Fixture App (počet kontajnerov: 2).', text)
self.assertIn('odstránenie celej aplikácie', text)
self.assertNotIn('nádob', text)
def test_free_bridge_is_only_targeted_and_firewall_removal_is_conditional(self):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Súkromná sieť určená na uvoľnenie, ak ju nepoužíva iná VM alebo iný CT: vmbr999', text)
self.assertIn('Môže sa odstrániť aj zodpovedajúce spravované pravidlo firewallu hostiteľa.', text)
def test_shared_bridge_is_kept_and_not_reported_as_released(self):
self.bridge.return_value = True
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('Súkromná sieť zachovaná', text)
self.assertNotIn('určená na uvoľnenie', text)
def test_missing_config_still_discloses_saved_host_paths_without_deleting(self):
with patch.object(management, '_selected_configuration', side_effect=OSError('fixture missing')):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertIn('/fixture/saved', text)
self.assertIn('rootfs -', text)
def test_no_from_real_remove_review_never_calls_lifecycle(self):
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999991))
review = self.ui.review.call_args
self.assertFalse(review.kwargs['default'])
self.assertEqual(review.kwargs['question'],
'Odstrániť aplikáciu? Jej kontajnerové disky sa vymažú a obnoviť ich možno iba zo zálohy.')
def test_cancelled_real_remove_review_never_calls_lifecycle(self):
self.ui.review.side_effect = UserCancelled('fixture cancelled')
with self.assertRaises(UserCancelled):
management._remove(ROOT / 'oci', self.ui, 999990)
def test_preparation_error_does_not_show_confirmation_or_call_lifecycle(self):
with patch.object(removal, 'members_of', side_effect=ValueError('fixture record invalid')):
self.assertFalse(management._remove(ROOT / 'oci', self.ui, 999990))
self.ui.review.assert_not_called()
self.assertIn('Odstránenie nebolo možné pripraviť:', self.ui.message.call_args.args[0])
def test_pending_operation_error_precedes_configuration_probe_and_all_mutations(self):
self.make_records(1)
self.records[999990]['pending_transaction'] = {'fixture': True}
self.original = copy.deepcopy(self.records)
with patch.object(removal, 'guest_config') as config:
with self.assertRaisesRegex(ValueError, 'Operácia tejto inštalácie sa ešte nedokončila'):
removal.remove(Path('/fixture/oci'), 999990)
config.assert_not_called()
def test_container_on_other_node_errors_before_host_cleanup(self):
with patch.object(removal, 'guest_config', return_value=None), \
patch.object(removal, 'guest_node', return_value='fixture-other-node'):
with self.assertRaisesRegex(ValueError, 'Kontajner beží na inom uzle klastra'):
removal.remove(Path('/fixture/oci'), 999990)
def run_main(self, result=False, busy=False):
with patch.object(sys, 'argv', ['oci_remove.py', '999990']), \
patch.object(removal.os, 'geteuid', return_value=0), \
patch.object(instances, 'locked', side_effect=BlockingIOError if busy else None,
return_value=nullcontext()), \
patch.object(removal, 'remove', return_value=result) as mocked_remove, \
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_error') as error:
code = removal.main()
return code, mocked_remove, ok, error
def test_busy_registry_error_does_not_enter_removal(self):
code, remove, ok, error = self.run_main(busy=True)
self.assertEqual(code, 1); remove.assert_not_called(); ok.assert_not_called()
self.assertEqual(error.call_args.args[0], 'Register inštancií používa iná operácia OCI')
def test_partial_completion_is_a_warning_to_review_not_unqualified_success(self):
code, remove, ok, error = self.run_main(result=True)
self.assertEqual(code, 0); error.assert_not_called()
self.assertEqual(ok.call_args.args[0],
'Príkaz na odstránenie sa dokončil; skontrolujte všetky upozornenia vyššie.')
self.assertNotEqual(ok.call_args.args[0], i18n.translate('The application was removed'))
def test_clean_completion_keeps_existing_success_contract(self):
code, remove, ok, error = self.run_main()
self.assertEqual(code, 0); error.assert_not_called()
self.assertEqual(ok.call_args.args[0], 'Aplikácia bola odstránená')
def test_english_primary_and_member_summaries_keep_identical_sentences(self):
with patch.object(i18n, '_language', 'en'):
primary = management._removal_summary(ROOT / 'oci', 999990)
member = management._removal_summary(ROOT / 'oci', 999991)
self.assertTrue(primary.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
self.assertTrue(member.startswith('CT 999991 is one of the 2 containers of Fixture App. It cannot be removed on its own,'))
def test_missing_translation_falls_back_to_the_same_english_summary(self):
with patch.object(i18n, '_cache', {}):
text = management._removal_summary(ROOT / 'oci', 999990)
self.assertTrue(text.startswith('Fixture App runs in 2 containers. All of them are targeted for removal.\n'))
def test_yes_only_builds_the_existing_command_when_lifecycle_is_inert(self):
self.ui.review.return_value = True
with patch.object(management, '_run_lifecycle', return_value=True) as inert:
self.assertTrue(management._remove(ROOT / 'oci', self.ui, 999990))
self.assertEqual(inert.call_args.args[0],
[sys.executable, str(ROOT / 'oci/remote/oci_remove.py'), '999990'])
def test_single_and_stack_menu_routes_use_the_translated_label_and_real_no_review(self):
for count in (1, 2):
with self.subTest(count=count):
self.make_records(count)
self.ui.reset_mock()
self.ui.choose.return_value = 'remove'
row = {'vmid': 999990, 'stack': count > 1, 'pending': False,
'status': 'installed', 'reason': 'matched'}
with patch.object(management, 'carry_records', return_value={}), \
patch.object(management, 'check_selected', return_value=row):
self.assertFalse(management.manage_instance(ROOT / 'oci', self.ui, row))
options = self.ui.choose.call_args.args[1]
self.assertIn(('remove', 'Odstrániť: zmazať aplikáciu a jej kontajnery'), options)
self.ui.review.assert_called_once()
self.assertFalse(self.ui.review.call_args.kwargs['default'])
def test_changed_identity_warning_does_not_claim_the_other_container_was_deleted(self):
self.make_records(1)
# Exercise the real backend branch; record cleanup is inert as well.
with patch.object(removal, 'guest_config', return_value=b'fixture'), \
patch.object(instances, 'identity', return_value='fixture-other-identity'), \
patch.object(removal, 'remove_owned_host_firewall', return_value=True), \
patch.object(removal, 'release_shared_host_files'), \
patch.object(removal.work_backup, 'forget'), \
patch.object(removal.image_cache, 'prune', return_value=[]), \
patch.object(Path, 'exists', return_value=False), \
patch.object(Path, 'is_dir', return_value=False), \
patch.object(removal, 'msg_warn') as warning, \
patch.object(removal, 'msg_ok') as ok, patch.object(removal, 'msg_info'):
self.assertTrue(removal.remove(Path('/fixture/oci'), 999990))
self.assertEqual(warning.call_args.args[0],
'VMID teraz patrí inému kontajneru; ten zostane nedotknutý: CT 999990')
self.assertNotIn('Odstránený kontajner:', str(ok.mock_calls))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,239 @@
"""Exercise the multi-container editor in Slovak without applying a change."""
from contextlib import redirect_stderr
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import host, i18n, installer, management, stack_recreation as editor
import oci_immich_recognition as recognition
import oci_instances as instances
import oci_stack_modify as modify
import oci_ui
CONFIG = {
'rootfs': 'fixture:vm-999991-disk-0,size=8G',
'mp0': 'fixture:vm-999991-disk-1,mp=/opt/recipes/mediafiles,size=8G',
'mp1': 'fixture:vm-999991-disk-2,mp=/extra,size=4G',
'mp2': '/fixture/shared,mp=/shared',
'dev0': 'path=/dev/ttyACM7,mode=0660',
}
class StackEditorSlovakTests(unittest.TestCase):
def setUp(self):
self.translations = json.loads((ROOT / 'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in (('_language', 'sk'), ('_cache', self.translations)):
item = patch.object(module, name, value)
item.start()
self.addCleanup(item.stop)
self.member = {'vmid': 999991, 'stack_member': {'name': 'application'},
'deployment': {'replay_profile': {'adapter': 'install_tandoor_stack.sh',
'role': 'application'}}}
self.primary = {'vmid': 999990, 'stack': {'members': [self.member,
{'vmid': 999992, 'deployment': {'replay_profile': {'role': 'database'}}}]}}
self.original = copy.deepcopy(self.primary)
self.ui = Mock()
self.ui.choose.return_value = None
self.ui.confirm.return_value = False
self.ui.review.return_value = False
self.ui.checklist.side_effect = lambda text, options, defaults: list(defaults)
config = patch.object(editor, '_config', return_value=CONFIG)
config.start()
self.addCleanup(config.stop)
self.forbidden = []
for target, name in ((subprocess, 'run'), (subprocess, 'Popen'),
(subprocess, 'check_output'), (Path, 'write_text'),
(Path, 'write_bytes'), (instances, 'write'),
(instances, 'command'), (modify, 'apply'),
(recognition, 'apply'), (recognition, 'configure'),
(management, '_run_lifecycle')):
item = patch.object(target, name, side_effect=AssertionError('Forbidden effect: ' + name))
self.forbidden.append(item.start())
self.addCleanup(item.stop)
self.lifecycle = Mock(side_effect=AssertionError('Editor called lifecycle'))
self.forbidden.append(self.lifecycle)
def tearDown(self):
self.assertEqual(self.primary, self.original)
for callback in self.forbidden:
callback.assert_not_called()
def shown(self):
return '\n'.join(str(call) for call in self.ui.mock_calls)
def immich(self, acceleration='cpu'):
self.member['deployment']['replay_profile'] = {
'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}
self.member['deployment']['machine_learning'] = {'acceleration': acceleration}
self.original = copy.deepcopy(self.primary)
return self.member
def changes(self, **values):
return {'add_mounts': [], 'remove_mounts': [], 'add_devices': [],
'remove_devices': [], **values}
def test_editor_only_offers_extra_paths_and_leaves_database_out(self):
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
shown = self.shown()
self.assertIn('odznačené sa odstránia po potvrdení', shown)
self.assertIn('odznačené sa odpoja po potvrdení', shown)
options = self.ui.checklist.call_args_list[0].args[1]
self.assertEqual([path for path, label in options], ['/extra', '/shared'])
self.assertNotIn('zásobník', shown)
def test_declining_volume_deletion_keeps_it_and_only_detaches_host_path_in_draft(self):
self.ui.checklist.side_effect = [[], ['/dev/ttyACM7']]
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
warning = self.ui.confirm.call_args_list[0].args[0]
self.assertIn('trvalé úložisko aj všetky údaje v ňom', warning)
self.assertIn('/extra', warning)
summary = self.ui.review.call_args.args[0]
self.assertIn('- /shared', summary)
self.assertNotIn('- /extra', summary)
self.assertIn('Spustený kontajner sa reštartuje', summary)
self.assertIn('zastavený zostane zastavený', summary)
self.assertIn('Databáza ani ostatné kontajnery sa nemenia', summary)
def test_declined_review_describes_confirmed_volume_deletion_without_performing_it(self):
self.ui.checklist.side_effect = [[], []]
self.ui.confirm.side_effect = [True, False, False]
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('- /extra', self.ui.review.call_args.args[0])
self.assertIn('vymažú aj údaje', self.ui.review.call_args.args[0])
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Použiť tieto zmeny?')
def test_member_selection_cancels_before_any_configuration_read(self):
other = copy.deepcopy(self.member)
other['vmid'] = 999993
self.primary['stack']['members'].append(other)
self.original = copy.deepcopy(self.primary)
with patch.object(editor, '_config') as read:
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
read.assert_not_called()
self.assertEqual(self.ui.choose.call_args.args[0], 'Kontajner, ktorý chcete upraviť')
def test_missing_member_error_uses_application_language(self):
self.primary['stack']['members'] = []
self.original = copy.deepcopy(self.primary)
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('Pre túto aplikáciu nie je uložený zoznam kontajnerov.', self.shown())
self.assertIn('Správa OCI aplikácie s viacerými kontajnermi', self.shown())
def test_immich_action_menu_identifies_recognition_instead_of_video_transcoding(self):
self.immich()
self.assertFalse(editor.modify_stack(ROOT / 'oci', self.ui, self.primary, self.lifecycle))
self.assertIn('Vybrať CPU alebo GPU pre rozpoznávanie', self.shown())
self.assertIn('Upraviť pripojené cesty a zariadenia', self.shown())
def test_cpu_only_host_explains_why_no_gpu_choice_is_offered(self):
member = self.immich()
with patch.object(host, 'gpus', return_value={'intel': [], 'amd': [], 'nvidia': []}):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
self.assertIn('nenašla použiteľná grafická karta', self.shown())
self.assertIn('Rozpoznávanie zostáva na CPU.', self.shown())
self.ui.review.assert_not_called()
def test_gpu_review_explains_whole_application_update_and_kept_data_before_no(self):
member = self.immich()
self.ui.choose.return_value = 'openvino'
found = {'intel': ['/dev/dri/renderD128'], 'amd': [], 'nvidia': []}
with patch.object(host, 'gpus', return_value=found):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
choices = self.ui.choose.call_args.args[1]
self.assertEqual(choices, [('cpu', 'CPU'), ('openvino', 'Intel GPU')])
text = self.ui.review.call_args.args[0]
for fragment in ('CPU → Intel GPU', 'pamäť modelov, knižnica a databáza sa zachovajú',
'Celá aplikácia sa zastaví a aktualizuje', 'ProxMenux sa pokúsi obnoviť',
'ponuky správy OCI'):
self.assertIn(fragment, text)
self.assertEqual(self.ui.review.call_args.kwargs['question'],
'Použiť zvolený CPU/GPU pre rozpoznávanie?')
def test_unchanged_recognition_does_not_open_confirmation_or_apply(self):
member = self.immich()
self.ui.choose.return_value = 'cpu'
with patch.object(editor, 'recognition_choices', return_value=[('cpu', 'CPU', {}),
('cuda', 'NVIDIA GPU', {})]):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
self.ui.review.assert_not_called()
self.assertIn('Nič sa nezmenilo.', self.shown())
def test_experimental_rocm_can_be_declined_before_final_review(self):
member = self.immich()
self.ui.choose.return_value = 'rocm'
choices = [('cpu', 'CPU', {}), ('rocm', 'AMD GPU', {'experimental': True})]
with patch.object(editor, 'recognition_choices', return_value=choices), \
patch.object(host, 'amd_gpu_name', return_value='Fixture AMD'):
self.assertFalse(editor.change_recognition(ROOT / 'oci', self.ui, self.primary,
member, self.lifecycle))
text = self.ui.confirm.call_args.args[0]
self.assertIn('ROCm túto grafickú kartu oficiálne nepodporuje.', text)
self.assertIn('GPU prestať reagovať', text)
self.assertIn('bez preinštalovania', text)
self.assertIn('Použiť napriek tomu GPU s ROCm?', text)
self.assertFalse(self.ui.confirm.call_args.args[1])
self.ui.review.assert_not_called()
def test_backend_invalid_volume_size_preserves_path_and_fails_before_mutation(self):
mount = {'type': 'managed-volume', 'container_path': '/new-data',
'size_gb': 0, 'source': 'fixture'}
with patch.object(modify, 'entries', return_value={}):
with self.assertRaisesRegex(ValueError, 'Neplatná veľkosť trvalého úložiska: /new-data'):
modify.validate(999991, self.changes(add_mounts=[mount]))
def test_backend_unsupported_device_explains_separate_profile_before_mutation(self):
device = {'kind': 'character-device', 'host_path': '/dev/sda'}
with patch.object(modify, 'entries', return_value={}):
with self.assertRaisesRegex(ValueError, 'NVIDIA a celé stromy zariadení vyžadujú iný profil: /dev/sda'):
modify.validate(999991, self.changes(add_devices=[device]))
def test_root_error_is_localized_without_starting_the_operation(self):
with patch.object(modify.os, 'geteuid', return_value=1000), \
patch.object(sys, 'argv', ['fixture', '999991', '--changes', '/not-read.json']), \
redirect_stderr(io.StringIO()) as output:
with self.assertRaises(SystemExit) as result:
modify.main()
self.assertEqual(result.exception.code, 2)
self.assertIn('Vyžadujú sa oprávnenia používateľa root', output.getvalue())
self.assertNotIn('required', output.getvalue())
def test_invalid_recognition_profile_is_localized_without_any_gpu_probe(self):
with self.assertRaisesRegex(ValueError, 'Profil GPU pre Immich nebol overený'):
recognition.validate('unknown', None, None)
def test_recognition_errors_distinguish_unchanged_from_incomplete_operation(self):
incomplete = RuntimeError('fixture diagnostic')
incomplete.recognition_kept = False
for error, expected in ((ValueError('fixture diagnostic'), 'Spôsob rozpoznávania v Immichu sa nezmenil:'),
(incomplete, 'Zmenu CPU/GPU pre rozpoznávanie v Immichu sa nepodarilo dokončiť:')):
with self.subTest(error=type(error).__name__), \
patch.object(recognition.os, 'geteuid', return_value=0), \
patch.object(sys, 'argv', ['fixture', '999990', '--acceleration', 'cpu']), \
patch.object(recognition, 'change', side_effect=error), \
patch.object(recognition, 'msg_error') as message:
self.assertEqual(recognition.main(), 1)
self.assertEqual(message.call_args.args[0], expected + ' fixture diagnostic')
def test_english_and_missing_translation_fallback_preserve_source_text(self):
changes = self.changes(remove_mounts=['/extra'])
for language, cache in (('en', self.translations), ('sk', {})):
with self.subTest(language=language, empty_cache=not cache), \
patch.object(i18n, '_language', language), patch.object(i18n, '_cache', cache):
text = editor.summary(self.member, changes)
self.assertIn('A running container is restarted', text)
self.assertIn('- /extra', text)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,196 @@
"""Coordinated update/recreate contracts; no host lifecycle is permitted."""
import copy
import json
from pathlib import Path
import subprocess
import sys
from unittest.mock import patch
from test_oci_update_cancellation import CancellationFixture, ROOT
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
import oci_instances as instances
import oci_stack_plan as stack_plan
import oci_stack_transaction as transaction
import oci_ui
class StackFixture(CancellationFixture):
def setUp(self):
super().setUp()
self.row['stack'] = True
self.record.update(installation_id='fiction-main', stack={
'id': 'fiction-main', 'members': [
{'vmid': vmid, 'installation_id': identity, 'status': 'installed',
'stack_member': {'stack_id': 'fiction-main', 'primary_vmid': 110}}
for vmid, identity in [(110, 'fiction-main'), (111, 'fiction-dependent')]],
'deployment': {'services': [{'vmid': 111}, {'vmid': 110}]}})
self.original = copy.deepcopy(self.record)
self.extra_effects = [self.guard(Path, name) for name in ('write_text', 'write_bytes', 'unlink')]
self.extra_effects += [self.guard(subprocess, name) for name in ('run', 'Popen', 'check_output')]
self.extra_effects += [self.guard(transaction, 'execute'), self.guard(transaction, 'save'),
self.guard(menu.images, 'offer_removal')]
def no_effects(self):
super().no_effects()
for effect in self.extra_effects:
effect.assert_not_called()
class StackCancellationTests(StackFixture):
def test_update_and_recreate_no_are_falsy_distinct_cancellation(self):
for action in ('update', 'recreate'):
with self.subTest(action=action):
result = menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
self.assertIs(result, menu.CANCELLED)
self.assertFalse(result)
self.no_effects()
def test_real_cli_cancel_returns_six_for_both_actions(self):
for action in ('update', 'recreate'):
with self.subTest(action=action), patch.object(cli, 'Catalog'):
self.assertEqual(cli.main(['manage', '110', '--action', action]), 6)
self.no_effects()
def test_actual_dialog_no_and_escape_return_six_before_any_effect(self):
dialog = native_ui.DialogUI()
for action in ('update', 'recreate'):
for code in (1, 255):
with self.subTest(action=action, code=code), \
patch.object(native_ui, 'interactive_ui', return_value=dialog), \
patch.object(dialog, '_run', return_value=subprocess.CompletedProcess('dialog', code, stderr='')):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 6)
self.no_effects()
def test_confirmation_defaults_and_member_count_are_preserved(self):
for action, default in [('update', True), ('recreate', False)]:
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
args, kwargs = self.ui.review.call_args
self.assertIn('2 containers', args[0])
self.assertIn('main CT: 110', args[0])
self.assertEqual(kwargs['default'], default)
self.no_effects()
def test_native_menu_returns_to_the_application_list_after_no(self):
for action in ('update', 'recreate'):
with self.subTest(action=action):
self.ui.choose.reset_mock()
self.ui.choose.side_effect = ['110', action, None]
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT/'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 3)
self.no_effects()
def test_batch_cancel_never_checks_the_next_application(self):
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 112, 'title': 'Second'}]
self.ui.checklist.return_value = ['all']
self.ui.review.side_effect = [True, False]
with patch.object(updates, 'UpdateCheck') as checker, \
patch.object(menu, 'saved_inventory', return_value=[self.row, {**self.row, 'vmid': 112}]), \
patch.object(menu, 'check_selected', side_effect=lambda project, row: row) as selected:
checker.return_value.status.return_value = {'available': available}
self.assertFalse(updates.select_updates(ROOT/'oci', self.ui, available))
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
self.no_effects()
def test_rejected_backup_remains_failure_one_without_dispatch(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = False
for action in ('update', 'recreate'):
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action), 1)
self.lifecycle.assert_not_called()
self.assertEqual(self.record, self.original)
def test_positive_confirmation_preserves_success_failure_and_exact_commands(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
for action in ('update', 'recreate'):
for result in (False, True):
self.lifecycle.reset_mock()
self.lifecycle.return_value = result
with patch.object(menu.images, 'offer_removal') as offer:
self.assertEqual(menu.direct_management(ROOT/'oci', 110, action,
lifecycle_args=('--keep-backup',)), 0 if result else 1)
expected = [sys.executable, str(ROOT/'oci/remote/oci_stack_native.py'), '110', '--keep-backup']
if action == 'recreate':
expected += ['--operation', 'recreate']
self.assertEqual(self.lifecycle.call_args.args[0], expected+['--acknowledge-external-data'])
self.assertEqual(offer.call_count, int(result))
self.assertEqual(self.record, self.original)
self.write.assert_not_called()
self.command.assert_not_called()
def test_acknowledgement_is_not_duplicated(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = False
menu.direct_management(ROOT/'oci', 110, 'recreate', lifecycle_args=('--acknowledge-external-data',))
self.assertEqual(self.lifecycle.call_args.args[0].count('--acknowledge-external-data'), 1)
class StackSlovakTests(StackFixture):
def setUp(self):
super().setUp()
self.catalog = json.loads((ROOT/'lang/sk.json').read_text())
for module in (i18n, oci_ui):
for name, value in [('_language', 'sk'), ('_cache', self.catalog)]:
item = patch.object(module, name, value)
item.start()
self.addCleanup(item.stop)
def test_questions_and_titles_name_the_whole_application(self):
for action, question in [('update', 'Aktualizovať celú aplikáciu?'),
('recreate', 'Znovu vytvoriť všetky kontajnery aplikácie?')]:
menu.manage_instance(ROOT/'oci', self.ui, self.row, action=action)
self.assertEqual(self.ui.review.call_args.kwargs['question'], question)
self.assertIn('aplikáciu s viacerými kontajnermi', self.ui.review.call_args.args[1])
self.no_effects()
def test_recreation_dialog_explains_saved_images_and_restoring_backups(self):
menu.manage_instance(ROOT/'oci', self.ui, self.row, action='recreate')
text = self.ui.review.call_args.args[0]
self.assertIn('uložených identifikátorov obrazov', text)
self.assertIn('2', text)
self.assertIn('110', text)
self.assertIn('zálohy sa obnovia', text)
self.assertNotIn('Stack', text)
self.no_effects()
def test_shared_missing_primary_error_in_plan_and_publish_before_any_effect(self):
primary = copy.deepcopy(self.record)
primary['vmid'] = 112
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
stack_plan.build(primary, {}, {}, 'update')
with self.assertRaisesRegex(ValueError, '^Chýba hlavný kontajner aplikácie$'):
instances.publish_stack(Path('/fiction-only'), 110, {}, {}, [])
self.no_effects()
def test_missing_member_error_keeps_volume_verification_requirement(self):
records = {row['vmid']: row for row in self.record['stack']['members']}
with self.assertRaisesRegex(ValueError, 'znova ich vytvorte až po overení ich dátových zväzkov'):
stack_plan.build(self.record, records, {110: 'fiction-main'}, 'update')
self.no_effects()
def test_pending_and_conflicting_member_errors_keep_the_identifier(self):
records = {row['vmid']: copy.deepcopy(row) for row in self.record['stack']['members']}
inventory = {key: row['installation_id'] for key, row in records.items()}
records[111]['status'] = 'pending'
with self.assertRaisesRegex(ValueError, '^Nedokončená operácia existuje pre CT 111$'):
stack_plan.build(self.record, records, inventory, 'update')
records[111]['status'] = 'installed'
inventory[111] = 'different-installation'
with self.assertRaisesRegex(ValueError, '^Iná inštancia už používa VMID 111$'):
stack_plan.build(self.record, records, inventory, 'update')
self.no_effects()
def test_plan_failure_is_read_only_and_distinguishes_invalid_identity(self):
primary = copy.deepcopy(self.record)
primary['stack']['id'] = 'different-stack'
with self.assertRaisesRegex(ValueError, '^Nekonzistentná identita aplikácie s viacerými kontajnermi$'):
stack_plan.build(primary, {}, {}, 'recreate')
self.no_effects()
@@ -0,0 +1,210 @@
"""Slovak storage questions through real builders and UI; installation forbidden."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
ROOT=Path(__file__).resolve().parents[3]
sys.path[:0]=[str(ROOT/'oci/src'),str(ROOT/'oci/remote')]
from proxmenux_oci import cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
from proxmenux_oci.catalog import Catalog
class Recorder:
def __init__(self, answers=None):
self.answers=answers or {}
self.asked=[]
self.messages=[]
def _answer(self,text,default):
self.asked.append(text)
return self.answers.get(text,default)
def ask(self,text,default=None,**kwargs):return self._answer(text,default if default is not None else '')
def password(self,text,**kwargs):return self._answer(text,'fictional-value-for-tests-000000')
def choose(self,text,options,default=None,**kwargs):return self._answer(text,default)
def confirm(self,text,default=False,**kwargs):return self._answer(text,default)
def checklist(self,text,options,defaults=None,**kwargs):return self._answer(text,defaults or [])
def review(self,*args,**kwargs):return False
def info(self,text):pass
def message(self,text,*args):self.messages.append(text)
class StorageFixture(unittest.TestCase):
def setUp(self):
self.catalog=Catalog(ROOT/'oci')
self.sk=json.loads((ROOT/'lang/sk.json').read_text())
self.forbidden=[]
self.patch(i18n,'_language','sk')
self.patch(i18n,'_cache',self.sk)
self.patch(installer.host,'storages',side_effect=lambda content: [
{'storage':'local-lvm','type':'lvmthin','avail':100*2**30},
{'storage':'fiction-storage','type':'dir','avail':200*2**30}] if content=='rootdir' else [
{'storage':'local','type':'dir','avail':100*2**30}])
self.patch(installer.host,'bridges',return_value=[{'iface':'vmbr0','cidr':'192.0.2.10/24'}])
self.patch(installer.host,'timezone',return_value='Europe/Bratislava')
self.patch(installer.host,'gpus',return_value={'intel':[],'amd':[],'nvidia':False})
self.patch(installer.access,'ask_addresses',side_effect=lambda ui,bridge,names,*a:({name:'dhcp' for name in names},None))
self.patch(installer.access,'ask_ipv4',return_value=('dhcp',None))
for module,name in [(installer,'run_remote_install'),(cli,'run_remote_install'),
(cli.images,'offer_removal'),(subprocess,'run'),(subprocess,'Popen'),(subprocess,'check_output'),
(Path,'write_text'),(Path,'write_bytes'),(Path,'unlink')]:
self.forbidden.append(self.patch(module,name,side_effect=AssertionError('Forbidden effect: '+name)))
self.addCleanup(self.no_effects)
def patch(self,module,name,*args,**kwargs):
item=patch.object(module,name,*args,**kwargs);value=item.start();self.addCleanup(item.stop);return value
def no_effects(self):
for effect in self.forbidden:effect.assert_not_called()
def recorder(self,answers=None):
return Recorder({i18n.translate(key):value for key,value in (answers or {}).items()})
def build(self,app,answers=None,mode=installer.ADVANCED_MODE):
ui=self.recorder(answers)
plan=installer.build_deployment(self.catalog.compose(app),ui,mode)
return ui,plan
class StorageWordingTests(StorageFixture):
def test_tandoor_files_question_and_minimum_error_keep_the_limit(self):
ui,plan=self.build('tandoor',{'Files volume size in GB':'9'})
self.assertIn('Veľkosť dátového zväzku pre súbory v GB',ui.asked)
self.assertEqual(plan['media']['size_gb'],9)
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok súborov Tandoor musí mať najmenej 2 GB'):
self.build('tandoor',{'Files volume size in GB':'1'})
def test_paperless_storage_documents_and_consume_export_names(self):
ui,plan=self.build('paperless-ngx',{'Documents volume size in GB':'30',
'Where to store the consume and export folders':'managed-volume','Size of each consume/export volume in GB':'3'})
for text in ['Úložisko pre údaje a dokumenty aplikácie Paperless',
'Kam uložiť priečinky consume a export',
'Veľkosť dátového zväzku pre dokumenty v GB',
'Veľkosť každého dátového zväzku pre consume a export v GB']:
self.assertIn(text,ui.asked)
self.assertEqual((plan['media_size_gb'],plan['transfer']['size_gb'],plan['transfer']['backup']),(30,3,True))
def test_paperless_volume_limit_and_transfer_limit_errors(self):
with self.assertRaisesRegex(installer.InstallError,'Trvalé dátové zväzky aplikácie Paperless musia mať najmenej 8 GB'):
self.build('paperless-ngx',{'Documents volume size in GB':'7'})
with self.assertRaisesRegex(installer.InstallError,'Dátové zväzky pre consume a export musia mať najmenej 1 GB'):
self.build('paperless-ngx',{'Where to store the consume and export folders':'managed-volume',
'Size of each consume/export volume in GB':'0'})
def test_postgresql_question_and_error_are_shared_by_immich_and_nextcloud(self):
for app in ('immich','nextcloud-stack'):
with self.subTest(app=app):
ui,plan=self.build(app,{'PostgreSQL volume size in GB':'12'})
self.assertIn('Veľkosť dátového zväzku PostgreSQL v GB',ui.asked)
self.assertEqual(plan['database_size_gb'],12)
with self.assertRaisesRegex(installer.InstallError,'Dátový zväzok PostgreSQL musí mať najmenej 8 GB'):
self.build(app,{'PostgreSQL volume size in GB':'7'})
def test_host_directory_remains_outside_the_container_backup(self):
ui,plan=self.build('nextcloud-stack',{'Where to store the Nextcloud files, configuration and data':'host-bind',
'Shared host directory':'/fiction-only/existing-data'})
self.assertIn('Zdieľaný adresár hostiteľa',ui.asked)
self.assertEqual((plan['application']['mode'],plan['application']['host_path'],plan['application']['backup']),
('host-bind','/fiction-only/existing-data',False))
def test_default_and_advanced_builders_preserve_language_independent_plans(self):
for app in ('jellyfin','immich','nextcloud-stack','paperless-ngx','tandoor'):
for mode in (installer.DEFAULT_MODE,installer.ADVANCED_MODE):
with self.subTest(app=app,mode=mode):
_ui,slovak=self.build(app,mode=mode)
with patch.object(i18n,'_language','en'):
_ui,english=self.build(app,mode=mode)
self.assertEqual(slovak,english)
def test_multi_path_cancel_and_invalid_selection_preserve_all_members(self):
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
{'name':'database','deployment':{'mounts':[]}}]
original=copy.deepcopy(services)
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':[]})
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertIn('Pridať ďalšie cesty do aplikácie s viacerými kontajnermi',ui.asked)
self.assertEqual(services,original)
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main'],
'Data location':None})
with self.assertRaises(native_ui.UserCancelled):custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertEqual(services,original)
def test_shared_selection_error_in_device_flow_also_preserves_members(self):
services=[{'name':'main','main':True,'deployment':{}}];original=copy.deepcopy(services)
ui=self.recorder()
with patch.object(extra_devices,'ask_extra_devices',return_value=[{'kind':'usb','host_path':'/fiction-only/device'}]), \
patch.object(ui,'checklist',return_value=[]):
with self.assertRaisesRegex(ValueError,'^Vyberte aspoň jeden kontajner aplikácie$'):
extra_devices.ask_stack_extra_devices(ui,services)
self.assertEqual(services,original)
def test_selected_path_is_added_only_to_selected_member_with_backup(self):
services=[{'name':'main','main':True,'deployment':{'mounts':[]}},
{'name':'database','deployment':{'mounts':[]}}]
ui=self.recorder({'Add extra paths to this stack':True,'Containers that will receive this path':['main']})
custom_mounts.ask_stack_custom_mounts(ui,services,'fiction-storage')
self.assertEqual(services[1]['deployment']['mounts'],[])
mount=services[0]['deployment']['mounts'][0]
self.assertEqual((mount['source'],mount['container_path'],mount['size_gb'],mount['backup']),
('fiction-storage','/media-extra',8,True))
self.assertIn('Pridať ďalšiu cestu do aplikácie s viacerými kontajnermi',ui.asked)
def test_rclone_shared_directory_question_is_localized_without_creating_it(self):
ui=self.recorder({'Shared directory for copy/sync operations':'/fiction-only/rclone'})
with patch.object(ui,'confirm',return_value=True):
plan=installer.build_deployment(self.catalog.compose('rclone'),ui,installer.ADVANCED_MODE)
self.assertIn('Zdieľaný adresár na kopírovanie a synchronizáciu',ui.asked)
self.assertTrue(any(m.get('source')=='/fiction-only/rclone' and not m['backup'] for m in plan['mounts']))
class InputAndCancellationTests(StorageFixture):
def test_dialog_required_value_and_password_use_slovak_then_retry(self):
dialog=native_ui.DialogUI()
empty=subprocess.CompletedProcess('dialog',0,stderr='')
value=subprocess.CompletedProcess('dialog',0,stderr='fiction-value')
with patch.object(dialog,'_run',side_effect=[empty,value]),patch.object(dialog,'message') as message:
self.assertEqual(dialog.ask('fiction-field'),'fiction-value')
message.assert_called_once_with('Táto hodnota je povinná.')
with patch.object(dialog,'_run',side_effect=[empty,value,value]),patch.object(dialog,'message') as message:
self.assertEqual(dialog.password('fiction-field'),'fiction-value')
message.assert_called_once_with('Táto hodnota je povinná.')
def test_terminal_required_value_and_password_are_also_localized(self):
terminal=native_ui.TerminalUI()
with patch('builtins.input',side_effect=['','fiction-value']),redirect_stdout(io.StringIO()) as output:
self.assertEqual(terminal.ask('fiction-field'),'fiction-value')
self.assertIn('Táto hodnota je povinná.',output.getvalue())
with patch.object(native_ui.getpass,'getpass',side_effect=['','fiction-value','fiction-value']),redirect_stdout(io.StringIO()) as output:
self.assertEqual(terminal.password('fiction-field'),'fiction-value')
self.assertIn('Táto hodnota je povinná.',output.getvalue())
def test_real_install_flow_storage_cancel_and_final_no_never_dispatch(self):
template=self.catalog.compose('tandoor');original=copy.deepcopy(template)
dialog=native_ui.DialogUI()
for code in (1,255):
replies=[subprocess.CompletedProcess('dialog',0,stderr=value) for value in ('','fiction-stack','2','2048')]
replies.append(subprocess.CompletedProcess('dialog',code,stderr=''))
with self.subTest(code=code),patch.object(dialog,'_run',side_effect=replies) as run:
with self.assertRaises(native_ui.UserCancelled):cli.install_template(dialog,template,'tandoor',installer.ADVANCED_MODE)
self.assertEqual(run.call_count,5)
self.assertEqual(template,original)
self.assertIsNone(cli.install_template(self.recorder(),template,'tandoor',installer.ADVANCED_MODE))
self.assertEqual(template,original)
def test_numeric_retry_keeps_previous_answers_and_already_correct_slovak(self):
ui=Recorder();wizard=native_ui.BacktrackUI(ui)
wizard.answers=[('ask','previous'),('ask','abc')];wizard.cursor=2
self.assertTrue(wizard.retry_last(ValueError("invalid literal for int() with base 10: 'abc'")))
self.assertEqual(ui.messages,["Hodnota musí byť číslo: 'abc'\n\nZadajte hodnotu znova."])
self.assertEqual((wizard.answers,wizard.cursor),([('ask','previous')],0));wizard.close()
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
ui=Mock();ui.choose.side_effect=['custom',None]
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
patch.object(updates,'UpdateCheck') as checker:
check=checker.return_value.__enter__.return_value
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
self.assertEqual(cli.interactive(self.catalog),0)
ui.message.assert_called_once_with('Operáciu sa nepodarilo dokončiť:\n\nfiction-detail')
@@ -0,0 +1,168 @@
"""Real native OCI menu/CLI paths with all external effects replaced."""
from contextlib import redirect_stdout
import copy
import io
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3]
sys.path[:0] = [str(ROOT / 'oci/src'), str(ROOT / 'oci/remote')]
from proxmenux_oci import cli, i18n, management as menu, ui as native_ui, updates
import oci_instances as instances
import oci_instance_reconcile as reconcile
class CancellationFixture(unittest.TestCase):
def setUp(self):
self.record = {'status': 'installed', 'vmid': 110, 'deployment': {}, 'template': {}}
self.original = copy.deepcopy(self.record)
self.row = dict(vmid=110, reason='matched', stack=False, pending=False, status='installed',
title='Fixture application', hostname='fixture', image='fixture:latest')
self.ui = Mock()
self.ui.unattended = False
self.ui.review.return_value = False
for module, name, options in [
(i18n, '_language', dict(new='en')), (i18n, '_cache', dict(new={})),
(menu.os, 'geteuid', dict(return_value=0)), (menu.shutil, 'which', dict(return_value='/fixture/pct')),
(native_ui, 'interactive_ui', dict(return_value=self.ui)),
(menu, 'recover_automatically', dict(return_value=None)),
(menu, 'carry_records', dict(return_value={})),
(menu, 'check_selected', dict(side_effect=lambda project, row: row)),
(menu, 'saved_inventory', dict(return_value=[self.row])),
(menu, '_selected_configuration', dict(return_value=b'fixture configuration')),
(instances, 'read', dict(return_value=self.record)),
(reconcile, 'propose', dict(return_value=None)),
]:
item = patch.object(module, name, **options)
item.start()
self.addCleanup(item.stop)
self.lifecycle = self.guard(menu, '_run_lifecycle')
self.backup = self.guard(menu, '_work_backup_ready')
self.write = self.guard(instances, 'write')
self.command = self.guard(instances, 'command')
self.commit = self.guard(reconcile, 'commit')
def guard(self, module, name):
item = patch.object(module, name, side_effect=AssertionError('Forbidden effect: ' + name))
mock = item.start()
self.addCleanup(item.stop)
return mock
def no_effects(self):
for mock in [self.lifecycle, self.backup, self.write, self.command, self.commit]:
mock.assert_not_called()
self.assertEqual(self.record, self.original)
class UpdateCancellationTests(CancellationFixture):
def test_no_is_a_distinct_falsy_result_before_backup_or_lifecycle(self):
result = menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
self.assertIs(result, menu.CANCELLED)
self.assertFalse(result)
self.no_effects()
def test_real_cli_no_returns_cancel_code_instead_of_failure_or_success(self):
with patch.object(cli, 'Catalog'), redirect_stdout(io.StringIO()) as output:
result = cli.main(['manage', '110', '--action', 'update'])
self.assertEqual(result, 6)
self.assertEqual(output.getvalue(), '') # The terminal renders the localized outcome once.
self.no_effects()
def test_no_in_stack_confirmation_keeps_every_member_unchanged(self):
self.row['stack'] = True
self.record['stack'] = {'members': [{'vmid': 110}, {'vmid': 111}]}
self.original = copy.deepcopy(self.record)
with redirect_stdout(io.StringIO()):
result = menu.direct_management(ROOT / 'oci', 110, 'update')
self.assertEqual(result, 6)
self.no_effects()
def test_native_menu_returns_to_its_list_after_no(self):
self.ui.choose.side_effect = ['110', 'update', None]
with patch.object(menu, '_clean_orphans'), patch.object(menu, 'offer_recovery'), \
patch.object(menu, '_records_unread', return_value=[]):
menu.interactive_management(ROOT / 'oci', self.ui)
self.assertEqual(self.ui.choose.call_count, 3)
self.no_effects()
def test_batch_no_does_not_start_the_next_application(self):
available = [{'vmid': 110, 'title': 'First'}, {'vmid': 111, 'title': 'Second'}]
rows = [self.row, {**self.row, 'vmid': 111}]
self.ui.checklist.return_value = ['all']
self.ui.review.side_effect = [True, False] # Batch review, first application's confirmation.
with patch.object(updates, 'UpdateCheck') as checker, patch.object(menu, 'saved_inventory', return_value=rows), \
patch.object(menu, 'check_selected', wraps=lambda project, row: row) as selected:
checker.return_value.status.return_value = {'available': available}
self.assertFalse(updates.select_updates(ROOT / 'oci', self.ui, available))
self.assertEqual([call.args[1]['vmid'] for call in selected.call_args_list], [110])
self.no_effects()
def test_accepted_update_failure_keeps_failure_code_one(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = False
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
self.lifecycle.assert_not_called()
self.write.assert_not_called()
def test_accepted_update_preserves_success_and_existing_lifecycle_command(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = True
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 0)
self.lifecycle.assert_called_once()
command = self.lifecycle.call_args.args[0]
self.assertEqual(command[:3], [sys.executable, str(ROOT / 'oci/remote/oci_update_current.py'), '110'])
self.assertIn('--acknowledge-external-data', command)
self.write.assert_not_called()
def test_actual_lifecycle_failure_remains_failure_after_positive_confirmation(self):
self.ui.review.return_value = True
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = False
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 1)
self.lifecycle.assert_called_once()
self.write.assert_not_called()
def test_unattended_positive_default_still_reaches_existing_update(self):
self.backup.side_effect = None
self.backup.return_value = True
self.lifecycle.side_effect = None
self.lifecycle.return_value = True
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 0)
self.lifecycle.assert_called_once()
self.write.assert_not_called()
def test_existing_unattended_review_age_busy_and_missing_codes_are_preserved(self):
with patch.object(reconcile, 'propose', return_value={'details': ['external change']}), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True), 4)
with patch.object(menu, '_image_too_recent', return_value=True), redirect_stdout(io.StringIO()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update', unattended=True, min_image_age_days=3), 5)
with patch.object(menu, 'saved_inventory', return_value=[]):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 2)
with patch.object(menu, 'recover_automatically', side_effect=BlockingIOError()):
self.assertEqual(menu.direct_management(ROOT / 'oci', 110, 'update'), 3)
self.no_effects()
def test_slovak_dialog_and_recovery_choice_have_the_approved_meaning(self):
cache = json.loads((ROOT / 'lang/sk.json').read_text())
with patch.object(i18n, '_language', 'sk'), patch.object(i18n, '_cache', cache):
menu.manage_instance(ROOT / 'oci', self.ui, self.row, action='update')
self.assertEqual(i18n.translate('Recover the previous installation'), 'Obnoviť predchádzajúcu inštaláciu')
text = self.ui.review.call_args.args[0]
for part in ['novší obraz aplikácie', 'overí sa jej integrita', 'pokúsi obnoviť', 'Pripojené adresáre hostiteľa']:
self.assertIn(part, text)
self.assertNotIn('kanál obrazov', text)
self.assertEqual(self.ui.review.call_args.kwargs['question'], 'Spustiť aktualizáciu?')
self.no_effects()
if __name__ == '__main__':
unittest.main()
@@ -24,9 +24,12 @@ OLD_GUARD = 'The current template changes the image or identity; an explicit mig
GUARD = 'The current template changes the template identity or image repository; an explicit migration is required'
OLD_PREVIEW = ('The saved image channel is checked for a newer image. If replacement is needed, the CT is stopped and '
'a native backup is verified before its root is replaced. Host directories are outside that backup.')
PREVIEW = ('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with '
'the new image to update the container. If the update fails after the container was changed, the backup '
'is restored. An update that stops halfway is recovered from the OCI management menu.')
PREVIEW = ('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\n'
'If 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\n'
'Host directories mounted in the container are not restored by the container backup.')
def extracted(path, name, scope):
@@ -130,7 +133,7 @@ class UpdateWording(unittest.TestCase):
reconcile = ModuleType('oci_instance_reconcile')
reconcile.propose = lambda *args: None
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
'os': os, 'tempfile': tempfile, 'Path': Path,
'os': os, 'tempfile': tempfile, 'Path': Path, 'CANCELLED': None,
'translate': lambda text: text, 'check_selected': lambda project, row: row,
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
'images': SimpleNamespace(offer_removal=lambda *args: None),
+1 -1
View File
@@ -121,7 +121,7 @@ class OciWordingTests(TestCase):
def test_failed_registry_inspection_points_to_registry_accounts(self):
ui = SimpleNamespace(message=Mock())
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
fn = function(CUSTOM, '_explore', read_definition=lambda _: ('definition', 'source'),
ignored_settings=lambda _: [], template_from_compose=lambda _: {
'container_contract': {'image': {'reference': 'example:latest'}}},
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
@@ -20,7 +20,11 @@ class OutcomePromises(TestCase):
texts = oci_menu_texts()
self.assertEqual([text for text in texts if 'If anything fails' in text], [])
previews = [text for text in texts if 'stops halfway is recovered from the OCI management menu' in text]
self.assertEqual(len(previews), 3)
self.assertEqual(len(previews), 2) # Coordinated stack update/recreation previews.
update = next(text for text in texts if text.startswith('If a newer application image is available'))
self.assertIn('attempts to restore the previous installation', update)
self.assertIn('If the operation remains incomplete', update)
self.assertIn('choose "Recover the previous installation"', update)
def test_the_recovery_preview_says_when_each_step_happens(self):
preview = next(text for text in oci_menu_texts() if 'previous native backup' in text)
@@ -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())
@@ -107,6 +107,28 @@ class TerminalCatalogsTests(unittest.TestCase):
(root / 'lang/es.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Compruebe /etc/PVE/storage.cfg'}))
self.assertNotEqual(check('es,sk').returncode, 0)
def test_optional_completeness_still_rejects_invalid_present_translations(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / 'scripts').mkdir()
(root / 'lang').mkdir()
(root / 'scripts/check.sh').write_text('translate "Check /etc/pve/storage.cfg"')
(root / 'lang/sk.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Skontrolujte /etc/pve/storage.cfg'}))
(root / 'lang/es.json').write_text('{}')
def check(languages, *flags):
return subprocess.run(
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
'--root', str(root), '--languages', languages, *flags],
capture_output=True, text=True, timeout=60,
)
self.assertEqual(check('es,sk').returncode, 0)
self.assertEqual(check('sk', '--require-complete').returncode, 0)
self.assertNotEqual(check('es', '--require-complete').returncode, 0)
(root / 'lang/es.json').write_text(json.dumps({'Check /etc/pve/storage.cfg': 'Compruebe /etc/PVE/storage.cfg'}))
self.assertNotEqual(check('es,sk').returncode, 0)
if __name__ == '__main__':
unittest.main()
@@ -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})
+2 -2
View File
@@ -71,8 +71,8 @@ jobs:
run: |
test -x /bin/bash
test -x /usr/bin/jq
- name: Install OCI wording test dependency
run: python3 -m pip install 'PyYAML>=6,<7'
- name: Install OCI wording test dependencies
run: python3 -m pip install 'PyYAML>=6,<7' 'jsonschema==4.25.1'
- name: Run offline i18n and script regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
@@ -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/**'
+2
View File
@@ -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"
+45 -44
View File
@@ -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>
)
+12 -11
View File
@@ -37,6 +37,7 @@ import { fetchLxcApps, getLxcAppsCached, setLxcAppsCached, subscribeLxcApps } fr
import { categoryChipStyle, useIsLightTheme } from "../lib/category-color"
import { getCategoryLabel } from "../lib/category-label"
import { useT } from "@/lib/i18n/provider"
import { localizeAppEditorError } from "../lib/lxc-app-editor-errors"
// installed_via is optional now — an empty value means "register only,
// no version tracking, no warnings, just a clickable link". Docker
@@ -728,7 +729,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
if (lower.startsWith("registry unreachable")) {
return t("vmLxc.appEditor.upstreamErrorRegistry")
}
return msg
return localizeAppEditorError(msg, t)
}
const editorOpen = !!editing && editing.targetVmid === vmid
@@ -1212,7 +1213,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
}
const removeOne = async (appId: string) => {
if (!confirm("Remove this application from the CT's App tab?")) return
if (!confirm(t("vmLxc.appEditor.confirmRemove"))) return
setBusyAppId(appId)
setError(null)
try {
@@ -2069,14 +2070,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
id="app-package"
value={draft.package || ""}
onChange={(e) => setField({ package: e.target.value })}
placeholder={method === "dpkg" ? "e.g. jellyfin-server" : "e.g. tailscale"}
placeholder={t("vmLxc.appEditor.packageExample", { package: method === "dpkg" ? "jellyfin-server" : "tailscale" })}
/>
<div className="text-[10px] text-muted-foreground mt-1">
Auto-filled from Name. Verify with{" "}
{t("vmLxc.appEditor.packageHelpBefore")}{" "}
<code className="text-foreground/70">
{method === "dpkg" ? "dpkg -l | grep <app>" : "apk info | grep <app>"}
</code>{" "}
inside the CT.
{t("vmLxc.appEditor.packageHelpAfter")}
</div>
</div>
)}
@@ -2092,8 +2093,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
className="font-mono text-xs"
/>
<div className="text-[10px] text-muted-foreground mt-1">
Absolute path. Find it with{" "}
<code className="text-foreground/70">which &lt;app&gt;</code> or{" "}
{t("vmLxc.appEditor.binaryHelpBefore")}{" "}
<code className="text-foreground/70">which &lt;app&gt;</code>{" "}{t("vmLxc.appEditor.binaryHelpOr")}{" "}
<code className="text-foreground/70">systemctl show &lt;service&gt; -p ExecStart</code>.
</div>
</div>
@@ -2558,7 +2559,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
{detectorTest.installed.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.installed.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.installed.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.installed.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
<div className="mt-1.5 text-[10px] text-muted-foreground break-all">
{detectorTest.installed.method || "—"}
@@ -2574,7 +2575,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
) : detectorTest.upstream.version ? (
<div className="font-mono text-sm text-emerald-400">{detectorTest.upstream.version}</div>
) : (
<div className="text-xs text-amber-300">{detectorTest.upstream.error || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(detectorTest.upstream.error) || t("vmLxc.appEditor.detectorTestNoVersion")}</div>
)}
{detectorTest.upstream.type && (
<div className="mt-1.5 text-[10px] text-muted-foreground">{detectorTest.upstream.type}</div>
@@ -2587,7 +2588,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
<span>{localizeAppEditorError(error, t)}</span>
</div>
)}
@@ -3343,7 +3344,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<span>{error}</span>
<span>{localizeAppEditorError(error, t)}</span>
</div>
)}
</div>
+13 -9
View File
@@ -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"
+8 -5
View File
@@ -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>
+58 -32
View File
@@ -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>
}
+22 -10
View File
@@ -31,6 +31,7 @@ import {
import "xterm/css/xterm.css"
import { API_PORT } from "@/lib/api-config"
import { getTicketedWsUrl } from "@/lib/terminal-ws"
import { scriptCompletion, scriptCompletionOutput } from "@/lib/script-completion"
import { useT } from "../lib/i18n/provider"
interface WebInteraction {
@@ -52,6 +53,7 @@ interface ScriptTerminalModalProps {
params?: Record<string, string>
completedSuccessfullyMessage?: string
completedWithErrorMessage?: (exitCode: number) => string
cancelledExitCode?: number
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
@@ -69,6 +71,7 @@ export function ScriptTerminalModal({
params = { EXECUTION_MODE: "web" },
completedSuccessfullyMessage,
completedWithErrorMessage,
cancelledExitCode,
onComplete,
}: ScriptTerminalModalProps) {
const t = useT()
@@ -103,10 +106,12 @@ export function ScriptTerminalModal({
const getCompletionMessage = useCallback(
(exitCode: number) =>
exitCode === 0
scriptCompletion(exitCode, cancelledExitCode).kind === "cancelled"
? t("scriptTerminal.cancelled")
: exitCode === 0
? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully"))
: (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })),
[completedSuccessfullyMessage, completedWithErrorMessage, t],
[completedSuccessfullyMessage, completedWithErrorMessage, cancelledExitCode, t],
)
const terminalContainerRef = useRef<HTMLDivElement>(null)
@@ -139,6 +144,11 @@ export function ScriptTerminalModal({
reconnectTimeoutRef.current = setTimeout(async () => {
if (wsRef.current?.readyState !== WebSocket.OPEN && termRef.current) {
if (wsRef.current) {
// Replacing a stale socket is not a script result. Its close event
// must not complete the operation before the new socket reports it.
wsRef.current.onclose = null
wsRef.current.onerror = null
wsRef.current.onmessage = null
wsRef.current.close()
}
@@ -176,6 +186,7 @@ const initMessage = {
}
ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
@@ -189,10 +200,10 @@ const initMessage = {
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
termRef.current?.write(event.data)
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") termRef.current?.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -223,7 +234,7 @@ const initMessage = {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
termRef.current?.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -261,7 +272,7 @@ const initMessage = {
}
}
}, 1000)
}, [isOpen, isComplete, scriptPath, getCompletionMessage, t])
}, [isOpen, isComplete, scriptPath, getCompletionMessage, cancelledExitCode, t])
const sendKey = useCallback((key: string) => {
if (!termRef.current) return
@@ -392,6 +403,7 @@ const initMessage = {
}
ws.onmessage = (event) => {
if (completionReceivedRef.current) return
// Filter out pong responses from heartbeat - don't display in terminal
if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') {
return
@@ -405,10 +417,10 @@ const initMessage = {
: null
if (exitMatch) {
const exitCode = Number(exitMatch[1])
term.write(event.data)
if (scriptCompletion(exitCode, cancelledExitCode).kind !== "cancelled") term.write(event.data)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -441,7 +453,7 @@ const initMessage = {
const exitCode = Number(msg.exit_code ?? 1)
completionReceivedRef.current = true
setIsComplete(true)
term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`)
term.writeln(scriptCompletionOutput(exitCode, getCompletionMessage(exitCode), cancelledExitCode))
onCompleteRef.current?.(exitCode)
if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete")
return
@@ -814,7 +826,7 @@ const initMessage = {
)}
{(isMobile || isTablet) && (
<div className="flex items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex flex-wrap items-center justify-center gap-1.5 px-1 py-2 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button
onPointerDown={(e) => {
e.preventDefault()
+46 -17
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Package, Ruler, HeartPulse, Cpu, MemoryStick, HardDrive, CircleDot, Network, Server, Settings2, FileText, RefreshCw, Shield, AlertTriangle, Info, Loader2, Check, Database, CloudOff, Code, X, Copy, Sparkles, ArrowUpCircle, BellOff, Globe2, Github } from "lucide-react"
import { Badge } from "./ui/badge"
@@ -20,6 +20,7 @@ import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
import { useIsPbs } from "../lib/use-platform"
import type { LanguageCode } from "../lib/i18n/languages"
import { NavTabOrderCard } from "./nav-tab-order-card"
import { optimizationSourceError, optimizationSourceName, type OptimizationSourceError, type OptimizationSourceResponse } from "../lib/optimization-source-errors"
// GitHub Dark color palette for bash syntax highlighting
const BASH_KEYWORDS = new Set([
@@ -355,15 +356,31 @@ export function Settings() {
open: boolean
loading: boolean
toolName: string
toolKey: string
version: string
availableVersion: string
functionName: string
source: string
script: string
error: string
error: OptimizationSourceError | null
deprecated: boolean
}>({ open: false, loading: false, toolName: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: '', deprecated: false })
}>({ open: false, loading: false, toolName: '', toolKey: '', version: '', availableVersion: '', functionName: '', source: '', script: '', error: null, deprecated: false })
const [codeCopied, setCodeCopied] = useState(false)
const codeRequest = useRef(0)
const closeCodeModal = () => {
codeRequest.current += 1
setCodeModal(prev => ({ ...prev, open: false }))
setCodeCopied(false)
}
useEffect(() => () => { codeRequest.current += 1 }, [])
useEffect(() => {
if (!codeModal.open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeCodeModal()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [codeModal.open])
// Health Monitor suppression settings
const [suppressionCategories, setSuppressionCategories] = useState<SuppressionCategory[]>([])
@@ -664,31 +681,39 @@ export function Settings() {
}
const viewToolSource = async (tool: ProxMenuxTool) => {
const request = ++codeRequest.current
setCodeCopied(false)
setCodeModal({
open: true,
loading: true,
toolName: tool.name,
toolKey: tool.key,
version: tool.version || '1.0',
availableVersion: tool.available_version || tool.version || '1.0',
functionName: '',
source: '',
script: '',
error: '',
error: null,
deprecated: !!tool.deprecated,
})
try {
const data = await fetchApi(`/api/proxmenux/tool-source/${tool.key}`)
if (data.success) {
setCodeModal(prev => ({ ...prev, loading: false, functionName: data.function, source: data.source, script: data.script, deprecated: !!data.deprecated }))
const data = await fetchApi<OptimizationSourceResponse | null>(`/api/proxmenux/tool-source/${tool.key}`)
if (request !== codeRequest.current) return
if (data?.success === true && typeof data.source === "string" && data.source.trim()) {
const source = data.source
setCodeModal(prev => ({ ...prev, loading: false, functionName: typeof data.function === "string" ? data.function : '', source, script: typeof data.script === "string" ? data.script : '', deprecated: !!data.deprecated }))
} else {
setCodeModal(prev => ({ ...prev, loading: false, error: data.error || 'Source code not available' }))
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "unavailable", detail: typeof data?.error === "string" ? data.error : undefined } }))
}
} catch {
setCodeModal(prev => ({ ...prev, loading: false, error: 'Failed to load source code' }))
} catch (error: any) {
if (request !== codeRequest.current) return
const detail = typeof error?.body?.error === "string" ? error.body.error : undefined
setCodeModal(prev => ({ ...prev, loading: false, error: { kind: "loadFailed", detail } }))
}
}
const copySourceCode = async () => {
const request = codeRequest.current
const text = codeModal.source
let ok = false
@@ -722,9 +747,9 @@ export function Settings() {
}
}
if (ok) {
if (ok && request === codeRequest.current) {
setCodeCopied(true)
setTimeout(() => setCodeCopied(false), 2000)
setTimeout(() => { if (request === codeRequest.current) setCodeCopied(false) }, 2000)
}
}
@@ -2154,10 +2179,13 @@ export function Settings() {
{/* Code Viewer Modal */}
{codeModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ margin: 0 }} onClick={closeCodeModal}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-label={t("settings.optimizations.sourceDialogTitle", { name: optimizationSourceName(codeModal.toolKey, codeModal.toolName, t) })}
onClick={e => e.stopPropagation()}
>
{/* Header */}
@@ -2166,7 +2194,7 @@ export function Settings() {
<Code className={`h-5 w-5 flex-shrink-0 ${codeModal.deprecated ? 'text-amber-500' : 'text-orange-500'}`} />
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-semibold truncate">{codeModal.toolName}</h3>
<h3 className="text-sm font-semibold truncate">{optimizationSourceName(codeModal.toolKey, codeModal.toolName, t)}</h3>
{codeModal.deprecated && (
<span className="text-[9px] uppercase tracking-wider text-amber-500 bg-amber-500/10 border border-amber-500/30 px-1.5 py-0.5 rounded flex-shrink-0">
{t("settings.optimizations.legacy")}
@@ -2202,7 +2230,8 @@ export function Settings() {
</button>
)}
<button
onClick={() => setCodeModal(prev => ({ ...prev, open: false }))}
onClick={closeCodeModal}
aria-label={t("actions.close")}
className="p-1.5 rounded-md hover:bg-muted transition-colors"
>
<X className="h-4 w-4" />
@@ -2212,13 +2241,13 @@ export function Settings() {
{/* Body */}
<div className="flex-1 overflow-auto p-0">
{codeModal.loading ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center justify-center py-16" role="status" aria-label={t("settings.optimizations.sourceLoading")}>
<div className="animate-spin h-8 w-8 border-4 border-orange-500 border-t-transparent rounded-full" />
</div>
) : codeModal.error ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Code className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm">{codeModal.error}</p>
<p className="text-sm" role="alert">{optimizationSourceError(codeModal.error, t)}</p>
</div>
) : (
<pre
+20 -6
View File
@@ -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} />)}
+11 -4
View File
@@ -22,6 +22,7 @@ import useSWR from "swr"
import { MetricsView } from "./metrics-dialog"
import { LxcTerminalModal } from "./lxc-terminal-modal"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { OCI_CANCELLED_EXIT_CODE, shouldRefreshOciAfterCompletion } from "@/lib/script-completion"
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
import { GuestBackupList } from "./guest-backup-list"
import type { GuestBackup } from "../lib/guest-backup-archives"
@@ -977,7 +978,9 @@ export function VirtualMachines() {
// A container restored from a backup carries the mark of its installation
// and has no record on this host until it is recovered from the menu.
const [ociRestored, setOciRestored] = useState(false)
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover" } | null>(null)
const [ociAction, setOciAction] = useState<{ vmid: number; action: "update" | "modify" | "recover"; recoverPrevious?: boolean } | null>(null)
const ociCompletionCodeRef = useRef<number | undefined>(undefined)
useEffect(() => { ociCompletionCodeRef.current = undefined }, [ociAction])
// Firewall log state — fetched only when the operator opens that tab
// so a CT/VM without firewall use doesn't pay the pvesh cost on every
@@ -5515,7 +5518,7 @@ export function VirtualMachines() {
<Button
size="sm"
className={updateBtnCls}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update" })}
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "update", recoverPrevious: !!ociInstance.pending })}
>
{hasUpdate
? <ArrowUpCircle className="h-4 w-4 mr-1.5" />
@@ -5744,7 +5747,7 @@ export function VirtualMachines() {
onClick={() => setOciAction({ vmid: selectedVM.vmid, action: "recover" })}
>
<RotateCcw className="h-4 w-4 mr-1.5" />
{t("vmLxc.ociUpdates.recover")}
{t("vmLxc.ociUpdates.registerRestored")}
</Button>
</div>
)}
@@ -8014,6 +8017,7 @@ export function VirtualMachines() {
onClose={() => {
const vmid = ociAction.vmid
setOciAction(null)
if (!shouldRefreshOciAfterCompletion(ociCompletionCodeRef.current)) return
// The image, the digest and the record changed: read them again.
fetchApi(`/api/vms/${vmid}/apps/check`, { method: "POST" })
.catch(() => undefined)
@@ -8027,7 +8031,10 @@ export function VirtualMachines() {
}}
scriptPath="/usr/local/share/proxmenux/scripts/oci/manage_instance.sh"
scriptName="oci_manage_instance"
title={ociAction.action === "update" ? t("vmLxc.ociUpdates.terminalTitleUpdate")
cancelledExitCode={OCI_CANCELLED_EXIT_CODE}
onComplete={(exitCode) => { ociCompletionCodeRef.current = exitCode }}
title={ociAction.action === "update" ? t(ociAction.recoverPrevious
? "vmLxc.ociUpdates.terminalTitleRecoverPrevious" : "vmLxc.ociUpdates.terminalTitleUpdate")
: ociAction.action === "recover" ? t("vmLxc.ociUpdates.terminalTitleRecover")
: t("vmLxc.ociUpdates.terminalTitleModify")}
description={t("vmLxc.ociUpdates.terminalDescription")}
+141
View File
@@ -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
}
+130
View File
@@ -0,0 +1,130 @@
type Translate = (key: string, params?: Record<string, string | number>) => string
// Exact lxc_apps validation/persistence messages; never change their API contract.
// Anchored patterns leave unknown diagnostics intact, including paths and commands.
const RULES: [RegExp, string, string[]][] = [
[/^an OCI operation on this container is running$/, "ociBusy", []],
[/^([\s\S]+) is not installed via dpkg$/, "dpkgMissing", ["package"]],
[/^dpkg\-query failed$/, "dpkgFailed", []],
[/^no version matched in dpkg output$/, "dpkgNoVersion", []],
[/^apk info failed$/, "apkFailed", []],
[/^no version matched in apk output$/, "apkNoVersion", []],
[/^could not read ([\s\S]+)$/, "fileRead", ["path"]],
[/^file_regex did not match ([\s\S]+)$/, "fileNoMatch", ["path"]],
[/^binary invocation failed$/, "binaryFailed", []],
[/^no version matched in binary output$/, "binaryNoVersion", []],
[/^python \-c importlib\.metadata failed$/, "pythonFailed", []],
[/^no version matched in Python distribution output$/, "pythonNoVersion", []],
[/^could not resolve the container's installed image$/, "dockerImageResolve", []],
[/^docker inspect failed$/, "dockerInspectFailed", []],
[/^container has no ([\s\S]+) label$/, "dockerLabelMissing", ["label"]],
[/^docker label reports ([\s\S]+) \(mutable tag, not a version\)$/, "dockerLabelMutable", ["tag"]],
[/^no version matched in docker label output$/, "dockerLabelNoVersion", []],
[/^docker exec failed$/, "dockerExecFailed", []],
[/^no version matched in docker exec output$/, "dockerExecNoVersion", []],
[/^command_argv is empty$/, "commandEmpty", []],
[/^command failed$/, "commandFailed", []],
[/^no version matched in command output$/, "commandNoVersion", []],
[/^unsupported method: ([\s\S]+)$/, "unsupportedMethod", ["method"]],
[/^unknown upstream_type: ([\s\S]+)$/, "unknownUpstream", ["type"]],
[/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+): not found$/, "githubNotFound", ["repo"]],
[/^github rejected the request \(403\)$/, "githubRejected", []],
[/^github error (\d+)$/, "githubError", ["code"]],
[/^no tag \/ release name in response$/, "noRelease", []],
[/^tag_regex did not match '([\s\S]+)'$/, "tagNoMatch", ["tag"]],
[/^http error (\d+)$/, "httpError", ["code"]],
[/^invalid JSON response: ([\s\S]+)$/, "invalidJson", ["detail"]],
[/^json_path '([\s\S]+)' did not resolve$/, "jsonPathUnresolved", ["path"]],
[/^empty value at json_path$/, "jsonEmpty", []],
[/^no tags matched \(docker_hub\)$/, "dockerNoTags", []],
[/^tag_regex matched only moving tags; use Docker image digest tracking$/, "dockerMovingOnly", []],
[/^docker hub error (\d+)$/, "dockerHubError", ["code"]],
[/^invalid docker hub JSON: ([\s\S]+)$/, "dockerHubJson", ["detail"]],
[/^no tags returned by docker hub$/, "dockerHubEmpty", []],
[/^payload must be a JSON object$/, "payload", []],
[/^name is required and must be 1\-64 chars of letters\/digits\/spaces\/\._\+\-\(\)\/$/, "name", []],
[/^installed_via must be one of: ([\s\S]+) or empty$/, "method", ["values"]],
[/^package is required \(letters\/digits\/\._\+@:\/ up to 127 chars\)$/, "package", []],
[/^file_path is required and must be an absolute path$/, "filePath", []],
[/^file_regex is required$/, "fileRegex", []],
[/^file_regex is not a valid regex: ([\s\S]+)$/, "fileRegexInvalid", ["detail"]],
[/^binary_path is required and must be an absolute path$/, "binaryPath", []],
[/^python_path is required and must be an absolute path$/, "pythonPath", []],
[/^distribution is required \(PEP 503 name\)$/, "distribution", []],
[/^container_name is required \(docker naming rules\)$/, "container", []],
[/^label is required \(OCI label naming rules\)$/, "label", []],
[/^binary_path is required \(absolute path or bare command\)$/, "dockerBinary", []],
[/^installed_version is required for the manual method$/, "manualVersion", []],
[/^installed_version exceeds (\d+) chars$/, "versionLength", ["max"]],
[/^installed_version contains a forbidden control character$/, "versionControl", []],
[/^installed_regex is not a valid regex: ([\s\S]+)$/, "installedRegexInvalid", ["detail"]],
[/^upstream_type must be one of: ([\s\S]+)$/, "upstreamType", ["values"]],
[/^tag_regex is not a valid regex: ([\s\S]+)$/, "tagRegexInvalid", ["detail"]],
[/^repo is required for upstream_type=github$/, "repoRequired", []],
[/^repo must be 'owner\/name'$/, "repoFormat", []],
[/^github_source must be one of: ([\s\S]+)$/, "githubSource", ["values"]],
[/^upstream_url is required for upstream_type=http_json$/, "upstreamUrlRequired", []],
[/^upstream_url must be an http\(s\) URL$/, "upstreamUrl", []],
[/^upstream_url exceeds (\d+) chars$/, "upstreamUrlLength", ["max"]],
[/^upstream_json_path is required for upstream_type=http_json$/, "jsonPathRequired", []],
[/^upstream_json_path exceeds (\d+) chars$/, "jsonPathLength", ["max"]],
[/^upstream_json_path uses forbidden characters$/, "jsonPathCharacters", []],
[/^docker_image is required for upstream_type=docker_hub$/, "dockerImageRequired", []],
[/^docker_image exceeds (\d+) chars$/, "dockerImageLength", ["max"]],
[/^docker_image must match Docker Hub naming \(owner\/name or name\)$/, "dockerImageFormat", []],
[/^health_path must be a valid URL path \(max 255 chars\)$/, "healthPath", []],
[/^logo_url must be an http\(s\) URL or this VM's cached local icon$/, "logoUrl", []],
[/^invalid discovery_id$/, "discoveryId", []],
[/^helper_slug must be a lowercase slug \(letters\/digits\/\._\-\)$/, "helperSlug", []],
[/^update_via only accepts 'docker'$/, "updateVia", []],
[/^update_via=docker requires a docker_label or docker_exec detector$/, "dockerDetector", []],
[/^update_via=docker cannot be combined with ([\s\S]+)$/, "dockerConflict", ["field"]],
[/^update_command must be a string$/, "updateCommandType", []],
[/^update_command exceeds (\d+) chars$/, "updateCommandLength", ["max"]],
[/^update_command contains a null byte$/, "updateCommandNull", []],
[/^update_method must be none, helper or custom$/, "updateMethod", []],
[/^update_command is required for update_method=custom$/, "updateCommandRequired", []],
[/^update_command is only allowed for update_method=custom$/, "updateCommandOnlyCustom", []],
[/^a supported helper_slug is required for update_method=helper$/, "helperRequired", []],
[/^Docker\-delegated apps update through their image, not a separate app updater$/, "dockerUpdater", []],
[/^command_argv is required \(non\-empty list\)$/, "commandRequired", []],
[/^command_argv must be a non\-empty list of strings$/, "commandList", []],
[/^command_argv accepts at most (\d+) entries$/, "commandCount", ["max"]],
[/^command_argv\[(\d+)\] must be a non\-empty string$/, "commandEntry", ["index"]],
[/^command_argv\[(\d+)\] exceeds (\d+) chars$/, "commandEntryLength", ["index", "max"]],
[/^command_argv\[(\d+)\] contains a forbidden control character$/, "commandEntryControl", ["index"]],
[/^binary_args must be a list of strings$/, "binaryArgsList", []],
[/^binary_args accepts at most (\d+) entries$/, "binaryArgsCount", ["max"]],
[/^binary_args\[(\d+)\] must be a non\-empty string$/, "binaryArgEntry", ["index"]],
[/^binary_args\[(\d+)\] exceeds (\d+) chars$/, "binaryArgLength", ["index", "max"]],
[/^binary_args\[(\d+)\] contains a forbidden control character$/, "binaryArgControl", ["index"]],
[/^ports must be a list of \{port, description, scheme\}$/, "portsList", []],
[/^ports\[(\d+)\] must be an object$/, "portObject", ["index"]],
[/^ports\[(\d+)\]\.port is required$/, "portRequired", ["index"]],
[/^ports\[(\d+)\]\.port must be an integer$/, "portInteger", ["index"]],
[/^ports\[(\d+)\]\.port must be 1\-65535$/, "portRange", ["index"]],
[/^port (\d+) appears more than once for this app$/, "portDuplicate", ["port"]],
[/^ports\[(\d+)\]\.description has invalid characters$/, "portDescription", ["index"]],
[/^ports\[(\d+)\]\.scheme must be 'http' or 'https'$/, "portScheme", ["index"]],
[/^ports\[(\d+)\]\.web_path must be a valid URL path \(max 255 chars\)$/, "portPath", ["index"]],
[/^ports\[(\d+)\]\.logo_url must be an http\(s\) URL \(max 512 chars\)$/, "portLogo", ["index"]],
[/^ports\[(\d+)\]\.category has invalid characters or is too long$/, "portCategory", ["index"]],
[/^ports\[(\d+)\]\.custom_url must be an http\(s\) URL \(max 512 chars\)$/, "portCustomUrl", ["index"]],
[/^tag_regex exceeds 512 chars$/, "tagRegexLength", []],
[/^could not persist sidecar \(permission\?\)$/, "persistPermission", []],
[/^could not persist sidecar$/, "persist", []],
[/^no apps registered for this vmid$/, "noApps", []],
[/^app_id '([\s\S]+)' not found$/, "appMissing", ["appId"]],
[/^app not found$/, "appNotFound", []],
]
export function localizeAppEditorError(message: string | null | undefined, t: Translate): string {
if (!message) return ""
for (const [pattern, key, names] of RULES) {
const match = pattern.exec(message)
if (!match) continue
const params = Object.fromEntries(names.map((name, index) => [name, match[index + 1]]))
return t(`vmLxc.appEditor.errors.${key}`, params)
}
return message
}
+29
View File
@@ -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,39 @@
type Translate = (key: string, params?: Record<string, string | number>) => string
const SOURCE_TOOLS = new Set([
"subscription_banner", "time_sync", "apt_languages", "journald", "logrotate",
"system_limits", "memory_settings", "zram_swap", "kernel_panic", "apt_ipv4",
"kexec", "rpc", "motd", "system_utils", "network_optimization", "bashrc_custom",
"figurine", "fastfetch", "log2ram", "zfs_autotrim", "amd_fixes", "persistent_network",
"vfio_iommu", "lvm_repair", "repo_cleanup", "proxmox_repos", "entropy",
])
export function optimizationSourceName(tool: string, fallback: string, t: Translate): string {
return SOURCE_TOOLS.has(tool) ? t(`settings.optimizations.sourceToolNames.${tool}`) : fallback
}
export interface OptimizationSourceError {
kind: "unavailable" | "loadFailed"
detail?: string
}
export interface OptimizationSourceResponse {
success?: unknown
source?: unknown
function?: unknown
script?: unknown
deprecated?: unknown
error?: unknown
}
// Keep the blueprint's English API contract intact; translate only its display.
export function optimizationSourceError(error: OptimizationSourceError, t: Translate): string {
const detail = error.detail || ""
const key = "settings.optimizations."
if (detail === "Function not found in available scripts") return t(key + "sourceFunctionMissing")
if (detail.startsWith("Unknown tool: ")) return t(key + "sourceUnknownTool", { tool: detail.slice(14) })
if (detail.startsWith("No function mapping for ")) return t(key + "sourceNoMapping", { tool: detail.slice(24) })
if (detail === "Source code not available") return t(key + "sourceUnavailable")
const message = t(key + (error.kind === "unavailable" ? "sourceUnavailable" : "sourceLoadFailed"))
return detail ? `${message} ${detail}` : message
}
+85
View File
@@ -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)}`
}
+22
View File
@@ -0,0 +1,22 @@
// Native OCI `manage` reserves this code for explicit user cancellation.
export const OCI_CANCELLED_EXIT_CODE = 6
export function scriptCompletion(exitCode: number, cancelledExitCode?: number) {
if (exitCode === 0) return { kind: "success", color: "32" } as const
if (cancelledExitCode !== undefined && exitCode === cancelledExitCode) {
return { kind: "cancelled", color: "36" } as const
}
return { kind: "error", color: "31" } as const
}
export function shouldRefreshOciAfterCompletion(exitCode?: number) {
return exitCode !== OCI_CANCELLED_EXIT_CODE
}
export function scriptCompletionOutput(exitCode: number, message: string, cancelledExitCode?: number) {
const completion = scriptCompletion(exitCode, cancelledExitCode)
// Queue RIS with the result so earlier ncurses writes finish before reset.
// A synchronous terminal.reset() could be overwritten by that pending output.
const reset = completion.kind === "cancelled" ? "\x1bc" : ""
return `${reset}\x1b[${completion.color}m${message}\x1b[0m`
}
+375 -8
View File
@@ -1231,7 +1231,9 @@
"update": "Aktualisieren",
"modify": "Ändern",
"recover": "Wiederherstellen",
"registerRestored": "Erneut registrieren",
"terminalTitleUpdate": "OCI-Anwendung aktualisieren",
"terminalTitleRecoverPrevious": "Vorherige OCI-Installation wiederherstellen",
"terminalTitleModify": "OCI-Anwendung ändern",
"terminalTitleRecover": "Wiederhergestellte OCI-Anwendung registrieren",
"terminalDescription": "Es läuft derselbe Vorgang wie im ProxMenux-Menü: OCI manager Apps → Installierte OCI-Anwendungen verwalten.",
@@ -1760,7 +1762,128 @@
"dockerEvidenceImage": "Image",
"dockerEvidencePort": "Veröffentlichter TCP-Port",
"dockerEvidenceProject": "Compose-Projekt",
"dockerEndpointReview": "Container mit veröffentlichten TCP-Ports. Bestätigen Sie Namen und Webadresse vor dem Speichern. Die Registrierung richtet keine Updates ein."
"dockerEndpointReview": "Container mit veröffentlichten TCP-Ports. Bestätigen Sie Namen und Webadresse vor dem Speichern. Die Registrierung richtet keine Updates ein.",
"confirmRemove": "Den Eintrag dieser Anwendung aus der Registerkarte App entfernen? Die Anwendung wird dadurch nicht deinstalliert.",
"packageExample": "z. B. {package}",
"packageHelpBefore": "Automatisch aus dem Namen ausgefüllt. Prüfen Sie dies mit",
"packageHelpAfter": "innerhalb des CT.",
"binaryHelpBefore": "Absoluter Pfad. Ermitteln Sie ihn mit",
"binaryHelpOr": "oder",
"errors": {
"ociBusy": "ein OCI-Vorgang an diesem Container läuft",
"dpkgMissing": "{package} ist nicht über dpkg installiert",
"dpkgFailed": "dpkg-query ist fehlgeschlagen",
"dpkgNoVersion": "keine passende Version in der dpkg-Ausgabe",
"apkFailed": "apk info ist fehlgeschlagen",
"apkNoVersion": "keine passende Version in der apk-Ausgabe",
"fileRead": "{path} konnte nicht gelesen werden",
"fileNoMatch": "file_regex hat in {path} keine Übereinstimmung gefunden",
"binaryFailed": "Aufruf der Binärdatei fehlgeschlagen",
"binaryNoVersion": "keine passende Version in der Ausgabe der Binärdatei",
"pythonFailed": "python -c importlib.metadata ist fehlgeschlagen",
"pythonNoVersion": "keine passende Version in der Ausgabe der Python-Distribution",
"dockerImageResolve": "das installierte Image des Containers konnte nicht ermittelt werden",
"dockerInspectFailed": "docker inspect ist fehlgeschlagen",
"dockerLabelMissing": "der Container hat kein Label {label}",
"dockerLabelMutable": "das Docker-Label meldet {tag} (veränderlicher Tag, keine Version)",
"dockerLabelNoVersion": "keine passende Version in der Ausgabe des Docker-Labels",
"dockerExecFailed": "docker exec ist fehlgeschlagen",
"dockerExecNoVersion": "keine passende Version in der Ausgabe von docker exec",
"commandEmpty": "command_argv ist leer",
"commandFailed": "Befehl fehlgeschlagen",
"commandNoVersion": "keine passende Version in der Befehlsausgabe",
"unsupportedMethod": "nicht unterstützte Methode: {method}",
"unknownUpstream": "unbekannter upstream_type: {type}",
"githubNotFound": "{repo}: nicht gefunden",
"githubRejected": "GitHub hat die Anfrage abgelehnt (403)",
"githubError": "GitHub-Fehler {code}",
"noRelease": "kein Tag / Release-Name in der Antwort",
"tagNoMatch": "tag_regex passt nicht auf '{tag}'",
"httpError": "HTTP-Fehler {code}",
"invalidJson": "ungültige JSON-Antwort: {detail}",
"jsonPathUnresolved": "json_path '{path}' konnte nicht aufgelöst werden",
"jsonEmpty": "leerer Wert bei json_path",
"dockerNoTags": "keine passenden Tags (docker_hub)",
"dockerMovingOnly": "tag_regex passt nur auf bewegliche Tags; verwenden Sie die Digest-Verfolgung der Docker-Images",
"dockerHubError": "Docker-Hub-Fehler {code}",
"dockerHubJson": "ungültiges JSON von Docker Hub: {detail}",
"dockerHubEmpty": "Docker Hub hat keine Tags zurückgegeben",
"payload": "die Nutzdaten müssen ein JSON-Objekt sein",
"name": "name ist erforderlich und muss 1-64 Zeichen aus Buchstaben/Ziffern/Leerzeichen/._+-()/ enthalten",
"method": "installed_via muss einer der folgenden Werte sein: {values} oder leer",
"package": "package ist erforderlich (Buchstaben/Ziffern/._+@:/ bis zu 127 Zeichen)",
"filePath": "file_path ist erforderlich und muss ein absoluter Pfad sein",
"fileRegex": "file_regex ist erforderlich",
"fileRegexInvalid": "file_regex ist kein gültiger regulärer Ausdruck: {detail}",
"binaryPath": "binary_path ist erforderlich und muss ein absoluter Pfad sein",
"pythonPath": "python_path ist erforderlich und muss ein absoluter Pfad sein",
"distribution": "distribution ist erforderlich (Name nach PEP 503)",
"container": "container_name ist erforderlich (Docker-Namensregeln)",
"label": "label ist erforderlich (OCI-Namensregeln für Labels)",
"dockerBinary": "binary_path ist erforderlich (absoluter Pfad oder bloßer Befehl)",
"manualVersion": "installed_version ist für die manuelle Methode erforderlich",
"versionLength": "installed_version überschreitet {max} Zeichen",
"versionControl": "installed_version enthält ein unzulässiges Steuerzeichen",
"installedRegexInvalid": "installed_regex ist kein gültiger regulärer Ausdruck: {detail}",
"upstreamType": "upstream_type muss einer der folgenden Werte sein: {values}",
"tagRegexInvalid": "tag_regex ist kein gültiger regulärer Ausdruck: {detail}",
"repoRequired": "repo ist für upstream_type=github erforderlich",
"repoFormat": "repo muss 'owner/name' sein",
"githubSource": "github_source muss einer der folgenden Werte sein: {values}",
"upstreamUrlRequired": "upstream_url ist für upstream_type=http_json erforderlich",
"upstreamUrl": "upstream_url muss eine http(s)-URL sein",
"upstreamUrlLength": "upstream_url überschreitet {max} Zeichen",
"jsonPathRequired": "upstream_json_path ist für upstream_type=http_json erforderlich",
"jsonPathLength": "upstream_json_path überschreitet {max} Zeichen",
"jsonPathCharacters": "upstream_json_path verwendet unzulässige Zeichen",
"dockerImageRequired": "docker_image ist für upstream_type=docker_hub erforderlich",
"dockerImageLength": "docker_image überschreitet {max} Zeichen",
"dockerImageFormat": "docker_image muss der Docker-Hub-Benennung entsprechen (owner/name oder name)",
"healthPath": "health_path muss ein gültiger URL-Pfad sein (max. 255 Zeichen)",
"logoUrl": "logo_url muss eine http(s)-URL oder das lokal zwischengespeicherte Symbol dieser VM sein",
"discoveryId": "ungültige discovery_id",
"helperSlug": "helper_slug muss ein Slug in Kleinbuchstaben sein (Buchstaben/Ziffern/._-)",
"updateVia": "update_via akzeptiert nur 'docker'",
"dockerDetector": "update_via=docker erfordert einen Detektor docker_label oder docker_exec",
"dockerConflict": "update_via=docker kann nicht mit {field} kombiniert werden",
"updateCommandType": "update_command muss eine Zeichenkette sein",
"updateCommandLength": "update_command überschreitet {max} Zeichen",
"updateCommandNull": "update_command enthält ein Null-Byte",
"updateMethod": "update_method muss none, helper oder custom sein",
"updateCommandRequired": "update_command ist für update_method=custom erforderlich",
"updateCommandOnlyCustom": "update_command ist nur für update_method=custom zulässig",
"helperRequired": "für update_method=helper ist ein unterstützter helper_slug erforderlich",
"dockerUpdater": "An Docker delegierte Apps werden über ihr Image aktualisiert, nicht über einen separaten App-Updater",
"commandRequired": "command_argv ist erforderlich (nicht leere Liste)",
"commandList": "command_argv muss eine nicht leere Liste von Zeichenketten sein",
"commandCount": "command_argv akzeptiert höchstens {max} Einträge",
"commandEntry": "command_argv[{index}] muss eine nicht leere Zeichenkette sein",
"commandEntryLength": "command_argv[{index}] überschreitet {max} Zeichen",
"commandEntryControl": "command_argv[{index}] enthält ein unzulässiges Steuerzeichen",
"binaryArgsList": "binary_args muss eine Liste von Zeichenketten sein",
"binaryArgsCount": "binary_args akzeptiert höchstens {max} Einträge",
"binaryArgEntry": "binary_args[{index}] muss eine nicht leere Zeichenkette sein",
"binaryArgLength": "binary_args[{index}] überschreitet {max} Zeichen",
"binaryArgControl": "binary_args[{index}] enthält ein unzulässiges Steuerzeichen",
"portsList": "ports muss eine Liste von {port, description, scheme} sein",
"portObject": "ports[{index}] muss ein Objekt sein",
"portRequired": "ports[{index}].port ist erforderlich",
"portInteger": "ports[{index}].port muss eine Ganzzahl sein",
"portRange": "ports[{index}].port muss zwischen 1 und 65535 liegen",
"portDuplicate": "Port {port} kommt für diese App mehrfach vor",
"portDescription": "ports[{index}].description enthält ungültige Zeichen",
"portScheme": "ports[{index}].scheme muss 'http' oder 'https' sein",
"portPath": "ports[{index}].web_path muss ein gültiger URL-Pfad sein (max. 255 Zeichen)",
"portLogo": "ports[{index}].logo_url muss eine http(s)-URL sein (max. 512 Zeichen)",
"portCategory": "ports[{index}].category enthält ungültige Zeichen oder ist zu lang",
"portCustomUrl": "ports[{index}].custom_url muss eine http(s)-URL sein (max. 512 Zeichen)",
"tagRegexLength": "tag_regex überschreitet 512 Zeichen",
"persistPermission": "Sidecar konnte nicht gespeichert werden (Berechtigung?)",
"persist": "Sidecar konnte nicht gespeichert werden",
"noApps": "für diese vmid sind keine Apps registriert",
"appMissing": "app_id '{appId}' nicht gefunden",
"appNotFound": "App nicht gefunden"
}
},
"statusFilter": {
"ariaLabel": "Virtuelle Maschinen und Container filtern",
@@ -2294,6 +2417,42 @@
"updateManyDescription": "Wiederholen Sie die Nachinstallationsfunktionen von {count} nacheinander.",
"legacySourceTitle": "Legacy-Optimierung – klicken Sie, um die Quelle anzuzeigen",
"sourceTitle": "Klicken Sie hier, um den Quellcode anzuzeigen",
"sourceDialogTitle": "Quellcode: {name}",
"sourceLoading": "Quellcode wird geladen…",
"sourceUnavailable": "Der Quellcode ist nicht verfügbar.",
"sourceLoadFailed": "Der Quellcode konnte nicht geladen werden. Versuchen Sie es erneut.",
"sourceFunctionMissing": "Die Funktion wurde in den verfügbaren Skripten nicht gefunden.",
"sourceUnknownTool": "Unbekanntes Tool: {tool}",
"sourceNoMapping": "Dem Tool {tool} ist keine Funktion zugeordnet.",
"sourceToolNames": {
"subscription_banner": "Entfernung des Abonnementbanners",
"time_sync": "Zeitsynchronisierung",
"apt_languages": "APT-Sprachdateien überspringen",
"journald": "Journald-Optimierung",
"logrotate": "Logrotate-Optimierung",
"system_limits": "Erhöhung der Systemlimits",
"memory_settings": "Optimierung der Speichereinstellungen",
"zram_swap": "ZRAM-Swap-Profil",
"kernel_panic": "Konfiguration bei Kernel Panic",
"apt_ipv4": "APT IPv4 erzwingen",
"kexec": "kexec für schnelle Neustarts",
"rpc": "RPC / rpcbind deaktivieren",
"motd": "Benutzerdefiniertes MOTD-Banner",
"system_utils": "Systemdienstprogramme",
"network_optimization": "Netzwerkoptimierungen",
"bashrc_custom": "Bashrc-Anpassung",
"figurine": "Figurine",
"fastfetch": "Fastfetch",
"log2ram": "Log2ram (SSD-Schutz)",
"zfs_autotrim": "ZFS Autotrim",
"amd_fixes": "Korrekturen für AMD-CPUs (Ryzen/EPYC)",
"persistent_network": "Persistente Netzwerkschnittstellen festlegen",
"vfio_iommu": "VFIO/IOMMU-Passthrough",
"lvm_repair": "Reparatur der LVM-PV-Header",
"repo_cleanup": "Repository-Bereinigung",
"proxmox_repos": "Proxmox-APT-Repositorys",
"entropy": "Entropie-Erzeugung (haveged)"
},
"legacy": "Vermächtnis",
"updateToolTitle": "Aktualisieren Sie {name} auf v{version}",
"availableUpdates": "Verfügbare Updates",
@@ -4820,6 +4979,124 @@
"cancel": "Abbrechen",
"save": "Aufbewahrung speichern",
"useDefault": "Proxmox-Standard verwenden (keine Richtlinie für diesen Speicher)"
},
"apiErrors": {
"requestFailed": "Die Anfrage ist fehlgeschlagen.",
"absolutePath": "der Pfad muss absolut sein",
"pathConfigured": "dieser Pfad ist bereits konfiguriert",
"pathRequired": "der Pfad ist erforderlich",
"pathQuery": "der Abfrageparameter path ist erforderlich",
"pathNotFound": "Pfad nicht gefunden",
"invalidJobId": "ungültige Auftrags-ID",
"jobNotFound": "Auftrag nicht gefunden",
"runnerMissing": "Ausführungsskript nicht installiert",
"noRestore": "kein Wiederherstellungsstatus zum Verwerfen vorhanden",
"invalidFile": "ungültiger Dateiname",
"notFound": "nicht gefunden",
"logOutside": "Protokollpfad außerhalb des zulässigen Verzeichnisses",
"targetsList": "targets muss eine Liste sein",
"emptyExpression": "leerer Ausdruck",
"invalidId": "ungültige ID (Buchstaben, Ziffern, _ oder - verwenden)",
"backend": "backend muss pbs, local oder borg sein",
"remoteBackend": "backend muss pbs oder borg sein",
"remoteBackendQuoted": "backend muss 'pbs' oder 'borg' sein",
"profileMode": "profile_mode muss default oder custom sein",
"borgAttached": "borg kann nicht angehängt werden (PVE vzdump schreibt nur auf pbs/local-Speicher)",
"borgAttachedLong": "borg ist kein gültiges Backend für angehängte Aufträge (PVE vzdump schreibt nur auf pbs/local-Speicher)",
"parentRequired": "pve_parent_job_id und pve_storage sind für attached erforderlich",
"absolutePaths": "paths muss eine Liste absoluter Pfade sein",
"customPath": "das benutzerdefinierte Profil benötigt mindestens einen Pfad",
"defaultPaths": "die Pfade des Standardprofils konnten nicht ermittelt werden",
"retentionObject": "retention muss ein Objekt sein",
"pbsRepository": "pbs_repository ist erforderlich",
"pbsEncryption": "pbs_encrypt_mode muss 'none', 'new' oder 'existing' sein",
"pbsKeyFailed": "die PBS-Verschlüsselungsschlüsseldatei konnte nicht erstellt werden",
"pbsClientMissing": "die PBS-Verschlüsselungsschlüsseldatei konnte nicht erstellt werden (ist proxmox-backup-client installiert?)",
"borgRepository": "borg_repo ist erforderlich",
"borgEncryption": "borg_encrypt_mode muss einer der folgenden Werte sein: none, repokey, keyfile, authenticated",
"encryption": "encrypt_mode muss einer der folgenden Werte sein: none, repokey, keyfile, authenticated",
"borgPassphrase": "borg_passphrase ist erforderlich, wenn die Verschlüsselung aktiviert ist (am Ziel speichern oder in der Anfrage übergeben)",
"passphrase": "die Passphrase ist erforderlich, wenn die Verschlüsselung aktiviert ist",
"password": "das Passwort ist erforderlich",
"deviceConfirmation": "confirm_device muss exakt mit device übereinstimmen (Tippfehler-/Sicherheitsprüfung)",
"invalidDevice": "ungültiger Gerätepfad",
"absoluteMountpoint": "absoluter Einhängepunkt erforderlich",
"archiveLayout": "Archivstruktur nicht erkannt – kein rootfs/ gefunden",
"archiveMissing": "Archiv nicht gefunden",
"archiveNotFile": "der Archivpfad ist keine reguläre Datei",
"invalidName": "ungültiger Name",
"invalidStaging": "ungültiger staging_path",
"jobRename": "der Auftragsname kann nicht geändert werden (zum Umbenennen löschen und neu erstellen)",
"absoluteKey": "key_path muss absolut sein",
"mode": "mode muss \"local\" oder \"ssh\" sein",
"nameRequired": "der Name ist erforderlich (Buchstaben, Ziffern, _ oder -)",
"noLog": "für diesen Auftrag ist noch kein Protokoll verfügbar",
"noPbsConfig": "keine Datei mit manuellen PBS-Konfigurationen",
"outputMissing": "Ausgabedatei fehlt",
"parserJson": "die Ausgabe des Parsers war kein gültiges JSON",
"restoreJson": "die Ausgabe von run_restore war kein gültiges JSON",
"repoRequired": "repo ist erforderlich",
"repoSnapshot": "repo_name und snapshot sind erforderlich",
"borgRepoSnapshot": "repo_name und snapshot sind für borg erforderlich",
"pbsRepoSnapshot": "repo_name und snapshot sind für pbs erforderlich",
"repoName": "repo_name ist erforderlich",
"restoreMissing": "die Wiederherstellungswerkzeuge sind auf diesem Host nicht installiert",
"serverDatastore": "server und datastore sind erforderlich",
"snapshotRequired": "snapshot ist erforderlich",
"source": "source muss pbs, borg oder local sein",
"sshPort": "ssh_port muss eine Ganzzahl zwischen 1 und 65535 sein",
"sshRequired": "ssh_user, ssh_host und ssh_remote_path sind für den ssh-Modus erforderlich",
"repositoryPath": "das Ziel hat keinen Repository-Pfad",
"taskExpired": "Aufgabe nicht gefunden oder abgelaufen",
"invalidExpression": "ungültiger Ausdruck",
"noReport": "kein Bericht ausgegeben",
"parseManifestFailed": "parse_manifest wurde mit einem Fehlercode beendet",
"jobExists": "Auftrag existiert bereits: {value}",
"pathMissing": "Pfad existiert auf diesem Host nicht: {value}",
"deviceMissing": "Gerät existiert nicht: {value}",
"mountpoint": "kein Einhängepunkt: {value}",
"localArchiveMissing": "lokales Archiv nicht gefunden: {value}",
"borgDestination": "Borg-Ziel '{value}' nicht gefunden",
"pbsDestination": "PBS-Ziel '{value}' nicht gefunden",
"pbsPassword": "kein Passwort für das PBS-Repo '{value}' verfügbar",
"borgRepoMissing": "Borg-Repo \"{value}\" nicht konfiguriert",
"pbsRepoMissing": "PBS-Repo \"{value}\" nicht konfiguriert",
"borgTargetMissing": "Borg-Ziel nicht gefunden: {value}",
"pbsTargetMissing": "PBS-Repo nicht gefunden: {value}",
"pveJobMissing": "PVE-vzdump-Auftrag nicht gefunden: {value}",
"statePersist": "Status kann nicht gespeichert werden: {value}",
"sshDirectory": "SSH-Verzeichnis kann nicht vorbereitet werden: {value}",
"logRead": "Protokoll kann nicht gelesen werden: {value}",
"envUpdate": ".env konnte nicht aktualisiert werden: {value}",
"pbsConfigWrite": "PBS-Konfiguration konnte nicht gespeichert werden: {value}",
"persist": "Speichern fehlgeschlagen: {value}",
"archiveRemove": "Archiv konnte nicht entfernt werden: {value}",
"start": "Start fehlgeschlagen: {value}",
"borgTargetsUpdate": "borg-targets.txt konnte nicht aktualisiert werden: {value}",
"pbsTargetsUpdate": "pbs-manual-configs.txt konnte nicht aktualisiert werden: {value}",
"unitsUpdate": "systemd-Units konnten nicht aktualisiert werden: {value}",
"borgTargetsWrite": "borg-targets.txt konnte nicht geschrieben werden: {value}",
"jobWrite": "Auftragsdateien konnten nicht geschrieben werden: {value}",
"localTargetWrite": "local-target.conf konnte nicht geschrieben werden: {value}",
"unitsWrite": "systemd-Units konnten nicht geschrieben werden: {value}",
"format": "Formatieren fehlgeschlagen: {value}",
"calendar": "ungültiger on_calendar-Wert: {value}",
"mount": "Einhängen fehlgeschlagen: {value}",
"parserInvocation": "Aufruf des Parsers fehlgeschlagen: {value}",
"preflightInvocation": "Aufruf der Vorabprüfung fehlgeschlagen: {value}",
"publicKeyMissing": "öffentlicher Schlüssel nach der Erzeugung nicht gefunden: {value}",
"retentionInteger": "retention.{value} muss eine Ganzzahl sein",
"retentionNegative": "retention.{value} darf nicht negativ sein",
"keygen": "ssh-keygen fehlgeschlagen: {value}",
"systemctl": "systemctl fehlgeschlagen: {value}",
"unmount": "umount fehlgeschlagen: {value}",
"taskIncomplete": "die Aufgabe ist im Status \"{value}\", nicht abgeschlossen",
"unknownKind": "unbekannte Art: {value}",
"unknownMode": "unbekannter Modus \"{value}\"",
"defaultRemove": "{value} ist der integrierte Standard und kann nicht entfernt werden",
"defaultAdd": "{value} ist der integrierte Standard; er muss nicht hinzugefügt werden",
"keyImport": "Unter {value} ist keine PBS-Schlüsseldatei installiert. Importieren Sie zuerst die Schlüsseldatei.",
"storageMismatch": "Speicher des PVE-Auftrags stimmt nicht überein (Auftrag verwendet {value}, Nutzdaten geben {other} an)"
}
},
"healthStatus": {
@@ -5320,6 +5597,7 @@
"websocketError": "Es ist ein WebSocket-Fehler aufgetreten",
"connectionClosed": "Verbindung geschlossen",
"completedSuccessfully": "Skript erfolgreich abgeschlossen",
"cancelled": "Vorgang abgebrochen.",
"completedWithError": "Skript mit Fehlercode {code} beendet",
"yes": "Ja",
"yourInput": "Ihr Beitrag:",
@@ -7311,7 +7589,7 @@
"signInWith": "Anmelden mit",
"methodPassword": "Benutzer und Passwort",
"methodToken": "API-Token",
"username": "Benutzer (benutzer@realm)",
"username": "Benutzer",
"password": "Passwort",
"passwordHelp": "Das Passwort wird nur einmal verwendet, um ein schreibgeschütztes API-Token (Rolle Audit) für diesen Monitor zu erstellen. Gespeichert wird nur dieses Token, nie das Passwort. Aktivieren Sie zuerst die Monitor-Authentifizierung.",
"cancel": "Abbrechen",
@@ -7324,10 +7602,10 @@
"editConnection": "Zu bearbeitende Verbindung",
"add": "Neue Verbindung hinzufügen",
"name": "Name",
"url": "HTTPS-URL (für den nativen PBS-Port :8007 angeben)",
"token_id": "API-Token-ID (user@realm!token)",
"token_secret": "API-Token-Geheimnis (leer lassen, um das gespeicherte Geheimnis beizubehalten)",
"fingerprint": "SHA256-Zertifikatfingerabdruck (optional)",
"url": "HTTPS-Adresse",
"token_id": "API-Token-ID",
"token_secret": "API-Token-Geheimnis",
"fingerprint": "SHA256-Fingerabdruck (optional)",
"tlsHelp": "Lassen Sie den Fingerabdruck für die normale Prüfung von Zertifizierungsstelle und Hostname leer. Kopieren Sie bei einem selbstsignierten Zertifikat dessen SHA256-Fingerabdruck aus der PBS-Konsole oder einem anderen vertrauenswürdigen Kanal. Der Fingerabdruck wird vor der Übermittlung von Zugangsdaten geprüft.",
"saved": "PBS-Verbindung geprüft und gespeichert.",
"saveFailed": "PBS-Verbindung konnte nicht gespeichert werden.",
@@ -7369,7 +7647,85 @@
"estimatedFull": "Voraussichtlich voll",
"estimatedFullUnknown": "Noch nicht genügend Daten",
"duration": "Dauer",
"noJobs": "Es sind keine Synchronisierungs-, Überprüfungs- oder Löschzeitpläne konfiguriert."
"noJobs": "Es sind keine Synchronisierungs-, Überprüfungs- oder Löschzeitpläne konfiguriert.",
"errors": {
"configRead": "Die gespeicherten PBS-Verbindungen konnten nicht gelesen werden. Prüfen Sie die Konfigurationsdatei und die Berechtigungen.",
"invalidConfig": "Die Einstellungen der PBS-Verbindung sind ungültig. Prüfen Sie das Formular und versuchen Sie es erneut.",
"invalidField": "Ein Verbindungsfeld ist ungültig oder zu lang.",
"invalidUrl": "Verwenden Sie eine HTTPS-Adresse des PBS ohne eingebettete Zugangsdaten, Abfrageparameter oder Fragment.",
"nameRequired": "Geben Sie einen Servernamen mit höchstens 128 Zeichen ein.",
"tokenIdFormat": "Geben Sie die PBS-API-Token-ID im Format user@realm!token ein.",
"tokenSecret": "Geben Sie ein gültiges API-Token-Geheimnis ohne Leerzeichen oder Steuerzeichen ein.",
"invalidTokenId": "Die API-Token-ID darf nur ASCII-Zeichen enthalten.",
"invalidFingerprint": "Geben Sie den SHA256-Fingerabdruck des Zertifikats ein: 64 Hexadezimalzeichen, optional durch Doppelpunkte getrennt.",
"fingerprintMismatch": "Das PBS-Zertifikat stimmt nicht mit dem Fingerabdruck überein. Es wurden keine Zugangsdaten gesendet.",
"tokenRejected": "PBS hat das API-Token oder seine Berechtigungen abgelehnt. Prüfen Sie die ACLs sowohl des Benutzers als auch des Tokens.",
"invalidResponse": "PBS hat eine unerwartete Antwort zurückgegeben. Weiterleitungen werden nicht verfolgt.",
"invalidJson": "PBS hat ein ungültiges JSON-Dokument zurückgegeben.",
"connectFailed": "Über verifiziertes HTTPS konnte keine Verbindung zu PBS hergestellt werden. Prüfen Sie Adresse, Zertifikat und Dienst.",
"invalidId": "Die ID der PBS-Verbindung ist ungültig. Laden Sie die Verbindungsliste neu.",
"invalidDatastores": "PBS hat eine ungültige Datenspeicherliste zurückgegeben.",
"connectionLimit": "Es können höchstens {count} PBS-Verbindungen konfiguriert werden.",
"username": "Geben Sie den PBS-Benutzer im Format user@realm ein, zum Beispiel root@pam.",
"password": "Geben Sie das Passwort des PBS-Benutzers ein.",
"loginRejected": "PBS hat den Benutzernamen oder das Passwort abgelehnt.",
"invalidLogin": "PBS hat eine ungültige Anmeldeantwort zurückgegeben.",
"tokenCreateDenied": "Dieser PBS-Benutzer kann keine API-Tokens erstellen.",
"invalidToken": "PBS hat ein ungültiges API-Token zurückgegeben.",
"auditRoleDenied": "Dieser PBS-Benutzer kann die Rolle Audit nicht vergeben. Melden Sie sich als root@pam oder als anderer Administrator an.",
"notFound": "Die PBS-Verbindung wurde nicht gefunden. Laden Sie die Verbindungsliste neu.",
"readFailed": "PBS-Daten konnten nicht gelesen werden. Prüfen Sie den Dienst und die Berechtigungen.",
"invalidList": "PBS hat eine ungültige Datenliste zurückgegeben.",
"localUnavailable": "PBS ist auf diesem Host nicht verfügbar.",
"authRequired": "Aktivieren Sie die lokale Authentifizierung im Monitor, bevor Sie entfernte PBS-Verbindungen verwalten.",
"datastoreOffline": "Der Datenspeicher ist offline, im Wartungsmodus oder sein zugrunde liegendes Gerät ist nicht eingehängt.",
"datastoreUnavailable": "Das Dateisystem des Datenspeichers ist nicht verfügbar."
},
"taskTypes": {
"backup": "Backup",
"restore": "Wiederherstellung",
"verify": "Verifizierung",
"verifyGroup": "Verifizierung der Backup-Gruppe",
"verifySnapshot": "Verifizierung des Snapshots",
"prune": "Bereinigung",
"garbageCollection": "Garbage Collection",
"sync": "Synchronisierung",
"updateCheck": "Nach Updates suchen",
"terminal": "Terminal",
"createDatastore": "Datenspeicher erstellen",
"other": "Andere Aufgabe ({type})"
},
"states": {
"ok": "OK",
"unknown": "Unbekannt",
"warnings": "Warnungen: {count}",
"warning": "Warnung",
"error": "Fehler"
},
"serviceStates": {
"active": "Aktiv",
"inactive": "Inaktiv",
"failed": "Fehlgeschlagen",
"activating": "Wird gestartet",
"deactivating": "Wird gestoppt",
"unknown": "Unbekannt"
},
"durationUnits": {
"seconds": "{count} s",
"minutes": "{count} min",
"hours": "{count} h",
"days": "{count} T"
},
"urlHelp": "Geben Sie :8007 für den Standard-PBS-Port an, zum Beispiel https://pbs.example:8007.",
"usernameHelp": "Verwenden Sie user@realm, zum Beispiel root@pam.",
"tokenIdHelp": "Verwenden Sie user@realm!token, zum Beispiel monitor@pbs!proxmenux.",
"tokenSecretHelp": "Geben Sie das Geheimnis dieses API-Tokens ein.",
"tokenSecretKeepHelp": "Leer lassen, um das gespeicherte Token-Geheimnis beizubehalten.",
"removeFailed": "Die PBS-Verbindung konnte nicht entfernt werden.",
"savedRefreshFailed": "Die PBS-Verbindung wurde gespeichert, aber die Verbindungsliste konnte nicht aktualisiert werden. Laden Sie die Seite neu, bevor Sie eine weitere Änderung vornehmen.",
"removedRefreshFailed": "Die PBS-Verbindung wurde entfernt, aber die Verbindungsliste konnte nicht aktualisiert werden. Laden Sie die Seite neu.",
"technicalDetails": "Technische Details",
"datastoreType": "Datenspeicher"
},
"monitorLinks": {
"groups": {
@@ -7408,6 +7764,17 @@
"addSelected": "Auswahl hinzufügen",
"pbsWebInterface": "PBS-Weboberfläche",
"changeAddress": "Adresse ändern",
"cancel": "Abbrechen"
"cancel": "Abbrechen",
"errors": {
"addressRequired": "Geben Sie die Adresse des Monitors ein.",
"invalidAddress": "Die Adresse muss mit http:// oder https:// beginnen und einen Host angeben.",
"addressCredentials": "Die Adresse darf keinen Benutzer und kein Passwort enthalten.",
"invalidPort": "Der Port der Adresse ist ungültig.",
"invalidRequest": "Die Anfrage ist ungültig. Laden Sie die Seite neu und versuchen Sie es erneut.",
"limit": "Es können höchstens {count} Knoten hinzugefügt werden.",
"duplicate": "Diese Adresse ist bereits in der Liste.",
"notFound": "Der Knoten wurde nicht gefunden. Laden Sie die Liste neu und versuchen Sie es erneut.",
"detectedRemove": "Nur manuell hinzugefügte Knoten können entfernt werden. Erkannte Knoten können ausgeblendet werden."
}
}
}
+351 -9
View File
@@ -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",
@@ -1327,9 +1380,11 @@
"update": "Update",
"modify": "Modify",
"recover": "Recover",
"registerRestored": "Register again",
"terminalTitleUpdate": "Update OCI application",
"terminalTitleRecoverPrevious": "Recover the previous OCI installation",
"terminalTitleModify": "Modify OCI application",
"terminalTitleRecover": "Recover restored OCI application",
"terminalTitleRecover": "Register restored OCI applications",
"terminalDescription": "Runs the same process as the ProxMenux menu: OCI manager Apps → Manage installed OCI applications.",
"keepBackup": "Keep the backup taken before updating",
"noKeepBackup": "The backup taken before updating is not kept",
@@ -1560,7 +1615,7 @@
"ociTitle": "OCI image container",
"ociBody": "This container was created from an OCI image outside OCI manager Apps. It is updated by replacing its image, which ProxMenux does not manage for it.",
"ociRestoredTitle": "Restored OCI application",
"ociRestoredBody": "This container was restored from a backup and this host has no record of its application, so it cannot be updated from here yet. Recover registers it again on this host, the same as OCI manager Apps → Manage installed OCI applications.",
"ociRestoredBody": "This container was restored from a backup and this host has no record of its OCI application, so it cannot be updated here yet. Register again opens the registration of restored OCI applications on this node, as in OCI manager Apps → Manage installed OCI applications.",
"dockerImagesTitle": "Docker images",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Images",
@@ -1620,6 +1675,127 @@
"loadFailed": "Could not load the bulk update configuration."
},
"appEditor": {
"confirmRemove": "Remove this application's record from the App tab? This does not uninstall the application.",
"packageExample": "e.g. {package}",
"packageHelpBefore": "Auto-filled from Name. Verify with",
"packageHelpAfter": "inside the CT.",
"binaryHelpBefore": "Absolute path. Find it with",
"binaryHelpOr": "or",
"errors": {
"ociBusy": "an OCI operation on this container is running",
"dpkgMissing": "{package} is not installed via dpkg",
"dpkgFailed": "dpkg-query failed",
"dpkgNoVersion": "no version matched in dpkg output",
"apkFailed": "apk info failed",
"apkNoVersion": "no version matched in apk output",
"fileRead": "could not read {path}",
"fileNoMatch": "file_regex did not match {path}",
"binaryFailed": "binary invocation failed",
"binaryNoVersion": "no version matched in binary output",
"pythonFailed": "python -c importlib.metadata failed",
"pythonNoVersion": "no version matched in Python distribution output",
"dockerImageResolve": "could not resolve the container's installed image",
"dockerInspectFailed": "docker inspect failed",
"dockerLabelMissing": "container has no {label} label",
"dockerLabelMutable": "docker label reports {tag} (mutable tag, not a version)",
"dockerLabelNoVersion": "no version matched in docker label output",
"dockerExecFailed": "docker exec failed",
"dockerExecNoVersion": "no version matched in docker exec output",
"commandEmpty": "command_argv is empty",
"commandFailed": "command failed",
"commandNoVersion": "no version matched in command output",
"unsupportedMethod": "unsupported method: {method}",
"unknownUpstream": "unknown upstream_type: {type}",
"githubNotFound": "{repo}: not found",
"githubRejected": "github rejected the request (403)",
"githubError": "github error {code}",
"noRelease": "no tag / release name in response",
"tagNoMatch": "tag_regex did not match '{tag}'",
"httpError": "http error {code}",
"invalidJson": "invalid JSON response: {detail}",
"jsonPathUnresolved": "json_path '{path}' did not resolve",
"jsonEmpty": "empty value at json_path",
"dockerNoTags": "no tags matched (docker_hub)",
"dockerMovingOnly": "tag_regex matched only moving tags; use Docker image digest tracking",
"dockerHubError": "docker hub error {code}",
"dockerHubJson": "invalid docker hub JSON: {detail}",
"dockerHubEmpty": "no tags returned by docker hub",
"payload": "payload must be a JSON object",
"name": "name is required and must be 1-64 chars of letters/digits/spaces/._+-()/",
"method": "installed_via must be one of: {values} or empty",
"package": "package is required (letters/digits/._+@:/ up to 127 chars)",
"filePath": "file_path is required and must be an absolute path",
"fileRegex": "file_regex is required",
"fileRegexInvalid": "file_regex is not a valid regex: {detail}",
"binaryPath": "binary_path is required and must be an absolute path",
"pythonPath": "python_path is required and must be an absolute path",
"distribution": "distribution is required (PEP 503 name)",
"container": "container_name is required (docker naming rules)",
"label": "label is required (OCI label naming rules)",
"dockerBinary": "binary_path is required (absolute path or bare command)",
"manualVersion": "installed_version is required for the manual method",
"versionLength": "installed_version exceeds {max} chars",
"versionControl": "installed_version contains a forbidden control character",
"installedRegexInvalid": "installed_regex is not a valid regex: {detail}",
"upstreamType": "upstream_type must be one of: {values}",
"tagRegexInvalid": "tag_regex is not a valid regex: {detail}",
"repoRequired": "repo is required for upstream_type=github",
"repoFormat": "repo must be 'owner/name'",
"githubSource": "github_source must be one of: {values}",
"upstreamUrlRequired": "upstream_url is required for upstream_type=http_json",
"upstreamUrl": "upstream_url must be an http(s) URL",
"upstreamUrlLength": "upstream_url exceeds {max} chars",
"jsonPathRequired": "upstream_json_path is required for upstream_type=http_json",
"jsonPathLength": "upstream_json_path exceeds {max} chars",
"jsonPathCharacters": "upstream_json_path uses forbidden characters",
"dockerImageRequired": "docker_image is required for upstream_type=docker_hub",
"dockerImageLength": "docker_image exceeds {max} chars",
"dockerImageFormat": "docker_image must match Docker Hub naming (owner/name or name)",
"healthPath": "health_path must be a valid URL path (max 255 chars)",
"logoUrl": "logo_url must be an http(s) URL or this VM's cached local icon",
"discoveryId": "invalid discovery_id",
"helperSlug": "helper_slug must be a lowercase slug (letters/digits/._-)",
"updateVia": "update_via only accepts 'docker'",
"dockerDetector": "update_via=docker requires a docker_label or docker_exec detector",
"dockerConflict": "update_via=docker cannot be combined with {field}",
"updateCommandType": "update_command must be a string",
"updateCommandLength": "update_command exceeds {max} chars",
"updateCommandNull": "update_command contains a null byte",
"updateMethod": "update_method must be none, helper or custom",
"updateCommandRequired": "update_command is required for update_method=custom",
"updateCommandOnlyCustom": "update_command is only allowed for update_method=custom",
"helperRequired": "a supported helper_slug is required for update_method=helper",
"dockerUpdater": "Docker-delegated apps update through their image, not a separate app updater",
"commandRequired": "command_argv is required (non-empty list)",
"commandList": "command_argv must be a non-empty list of strings",
"commandCount": "command_argv accepts at most {max} entries",
"commandEntry": "command_argv[{index}] must be a non-empty string",
"commandEntryLength": "command_argv[{index}] exceeds {max} chars",
"commandEntryControl": "command_argv[{index}] contains a forbidden control character",
"binaryArgsList": "binary_args must be a list of strings",
"binaryArgsCount": "binary_args accepts at most {max} entries",
"binaryArgEntry": "binary_args[{index}] must be a non-empty string",
"binaryArgLength": "binary_args[{index}] exceeds {max} chars",
"binaryArgControl": "binary_args[{index}] contains a forbidden control character",
"portsList": "ports must be a list of {port, description, scheme}",
"portObject": "ports[{index}] must be an object",
"portRequired": "ports[{index}].port is required",
"portInteger": "ports[{index}].port must be an integer",
"portRange": "ports[{index}].port must be 1-65535",
"portDuplicate": "port {port} appears more than once for this app",
"portDescription": "ports[{index}].description has invalid characters",
"portScheme": "ports[{index}].scheme must be 'http' or 'https'",
"portPath": "ports[{index}].web_path must be a valid URL path (max 255 chars)",
"portLogo": "ports[{index}].logo_url must be an http(s) URL (max 512 chars)",
"portCategory": "ports[{index}].category has invalid characters or is too long",
"portCustomUrl": "ports[{index}].custom_url must be an http(s) URL (max 512 chars)",
"tagRegexLength": "tag_regex exceeds 512 chars",
"persistPermission": "could not persist sidecar (permission?)",
"persist": "could not persist sidecar",
"noApps": "no apps registered for this vmid",
"appMissing": "app_id '{appId}' not found",
"appNotFound": "app not found"
},
"closePanel": "Close panel",
"cancelButton": "Cancel",
"saveButton": "Save",
@@ -2370,6 +2546,42 @@
"updateManyDescription": "Re-running {count} post-install functions in sequence.",
"legacySourceTitle": "Legacy optimization - click to view source",
"sourceTitle": "Click to view source code",
"sourceDialogTitle": "Source code: {name}",
"sourceLoading": "Loading source code…",
"sourceUnavailable": "Source code is not available.",
"sourceLoadFailed": "Could not load the source code. Try again.",
"sourceFunctionMissing": "The function was not found in the available scripts.",
"sourceUnknownTool": "Unknown tool: {tool}",
"sourceNoMapping": "No function is assigned to the tool {tool}.",
"sourceToolNames": {
"subscription_banner": "Subscription Banner Removal",
"time_sync": "Time Synchronization",
"apt_languages": "APT Language Skip",
"journald": "Journald Optimization",
"logrotate": "Logrotate Optimization",
"system_limits": "System Limits Increase",
"memory_settings": "Memory Settings Optimization",
"zram_swap": "ZRAM Swap Profile",
"kernel_panic": "Kernel Panic Configuration",
"apt_ipv4": "APT IPv4 Force",
"kexec": "kexec for quick reboots",
"rpc": "RPC / rpcbind Disable",
"motd": "Custom MOTD Banner",
"system_utils": "System Utilities",
"network_optimization": "Network Optimizations",
"bashrc_custom": "Bashrc Customization",
"figurine": "Figurine",
"fastfetch": "Fastfetch",
"log2ram": "Log2ram (SSD Protection)",
"zfs_autotrim": "ZFS Autotrim",
"amd_fixes": "AMD CPU (Ryzen/EPYC) fixes",
"persistent_network": "Setting persistent network interfaces",
"vfio_iommu": "VFIO/IOMMU Passthrough",
"lvm_repair": "LVM PV Headers Repair",
"repo_cleanup": "Repository Cleanup",
"proxmox_repos": "Proxmox APT Repositories",
"entropy": "Entropy Generation (haveged)"
},
"legacy": "legacy",
"updateToolTitle": "Update {name} to v{version}",
"availableUpdates": "Available updates",
@@ -4383,6 +4595,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}",
@@ -5463,6 +5793,7 @@
"websocketError": "WebSocket error occurred",
"connectionClosed": "Connection closed",
"completedSuccessfully": "Script completed successfully",
"cancelled": "Operation cancelled.",
"completedWithError": "Script finished with error code {code}",
"yes": "Yes",
"yourInput": "Your input:",
@@ -6684,7 +7015,18 @@
"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."
}
},
"apiErrors": {
"http": "API request failed: {status} {statusText}",
+375 -8
View File
@@ -14,7 +14,7 @@
"signInWith": "Iniciar sesión con",
"methodPassword": "Usuario y contraseña",
"methodToken": "Token API",
"username": "Usuario (usuario@realm)",
"username": "Usuario",
"password": "Contraseña",
"passwordHelp": "La contraseña se usa una sola vez para crear un token API de solo lectura (rol Audit) para este Monitor. Solo se guarda ese token, nunca la contraseña. Activa antes la autenticación del Monitor.",
"cancel": "Cancelar",
@@ -27,10 +27,10 @@
"editConnection": "Conexión que deseas editar",
"add": "Añadir una conexión",
"name": "Nombre",
"url": "URL HTTPS (incluye :8007 para el puerto nativo de PBS)",
"token_id": "ID del token API (usuario@realm!token)",
"token_secret": "Secreto del token API (vacío para conservar el guardado)",
"fingerprint": "Huella SHA256 del certificado (opcional)",
"url": "Dirección HTTPS",
"token_id": "ID del token API",
"token_secret": "Secreto del token API",
"fingerprint": "Huella SHA256 (opcional)",
"tlsHelp": "Deja la huella vacía para verificar la CA y el nombre del servidor. Si el certificado es autofirmado, copia su huella SHA256 desde la consola de PBS u otro canal fiable. Se verifica antes de enviar las credenciales.",
"saved": "Conexión PBS verificada y guardada.",
"saveFailed": "No se pudo guardar la conexión PBS.",
@@ -72,7 +72,85 @@
"estimatedFull": "Lleno previsto",
"estimatedFullUnknown": "Aún no hay datos suficientes",
"duration": "Duración",
"noJobs": "No hay programaciones de sincronización, verificación ni prune."
"noJobs": "No hay programaciones de sincronización, verificación ni prune.",
"errors": {
"configRead": "No se pudieron leer las conexiones PBS guardadas. Revisa el archivo de configuración y los permisos.",
"invalidConfig": "La configuración de la conexión PBS no es válida. Revisa el formulario y vuelve a intentarlo.",
"invalidField": "Un campo de la conexión no es válido o es demasiado largo.",
"invalidUrl": "Usa una dirección HTTPS de PBS sin credenciales incrustadas, parámetros de consulta ni fragmento.",
"nameRequired": "Introduce un nombre de servidor de 128 caracteres como máximo.",
"tokenIdFormat": "Introduce el ID del token API de PBS con el formato usuario@realm!token.",
"tokenSecret": "Introduce un secreto de token API válido, sin espacios ni caracteres de control.",
"invalidTokenId": "El ID del token API solo puede contener caracteres ASCII.",
"invalidFingerprint": "Introduce la huella SHA256 del certificado: 64 caracteres hexadecimales, opcionalmente separados por dos puntos.",
"fingerprintMismatch": "El certificado de PBS no coincide con la huella. No se ha enviado ninguna credencial.",
"tokenRejected": "PBS ha rechazado el token API o sus permisos. Revisa las ACL del usuario y del token.",
"invalidResponse": "PBS ha devuelto una respuesta inesperada. No se siguen las redirecciones.",
"invalidJson": "PBS ha devuelto un documento JSON no válido.",
"connectFailed": "No se pudo conectar con PBS mediante HTTPS verificado. Revisa la dirección, el certificado y el servicio.",
"invalidId": "El ID de la conexión PBS no es válido. Recarga la lista de conexiones.",
"invalidDatastores": "PBS ha devuelto una lista de datastores no válida.",
"connectionLimit": "Se pueden configurar como máximo {count} conexiones PBS.",
"username": "Introduce el usuario de PBS con el formato usuario@realm, por ejemplo root@pam.",
"password": "Introduce la contraseña del usuario de PBS.",
"loginRejected": "PBS ha rechazado el usuario o la contraseña.",
"invalidLogin": "PBS ha devuelto una respuesta de inicio de sesión no válida.",
"tokenCreateDenied": "Este usuario de PBS no puede crear tokens API.",
"invalidToken": "PBS ha devuelto un token API no válido.",
"auditRoleDenied": "Este usuario de PBS no puede conceder el rol Audit. Inicia sesión como root@pam u otro administrador.",
"notFound": "No se encontró la conexión PBS. Recarga la lista de conexiones.",
"readFailed": "No se pudieron leer los datos de PBS. Revisa su servicio y los permisos.",
"invalidList": "PBS ha devuelto una lista de datos no válida.",
"localUnavailable": "PBS no está disponible en este host.",
"authRequired": "Activa la autenticación local en Monitor antes de gestionar conexiones PBS remotas.",
"datastoreOffline": "El datastore está desconectado, en modo de mantenimiento o su dispositivo no está montado.",
"datastoreUnavailable": "El sistema de archivos del datastore no está disponible."
},
"taskTypes": {
"backup": "Backup",
"restore": "Restauración",
"verify": "Verificación",
"verifyGroup": "Verificación de grupo de backups",
"verifySnapshot": "Verificación de snapshot",
"prune": "Prune",
"garbageCollection": "Recolección de basura",
"sync": "Sincronización",
"updateCheck": "Comprobación de actualizaciones",
"terminal": "Terminal",
"createDatastore": "Creación de datastore",
"other": "Otra tarea ({type})"
},
"states": {
"ok": "OK",
"unknown": "Desconocido",
"warnings": "Advertencias: {count}",
"warning": "Advertencia",
"error": "Error"
},
"serviceStates": {
"active": "Activo",
"inactive": "Inactivo",
"failed": "Fallido",
"activating": "Iniciando",
"deactivating": "Deteniendo",
"unknown": "Desconocido"
},
"durationUnits": {
"seconds": "{count} s",
"minutes": "{count} min",
"hours": "{count} h",
"days": "{count} d"
},
"urlHelp": "Incluye :8007 para el puerto predeterminado de PBS, por ejemplo https://pbs.example:8007.",
"usernameHelp": "Usa usuario@realm, por ejemplo root@pam.",
"tokenIdHelp": "Usa usuario@realm!token, por ejemplo monitor@pbs!proxmenux.",
"tokenSecretHelp": "Introduce el secreto de este token API.",
"tokenSecretKeepHelp": "Déjalo vacío para conservar el secreto del token guardado.",
"removeFailed": "No se pudo eliminar la conexión PBS.",
"savedRefreshFailed": "La conexión PBS se ha guardado, pero no se pudo actualizar la lista de conexiones. Recarga la página antes de hacer otro cambio.",
"removedRefreshFailed": "La conexión PBS se ha eliminado, pero no se pudo actualizar la lista de conexiones. Recarga la página.",
"technicalDetails": "Detalles técnicos",
"datastoreType": "Datastore"
},
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
"app": {
@@ -1306,7 +1384,9 @@
"update": "Actualizar",
"modify": "Modificar",
"recover": "Recuperar",
"registerRestored": "Registrar de nuevo",
"terminalTitleUpdate": "Actualizar aplicación OCI",
"terminalTitleRecoverPrevious": "Recuperar la instalación OCI anterior",
"terminalTitleModify": "Modificar aplicación OCI",
"terminalTitleRecover": "Recuperar aplicación OCI restaurada",
"terminalDescription": "Se ejecuta el mismo proceso que en el menú de ProxMenux: OCI manager Apps → Gestionar aplicaciones OCI instaladas.",
@@ -1835,7 +1915,128 @@
"ociNewImage": "Nueva imagen",
"ociImageCurrent": "Versión",
"refreshData": "Actualizar datos",
"refreshingData": "Actualizando…"
"refreshingData": "Actualizando…",
"confirmRemove": "¿Eliminar el registro de esta aplicación de la pestaña App? Esto no desinstala la aplicación.",
"packageExample": "por ejemplo, {package}",
"packageHelpBefore": "Se rellena automáticamente a partir del nombre. Compruébalo con",
"packageHelpAfter": "dentro del CT.",
"binaryHelpBefore": "Ruta absoluta. Búscala con",
"binaryHelpOr": "o",
"errors": {
"ociBusy": "hay una operación OCI en curso en este contenedor",
"dpkgMissing": "{package} no está instalado mediante dpkg",
"dpkgFailed": "dpkg-query falló",
"dpkgNoVersion": "ninguna versión coincide en la salida de dpkg",
"apkFailed": "apk info falló",
"apkNoVersion": "ninguna versión coincide en la salida de apk",
"fileRead": "no se pudo leer {path}",
"fileNoMatch": "file_regex no coincide con {path}",
"binaryFailed": "falló la invocación del binario",
"binaryNoVersion": "ninguna versión coincide en la salida del binario",
"pythonFailed": "python -c importlib.metadata falló",
"pythonNoVersion": "ninguna versión coincide en la salida de la distribución de Python",
"dockerImageResolve": "no se pudo resolver la imagen instalada del contenedor",
"dockerInspectFailed": "docker inspect falló",
"dockerLabelMissing": "el contenedor no tiene la etiqueta {label}",
"dockerLabelMutable": "la etiqueta de docker indica {tag} (tag mutable, no una versión)",
"dockerLabelNoVersion": "ninguna versión coincide en la salida de la etiqueta de docker",
"dockerExecFailed": "docker exec falló",
"dockerExecNoVersion": "ninguna versión coincide en la salida de docker exec",
"commandEmpty": "command_argv está vacío",
"commandFailed": "el comando falló",
"commandNoVersion": "ninguna versión coincide en la salida del comando",
"unsupportedMethod": "método no compatible: {method}",
"unknownUpstream": "upstream_type desconocido: {type}",
"githubNotFound": "{repo}: no encontrado",
"githubRejected": "github rechazó la solicitud (403)",
"githubError": "error de github {code}",
"noRelease": "no hay tag ni nombre de release en la respuesta",
"tagNoMatch": "tag_regex no coincide con '{tag}'",
"httpError": "error http {code}",
"invalidJson": "respuesta JSON no válida: {detail}",
"jsonPathUnresolved": "json_path '{path}' no se pudo resolver",
"jsonEmpty": "valor vacío en json_path",
"dockerNoTags": "ningún tag coincide (docker_hub)",
"dockerMovingOnly": "tag_regex solo coincide con tags móviles; usa el seguimiento por digest de la imagen Docker",
"dockerHubError": "error de docker hub {code}",
"dockerHubJson": "JSON de docker hub no válido: {detail}",
"dockerHubEmpty": "docker hub no devolvió ningún tag",
"payload": "el payload debe ser un objeto JSON",
"name": "name es obligatorio y debe tener 1-64 caracteres de letras/dígitos/espacios/._+-()/",
"method": "installed_via debe ser uno de: {values} o vacío",
"package": "package es obligatorio (letras/dígitos/._+@:/ hasta 127 caracteres)",
"filePath": "file_path es obligatorio y debe ser una ruta absoluta",
"fileRegex": "file_regex es obligatorio",
"fileRegexInvalid": "file_regex no es una regex válida: {detail}",
"binaryPath": "binary_path es obligatorio y debe ser una ruta absoluta",
"pythonPath": "python_path es obligatorio y debe ser una ruta absoluta",
"distribution": "distribution es obligatorio (nombre PEP 503)",
"container": "container_name es obligatorio (reglas de nombres de docker)",
"label": "label es obligatorio (reglas de nombres de etiquetas OCI)",
"dockerBinary": "binary_path es obligatorio (ruta absoluta o comando simple)",
"manualVersion": "installed_version es obligatorio para el método manual",
"versionLength": "installed_version supera {max} caracteres",
"versionControl": "installed_version contiene un carácter de control no permitido",
"installedRegexInvalid": "installed_regex no es una regex válida: {detail}",
"upstreamType": "upstream_type debe ser uno de: {values}",
"tagRegexInvalid": "tag_regex no es una regex válida: {detail}",
"repoRequired": "repo es obligatorio para upstream_type=github",
"repoFormat": "repo debe tener el formato 'owner/name'",
"githubSource": "github_source debe ser uno de: {values}",
"upstreamUrlRequired": "upstream_url es obligatorio para upstream_type=http_json",
"upstreamUrl": "upstream_url debe ser una URL http(s)",
"upstreamUrlLength": "upstream_url supera {max} caracteres",
"jsonPathRequired": "upstream_json_path es obligatorio para upstream_type=http_json",
"jsonPathLength": "upstream_json_path supera {max} caracteres",
"jsonPathCharacters": "upstream_json_path usa caracteres no permitidos",
"dockerImageRequired": "docker_image es obligatorio para upstream_type=docker_hub",
"dockerImageLength": "docker_image supera {max} caracteres",
"dockerImageFormat": "docker_image debe seguir el formato de nombres de Docker Hub (owner/name o name)",
"healthPath": "health_path debe ser una ruta URL válida (máx. 255 caracteres)",
"logoUrl": "logo_url debe ser una URL http(s) o el icono local en caché de esta VM",
"discoveryId": "discovery_id no válido",
"helperSlug": "helper_slug debe ser un slug en minúsculas (letras/dígitos/._-)",
"updateVia": "update_via solo acepta 'docker'",
"dockerDetector": "update_via=docker requiere un detector docker_label o docker_exec",
"dockerConflict": "update_via=docker no se puede combinar con {field}",
"updateCommandType": "update_command debe ser una cadena",
"updateCommandLength": "update_command supera {max} caracteres",
"updateCommandNull": "update_command contiene un byte nulo",
"updateMethod": "update_method debe ser none, helper o custom",
"updateCommandRequired": "update_command es obligatorio para update_method=custom",
"updateCommandOnlyCustom": "update_command solo se permite con update_method=custom",
"helperRequired": "se requiere un helper_slug compatible para update_method=helper",
"dockerUpdater": "las aplicaciones delegadas en Docker se actualizan mediante su imagen, no con un actualizador de aplicación aparte",
"commandRequired": "command_argv es obligatorio (lista no vacía)",
"commandList": "command_argv debe ser una lista no vacía de cadenas",
"commandCount": "command_argv admite como máximo {max} entradas",
"commandEntry": "command_argv[{index}] debe ser una cadena no vacía",
"commandEntryLength": "command_argv[{index}] supera {max} caracteres",
"commandEntryControl": "command_argv[{index}] contiene un carácter de control no permitido",
"binaryArgsList": "binary_args debe ser una lista de cadenas",
"binaryArgsCount": "binary_args admite como máximo {max} entradas",
"binaryArgEntry": "binary_args[{index}] debe ser una cadena no vacía",
"binaryArgLength": "binary_args[{index}] supera {max} caracteres",
"binaryArgControl": "binary_args[{index}] contiene un carácter de control no permitido",
"portsList": "ports debe ser una lista de {port, description, scheme}",
"portObject": "ports[{index}] debe ser un objeto",
"portRequired": "ports[{index}].port es obligatorio",
"portInteger": "ports[{index}].port debe ser un número entero",
"portRange": "ports[{index}].port debe estar en el rango 1-65535",
"portDuplicate": "el puerto {port} aparece más de una vez en esta aplicación",
"portDescription": "ports[{index}].description tiene caracteres no válidos",
"portScheme": "ports[{index}].scheme debe ser 'http' o 'https'",
"portPath": "ports[{index}].web_path debe ser una ruta URL válida (máx. 255 caracteres)",
"portLogo": "ports[{index}].logo_url debe ser una URL http(s) (máx. 512 caracteres)",
"portCategory": "ports[{index}].category tiene caracteres no válidos o es demasiado largo",
"portCustomUrl": "ports[{index}].custom_url debe ser una URL http(s) (máx. 512 caracteres)",
"tagRegexLength": "tag_regex supera 512 caracteres",
"persistPermission": "no se pudo guardar el sidecar (¿permisos?)",
"persist": "no se pudo guardar el sidecar",
"noApps": "no hay aplicaciones registradas para este vmid",
"appMissing": "no se encontró app_id '{appId}'",
"appNotFound": "aplicación no encontrada"
}
},
"statusFilter": {
"ariaLabel": "Filtrar máquinas virtuales y contenedores",
@@ -2369,6 +2570,42 @@
"updateManyDescription": "Volver a ejecutar {count} funciones posteriores a la instalación en secuencia.",
"legacySourceTitle": "Optimización heredada: haz clic para ver la fuente",
"sourceTitle": "Haz clic para ver el código fuente",
"sourceDialogTitle": "Código fuente: {name}",
"sourceLoading": "Cargando el código fuente…",
"sourceUnavailable": "El código fuente no está disponible.",
"sourceLoadFailed": "No se pudo cargar el código fuente. Vuelve a intentarlo.",
"sourceFunctionMissing": "No se encontró la función en los scripts disponibles.",
"sourceUnknownTool": "Herramienta desconocida: {tool}",
"sourceNoMapping": "No hay ninguna función asignada a la herramienta {tool}.",
"sourceToolNames": {
"subscription_banner": "Eliminación del banner de suscripción",
"time_sync": "Sincronización horaria",
"apt_languages": "Omitir idiomas de APT",
"journald": "Optimización de Journald",
"logrotate": "Optimización de Logrotate",
"system_limits": "Aumento de los límites del sistema",
"memory_settings": "Optimización de la configuración de memoria",
"zram_swap": "Perfil de swap con ZRAM",
"kernel_panic": "Configuración de kernel panic",
"apt_ipv4": "Forzar IPv4 en APT",
"kexec": "kexec para reinicios rápidos",
"rpc": "Desactivar RPC / rpcbind",
"motd": "Banner MOTD personalizado",
"system_utils": "Utilidades del sistema",
"network_optimization": "Optimizaciones de red",
"bashrc_custom": "Personalización de Bashrc",
"figurine": "Figurine",
"fastfetch": "Fastfetch",
"log2ram": "Log2ram (protección de SSD)",
"zfs_autotrim": "ZFS Autotrim",
"amd_fixes": "Correcciones para CPU AMD (Ryzen/EPYC)",
"persistent_network": "Configurar interfaces de red persistentes",
"vfio_iommu": "Passthrough VFIO/IOMMU",
"lvm_repair": "Reparación de cabeceras PV de LVM",
"repo_cleanup": "Limpieza de repositorios",
"proxmox_repos": "Repositorios APT de Proxmox",
"entropy": "Generación de entropía (haveged)"
},
"legacy": "legado",
"updateToolTitle": "Actualizar {name} a v{version}",
"availableUpdates": "Actualizaciones disponibles",
@@ -4895,6 +5132,124 @@
"effect": "Se aplica a los próximos backups que hereden la política de este almacenamiento. Guardar no elimina los backups existentes, y los protegidos siguen protegidos.",
"cancel": "Cancelar",
"save": "Guardar retención"
},
"apiErrors": {
"requestFailed": "La solicitud ha fallado.",
"absolutePath": "la ruta debe ser absoluta",
"pathConfigured": "esta ruta ya está configurada",
"pathRequired": "la ruta es obligatoria",
"pathQuery": "el parámetro de consulta path es obligatorio",
"pathNotFound": "ruta no encontrada",
"invalidJobId": "id de tarea no válido",
"jobNotFound": "tarea no encontrada",
"runnerMissing": "el script de ejecución no está instalado",
"noRestore": "no hay ningún estado de restauración que descartar",
"invalidFile": "nombre de archivo no válido",
"notFound": "no encontrado",
"logOutside": "la ruta del log está fuera del directorio permitido",
"targetsList": "targets debe ser una lista",
"emptyExpression": "expresión vacía",
"invalidId": "id no válido (usa letras, dígitos, _ o -)",
"backend": "backend debe ser pbs, local o borg",
"remoteBackend": "backend debe ser pbs o borg",
"remoteBackendQuoted": "backend debe ser 'pbs' o 'borg'",
"profileMode": "profile_mode debe ser default o custom",
"borgAttached": "borg no se puede vincular (vzdump de PVE solo escribe en almacenamientos pbs/local)",
"borgAttachedLong": "borg no es un backend válido para tareas vinculadas (vzdump de PVE solo escribe en almacenamientos pbs/local)",
"parentRequired": "pve_parent_job_id y pve_storage son obligatorios para attached",
"absolutePaths": "paths debe ser una lista de rutas absolutas",
"customPath": "el perfil personalizado necesita al menos una ruta",
"defaultPaths": "no se pudieron resolver las rutas del perfil predeterminado",
"retentionObject": "retention debe ser un objeto",
"pbsRepository": "pbs_repository es obligatorio",
"pbsEncryption": "pbs_encrypt_mode debe ser 'none', 'new' o 'existing'",
"pbsKeyFailed": "no se pudo crear el archivo de claves de cifrado de PBS",
"pbsClientMissing": "no se pudo crear el archivo de claves de cifrado de PBS (¿está instalado proxmox-backup-client?)",
"borgRepository": "borg_repo es obligatorio",
"borgEncryption": "borg_encrypt_mode debe ser uno de: none, repokey, keyfile, authenticated",
"encryption": "encrypt_mode debe ser uno de: none, repokey, keyfile, authenticated",
"borgPassphrase": "borg_passphrase es obligatorio cuando el cifrado está activado (guárdala en el destino o indica una en la solicitud)",
"passphrase": "la frase de contraseña es obligatoria cuando el cifrado está activado",
"password": "la contraseña es obligatoria",
"deviceConfirmation": "confirm_device debe coincidir exactamente con device (comprobación de errores tipográficos / seguridad)",
"invalidDevice": "ruta de dispositivo no válida",
"absoluteMountpoint": "se requiere un punto de montaje absoluto",
"archiveLayout": "estructura del archivo no reconocida: no se encontró rootfs/",
"archiveMissing": "archivo no encontrado",
"archiveNotFile": "la ruta del archivo no es un archivo regular",
"invalidName": "nombre no válido",
"invalidStaging": "staging_path no válido",
"jobRename": "no se puede cambiar el nombre de la tarea (elimínala y vuelve a crearla para renombrarla)",
"absoluteKey": "key_path debe ser absoluta",
"mode": "mode debe ser \"local\" o \"ssh\"",
"nameRequired": "el nombre es obligatorio (letras, dígitos, _ o -)",
"noLog": "aún no hay ningún log disponible para esta tarea",
"noPbsConfig": "no hay archivo de configuraciones manuales de PBS",
"outputMissing": "falta el archivo de salida",
"parserJson": "la salida del analizador no era un JSON válido",
"restoreJson": "la salida de run_restore no era un JSON válido",
"repoRequired": "repo es obligatorio",
"repoSnapshot": "repo_name y snapshot son obligatorios",
"borgRepoSnapshot": "repo_name y snapshot son obligatorios para borg",
"pbsRepoSnapshot": "repo_name y snapshot son obligatorios para pbs",
"repoName": "repo_name es obligatorio",
"restoreMissing": "las herramientas de restauración no están instaladas en este host",
"serverDatastore": "el servidor y el datastore son obligatorios",
"snapshotRequired": "el snapshot es obligatorio",
"source": "source debe ser pbs, borg o local",
"sshPort": "ssh_port debe ser un número entero entre 1 y 65535",
"sshRequired": "ssh_user, ssh_host y ssh_remote_path son obligatorios en el modo ssh",
"repositoryPath": "el destino no tiene ruta de repositorio",
"taskExpired": "tarea no encontrada o caducada",
"invalidExpression": "expresión no válida",
"noReport": "no se ha emitido ningún informe",
"parseManifestFailed": "parse_manifest terminó con un código distinto de cero",
"jobExists": "la tarea ya existe: {value}",
"pathMissing": "la ruta no existe en este host: {value}",
"deviceMissing": "el dispositivo no existe: {value}",
"mountpoint": "no es un punto de montaje: {value}",
"localArchiveMissing": "archivo local no encontrado: {value}",
"borgDestination": "no se encontró el destino Borg '{value}'",
"pbsDestination": "no se encontró el destino PBS '{value}'",
"pbsPassword": "no hay contraseña disponible para el repo PBS '{value}'",
"borgRepoMissing": "el repo Borg \"{value}\" no está configurado",
"pbsRepoMissing": "el repo PBS \"{value}\" no está configurado",
"borgTargetMissing": "destino Borg no encontrado: {value}",
"pbsTargetMissing": "repo PBS no encontrado: {value}",
"pveJobMissing": "tarea vzdump de PVE no encontrada: {value}",
"statePersist": "no se puede guardar el estado: {value}",
"sshDirectory": "no se puede preparar el directorio ssh: {value}",
"logRead": "no se puede leer el log: {value}",
"envUpdate": "no se pudo actualizar .env: {value}",
"pbsConfigWrite": "no se pudo guardar la configuración de PBS: {value}",
"persist": "no se pudo guardar: {value}",
"archiveRemove": "no se pudo eliminar el archivo: {value}",
"start": "no se pudo iniciar: {value}",
"borgTargetsUpdate": "no se pudo actualizar borg-targets.txt: {value}",
"pbsTargetsUpdate": "no se pudo actualizar pbs-manual-configs.txt: {value}",
"unitsUpdate": "no se pudieron actualizar las unidades de systemd: {value}",
"borgTargetsWrite": "no se pudo escribir borg-targets.txt: {value}",
"jobWrite": "no se pudieron escribir los archivos de la tarea: {value}",
"localTargetWrite": "no se pudo escribir local-target.conf: {value}",
"unitsWrite": "no se pudieron escribir las unidades de systemd: {value}",
"format": "falló el formateo: {value}",
"calendar": "on_calendar no válido: {value}",
"mount": "falló el montaje: {value}",
"parserInvocation": "falló la invocación del analizador: {value}",
"preflightInvocation": "falló la invocación de la verificación previa: {value}",
"publicKeyMissing": "no se encontró la clave pública tras generarla: {value}",
"retentionInteger": "retention.{value} debe ser un número entero",
"retentionNegative": "retention.{value} no puede ser negativo",
"keygen": "ssh-keygen falló: {value}",
"systemctl": "systemctl falló: {value}",
"unmount": "umount falló: {value}",
"taskIncomplete": "la tarea está en estado \"{value}\", no completada",
"unknownKind": "tipo desconocido: {value}",
"unknownMode": "modo desconocido \"{value}\"",
"defaultRemove": "{value} es el valor predeterminado integrado y no se puede eliminar",
"defaultAdd": "{value} es el valor predeterminado integrado; no hace falta añadirlo",
"keyImport": "No hay ningún archivo de claves de PBS instalado en {value}. Importa primero el archivo de claves.",
"storageMismatch": "el almacenamiento de la tarea de PVE no coincide (la tarea usa {value}, el payload indica {other})"
}
},
"healthStatus": {
@@ -5396,6 +5751,7 @@
"websocketError": "Ocurrió un error en WebSocket",
"connectionClosed": "Conexión cerrada",
"completedSuccessfully": "Guión completado con éxito",
"cancelled": "Operación cancelada.",
"completedWithError": "Script terminado con el código de error {code}",
"yes": "Sí",
"yourInput": "Su entrada:",
@@ -7408,6 +7764,17 @@
"addSelected": "Añadir seleccionados",
"pbsWebInterface": "Interfaz web de PBS",
"changeAddress": "Cambiar dirección",
"cancel": "Cancelar"
"cancel": "Cancelar",
"errors": {
"addressRequired": "Introduce la dirección del Monitor.",
"invalidAddress": "La dirección debe empezar por http:// o https:// e indicar un host.",
"addressCredentials": "La dirección no debe contener un usuario ni una contraseña.",
"invalidPort": "El puerto de la dirección no es válido.",
"invalidRequest": "La solicitud no es válida. Recarga la página y vuelve a intentarlo.",
"limit": "Se pueden añadir como máximo {count} nodos.",
"duplicate": "Esta dirección ya está en la lista.",
"notFound": "No se encontró el nodo. Recarga la lista y vuelve a intentarlo.",
"detectedRemove": "Solo se pueden eliminar los nodos añadidos a mano. Los nodos detectados se pueden ocultar."
}
}
}
+375 -8
View File
@@ -1231,7 +1231,9 @@
"update": "Mettre à jour",
"modify": "Modifier",
"recover": "Récupérer",
"registerRestored": "Enregistrer à nouveau",
"terminalTitleUpdate": "Mettre à jour l'application OCI",
"terminalTitleRecoverPrevious": "Récupérer l'installation OCI précédente",
"terminalTitleModify": "Modifier l'application OCI",
"terminalTitleRecover": "Récupérer l'application OCI restaurée",
"terminalDescription": "Le même processus que dans le menu ProxMenux s'exécute : OCI manager Apps → Gérer les applications OCI installées.",
@@ -1760,7 +1762,128 @@
"dockerEvidenceImage": "Image",
"dockerEvidencePort": "Port TCP publié",
"dockerEvidenceProject": "Projet Compose",
"dockerEndpointReview": "Conteneur avec des ports TCP publiés. Confirmez le nom et l'adresse web avant d'enregistrer. L'enregistrement ne configure pas les mises à jour."
"dockerEndpointReview": "Conteneur avec des ports TCP publiés. Confirmez le nom et l'adresse web avant d'enregistrer. L'enregistrement ne configure pas les mises à jour.",
"confirmRemove": "Supprimer l'enregistrement de cette application de l'onglet App ? Cela ne désinstalle pas l'application.",
"packageExample": "par ex. {package}",
"packageHelpBefore": "Rempli automatiquement à partir du nom. Vérifiez avec",
"packageHelpAfter": "dans le CT.",
"binaryHelpBefore": "Chemin absolu. Trouvez-le avec",
"binaryHelpOr": "ou",
"errors": {
"ociBusy": "une opération OCI est en cours sur ce conteneur",
"dpkgMissing": "{package} n'est pas installé via dpkg",
"dpkgFailed": "échec de dpkg-query",
"dpkgNoVersion": "aucune version trouvée dans la sortie de dpkg",
"apkFailed": "échec de apk info",
"apkNoVersion": "aucune version trouvée dans la sortie de apk",
"fileRead": "impossible de lire {path}",
"fileNoMatch": "file_regex ne correspond pas à {path}",
"binaryFailed": "échec de l'appel du binaire",
"binaryNoVersion": "aucune version trouvée dans la sortie du binaire",
"pythonFailed": "échec de python -c importlib.metadata",
"pythonNoVersion": "aucune version trouvée dans la sortie de la distribution Python",
"dockerImageResolve": "impossible de déterminer l'image installée du conteneur",
"dockerInspectFailed": "échec de docker inspect",
"dockerLabelMissing": "le conteneur n'a pas d'étiquette {label}",
"dockerLabelMutable": "l'étiquette docker indique {tag} (tag mobile, pas une version)",
"dockerLabelNoVersion": "aucune version trouvée dans la sortie de l'étiquette docker",
"dockerExecFailed": "échec de docker exec",
"dockerExecNoVersion": "aucune version trouvée dans la sortie de docker exec",
"commandEmpty": "command_argv est vide",
"commandFailed": "échec de la commande",
"commandNoVersion": "aucune version trouvée dans la sortie de la commande",
"unsupportedMethod": "méthode non prise en charge : {method}",
"unknownUpstream": "upstream_type inconnu : {type}",
"githubNotFound": "{repo} : introuvable",
"githubRejected": "github a refusé la requête (403)",
"githubError": "erreur github {code}",
"noRelease": "aucun tag ni nom de release dans la réponse",
"tagNoMatch": "tag_regex ne correspond pas à '{tag}'",
"httpError": "erreur http {code}",
"invalidJson": "réponse JSON non valide : {detail}",
"jsonPathUnresolved": "json_path '{path}' n'a pas pu être résolu",
"jsonEmpty": "valeur vide à json_path",
"dockerNoTags": "aucun tag ne correspond (docker_hub)",
"dockerMovingOnly": "tag_regex ne correspond qu'à des tags mobiles ; utilisez le suivi par digest de l'image Docker",
"dockerHubError": "erreur docker hub {code}",
"dockerHubJson": "JSON docker hub non valide : {detail}",
"dockerHubEmpty": "aucun tag renvoyé par docker hub",
"payload": "la charge utile doit être un objet JSON",
"name": "name est obligatoire et doit comporter de 1 à 64 caractères parmi lettres/chiffres/espaces/._+-()/",
"method": "installed_via doit être l'une des valeurs suivantes : {values} ou vide",
"package": "package est obligatoire (lettres/chiffres/._+@:/, 127 caractères max.)",
"filePath": "file_path est obligatoire et doit être un chemin absolu",
"fileRegex": "file_regex est obligatoire",
"fileRegexInvalid": "file_regex n'est pas une regex valide : {detail}",
"binaryPath": "binary_path est obligatoire et doit être un chemin absolu",
"pythonPath": "python_path est obligatoire et doit être un chemin absolu",
"distribution": "distribution est obligatoire (nom PEP 503)",
"container": "container_name est obligatoire (règles de nommage docker)",
"label": "label est obligatoire (règles de nommage des étiquettes OCI)",
"dockerBinary": "binary_path est obligatoire (chemin absolu ou commande seule)",
"manualVersion": "installed_version est obligatoire pour la méthode manuelle",
"versionLength": "installed_version dépasse {max} caractères",
"versionControl": "installed_version contient un caractère de contrôle interdit",
"installedRegexInvalid": "installed_regex n'est pas une regex valide : {detail}",
"upstreamType": "upstream_type doit être l'une des valeurs suivantes : {values}",
"tagRegexInvalid": "tag_regex n'est pas une regex valide : {detail}",
"repoRequired": "repo est obligatoire pour upstream_type=github",
"repoFormat": "repo doit être au format 'owner/name'",
"githubSource": "github_source doit être l'une des valeurs suivantes : {values}",
"upstreamUrlRequired": "upstream_url est obligatoire pour upstream_type=http_json",
"upstreamUrl": "upstream_url doit être une URL http(s)",
"upstreamUrlLength": "upstream_url dépasse {max} caractères",
"jsonPathRequired": "upstream_json_path est obligatoire pour upstream_type=http_json",
"jsonPathLength": "upstream_json_path dépasse {max} caractères",
"jsonPathCharacters": "upstream_json_path utilise des caractères interdits",
"dockerImageRequired": "docker_image est obligatoire pour upstream_type=docker_hub",
"dockerImageLength": "docker_image dépasse {max} caractères",
"dockerImageFormat": "docker_image doit respecter le nommage Docker Hub (owner/name ou name)",
"healthPath": "health_path doit être un chemin d'URL valide (255 caractères max.)",
"logoUrl": "logo_url doit être une URL http(s) ou l'icône locale en cache de cette VM",
"discoveryId": "discovery_id non valide",
"helperSlug": "helper_slug doit être un slug en minuscules (lettres/chiffres/._-)",
"updateVia": "update_via n'accepte que 'docker'",
"dockerDetector": "update_via=docker nécessite un détecteur docker_label ou docker_exec",
"dockerConflict": "update_via=docker ne peut pas être combiné avec {field}",
"updateCommandType": "update_command doit être une chaîne",
"updateCommandLength": "update_command dépasse {max} caractères",
"updateCommandNull": "update_command contient un octet nul",
"updateMethod": "update_method doit être none, helper ou custom",
"updateCommandRequired": "update_command est obligatoire pour update_method=custom",
"updateCommandOnlyCustom": "update_command n'est autorisé que pour update_method=custom",
"helperRequired": "un helper_slug pris en charge est obligatoire pour update_method=helper",
"dockerUpdater": "Les applications déléguées à Docker se mettent à jour via leur image, pas via un outil de mise à jour d'application distinct",
"commandRequired": "command_argv est obligatoire (liste non vide)",
"commandList": "command_argv doit être une liste non vide de chaînes",
"commandCount": "command_argv accepte au maximum {max} entrées",
"commandEntry": "command_argv[{index}] doit être une chaîne non vide",
"commandEntryLength": "command_argv[{index}] dépasse {max} caractères",
"commandEntryControl": "command_argv[{index}] contient un caractère de contrôle interdit",
"binaryArgsList": "binary_args doit être une liste de chaînes",
"binaryArgsCount": "binary_args accepte au maximum {max} entrées",
"binaryArgEntry": "binary_args[{index}] doit être une chaîne non vide",
"binaryArgLength": "binary_args[{index}] dépasse {max} caractères",
"binaryArgControl": "binary_args[{index}] contient un caractère de contrôle interdit",
"portsList": "ports doit être une liste de {port, description, scheme}",
"portObject": "ports[{index}] doit être un objet",
"portRequired": "ports[{index}].port est obligatoire",
"portInteger": "ports[{index}].port doit être un entier",
"portRange": "ports[{index}].port doit être compris entre 1 et 65535",
"portDuplicate": "le port {port} apparaît plusieurs fois pour cette application",
"portDescription": "ports[{index}].description contient des caractères non valides",
"portScheme": "ports[{index}].scheme doit être 'http' ou 'https'",
"portPath": "ports[{index}].web_path doit être un chemin d'URL valide (255 caractères max.)",
"portLogo": "ports[{index}].logo_url doit être une URL http(s) (512 caractères max.)",
"portCategory": "ports[{index}].category contient des caractères non valides ou est trop long",
"portCustomUrl": "ports[{index}].custom_url doit être une URL http(s) (512 caractères max.)",
"tagRegexLength": "tag_regex dépasse 512 caractères",
"persistPermission": "impossible d'enregistrer le fichier sidecar (problème de permissions ?)",
"persist": "impossible d'enregistrer le fichier sidecar",
"noApps": "aucune application enregistrée pour ce vmid",
"appMissing": "app_id '{appId}' introuvable",
"appNotFound": "application introuvable"
}
},
"statusFilter": {
"ariaLabel": "Filtrer les machines virtuelles et les conteneurs",
@@ -2294,6 +2417,42 @@
"updateManyDescription": "Réexécution des fonctions de post-installation {count} dans l'ordre.",
"legacySourceTitle": "Optimisation héritée – cliquez pour afficher la source",
"sourceTitle": "Cliquez pour voir le code source",
"sourceDialogTitle": "Code source : {name}",
"sourceLoading": "Chargement du code source…",
"sourceUnavailable": "Le code source n'est pas disponible.",
"sourceLoadFailed": "Impossible de charger le code source. Réessayez.",
"sourceFunctionMissing": "La fonction est introuvable dans les scripts disponibles.",
"sourceUnknownTool": "Outil inconnu : {tool}",
"sourceNoMapping": "Aucune fonction n'est associée à l'outil {tool}.",
"sourceToolNames": {
"subscription_banner": "Suppression de la bannière d'abonnement",
"time_sync": "Synchronisation de l'heure",
"apt_languages": "Ignorer les langues APT",
"journald": "Optimisation de journald",
"logrotate": "Optimisation de logrotate",
"system_limits": "Augmentation des limites du système",
"memory_settings": "Optimisation des paramètres de mémoire",
"zram_swap": "Profil d'échange ZRAM",
"kernel_panic": "Configuration de la panique du noyau",
"apt_ipv4": "Forcer IPv4 pour APT",
"kexec": "kexec pour des redémarrages rapides",
"rpc": "Désactivation de RPC / rpcbind",
"motd": "Bannière MOTD personnalisée",
"system_utils": "Utilitaires système",
"network_optimization": "Optimisations du réseau",
"bashrc_custom": "Personnalisation de Bashrc",
"figurine": "Figurine",
"fastfetch": "Fastfetch",
"log2ram": "Log2ram (protection SSD)",
"zfs_autotrim": "Autotrim ZFS",
"amd_fixes": "Correctifs CPU AMD (Ryzen/EPYC)",
"persistent_network": "Configuration d'interfaces réseau persistantes",
"vfio_iommu": "Passthrough VFIO/IOMMU",
"lvm_repair": "Réparation des en-têtes PV LVM",
"repo_cleanup": "Nettoyage des référentiels",
"proxmox_repos": "Référentiels APT Proxmox",
"entropy": "Génération d'entropie (haveged)"
},
"legacy": "héritage",
"updateToolTitle": "Mettez à jour {name} vers v{version}",
"availableUpdates": "Mises à jour disponibles",
@@ -4820,6 +4979,124 @@
"cancel": "Annuler",
"save": "Enregistrer la rétention",
"useDefault": "Utiliser la valeur par défaut de Proxmox (aucune politique sur ce stockage)"
},
"apiErrors": {
"requestFailed": "La requête a échoué.",
"absolutePath": "le chemin doit être absolu",
"pathConfigured": "ce chemin est déjà configuré",
"pathRequired": "le chemin est obligatoire",
"pathQuery": "le paramètre de requête path est obligatoire",
"pathNotFound": "chemin introuvable",
"invalidJobId": "ID de tâche non valide",
"jobNotFound": "tâche introuvable",
"runnerMissing": "script d'exécution non installé",
"noRestore": "aucun état de restauration à ignorer",
"invalidFile": "nom de fichier non valide",
"notFound": "introuvable",
"logOutside": "chemin du journal hors du répertoire autorisé",
"targetsList": "targets doit être une liste",
"emptyExpression": "expression vide",
"invalidId": "ID non valide (utilisez des lettres, des chiffres, _ ou -)",
"backend": "le backend doit être pbs, local ou borg",
"remoteBackend": "le backend doit être pbs ou borg",
"remoteBackendQuoted": "le backend doit être 'pbs' ou 'borg'",
"profileMode": "profile_mode doit être default ou custom",
"borgAttached": "borg ne peut pas être rattaché (vzdump de PVE n'écrit que sur des stockages pbs/local)",
"borgAttachedLong": "borg n'est pas un backend valide pour les tâches rattachées (vzdump de PVE n'écrit que sur des stockages pbs/local)",
"parentRequired": "pve_parent_job_id et pve_storage sont obligatoires pour une tâche rattachée",
"absolutePaths": "paths doit être une liste de chemins absolus",
"customPath": "le profil personnalisé nécessite au moins un chemin",
"defaultPaths": "impossible de déterminer les chemins du profil par défaut",
"retentionObject": "retention doit être un objet",
"pbsRepository": "pbs_repository est obligatoire",
"pbsEncryption": "pbs_encrypt_mode doit être 'none', 'new' ou 'existing'",
"pbsKeyFailed": "échec de la création du fichier de clé de chiffrement PBS",
"pbsClientMissing": "échec de la création du fichier de clé de chiffrement PBS (proxmox-backup-client est-il installé ?)",
"borgRepository": "borg_repo est obligatoire",
"borgEncryption": "borg_encrypt_mode doit être l'une des valeurs suivantes : none, repokey, keyfile, authenticated",
"encryption": "encrypt_mode doit être l'une des valeurs suivantes : none, repokey, keyfile, authenticated",
"borgPassphrase": "borg_passphrase est obligatoire lorsque le chiffrement est activé (enregistrez-la sur la destination ou transmettez-en une dans la requête)",
"passphrase": "la phrase secrète est obligatoire lorsque le chiffrement est activé",
"password": "le mot de passe est obligatoire",
"deviceConfirmation": "confirm_device doit correspondre exactement au périphérique (contrôle de frappe / sécurité)",
"invalidDevice": "chemin de périphérique non valide",
"absoluteMountpoint": "point de montage absolu requis",
"archiveLayout": "structure d'archive non reconnue — aucun rootfs/ trouvé",
"archiveMissing": "archive introuvable",
"archiveNotFile": "le chemin de l'archive n'est pas un fichier ordinaire",
"invalidName": "nom non valide",
"invalidStaging": "staging_path non valide",
"jobRename": "le nom de la tâche ne peut pas être modifié (supprimez-la puis recréez-la pour la renommer)",
"absoluteKey": "key_path doit être absolu",
"mode": "mode doit être \"local\" ou \"ssh\"",
"nameRequired": "le nom est obligatoire (lettres, chiffres, _ ou -)",
"noLog": "aucun journal disponible pour cette tâche pour le moment",
"noPbsConfig": "aucun fichier de configurations PBS manuelles",
"outputMissing": "fichier de sortie manquant",
"parserJson": "la sortie de l'analyseur n'était pas un JSON valide",
"restoreJson": "la sortie de run_restore n'était pas un JSON valide",
"repoRequired": "repo est obligatoire",
"repoSnapshot": "repo_name et snapshot sont obligatoires",
"borgRepoSnapshot": "repo_name et snapshot sont obligatoires pour borg",
"pbsRepoSnapshot": "repo_name et snapshot sont obligatoires pour pbs",
"repoName": "repo_name est obligatoire",
"restoreMissing": "outils de restauration non installés sur cet hôte",
"serverDatastore": "server et datastore sont obligatoires",
"snapshotRequired": "snapshot est obligatoire",
"source": "source doit être pbs, borg ou local",
"sshPort": "ssh_port doit être un entier compris entre 1 et 65535",
"sshRequired": "ssh_user, ssh_host et ssh_remote_path sont obligatoires pour le mode ssh",
"repositoryPath": "la cible n'a pas de chemin de référentiel",
"taskExpired": "tâche introuvable ou expirée",
"invalidExpression": "expression non valide",
"noReport": "aucun rapport émis",
"parseManifestFailed": "parse_manifest s'est terminé avec un code non nul",
"jobExists": "la tâche existe déjà : {value}",
"pathMissing": "le chemin n'existe pas sur cet hôte : {value}",
"deviceMissing": "le périphérique n'existe pas : {value}",
"mountpoint": "pas un point de montage : {value}",
"localArchiveMissing": "archive locale introuvable : {value}",
"borgDestination": "Destination Borg '{value}' introuvable",
"pbsDestination": "Destination PBS '{value}' introuvable",
"pbsPassword": "aucun mot de passe disponible pour le référentiel PBS '{value}'",
"borgRepoMissing": "Référentiel Borg \"{value}\" non configuré",
"pbsRepoMissing": "Référentiel PBS \"{value}\" non configuré",
"borgTargetMissing": "Cible Borg introuvable : {value}",
"pbsTargetMissing": "Référentiel PBS introuvable : {value}",
"pveJobMissing": "Tâche vzdump PVE introuvable : {value}",
"statePersist": "impossible d'enregistrer l'état : {value}",
"sshDirectory": "impossible de préparer le répertoire ssh : {value}",
"logRead": "impossible de lire le journal : {value}",
"envUpdate": "impossible de mettre à jour .env : {value}",
"pbsConfigWrite": "échec de l'enregistrement de la configuration PBS : {value}",
"persist": "échec de l'enregistrement : {value}",
"archiveRemove": "échec de la suppression de l'archive : {value}",
"start": "échec du démarrage : {value}",
"borgTargetsUpdate": "échec de la mise à jour de borg-targets.txt : {value}",
"pbsTargetsUpdate": "échec de la mise à jour de pbs-manual-configs.txt : {value}",
"unitsUpdate": "échec de la mise à jour des unités systemd : {value}",
"borgTargetsWrite": "échec de l'écriture de borg-targets.txt : {value}",
"jobWrite": "échec de l'écriture des fichiers de la tâche : {value}",
"localTargetWrite": "échec de l'écriture de local-target.conf : {value}",
"unitsWrite": "échec de l'écriture des unités systemd : {value}",
"format": "échec du formatage : {value}",
"calendar": "on_calendar non valide : {value}",
"mount": "échec du montage : {value}",
"parserInvocation": "échec de l'appel de l'analyseur : {value}",
"preflightInvocation": "échec de l'appel de la vérification préalable : {value}",
"publicKeyMissing": "clé publique introuvable après la génération : {value}",
"retentionInteger": "retention.{value} doit être un entier",
"retentionNegative": "retention.{value} doit être positif ou nul",
"keygen": "échec de ssh-keygen : {value}",
"systemctl": "échec de systemctl : {value}",
"unmount": "échec de umount : {value}",
"taskIncomplete": "la tâche est dans l'état \"{value}\", et non terminée",
"unknownKind": "type inconnu : {value}",
"unknownMode": "mode inconnu \"{value}\"",
"defaultRemove": "{value} est la valeur par défaut intégrée et ne peut pas être supprimé",
"defaultAdd": "{value} est la valeur par défaut intégrée ; inutile de l'ajouter",
"keyImport": "Aucun fichier de clé PBS n'est installé dans {value}. Importez d'abord le fichier de clé.",
"storageMismatch": "Incohérence du stockage de la tâche PVE (la tâche utilise {value}, la requête indique {other})"
}
},
"healthStatus": {
@@ -5320,6 +5597,7 @@
"websocketError": "Une erreur WebSocket s'est produite",
"connectionClosed": "Connexion fermée",
"completedSuccessfully": "Script terminé avec succès",
"cancelled": "Opération annulée.",
"completedWithError": "Script terminé avec le code d'erreur {code}",
"yes": "Oui",
"yourInput": "Votre contribution :",
@@ -7311,7 +7589,7 @@
"signInWith": "Se connecter avec",
"methodPassword": "Utilisateur et mot de passe",
"methodToken": "Jeton API",
"username": "Utilisateur (utilisateur@realm)",
"username": "Utilisateur",
"password": "Mot de passe",
"passwordHelp": "Le mot de passe n'est utilisé qu'une fois pour créer un jeton API en lecture seule (rôle Audit) pour ce Monitor. Seul ce jeton est enregistré, jamais le mot de passe. Activez d'abord l'authentification du Monitor.",
"cancel": "Annuler",
@@ -7324,10 +7602,10 @@
"editConnection": "Connexion à modifier",
"add": "Ajouter une connexion",
"name": "Nom",
"url": "URL HTTPS (inclure :8007 pour le port natif de PBS)",
"token_id": "Identifiant du jeton API (user@realm!token)",
"token_secret": "Secret du jeton API (laisser vide pour conserver le secret enregistré)",
"fingerprint": "Empreinte SHA256 du certificat (facultative)",
"url": "Adresse HTTPS",
"token_id": "ID du jeton API",
"token_secret": "Secret du jeton API",
"fingerprint": "Empreinte SHA256 (facultative)",
"tlsHelp": "Laissez l'empreinte vide pour la vérification habituelle de l'autorité de certification et du nom d'hôte. Pour un certificat autosigné, copiez son empreinte SHA256 depuis la console PBS ou un autre canal de confiance. L'empreinte est vérifiée avant l'envoi des identifiants.",
"saved": "Connexion PBS vérifiée et enregistrée.",
"saveFailed": "Impossible d'enregistrer la connexion PBS.",
@@ -7369,7 +7647,85 @@
"estimatedFull": "Estimé complet",
"estimatedFullUnknown": "Pas encore assez de données",
"duration": "Durée",
"noJobs": "Aucune planification de synchronisation, de vérification ou d'élagage n'est configurée."
"noJobs": "Aucune planification de synchronisation, de vérification ou d'élagage n'est configurée.",
"errors": {
"configRead": "Impossible de lire les connexions PBS enregistrées. Vérifiez le fichier de configuration et les permissions.",
"invalidConfig": "Les paramètres de connexion PBS ne sont pas valides. Vérifiez le formulaire et réessayez.",
"invalidField": "Un champ de la connexion n'est pas valide ou est trop long.",
"invalidUrl": "Utilisez une adresse PBS HTTPS sans identifiants intégrés, paramètres de requête ni fragment.",
"nameRequired": "Saisissez un nom de serveur de 128 caractères au maximum.",
"tokenIdFormat": "Saisissez l'ID du jeton API PBS au format user@realm!token.",
"tokenSecret": "Saisissez un secret de jeton API valide, sans espaces ni caractères de contrôle.",
"invalidTokenId": "L'ID du jeton API ne doit contenir que des caractères ASCII.",
"invalidFingerprint": "Saisissez l'empreinte SHA256 du certificat : 64 caractères hexadécimaux, éventuellement séparés par des deux-points.",
"fingerprintMismatch": "Le certificat PBS ne correspond pas à l'empreinte. Aucun identifiant n'a été envoyé.",
"tokenRejected": "PBS a refusé le jeton API ou ses permissions. Vérifiez les ACL de l'utilisateur et du jeton.",
"invalidResponse": "PBS a renvoyé une réponse inattendue. Les redirections ne sont pas suivies.",
"invalidJson": "PBS a renvoyé un document JSON non valide.",
"connectFailed": "Impossible de se connecter à PBS en HTTPS vérifié. Vérifiez l'adresse, le certificat et le service.",
"invalidId": "L'ID de la connexion PBS n'est pas valide. Rechargez la liste des connexions.",
"invalidDatastores": "PBS a renvoyé une liste de magasins de données non valide.",
"connectionLimit": "Vous pouvez configurer au maximum {count} connexions PBS.",
"username": "Saisissez l'utilisateur PBS au format user@realm, par exemple root@pam.",
"password": "Saisissez le mot de passe de l'utilisateur PBS.",
"loginRejected": "PBS a refusé le nom d'utilisateur ou le mot de passe.",
"invalidLogin": "PBS a renvoyé une réponse de connexion non valide.",
"tokenCreateDenied": "Cet utilisateur PBS ne peut pas créer de jetons API.",
"invalidToken": "PBS a renvoyé un jeton API non valide.",
"auditRoleDenied": "Cet utilisateur PBS ne peut pas attribuer le rôle Audit. Connectez-vous en tant que root@pam ou avec un autre administrateur.",
"notFound": "La connexion PBS est introuvable. Rechargez la liste des connexions.",
"readFailed": "Impossible de lire les données PBS. Vérifiez son service et les permissions.",
"invalidList": "PBS a renvoyé une liste de données non valide.",
"localUnavailable": "PBS n'est pas disponible sur cet hôte.",
"authRequired": "Activez l'authentification locale dans Monitor avant de gérer des connexions PBS distantes.",
"datastoreOffline": "Le magasin de données est hors ligne, en mode maintenance, ou son périphérique de stockage n'est pas monté.",
"datastoreUnavailable": "Le système de fichiers du magasin de données n'est pas disponible."
},
"taskTypes": {
"backup": "Sauvegarde",
"restore": "Restauration",
"verify": "Vérification",
"verifyGroup": "Vérification du groupe de sauvegardes",
"verifySnapshot": "Vérification de l'instantané",
"prune": "Purge",
"garbageCollection": "Ramasse-miettes",
"sync": "Synchronisation",
"updateCheck": "Recherche de mises à jour",
"terminal": "Terminal",
"createDatastore": "Créer un magasin de données",
"other": "Autre tâche ({type})"
},
"states": {
"ok": "OK",
"unknown": "Inconnu",
"warnings": "Avertissements : {count}",
"warning": "Avertissement",
"error": "Erreur"
},
"serviceStates": {
"active": "Actif",
"inactive": "Inactif",
"failed": "Échoué",
"activating": "Démarrage",
"deactivating": "Arrêt",
"unknown": "Inconnu"
},
"durationUnits": {
"seconds": "{count} s",
"minutes": "{count} min",
"hours": "{count} h",
"days": "{count} j"
},
"urlHelp": "Incluez :8007 pour le port PBS par défaut, par exemple https://pbs.example:8007.",
"usernameHelp": "Utilisez user@realm, par exemple root@pam.",
"tokenIdHelp": "Utilisez user@realm!token, par exemple monitor@pbs!proxmenux.",
"tokenSecretHelp": "Saisissez le secret de ce jeton API.",
"tokenSecretKeepHelp": "Laissez vide pour conserver le secret du jeton enregistré.",
"removeFailed": "Impossible de supprimer la connexion PBS.",
"savedRefreshFailed": "La connexion PBS a été enregistrée, mais la liste des connexions n'a pas pu être actualisée. Rechargez la page avant de tenter une autre modification.",
"removedRefreshFailed": "La connexion PBS a été supprimée, mais la liste des connexions n'a pas pu être actualisée. Rechargez la page.",
"technicalDetails": "Détails techniques",
"datastoreType": "Magasin de données"
},
"monitorLinks": {
"groups": {
@@ -7408,6 +7764,17 @@
"addSelected": "Ajouter la sélection",
"pbsWebInterface": "Interface web de PBS",
"changeAddress": "Modifier l'adresse",
"cancel": "Annuler"
"cancel": "Annuler",
"errors": {
"addressRequired": "Saisissez l'adresse du Monitor.",
"invalidAddress": "L'adresse doit commencer par http:// ou https:// et indiquer un hôte.",
"addressCredentials": "L'adresse ne doit contenir ni utilisateur ni mot de passe.",
"invalidPort": "Le port de l'adresse n'est pas valide.",
"invalidRequest": "La requête n'est pas valide. Rechargez la page et réessayez.",
"limit": "Vous pouvez ajouter au maximum {count} nœuds.",
"duplicate": "Cette adresse figure déjà dans la liste.",
"notFound": "Le nœud est introuvable. Rechargez la liste et réessayez.",
"detectedRemove": "Seuls les nœuds ajoutés manuellement peuvent être supprimés. Les nœuds détectés peuvent être masqués."
}
}
}
+375 -8
View File
@@ -1231,7 +1231,9 @@
"update": "Aggiorna",
"modify": "Modifica",
"recover": "Recupera",
"registerRestored": "Registra di nuovo",
"terminalTitleUpdate": "Aggiorna applicazione OCI",
"terminalTitleRecoverPrevious": "Ripristina l'installazione OCI precedente",
"terminalTitleModify": "Modifica applicazione OCI",
"terminalTitleRecover": "Recupera applicazione OCI ripristinata",
"terminalDescription": "Lo stesso percorso di OCI manager Apps → Gestisci le applicazioni OCI installate.",
@@ -1760,7 +1762,128 @@
"dockerEvidenceImage": "Immagine",
"dockerEvidencePort": "Porta TCP pubblicata",
"dockerEvidenceProject": "Progetto Compose",
"dockerEndpointReview": "Container con porte TCP pubblicate. Conferma il nome e l'indirizzo web prima di salvare. La registrazione non configura gli aggiornamenti."
"dockerEndpointReview": "Container con porte TCP pubblicate. Conferma il nome e l'indirizzo web prima di salvare. La registrazione non configura gli aggiornamenti.",
"confirmRemove": "Rimuovere il record di questa applicazione dalla scheda App? L'applicazione non viene disinstallata.",
"packageExample": "es. {package}",
"packageHelpBefore": "Compilato automaticamente dal nome. Verifica con",
"packageHelpAfter": "all'interno del CT.",
"binaryHelpBefore": "Percorso assoluto. Trovalo con",
"binaryHelpOr": "o",
"errors": {
"ociBusy": "è in corso un'operazione OCI su questo container",
"dpkgMissing": "{package} non è installato tramite dpkg",
"dpkgFailed": "dpkg-query non riuscito",
"dpkgNoVersion": "nessuna versione trovata nell'output di dpkg",
"apkFailed": "apk info non riuscito",
"apkNoVersion": "nessuna versione trovata nell'output di apk",
"fileRead": "impossibile leggere {path}",
"fileNoMatch": "file_regex non corrisponde a {path}",
"binaryFailed": "invocazione del binario non riuscita",
"binaryNoVersion": "nessuna versione trovata nell'output del binario",
"pythonFailed": "python -c importlib.metadata non riuscito",
"pythonNoVersion": "nessuna versione trovata nell'output della distribuzione Python",
"dockerImageResolve": "impossibile risolvere l'immagine installata del container",
"dockerInspectFailed": "docker inspect non riuscito",
"dockerLabelMissing": "il container non ha l'etichetta {label}",
"dockerLabelMutable": "l'etichetta Docker riporta {tag} (tag mobile, non una versione)",
"dockerLabelNoVersion": "nessuna versione trovata nell'output dell'etichetta Docker",
"dockerExecFailed": "docker exec non riuscito",
"dockerExecNoVersion": "nessuna versione trovata nell'output di docker exec",
"commandEmpty": "command_argv è vuoto",
"commandFailed": "comando non riuscito",
"commandNoVersion": "nessuna versione trovata nell'output del comando",
"unsupportedMethod": "metodo non supportato: {method}",
"unknownUpstream": "upstream_type sconosciuto: {type}",
"githubNotFound": "{repo}: non trovato",
"githubRejected": "GitHub ha rifiutato la richiesta (403)",
"githubError": "errore GitHub {code}",
"noRelease": "nessun nome di tag / release nella risposta",
"tagNoMatch": "tag_regex non corrisponde a '{tag}'",
"httpError": "errore HTTP {code}",
"invalidJson": "risposta JSON non valida: {detail}",
"jsonPathUnresolved": "json_path '{path}' non è stato risolto",
"jsonEmpty": "valore vuoto in json_path",
"dockerNoTags": "nessun tag corrispondente (docker_hub)",
"dockerMovingOnly": "tag_regex corrisponde solo a tag mobili; usa il monitoraggio tramite digest dell'immagine Docker",
"dockerHubError": "errore Docker Hub {code}",
"dockerHubJson": "JSON di Docker Hub non valido: {detail}",
"dockerHubEmpty": "nessun tag restituito da Docker Hub",
"payload": "il payload deve essere un oggetto JSON",
"name": "il nome è obbligatorio e deve contenere da 1 a 64 caratteri tra lettere/cifre/spazi/._+-()/",
"method": "installed_via deve essere uno tra: {values} oppure vuoto",
"package": "il pacchetto è obbligatorio (lettere/cifre/._+@:/ fino a 127 caratteri)",
"filePath": "file_path è obbligatorio e deve essere un percorso assoluto",
"fileRegex": "file_regex è obbligatorio",
"fileRegexInvalid": "file_regex non è una regex valida: {detail}",
"binaryPath": "binary_path è obbligatorio e deve essere un percorso assoluto",
"pythonPath": "python_path è obbligatorio e deve essere un percorso assoluto",
"distribution": "la distribuzione è obbligatoria (nome PEP 503)",
"container": "container_name è obbligatorio (regole di denominazione Docker)",
"label": "l'etichetta è obbligatoria (regole di denominazione delle etichette OCI)",
"dockerBinary": "binary_path è obbligatorio (percorso assoluto o comando semplice)",
"manualVersion": "installed_version è obbligatorio per il metodo manuale",
"versionLength": "installed_version supera {max} caratteri",
"versionControl": "installed_version contiene un carattere di controllo non consentito",
"installedRegexInvalid": "installed_regex non è una regex valida: {detail}",
"upstreamType": "upstream_type deve essere uno tra: {values}",
"tagRegexInvalid": "tag_regex non è una regex valida: {detail}",
"repoRequired": "repo è obbligatorio per upstream_type=github",
"repoFormat": "repo deve essere nel formato 'owner/name'",
"githubSource": "github_source deve essere uno tra: {values}",
"upstreamUrlRequired": "upstream_url è obbligatorio per upstream_type=http_json",
"upstreamUrl": "upstream_url deve essere un URL http(s)",
"upstreamUrlLength": "upstream_url supera {max} caratteri",
"jsonPathRequired": "upstream_json_path è obbligatorio per upstream_type=http_json",
"jsonPathLength": "upstream_json_path supera {max} caratteri",
"jsonPathCharacters": "upstream_json_path usa caratteri non consentiti",
"dockerImageRequired": "docker_image è obbligatorio per upstream_type=docker_hub",
"dockerImageLength": "docker_image supera {max} caratteri",
"dockerImageFormat": "docker_image deve rispettare la denominazione di Docker Hub (owner/name o name)",
"healthPath": "health_path deve essere un percorso URL valido (max 255 caratteri)",
"logoUrl": "logo_url deve essere un URL http(s) o l'icona locale in cache di questa VM",
"discoveryId": "discovery_id non valido",
"helperSlug": "helper_slug deve essere uno slug in minuscolo (lettere/cifre/._-)",
"updateVia": "update_via accetta solo 'docker'",
"dockerDetector": "update_via=docker richiede un rilevatore docker_label o docker_exec",
"dockerConflict": "update_via=docker non può essere combinato con {field}",
"updateCommandType": "update_command deve essere una stringa",
"updateCommandLength": "update_command supera {max} caratteri",
"updateCommandNull": "update_command contiene un byte nullo",
"updateMethod": "update_method deve essere none, helper o custom",
"updateCommandRequired": "update_command è obbligatorio per update_method=custom",
"updateCommandOnlyCustom": "update_command è consentito solo per update_method=custom",
"helperRequired": "per update_method=helper è obbligatorio un helper_slug supportato",
"dockerUpdater": "Le app delegate a Docker si aggiornano tramite la loro immagine, non con un programma di aggiornamento separato",
"commandRequired": "command_argv è obbligatorio (elenco non vuoto)",
"commandList": "command_argv deve essere un elenco non vuoto di stringhe",
"commandCount": "command_argv accetta al massimo {max} voci",
"commandEntry": "command_argv[{index}] deve essere una stringa non vuota",
"commandEntryLength": "command_argv[{index}] supera {max} caratteri",
"commandEntryControl": "command_argv[{index}] contiene un carattere di controllo non consentito",
"binaryArgsList": "binary_args deve essere un elenco di stringhe",
"binaryArgsCount": "binary_args accetta al massimo {max} voci",
"binaryArgEntry": "binary_args[{index}] deve essere una stringa non vuota",
"binaryArgLength": "binary_args[{index}] supera {max} caratteri",
"binaryArgControl": "binary_args[{index}] contiene un carattere di controllo non consentito",
"portsList": "ports deve essere un elenco di {port, description, scheme}",
"portObject": "ports[{index}] deve essere un oggetto",
"portRequired": "ports[{index}].port è obbligatorio",
"portInteger": "ports[{index}].port deve essere un numero intero",
"portRange": "ports[{index}].port deve essere compreso tra 1 e 65535",
"portDuplicate": "la porta {port} compare più di una volta per questa app",
"portDescription": "ports[{index}].description contiene caratteri non validi",
"portScheme": "ports[{index}].scheme deve essere 'http' o 'https'",
"portPath": "ports[{index}].web_path deve essere un percorso URL valido (max 255 caratteri)",
"portLogo": "ports[{index}].logo_url deve essere un URL http(s) (max 512 caratteri)",
"portCategory": "ports[{index}].category contiene caratteri non validi o è troppo lungo",
"portCustomUrl": "ports[{index}].custom_url deve essere un URL http(s) (max 512 caratteri)",
"tagRegexLength": "tag_regex supera 512 caratteri",
"persistPermission": "impossibile salvare il file sidecar (permessi?)",
"persist": "impossibile salvare il file sidecar",
"noApps": "nessuna app registrata per questo vmid",
"appMissing": "app_id '{appId}' non trovato",
"appNotFound": "app non trovata"
}
},
"statusFilter": {
"ariaLabel": "Filtra macchine virtuali e container",
@@ -2294,6 +2417,42 @@
"updateManyDescription": "Riesecuzione di {count} funzioni di post-installazione in sequenza.",
"legacySourceTitle": "Ottimizzazione legacy: fai clic per visualizzare il sorgente",
"sourceTitle": "Fai clic per vedere il codice sorgente",
"sourceDialogTitle": "Codice sorgente: {name}",
"sourceLoading": "Caricamento del codice sorgente…",
"sourceUnavailable": "Il codice sorgente non è disponibile.",
"sourceLoadFailed": "Impossibile caricare il codice sorgente. Riprova.",
"sourceFunctionMissing": "La funzione non è stata trovata negli script disponibili.",
"sourceUnknownTool": "Strumento sconosciuto: {tool}",
"sourceNoMapping": "Nessuna funzione è assegnata allo strumento {tool}.",
"sourceToolNames": {
"subscription_banner": "Rimozione del banner di abbonamento",
"time_sync": "Sincronizzazione dell'ora",
"apt_languages": "Salto delle lingue APT",
"journald": "Ottimizzazione di Journald",
"logrotate": "Ottimizzazione di Logrotate",
"system_limits": "Aumento dei limiti di sistema",
"memory_settings": "Ottimizzazione delle impostazioni di memoria",
"zram_swap": "Profilo di scambio ZRAM",
"kernel_panic": "Configurazione del kernel panic",
"apt_ipv4": "Forzatura IPv4 per APT",
"kexec": "kexec per riavvii rapidi",
"rpc": "Disattivazione di RPC / rpcbind",
"motd": "Banner MOTD personalizzato",
"system_utils": "Utilità di sistema",
"network_optimization": "Ottimizzazioni di rete",
"bashrc_custom": "Personalizzazione di Bashrc",
"figurine": "Figurine",
"fastfetch": "Fastfetch",
"log2ram": "Log2ram (protezione SSD)",
"zfs_autotrim": "ZFS Autotrim",
"amd_fixes": "Correzioni CPU AMD (Ryzen/EPYC)",
"persistent_network": "Configurazione di interfacce di rete persistenti",
"vfio_iommu": "Passthrough VFIO/IOMMU",
"lvm_repair": "Riparazione delle intestazioni PV LVM",
"repo_cleanup": "Pulizia dei repository",
"proxmox_repos": "Repository APT di Proxmox",
"entropy": "Generazione di entropia (haveged)"
},
"legacy": "legacy",
"updateToolTitle": "Aggiorna {name} a v{version}",
"availableUpdates": "Aggiornamenti disponibili",
@@ -4820,6 +4979,124 @@
"cancel": "Annulla",
"save": "Salva conservazione",
"useDefault": "Usa l'impostazione predefinita di Proxmox (nessuna policy su questo storage)"
},
"apiErrors": {
"requestFailed": "La richiesta non è riuscita.",
"absolutePath": "il percorso deve essere assoluto",
"pathConfigured": "questo percorso è già configurato",
"pathRequired": "il percorso è obbligatorio",
"pathQuery": "il parametro di query path è obbligatorio",
"pathNotFound": "percorso non trovato",
"invalidJobId": "ID del job non valido",
"jobNotFound": "job non trovato",
"runnerMissing": "script di esecuzione non installato",
"noRestore": "nessuno stato di ripristino da chiudere",
"invalidFile": "nome file non valido",
"notFound": "non trovato",
"logOutside": "percorso del log al di fuori della directory consentita",
"targetsList": "targets deve essere un elenco",
"emptyExpression": "espressione vuota",
"invalidId": "ID non valido (usa lettere, cifre, _ o -)",
"backend": "il backend deve essere pbs, local o borg",
"remoteBackend": "il backend deve essere pbs o borg",
"remoteBackendQuoted": "il backend deve essere 'pbs' o 'borg'",
"profileMode": "profile_mode deve essere default o custom",
"borgAttached": "borg non può essere collegato (vzdump di PVE scrive solo su storage pbs/local)",
"borgAttachedLong": "borg non è un backend valido per i job collegati (vzdump di PVE scrive solo su storage pbs/local)",
"parentRequired": "pve_parent_job_id e pve_storage sono obbligatori per attached",
"absolutePaths": "paths deve essere un elenco di percorsi assoluti",
"customPath": "il profilo personalizzato richiede almeno un percorso",
"defaultPaths": "impossibile determinare i percorsi del profilo predefinito",
"retentionObject": "retention deve essere un oggetto",
"pbsRepository": "pbs_repository è obbligatorio",
"pbsEncryption": "pbs_encrypt_mode deve essere 'none', 'new' o 'existing'",
"pbsKeyFailed": "impossibile creare il file di chiavi di crittografia PBS",
"pbsClientMissing": "impossibile creare il file di chiavi di crittografia PBS (proxmox-backup-client è installato?)",
"borgRepository": "borg_repo è obbligatorio",
"borgEncryption": "borg_encrypt_mode deve essere uno tra: none, repokey, keyfile, authenticated",
"encryption": "encrypt_mode deve essere uno tra: none, repokey, keyfile, authenticated",
"borgPassphrase": "borg_passphrase è obbligatoria quando la crittografia è abilitata (salvala nella destinazione o passane una nella richiesta)",
"passphrase": "la passphrase è obbligatoria quando la crittografia è abilitata",
"password": "la password è obbligatoria",
"deviceConfirmation": "confirm_device deve corrispondere esattamente a device (controllo di sicurezza / refusi)",
"invalidDevice": "percorso del dispositivo non valido",
"absoluteMountpoint": "è richiesto un punto di montaggio assoluto",
"archiveLayout": "struttura dell'archivio non riconosciuta: nessuna rootfs/ trovata",
"archiveMissing": "archivio non trovato",
"archiveNotFile": "il percorso dell'archivio non è un file regolare",
"invalidName": "nome non valido",
"invalidStaging": "staging_path non valido",
"jobRename": "il nome del job non può essere modificato (elimina e ricrea per rinominarlo)",
"absoluteKey": "key_path deve essere assoluto",
"mode": "mode deve essere \"local\" o \"ssh\"",
"nameRequired": "il nome è obbligatorio (lettere, cifre, _ o -)",
"noLog": "ancora nessun log disponibile per questo job",
"noPbsConfig": "nessun file di configurazioni PBS manuali",
"outputMissing": "file di output mancante",
"parserJson": "l'output del parser non era un JSON valido",
"restoreJson": "l'output di run_restore non era un JSON valido",
"repoRequired": "repo è obbligatorio",
"repoSnapshot": "repo_name e snapshot sono obbligatori",
"borgRepoSnapshot": "repo_name e snapshot sono obbligatori per borg",
"pbsRepoSnapshot": "repo_name e snapshot sono obbligatori per pbs",
"repoName": "repo_name è obbligatorio",
"restoreMissing": "strumenti di ripristino non installati su questo host",
"serverDatastore": "server e datastore sono obbligatori",
"snapshotRequired": "lo snapshot è obbligatorio",
"source": "source deve essere pbs, borg o local",
"sshPort": "ssh_port deve essere un numero intero tra 1 e 65535",
"sshRequired": "ssh_user, ssh_host e ssh_remote_path sono obbligatori per la modalità ssh",
"repositoryPath": "la destinazione non ha un percorso di repository",
"taskExpired": "attività non trovata o scaduta",
"invalidExpression": "espressione non valida",
"noReport": "nessun report generato",
"parseManifestFailed": "parse_manifest è terminato con un codice diverso da zero",
"jobExists": "il job esiste già: {value}",
"pathMissing": "il percorso non esiste su questo host: {value}",
"deviceMissing": "il dispositivo non esiste: {value}",
"mountpoint": "non è un punto di montaggio: {value}",
"localArchiveMissing": "archivio locale non trovato: {value}",
"borgDestination": "Destinazione Borg '{value}' non trovata",
"pbsDestination": "Destinazione PBS '{value}' non trovata",
"pbsPassword": "nessuna password disponibile per il repository PBS '{value}'",
"borgRepoMissing": "Repository Borg \"{value}\" non configurato",
"pbsRepoMissing": "Repository PBS \"{value}\" non configurato",
"borgTargetMissing": "Destinazione Borg non trovata: {value}",
"pbsTargetMissing": "Repository PBS non trovato: {value}",
"pveJobMissing": "Job vzdump di PVE non trovato: {value}",
"statePersist": "impossibile salvare lo stato: {value}",
"sshDirectory": "impossibile preparare la directory ssh: {value}",
"logRead": "impossibile leggere il log: {value}",
"envUpdate": "impossibile aggiornare .env: {value}",
"pbsConfigWrite": "impossibile salvare la configurazione PBS: {value}",
"persist": "impossibile salvare: {value}",
"archiveRemove": "impossibile rimuovere l'archivio: {value}",
"start": "impossibile avviare: {value}",
"borgTargetsUpdate": "impossibile aggiornare borg-targets.txt: {value}",
"pbsTargetsUpdate": "impossibile aggiornare pbs-manual-configs.txt: {value}",
"unitsUpdate": "impossibile aggiornare le unità systemd: {value}",
"borgTargetsWrite": "impossibile scrivere borg-targets.txt: {value}",
"jobWrite": "impossibile scrivere i file del job: {value}",
"localTargetWrite": "impossibile scrivere local-target.conf: {value}",
"unitsWrite": "impossibile scrivere le unità systemd: {value}",
"format": "formattazione non riuscita: {value}",
"calendar": "on_calendar non valido: {value}",
"mount": "montaggio non riuscito: {value}",
"parserInvocation": "invocazione del parser non riuscita: {value}",
"preflightInvocation": "invocazione della verifica preliminare non riuscita: {value}",
"publicKeyMissing": "chiave pubblica non trovata dopo la generazione: {value}",
"retentionInteger": "retention.{value} deve essere un numero intero",
"retentionNegative": "retention.{value} non deve essere negativo",
"keygen": "ssh-keygen non riuscito: {value}",
"systemctl": "systemctl non riuscito: {value}",
"unmount": "umount non riuscito: {value}",
"taskIncomplete": "l'attività è nello stato \"{value}\", non completata",
"unknownKind": "tipo sconosciuto: {value}",
"unknownMode": "modalità sconosciuta \"{value}\"",
"defaultRemove": "{value} è il valore predefinito integrato e non può essere rimosso",
"defaultAdd": "{value} è il valore predefinito integrato; non è necessario aggiungerlo",
"keyImport": "Nessun file di chiavi PBS è installato in {value}. Importa prima il file di chiavi.",
"storageMismatch": "Lo storage del job PVE non corrisponde (il job usa {value}, il payload indica {other})"
}
},
"healthStatus": {
@@ -5320,6 +5597,7 @@
"websocketError": "Errore WebSocket",
"connectionClosed": "Connessione chiusa",
"completedSuccessfully": "Script completato con successo",
"cancelled": "Operazione annullata.",
"completedWithError": "Script terminato con codice di errore {code}",
"yes": "Sì",
"yourInput": "Input:",
@@ -7311,7 +7589,7 @@
"signInWith": "Accedi con",
"methodPassword": "Utente e password",
"methodToken": "Token API",
"username": "Utente (utente@realm)",
"username": "Utente",
"password": "Password",
"passwordHelp": "La password viene usata una sola volta per creare un token API di sola lettura (ruolo Audit) per questo Monitor. Viene salvato solo quel token, mai la password. Attiva prima l'autenticazione del Monitor.",
"cancel": "Annulla",
@@ -7324,10 +7602,10 @@
"editConnection": "Connessione da modificare",
"add": "Aggiungi una nuova connessione",
"name": "Nome",
"url": "URL HTTPS (includere :8007 per la porta nativa di PBS)",
"token_id": "ID del token API (user@realm!token)",
"token_secret": "Segreto del token API (lasciare vuoto per mantenere quello salvato)",
"fingerprint": "Impronta SHA256 del certificato (facoltativa)",
"url": "Indirizzo HTTPS",
"token_id": "ID del token API",
"token_secret": "Segreto del token API",
"fingerprint": "Impronta SHA256 (facoltativa)",
"tlsHelp": "Lascia vuota l'impronta per la normale verifica dell'autorità di certificazione e del nome host. Per un certificato autofirmato, copia la sua impronta SHA256 dalla console PBS o da un altro canale attendibile. L'impronta viene verificata prima dell'invio delle credenziali.",
"saved": "Connessione PBS verificata e salvata.",
"saveFailed": "Impossibile salvare la connessione PBS.",
@@ -7369,7 +7647,85 @@
"estimatedFull": "Pieno stimato",
"estimatedFullUnknown": "dati non ancora sufficienti",
"duration": "Durata",
"noJobs": "non è configurata alcuna pianificazione di sincronizzazione, verifica o eliminazione."
"noJobs": "non è configurata alcuna pianificazione di sincronizzazione, verifica o eliminazione.",
"errors": {
"configRead": "Impossibile leggere le connessioni PBS salvate. Controlla il file di configurazione e i permessi.",
"invalidConfig": "Le impostazioni della connessione PBS non sono valide. Controlla il modulo e riprova.",
"invalidField": "Un campo della connessione non è valido o è troppo lungo.",
"invalidUrl": "Usa un indirizzo PBS HTTPS senza credenziali incorporate, parametri di query o frammento.",
"nameRequired": "Inserisci un nome del server di massimo 128 caratteri.",
"tokenIdFormat": "Inserisci l'ID del token API PBS nel formato user@realm!token.",
"tokenSecret": "Inserisci un segreto del token API valido, senza spazi né caratteri di controllo.",
"invalidTokenId": "L'ID del token API deve contenere solo caratteri ASCII.",
"invalidFingerprint": "Inserisci l'impronta SHA256 del certificato: 64 caratteri esadecimali, facoltativamente separati da due punti.",
"fingerprintMismatch": "Il certificato PBS non corrisponde all'impronta. Non sono state inviate credenziali.",
"tokenRejected": "PBS ha rifiutato il token API o i suoi permessi. Controlla le ACL sia dell'utente sia del token.",
"invalidResponse": "PBS ha restituito una risposta inattesa. I reindirizzamenti non vengono seguiti.",
"invalidJson": "PBS ha restituito un documento JSON non valido.",
"connectFailed": "Impossibile connettersi a PBS tramite HTTPS verificato. Controlla l'indirizzo, il certificato e il servizio.",
"invalidId": "L'ID della connessione PBS non è valido. Ricarica l'elenco delle connessioni.",
"invalidDatastores": "PBS ha restituito un elenco di datastore non valido.",
"connectionLimit": "Si possono configurare al massimo {count} connessioni PBS.",
"username": "Inserisci l'utente PBS nel formato user@realm, ad esempio root@pam.",
"password": "Inserisci la password dell'utente PBS.",
"loginRejected": "PBS ha rifiutato il nome utente o la password.",
"invalidLogin": "PBS ha restituito una risposta di accesso non valida.",
"tokenCreateDenied": "Questo utente PBS non può creare token API.",
"invalidToken": "PBS ha restituito un token API non valido.",
"auditRoleDenied": "Questo utente PBS non può assegnare il ruolo Audit. Accedi come root@pam o con un altro amministratore.",
"notFound": "Connessione PBS non trovata. Ricarica l'elenco delle connessioni.",
"readFailed": "Impossibile leggere i dati PBS. Controlla il servizio e i permessi.",
"invalidList": "PBS ha restituito un elenco di dati non valido.",
"localUnavailable": "PBS non è disponibile su questo host.",
"authRequired": "Abilita l'autenticazione locale in Monitor prima di gestire le connessioni PBS remote.",
"datastoreOffline": "Il datastore è offline, in modalità manutenzione oppure il dispositivo su cui si basa non è montato.",
"datastoreUnavailable": "Il filesystem del datastore non è disponibile."
},
"taskTypes": {
"backup": "Backup",
"restore": "Ripristino",
"verify": "Verifica",
"verifyGroup": "Verifica del gruppo di backup",
"verifySnapshot": "Verifica dello snapshot",
"prune": "Pulizia",
"garbageCollection": "Garbage collection",
"sync": "Sincronizzazione",
"updateCheck": "Controllo aggiornamenti",
"terminal": "Terminale",
"createDatastore": "Creazione datastore",
"other": "Altra attività ({type})"
},
"states": {
"ok": "OK",
"unknown": "Sconosciuto",
"warnings": "Avvisi: {count}",
"warning": "Avviso",
"error": "Errore"
},
"serviceStates": {
"active": "Attivo",
"inactive": "Inattivo",
"failed": "Non riuscito",
"activating": "Avvio in corso",
"deactivating": "Arresto in corso",
"unknown": "Sconosciuto"
},
"durationUnits": {
"seconds": "{count} s",
"minutes": "{count} min",
"hours": "{count} h",
"days": "{count} g"
},
"urlHelp": "Includi :8007 per la porta predefinita di PBS, ad esempio https://pbs.example:8007.",
"usernameHelp": "Usa utente@realm, ad esempio root@pam.",
"tokenIdHelp": "Usa utente@realm!token, ad esempio monitor@pbs!proxmenux.",
"tokenSecretHelp": "Inserisci il segreto di questo token API.",
"tokenSecretKeepHelp": "Lascia vuoto per mantenere il segreto del token salvato.",
"removeFailed": "Impossibile rimuovere la connessione PBS.",
"savedRefreshFailed": "La connessione PBS è stata salvata, ma non è stato possibile aggiornare l'elenco delle connessioni. Ricarica la pagina prima di fare un'altra modifica.",
"removedRefreshFailed": "La connessione PBS è stata rimossa, ma non è stato possibile aggiornare l'elenco delle connessioni. Ricarica la pagina.",
"technicalDetails": "Dettagli tecnici",
"datastoreType": "Datastore"
},
"monitorLinks": {
"groups": {
@@ -7408,6 +7764,17 @@
"addSelected": "Aggiungi selezionati",
"pbsWebInterface": "Interfaccia web di PBS",
"changeAddress": "Cambia indirizzo",
"cancel": "Annulla"
"cancel": "Annulla",
"errors": {
"addressRequired": "Inserisci l'indirizzo del Monitor.",
"invalidAddress": "L'indirizzo deve iniziare con http:// o https:// e indicare un host.",
"addressCredentials": "L'indirizzo non deve contenere un utente o una password.",
"invalidPort": "La porta dell'indirizzo non è valida.",
"invalidRequest": "La richiesta non è valida. Ricarica la pagina e riprova.",
"limit": "Si possono aggiungere al massimo {count} nodi.",
"duplicate": "Questo indirizzo è già nell'elenco.",
"notFound": "Nodo non trovato. Ricarica l'elenco e riprova.",
"detectedRemove": "Si possono rimuovere solo i nodi aggiunti manualmente. I nodi rilevati possono essere nascosti."
}
}
}
+375 -8
View File
@@ -1231,7 +1231,9 @@
"update": "Atualizar",
"modify": "Modificar",
"recover": "Recuperar",
"registerRestored": "Registar novamente",
"terminalTitleUpdate": "Atualizar aplicação OCI",
"terminalTitleRecoverPrevious": "Recuperar a instalação OCI anterior",
"terminalTitleModify": "Modificar aplicação OCI",
"terminalTitleRecover": "Recuperar aplicação OCI restaurada",
"terminalDescription": "É executado o mesmo processo que no menu do ProxMenux: OCI manager Apps → Gerir aplicações OCI instaladas.",
@@ -1760,7 +1762,128 @@
"dockerEvidenceImage": "Imagem",
"dockerEvidencePort": "Porta TCP publicada",
"dockerEvidenceProject": "Projeto Compose",
"dockerEndpointReview": "Contentor com portas TCP publicadas. Confirme o nome e o endereço web antes de guardar. O registo não configura atualizações."
"dockerEndpointReview": "Contentor com portas TCP publicadas. Confirme o nome e o endereço web antes de guardar. O registo não configura atualizações.",
"confirmRemove": "Eliminar o registo desta aplicação do separador App? A aplicação não é desinstalada.",
"packageExample": "p. ex. {package}",
"packageHelpBefore": "Preenchido automaticamente a partir do Nome. Verifique com",
"packageHelpAfter": "dentro do CT.",
"binaryHelpBefore": "Caminho absoluto. Encontre-o com",
"binaryHelpOr": "ou",
"errors": {
"ociBusy": "está em curso uma operação OCI neste contentor",
"dpkgMissing": "{package} não está instalado através do dpkg",
"dpkgFailed": "dpkg-query falhou",
"dpkgNoVersion": "nenhuma versão encontrada na saída do dpkg",
"apkFailed": "apk info falhou",
"apkNoVersion": "nenhuma versão encontrada na saída do apk",
"fileRead": "não foi possível ler {path}",
"fileNoMatch": "file_regex não encontrou correspondência em {path}",
"binaryFailed": "a invocação do binário falhou",
"binaryNoVersion": "nenhuma versão encontrada na saída do binário",
"pythonFailed": "python -c importlib.metadata falhou",
"pythonNoVersion": "nenhuma versão encontrada na saída da distribuição Python",
"dockerImageResolve": "não foi possível resolver a imagem instalada do contentor",
"dockerInspectFailed": "docker inspect falhou",
"dockerLabelMissing": "o contentor não tem a etiqueta {label}",
"dockerLabelMutable": "a etiqueta do docker indica {tag} (tag mutável, não é uma versão)",
"dockerLabelNoVersion": "nenhuma versão encontrada na saída da etiqueta do docker",
"dockerExecFailed": "docker exec falhou",
"dockerExecNoVersion": "nenhuma versão encontrada na saída do docker exec",
"commandEmpty": "command_argv está vazio",
"commandFailed": "o comando falhou",
"commandNoVersion": "nenhuma versão encontrada na saída do comando",
"unsupportedMethod": "método não suportado: {method}",
"unknownUpstream": "upstream_type desconhecido: {type}",
"githubNotFound": "{repo}: não encontrado",
"githubRejected": "o github rejeitou o pedido (403)",
"githubError": "erro do github {code}",
"noRelease": "nenhuma tag / nome de release na resposta",
"tagNoMatch": "tag_regex não encontrou correspondência em '{tag}'",
"httpError": "erro http {code}",
"invalidJson": "resposta JSON inválida: {detail}",
"jsonPathUnresolved": "json_path '{path}' não foi resolvido",
"jsonEmpty": "valor vazio em json_path",
"dockerNoTags": "nenhuma tag encontrada (docker_hub)",
"dockerMovingOnly": "tag_regex só encontrou tags móveis; utilize o seguimento do digest da imagem Docker",
"dockerHubError": "erro do docker hub {code}",
"dockerHubJson": "JSON do docker hub inválido: {detail}",
"dockerHubEmpty": "o docker hub não devolveu nenhuma tag",
"payload": "o payload deve ser um objeto JSON",
"name": "name é obrigatório e deve ter 1-64 caracteres entre letras/dígitos/espaços/._+-()/",
"method": "installed_via deve ser um de: {values} ou vazio",
"package": "package é obrigatório (letras/dígitos/._+@:/ até 127 caracteres)",
"filePath": "file_path é obrigatório e deve ser um caminho absoluto",
"fileRegex": "file_regex é obrigatório",
"fileRegexInvalid": "file_regex não é uma regex válida: {detail}",
"binaryPath": "binary_path é obrigatório e deve ser um caminho absoluto",
"pythonPath": "python_path é obrigatório e deve ser um caminho absoluto",
"distribution": "distribution é obrigatório (nome PEP 503)",
"container": "container_name é obrigatório (regras de nomes do docker)",
"label": "label é obrigatório (regras de nomes de etiquetas OCI)",
"dockerBinary": "binary_path é obrigatório (caminho absoluto ou comando simples)",
"manualVersion": "installed_version é obrigatório para o método manual",
"versionLength": "installed_version excede {max} caracteres",
"versionControl": "installed_version contém um carácter de controlo proibido",
"installedRegexInvalid": "installed_regex não é uma regex válida: {detail}",
"upstreamType": "upstream_type deve ser um de: {values}",
"tagRegexInvalid": "tag_regex não é uma regex válida: {detail}",
"repoRequired": "repo é obrigatório para upstream_type=github",
"repoFormat": "repo deve ter o formato 'owner/name'",
"githubSource": "github_source deve ser um de: {values}",
"upstreamUrlRequired": "upstream_url é obrigatório para upstream_type=http_json",
"upstreamUrl": "upstream_url deve ser um URL http(s)",
"upstreamUrlLength": "upstream_url excede {max} caracteres",
"jsonPathRequired": "upstream_json_path é obrigatório para upstream_type=http_json",
"jsonPathLength": "upstream_json_path excede {max} caracteres",
"jsonPathCharacters": "upstream_json_path utiliza caracteres proibidos",
"dockerImageRequired": "docker_image é obrigatório para upstream_type=docker_hub",
"dockerImageLength": "docker_image excede {max} caracteres",
"dockerImageFormat": "docker_image deve seguir os nomes do Docker Hub (owner/name ou name)",
"healthPath": "health_path deve ser um caminho de URL válido (máx. 255 caracteres)",
"logoUrl": "logo_url deve ser um URL http(s) ou o ícone local em cache desta VM",
"discoveryId": "discovery_id inválido",
"helperSlug": "helper_slug deve ser um slug em minúsculas (letras/dígitos/._-)",
"updateVia": "update_via só aceita 'docker'",
"dockerDetector": "update_via=docker requer um detetor docker_label ou docker_exec",
"dockerConflict": "update_via=docker não pode ser combinado com {field}",
"updateCommandType": "update_command deve ser uma cadeia de texto",
"updateCommandLength": "update_command excede {max} caracteres",
"updateCommandNull": "update_command contém um byte nulo",
"updateMethod": "update_method deve ser none, helper ou custom",
"updateCommandRequired": "update_command é obrigatório para update_method=custom",
"updateCommandOnlyCustom": "update_command só é permitido para update_method=custom",
"helperRequired": "é necessário um helper_slug suportado para update_method=helper",
"dockerUpdater": "As aplicações delegadas no Docker atualizam-se através da sua imagem, não com um atualizador de aplicação separado",
"commandRequired": "command_argv é obrigatório (lista não vazia)",
"commandList": "command_argv deve ser uma lista não vazia de cadeias de texto",
"commandCount": "command_argv aceita no máximo {max} entradas",
"commandEntry": "command_argv[{index}] deve ser uma cadeia de texto não vazia",
"commandEntryLength": "command_argv[{index}] excede {max} caracteres",
"commandEntryControl": "command_argv[{index}] contém um carácter de controlo proibido",
"binaryArgsList": "binary_args deve ser uma lista de cadeias de texto",
"binaryArgsCount": "binary_args aceita no máximo {max} entradas",
"binaryArgEntry": "binary_args[{index}] deve ser uma cadeia de texto não vazia",
"binaryArgLength": "binary_args[{index}] excede {max} caracteres",
"binaryArgControl": "binary_args[{index}] contém um carácter de controlo proibido",
"portsList": "ports deve ser uma lista de {port, description, scheme}",
"portObject": "ports[{index}] deve ser um objeto",
"portRequired": "ports[{index}].port é obrigatório",
"portInteger": "ports[{index}].port deve ser um número inteiro",
"portRange": "ports[{index}].port deve estar entre 1 e 65535",
"portDuplicate": "a porta {port} aparece mais de uma vez para esta aplicação",
"portDescription": "ports[{index}].description tem caracteres inválidos",
"portScheme": "ports[{index}].scheme deve ser 'http' ou 'https'",
"portPath": "ports[{index}].web_path deve ser um caminho de URL válido (máx. 255 caracteres)",
"portLogo": "ports[{index}].logo_url deve ser um URL http(s) (máx. 512 caracteres)",
"portCategory": "ports[{index}].category tem caracteres inválidos ou é demasiado longo",
"portCustomUrl": "ports[{index}].custom_url deve ser um URL http(s) (máx. 512 caracteres)",
"tagRegexLength": "tag_regex excede 512 caracteres",
"persistPermission": "não foi possível guardar o sidecar (permissões?)",
"persist": "não foi possível guardar o sidecar",
"noApps": "nenhuma aplicação registada para este vmid",
"appMissing": "app_id '{appId}' não encontrado",
"appNotFound": "aplicação não encontrada"
}
},
"statusFilter": {
"ariaLabel": "Filtrar máquinas virtuais e contêineres",
@@ -2294,6 +2417,42 @@
"updateManyDescription": "Executando novamente as funções pós-instalação {count} em sequência.",
"legacySourceTitle": "Otimização legada – clique para ver a fonte",
"sourceTitle": "Clique para visualizar o código fonte",
"sourceDialogTitle": "Código-fonte: {name}",
"sourceLoading": "A carregar o código-fonte…",
"sourceUnavailable": "O código-fonte não está disponível.",
"sourceLoadFailed": "Não foi possível carregar o código-fonte. Tente novamente.",
"sourceFunctionMissing": "A função não foi encontrada nos scripts disponíveis.",
"sourceUnknownTool": "Ferramenta desconhecida: {tool}",
"sourceNoMapping": "Nenhuma função está atribuída à ferramenta {tool}.",
"sourceToolNames": {
"subscription_banner": "Remoção do banner de subscrição",
"time_sync": "Sincronização da hora",
"apt_languages": "Omitir idiomas do APT",
"journald": "Otimização do journald",
"logrotate": "Otimização do logrotate",
"system_limits": "Aumento dos limites do sistema",
"memory_settings": "Otimização das definições de memória",
"zram_swap": "Perfil de swap ZRAM",
"kernel_panic": "Configuração de kernel panic",
"apt_ipv4": "Forçar IPv4 no APT",
"kexec": "kexec para reinícios rápidos",
"rpc": "Desativar RPC / rpcbind",
"motd": "Banner MOTD personalizado",
"system_utils": "Utilitários do sistema",
"network_optimization": "Otimizações de rede",
"bashrc_custom": "Personalização do bashrc",
"figurine": "Figurine",
"fastfetch": "Fastfetch",
"log2ram": "Log2ram (proteção do SSD)",
"zfs_autotrim": "ZFS Autotrim",
"amd_fixes": "Correções para CPU AMD (Ryzen/EPYC)",
"persistent_network": "Definir interfaces de rede persistentes",
"vfio_iommu": "Passthrough VFIO/IOMMU",
"lvm_repair": "Reparação de cabeçalhos LVM PV",
"repo_cleanup": "Limpeza de repositórios",
"proxmox_repos": "Repositórios APT do Proxmox",
"entropy": "Geração de entropia (haveged)"
},
"legacy": "legado",
"updateToolTitle": "Atualizar {name} para v{version}",
"availableUpdates": "Atualizações disponíveis",
@@ -4820,6 +4979,124 @@
"cancel": "Cancelar",
"save": "Guardar retenção",
"useDefault": "Use o padrão Proxmox (nenhuma política neste armazenamento)"
},
"apiErrors": {
"requestFailed": "O pedido falhou.",
"absolutePath": "o caminho deve ser absoluto",
"pathConfigured": "este caminho já está configurado",
"pathRequired": "o caminho é obrigatório",
"pathQuery": "o parâmetro de consulta path é obrigatório",
"pathNotFound": "caminho não encontrado",
"invalidJobId": "ID de trabalho inválido",
"jobNotFound": "trabalho não encontrado",
"runnerMissing": "o script de execução não está instalado",
"noRestore": "nenhum estado de restauro para descartar",
"invalidFile": "nome de ficheiro inválido",
"notFound": "não encontrado",
"logOutside": "caminho do registo fora do diretório permitido",
"targetsList": "targets deve ser uma lista",
"emptyExpression": "expressão vazia",
"invalidId": "ID inválido (utilize letras, dígitos, _ ou -)",
"backend": "o backend deve ser pbs, local ou borg",
"remoteBackend": "o backend deve ser pbs ou borg",
"remoteBackendQuoted": "o backend deve ser 'pbs' ou 'borg'",
"profileMode": "profile_mode deve ser default ou custom",
"borgAttached": "o borg não pode ser anexado (o vzdump do PVE só escreve em armazenamentos pbs/local)",
"borgAttachedLong": "o borg não é um backend válido para trabalhos anexados (o vzdump do PVE só escreve em armazenamentos pbs/local)",
"parentRequired": "pve_parent_job_id e pve_storage são obrigatórios para attached",
"absolutePaths": "paths deve ser uma lista de caminhos absolutos",
"customPath": "o perfil personalizado precisa de pelo menos um caminho",
"defaultPaths": "não foi possível resolver os caminhos do perfil predefinido",
"retentionObject": "retention deve ser um objeto",
"pbsRepository": "pbs_repository é obrigatório",
"pbsEncryption": "pbs_encrypt_mode deve ser 'none', 'new' ou 'existing'",
"pbsKeyFailed": "falha ao criar o ficheiro de chave de encriptação do PBS",
"pbsClientMissing": "falha ao criar o ficheiro de chave de encriptação do PBS (o proxmox-backup-client está instalado?)",
"borgRepository": "borg_repo é obrigatório",
"borgEncryption": "borg_encrypt_mode deve ser um de: none, repokey, keyfile, authenticated",
"encryption": "encrypt_mode deve ser um de: none, repokey, keyfile, authenticated",
"borgPassphrase": "borg_passphrase é obrigatório quando a encriptação está ativada (guarde-o no destino ou indique um no pedido)",
"passphrase": "a frase-passe é obrigatória quando a encriptação está ativada",
"password": "a palavra-passe é obrigatória",
"deviceConfirmation": "confirm_device deve corresponder exatamente ao dispositivo (verificação de gralhas / segurança)",
"invalidDevice": "caminho de dispositivo inválido",
"absoluteMountpoint": "é necessário um ponto de montagem absoluto",
"archiveLayout": "estrutura do arquivo não reconhecida — não foi encontrado rootfs/",
"archiveMissing": "arquivo não encontrado",
"archiveNotFile": "o caminho do arquivo não é um ficheiro normal",
"invalidName": "nome inválido",
"invalidStaging": "staging_path inválido",
"jobRename": "o nome do trabalho não pode ser alterado (elimine e volte a criar para mudar o nome)",
"absoluteKey": "key_path deve ser absoluto",
"mode": "mode deve ser \"local\" ou \"ssh\"",
"nameRequired": "o nome é obrigatório (letras, dígitos, _ ou -)",
"noLog": "ainda não há registo disponível para este trabalho",
"noPbsConfig": "não existe ficheiro de configurações PBS manuais",
"outputMissing": "ficheiro de saída em falta",
"parserJson": "a saída do parser não era JSON válido",
"restoreJson": "a saída de run_restore não era JSON válido",
"repoRequired": "repo é obrigatório",
"repoSnapshot": "repo_name e snapshot são obrigatórios",
"borgRepoSnapshot": "repo_name e snapshot são obrigatórios para borg",
"pbsRepoSnapshot": "repo_name e snapshot são obrigatórios para pbs",
"repoName": "repo_name é obrigatório",
"restoreMissing": "as ferramentas de restauro não estão instaladas neste host",
"serverDatastore": "server e datastore são obrigatórios",
"snapshotRequired": "snapshot é obrigatório",
"source": "source deve ser pbs, borg ou local",
"sshPort": "ssh_port deve ser um número inteiro entre 1 e 65535",
"sshRequired": "ssh_user, ssh_host e ssh_remote_path são obrigatórios para o modo ssh",
"repositoryPath": "o destino não tem caminho de repositório",
"taskExpired": "tarefa não encontrada ou expirada",
"invalidExpression": "expressão inválida",
"noReport": "nenhum relatório emitido",
"parseManifestFailed": "parse_manifest terminou com um código diferente de zero",
"jobExists": "o trabalho já existe: {value}",
"pathMissing": "o caminho não existe neste host: {value}",
"deviceMissing": "o dispositivo não existe: {value}",
"mountpoint": "não é um ponto de montagem: {value}",
"localArchiveMissing": "arquivo local não encontrado: {value}",
"borgDestination": "Destino Borg '{value}' não encontrado",
"pbsDestination": "Destino PBS '{value}' não encontrado",
"pbsPassword": "nenhuma palavra-passe disponível para o repositório PBS '{value}'",
"borgRepoMissing": "Repositório Borg \"{value}\" não configurado",
"pbsRepoMissing": "Repositório PBS \"{value}\" não configurado",
"borgTargetMissing": "Destino Borg não encontrado: {value}",
"pbsTargetMissing": "Repositório PBS não encontrado: {value}",
"pveJobMissing": "Trabalho vzdump do PVE não encontrado: {value}",
"statePersist": "não é possível guardar o estado: {value}",
"sshDirectory": "não é possível preparar o diretório ssh: {value}",
"logRead": "não é possível ler o registo: {value}",
"envUpdate": "não foi possível atualizar o .env: {value}",
"pbsConfigWrite": "falha ao guardar a configuração PBS: {value}",
"persist": "falha ao guardar: {value}",
"archiveRemove": "falha ao eliminar o arquivo: {value}",
"start": "falha ao iniciar: {value}",
"borgTargetsUpdate": "falha ao atualizar borg-targets.txt: {value}",
"pbsTargetsUpdate": "falha ao atualizar pbs-manual-configs.txt: {value}",
"unitsUpdate": "falha ao atualizar as unidades systemd: {value}",
"borgTargetsWrite": "falha ao escrever borg-targets.txt: {value}",
"jobWrite": "falha ao escrever os ficheiros do trabalho: {value}",
"localTargetWrite": "falha ao escrever local-target.conf: {value}",
"unitsWrite": "falha ao escrever as unidades systemd: {value}",
"format": "a formatação falhou: {value}",
"calendar": "on_calendar inválido: {value}",
"mount": "a montagem falhou: {value}",
"parserInvocation": "a invocação do parser falhou: {value}",
"preflightInvocation": "a invocação da verificação prévia falhou: {value}",
"publicKeyMissing": "chave pública não encontrada após a geração: {value}",
"retentionInteger": "retention.{value} deve ser um número inteiro",
"retentionNegative": "retention.{value} não pode ser negativo",
"keygen": "ssh-keygen falhou: {value}",
"systemctl": "systemctl falhou: {value}",
"unmount": "umount falhou: {value}",
"taskIncomplete": "a tarefa está no estado \"{value}\", não concluída",
"unknownKind": "tipo desconhecido: {value}",
"unknownMode": "modo desconhecido \"{value}\"",
"defaultRemove": "{value} é a predefinição integrada e não pode ser eliminado",
"defaultAdd": "{value} é a predefinição integrada; não é necessário adicioná-lo",
"keyImport": "Não existe nenhum ficheiro de chave PBS instalado em {value}. Importe primeiro o ficheiro de chave.",
"storageMismatch": "O armazenamento do trabalho do PVE não corresponde (o trabalho utiliza {value}, o payload indica {other})"
}
},
"healthStatus": {
@@ -5320,6 +5597,7 @@
"websocketError": "Ocorreu um erro de WebSocket",
"connectionClosed": "Conexão fechada",
"completedSuccessfully": "Script concluído com sucesso",
"cancelled": "Operação cancelada.",
"completedWithError": "Script concluído com código de erro {code}",
"yes": "Sim",
"yourInput": "Sua entrada:",
@@ -7311,7 +7589,7 @@
"signInWith": "Iniciar sessão com",
"methodPassword": "Utilizador e palavra-passe",
"methodToken": "Token API",
"username": "Utilizador (utilizador@realm)",
"username": "Utilizador",
"password": "Palavra-passe",
"passwordHelp": "A palavra-passe é usada uma única vez para criar um token API só de leitura (função Audit) para este Monitor. Só esse token é guardado, nunca a palavra-passe. Ative primeiro a autenticação do Monitor.",
"cancel": "Cancelar",
@@ -7324,10 +7602,10 @@
"editConnection": "Ligação a editar",
"add": "Adicionar uma nova ligação",
"name": "Nome",
"url": "URL HTTPS (inclua :8007 para a porta nativa do PBS)",
"token_id": "ID do token API (user@realm!token)",
"token_secret": "Segredo do token API (deixe vazio para manter o segredo guardado)",
"fingerprint": "Impressão digital SHA256 do certificado (opcional)",
"url": "Endereço HTTPS",
"token_id": "ID do token API",
"token_secret": "Segredo do token API",
"fingerprint": "Impressão digital SHA256 (opcional)",
"tlsHelp": "Deixe a impressão digital vazia para a verificação normal da autoridade de certificação e do nome de anfitrião. Para um certificado autoassinado, copie a sua impressão digital SHA256 da consola PBS ou de outro canal de confiança. A impressão digital é verificada antes do envio de credenciais.",
"saved": "Ligação PBS verificada e guardada.",
"saveFailed": "Não foi possível guardar a ligação PBS.",
@@ -7369,7 +7647,85 @@
"estimatedFull": "estimativa completa",
"estimatedFullUnknown": "Not enough data yet",
"duration": "Duração",
"noJobs": "nenhuma programação de sincronização, verificação ou remoção está configurada."
"noJobs": "nenhuma programação de sincronização, verificação ou remoção está configurada.",
"errors": {
"configRead": "Não foi possível ler as ligações PBS guardadas. Verifique o ficheiro de configuração e as permissões.",
"invalidConfig": "As definições da ligação PBS não são válidas. Verifique o formulário e tente novamente.",
"invalidField": "Um campo da ligação não é válido ou é demasiado longo.",
"invalidUrl": "Utilize um endereço PBS HTTPS sem credenciais incorporadas, parâmetros de consulta nem fragmento.",
"nameRequired": "Introduza um nome de servidor com 128 caracteres no máximo.",
"tokenIdFormat": "Introduza o ID do token API do PBS no formato utilizador@realm!token.",
"tokenSecret": "Introduza um segredo de token API válido, sem espaços nem caracteres de controlo.",
"invalidTokenId": "O ID do token API só pode conter caracteres ASCII.",
"invalidFingerprint": "Introduza a impressão digital SHA256 do certificado: 64 caracteres hexadecimais, opcionalmente separados por dois pontos.",
"fingerprintMismatch": "O certificado do PBS não corresponde à impressão digital. Não foram enviadas credenciais.",
"tokenRejected": "O PBS rejeitou o token API ou as suas permissões. Verifique as ACL do utilizador e do token.",
"invalidResponse": "O PBS devolveu uma resposta inesperada. Os redirecionamentos não são seguidos.",
"invalidJson": "O PBS devolveu um documento JSON inválido.",
"connectFailed": "Não foi possível ligar ao PBS através de HTTPS verificado. Verifique o endereço, o certificado e o serviço.",
"invalidId": "O ID da ligação PBS não é válido. Recarregue a lista de ligações.",
"invalidDatastores": "O PBS devolveu uma lista de armazenamentos de dados inválida.",
"connectionLimit": "Podem ser configuradas no máximo {count} ligações PBS.",
"username": "Introduza o utilizador do PBS no formato utilizador@realm, por exemplo root@pam.",
"password": "Introduza a palavra-passe do utilizador do PBS.",
"loginRejected": "O PBS rejeitou o nome de utilizador ou a palavra-passe.",
"invalidLogin": "O PBS devolveu uma resposta de início de sessão inválida.",
"tokenCreateDenied": "Este utilizador do PBS não pode criar tokens API.",
"invalidToken": "O PBS devolveu um token API inválido.",
"auditRoleDenied": "Este utilizador do PBS não pode conceder a função Audit. Inicie sessão como root@pam ou outro administrador.",
"notFound": "A ligação PBS não foi encontrada. Recarregue a lista de ligações.",
"readFailed": "Não foi possível ler os dados do PBS. Verifique o seu serviço e as permissões.",
"invalidList": "O PBS devolveu uma lista de dados inválida.",
"localUnavailable": "O PBS não está disponível neste host.",
"authRequired": "Ative a autenticação local no Monitor antes de gerir ligações PBS remotas.",
"datastoreOffline": "O armazenamento de dados está offline, em modo de manutenção ou o seu dispositivo de suporte não está montado.",
"datastoreUnavailable": "O sistema de ficheiros do armazenamento de dados não está disponível."
},
"taskTypes": {
"backup": "Backup",
"restore": "Restauro",
"verify": "Verificação",
"verifyGroup": "Verificação do grupo de backups",
"verifySnapshot": "Verificação do snapshot",
"prune": "Limpeza (prune)",
"garbageCollection": "Recolha de lixo",
"sync": "Sincronização",
"updateCheck": "Procurar atualizações",
"terminal": "Terminal",
"createDatastore": "Criar armazenamento de dados",
"other": "Outra tarefa ({type})"
},
"states": {
"ok": "OK",
"unknown": "Desconhecido",
"warnings": "Avisos: {count}",
"warning": "Aviso",
"error": "Erro"
},
"serviceStates": {
"active": "Ativo",
"inactive": "Inativo",
"failed": "Com falha",
"activating": "A iniciar",
"deactivating": "A parar",
"unknown": "Desconhecido"
},
"durationUnits": {
"seconds": "{count} s",
"minutes": "{count} min",
"hours": "{count} h",
"days": "{count} d"
},
"urlHelp": "Inclua :8007 para a porta predefinida do PBS, por exemplo https://pbs.example:8007.",
"usernameHelp": "Utilize utilizador@realm, por exemplo root@pam.",
"tokenIdHelp": "Utilize utilizador@realm!token, por exemplo monitor@pbs!proxmenux.",
"tokenSecretHelp": "Introduza o segredo deste token API.",
"tokenSecretKeepHelp": "Deixe vazio para manter o segredo do token guardado.",
"removeFailed": "Não foi possível eliminar a ligação PBS.",
"savedRefreshFailed": "A ligação PBS foi guardada, mas não foi possível atualizar a lista de ligações. Recarregue a página antes de tentar outra alteração.",
"removedRefreshFailed": "A ligação PBS foi eliminada, mas não foi possível atualizar a lista de ligações. Recarregue a página.",
"technicalDetails": "Detalhes técnicos",
"datastoreType": "Armazenamento de dados"
},
"monitorLinks": {
"groups": {
@@ -7408,6 +7764,17 @@
"addSelected": "Adicionar selecionados",
"pbsWebInterface": "Interface web do PBS",
"changeAddress": "Alterar endereço",
"cancel": "Cancelar"
"cancel": "Cancelar",
"errors": {
"addressRequired": "Introduza o endereço do Monitor.",
"invalidAddress": "O endereço deve começar por http:// ou https:// e indicar um host.",
"addressCredentials": "O endereço não pode conter um utilizador nem uma palavra-passe.",
"invalidPort": "A porta do endereço não é válida.",
"invalidRequest": "O pedido não é válido. Recarregue a página e tente novamente.",
"limit": "Podem ser adicionados no máximo {count} nós.",
"duplicate": "Este endereço já está na lista.",
"notFound": "O nó não foi encontrado. Recarregue a lista e tente novamente.",
"detectedRemove": "Só podem ser eliminados os nós adicionados manualmente. Os nós detetados podem ser ocultados."
}
}
}
+408 -66
View File
@@ -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é",
@@ -1032,7 +1032,7 @@
"pciPassthrough": "PCI Passthrough",
"usbDevices": "USB zariadenia",
"serialPorts": "Sériové porty",
"bridge": "Sieťový bridge",
"bridge": "Sieťový most (bridge)",
"enabled": "Zapnuté",
"disabled": "Vypnuté",
"on": "zapnuté",
@@ -1248,13 +1248,15 @@
"stackNote": "Súčasť aplikácie s viacerými kontajnermi (hlavný CT {primary}, {count} kontajnerov): aktualizácia sa týka všetkých.",
"memberNote": "Tento kontajner patrí do aplikácie s viacerými kontajnermi, ktorej hlavný CT je {primary}, a neaktualizuje sa samostatne. Aktualizácia sa vykonáva z hlavného CT, ktorý aktualizuje všetkých {count} kontajnerov naraz.",
"openPrimary": "Otvoriť hlavný CT",
"pendingNote": "Operácia na tomto kontajneri bola prerušená; tlačidlo Obnoviť spustí obnovenie.",
"pendingNote": "Operácia na tomto kontajneri zostala nedokončená. Tlačidlo Obnoviť otvorí možnosti obnovenia.",
"update": "Aktualizovať",
"modify": "Upraviť",
"recover": "Obnoviť",
"registerRestored": "Znova zaregistrovať",
"terminalTitleUpdate": "Aktualizovať OCI aplikáciu",
"terminalTitleRecoverPrevious": "Obnoviť predchádzajúcu OCI inštaláciu",
"terminalTitleModify": "Upraviť OCI aplikáciu",
"terminalTitleRecover": "Obnoviť obnovenú OCI aplikáciu",
"terminalTitleRecover": "Zaregistrovať OCI aplikácie obnovené zo zálohy",
"terminalDescription": "Použije sa rovnaký postup ako v ponuke Správca OCI aplikácií → Spravovať nainštalované OCI aplikácie.",
"keepBackup": "Ponechať zálohu vytvorenú pred aktualizáciou",
"noKeepBackup": "Záloha vytvorená pred aktualizáciou sa neponecháva",
@@ -1485,7 +1487,7 @@
"ociTitle": "Kontajner z OCI obrazu",
"ociBody": "Tento kontajner bol vytvorený z OCI (Docker) obrazu mimo Správcu OCI aplikácií. Aktualizuje sa nahradením obrazu, čo ProxMenux pri tomto kontajneri nespravuje.",
"ociRestoredTitle": "Obnovená OCI aplikácia",
"ociRestoredBody": "Tento kontajner bol obnovený zo zálohy a tento hostiteľ nemá záznam o jeho aplikácii, preto ho zatiaľ nemožno aktualizovať odtiaľto. Obnoviť ho znova zaregistruje na tomto hostiteľovi, rovnako ako OCI manager Apps → Spravovať nainštalované OCI aplikácie.",
"ociRestoredBody": "Tento kontajner bol obnovený zo zálohy, ale tento server nemá záznam o jeho OCI aplikácii, preto ju tu zatiaľ nemožno aktualizovať. Možnosť Znova zaregistrovať otvorí registráciu obnovených OCI aplikácií na tomto uzle, rovnako ako ponuka Správca OCI aplikácií → Spravovať nainštalované OCI aplikácie.",
"dockerImagesTitle": "Docker obrazy",
"dockerAppTitle": "Docker",
"dockerImagesSubheading": "Obrazy",
@@ -1545,7 +1547,128 @@
"loadFailed": "Konfiguráciu hromadnej aktualizácie sa nepodarilo načítať."
},
"appEditor": {
"methodOciImage": "OCI obraz (nainštalovaný ProxMenux)",
"confirmRemove": "Odobrať záznam aplikácie zo záložky Aplikácie? Samotná aplikácia zostane nainštalovaná.",
"packageExample": "napr. {package}",
"packageHelpBefore": "Doplní sa podľa názvu. Over ho príkazom",
"packageHelpAfter": "v kontajneri.",
"binaryHelpBefore": "Úplná cesta k programu. Nájdeš ju príkazom",
"binaryHelpOr": "alebo",
"errors": {
"ociBusy": "V tomto kontajneri práve prebieha operácia OCI.",
"dpkgMissing": "Balík {package} nie je nainštalovaný cez dpkg.",
"dpkgFailed": "Príkaz dpkg-query zlyhal.",
"dpkgNoVersion": "Vo výstupe dpkg sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"apkFailed": "Príkaz apk info zlyhal.",
"apkNoVersion": "Vo výstupe apk sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"fileRead": "Súbor {path} sa nepodarilo prečítať.",
"fileNoMatch": "Regulárny výraz file_regex nenašiel zhodu v súbore {path}.",
"binaryFailed": "Program sa nepodarilo spustiť.",
"binaryNoVersion": "Vo výstupe programu sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"pythonFailed": "Zisťovanie verzie cez python -c importlib.metadata zlyhalo.",
"pythonNoVersion": "Vo výstupe balíka Pythonu sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"dockerImageResolve": "Nainštalovaný obraz kontajnera sa nepodarilo určiť.",
"dockerInspectFailed": "Príkaz docker inspect zlyhal.",
"dockerLabelMissing": "Kontajner nemá štítok {label}.",
"dockerLabelMutable": "Štítok Dockeru uvádza {tag}. Je to premenlivý tag, ktorý neurčuje konkrétnu verziu.",
"dockerLabelNoVersion": "V štítku Dockeru sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"dockerExecFailed": "Príkaz docker exec zlyhal.",
"dockerExecNoVersion": "Vo výstupe docker exec sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"commandEmpty": "Príkaz a argumenty (command_argv) nie sú vyplnené.",
"commandFailed": "Príkaz zlyhal.",
"commandNoVersion": "Vo výstupe príkazu sa nenašla verzia zodpovedajúca regulárnemu výrazu.",
"unsupportedMethod": "Nepodporovaný spôsob zisťovania verzie: {method}",
"unknownUpstream": "Neznámy zdroj dostupnej verzie (upstream_type): {type}",
"githubNotFound": "Repozitár {repo} sa nenašiel.",
"githubRejected": "GitHub odmietol požiadavku (403).",
"githubError": "Chyba GitHubu {code}.",
"noRelease": "Odpoveď neobsahuje tag ani názov vydania.",
"tagNoMatch": "Regulárny výraz tag_regex nenašiel zhodu s hodnotou „{tag}“.",
"httpError": "Chyba HTTP {code}.",
"invalidJson": "Odpoveď neobsahuje platný JSON: {detail}",
"jsonPathUnresolved": "Cesta v JSON „{path}“ sa nenašla.",
"jsonEmpty": "Na zadanej ceste v JSON je prázdna hodnota.",
"dockerNoTags": "Na Docker Hube sa nenašli tagy zodpovedajúce filtru.",
"dockerMovingOnly": "Filtru tag_regex zodpovedajú iba premenlivé tagy. Použi sledovanie obrazov Docker podľa digestu.",
"dockerHubError": "Chyba Docker Hubu {code}.",
"dockerHubJson": "Docker Hub nevrátil platný JSON: {detail}",
"dockerHubEmpty": "Docker Hub nevrátil žiadne tagy.",
"payload": "Údaje musia byť objekt JSON.",
"name": "Zadaj názov s dĺžkou 1 až 64 znakov. Povolené sú písmená, číslice, medzery a ._+-()/.",
"method": "Spôsob zisťovania verzie (installed_via) musí byť jedna z hodnôt: {values}, alebo môže zostať prázdny.",
"package": "Zadaj názov balíka (package), najviac 127 znakov. Povolené sú písmená, číslice a ._+@:/.",
"filePath": "Zadaj úplnú cestu k súboru (file_path), začínajúcu lomkou /.",
"fileRegex": "Zadaj regulárny výraz na získanie verzie zo súboru (file_regex).",
"fileRegexInvalid": "Regulárny výraz pre súbor (file_regex) je neplatný: {detail}",
"binaryPath": "Zadaj úplnú cestu k programu (binary_path), začínajúcu lomkou /.",
"pythonPath": "Zadaj úplnú cestu k interpretu Pythonu (python_path), začínajúcu lomkou /.",
"distribution": "Zadaj názov balíka Pythonu (distribution) podľa PEP 503.",
"container": "Zadaj názov kontajnera (container_name) podľa pravidiel Dockeru.",
"label": "Zadaj názov štítka (label) podľa pravidiel OCI.",
"dockerBinary": "Zadaj cestu k programu alebo samotný názov príkazu (binary_path).",
"manualVersion": "Pri ručnom zadávaní vyplň nainštalovanú verziu (installed_version).",
"versionLength": "Nainštalovaná verzia (installed_version) môže mať najviac {max} znakov.",
"versionControl": "Nainštalovaná verzia (installed_version) obsahuje nepovolený riadiaci znak.",
"installedRegexInvalid": "Regulárny výraz pre nainštalovanú verziu (installed_regex) je neplatný: {detail}",
"upstreamType": "Zdroj dostupnej verzie (upstream_type) musí byť jedna z hodnôt: {values}.",
"tagRegexInvalid": "Regulárny výraz pre tagy (tag_regex) je neplatný: {detail}",
"repoRequired": "Pre zdroj GitHub zadaj repozitár (repo).",
"repoFormat": "Repozitár (repo) musí mať tvar „vlastník/názov“ (owner/name).",
"githubSource": "Zdroj na GitHube (github_source) musí byť jedna z hodnôt: {values}.",
"upstreamUrlRequired": "Pre zdroj HTTP JSON zadaj adresu (upstream_url).",
"upstreamUrl": "Adresa zdroja (upstream_url) musí začínať na http:// alebo https://.",
"upstreamUrlLength": "Adresa zdroja (upstream_url) môže mať najviac {max} znakov.",
"jsonPathRequired": "Pre zdroj HTTP JSON zadaj cestu k verzii v JSON (upstream_json_path).",
"jsonPathLength": "Cesta k verzii v JSON (upstream_json_path) môže mať najviac {max} znakov.",
"jsonPathCharacters": "Cesta k verzii v JSON (upstream_json_path) obsahuje nepovolené znaky.",
"dockerImageRequired": "Pre zdroj Docker Hub zadaj názov obrazu (docker_image).",
"dockerImageLength": "Názov obrazu (docker_image) môže mať najviac {max} znakov.",
"dockerImageFormat": "Názov obrazu (docker_image) musí mať tvar owner/name alebo name podľa pravidiel Docker Hubu.",
"healthPath": "Cesta na kontrolu stavu (health_path) musí byť platná cesta URL s najviac 255 znakmi.",
"logoUrl": "Logo (logo_url) musí byť adresa HTTP/HTTPS alebo lokálna ikona uložená pre tento stroj.",
"discoveryId": "Identifikátor nájdenej aplikácie (discovery_id) je neplatný.",
"helperSlug": "Identifikátor inštalačného skriptu (helper_slug) môže obsahovať malé písmená, číslice a ._-.",
"updateVia": "Pole update_via podporuje iba hodnotu docker.",
"dockerDetector": "Pri update_via=docker vyber detektor docker_label alebo docker_exec.",
"dockerConflict": "Pole {field} nemožno použiť spolu s update_via=docker.",
"updateCommandType": "Aktualizačný príkaz (update_command) musí byť text.",
"updateCommandLength": "Aktualizačný príkaz (update_command) môže mať najviac {max} znakov.",
"updateCommandNull": "Aktualizačný príkaz (update_command) obsahuje nepovolený nulový znak.",
"updateMethod": "Spôsob aktualizácie (update_method) musí byť none, helper alebo custom.",
"updateCommandRequired": "Pre vlastnú aktualizáciu zadaj príkaz (update_command).",
"updateCommandOnlyCustom": "Aktualizačný príkaz (update_command) je povolený iba pri update_method=custom.",
"helperRequired": "Pre update_method=helper vyber podporovaný inštalačný skript (helper_slug).",
"dockerUpdater": "Aplikácia v Dockeri sa aktualizuje spolu s obrazom. Samostatný aktualizátor aplikácie tu nemožno nastaviť.",
"commandRequired": "Zadaj príkaz a jeho argumenty (command_argv) ako neprázdny zoznam.",
"commandList": "Príkaz a argumenty (command_argv) musia byť neprázdny zoznam textových hodnôt.",
"commandCount": "Príkaz a argumenty (command_argv) môžu mať najviac {max} položiek.",
"commandEntry": "Položka command_argv[{index}] musí obsahovať text.",
"commandEntryLength": "Položka command_argv[{index}] môže mať najviac {max} znakov.",
"commandEntryControl": "Položka command_argv[{index}] obsahuje nepovolený riadiaci znak.",
"binaryArgsList": "Argumenty programu (binary_args) musia byť zoznam textových hodnôt.",
"binaryArgsCount": "Argumenty programu (binary_args) môžu mať najviac {max} položiek.",
"binaryArgEntry": "Argument binary_args[{index}] musí obsahovať text.",
"binaryArgLength": "Argument binary_args[{index}] môže mať najviac {max} znakov.",
"binaryArgControl": "Argument binary_args[{index}] obsahuje nepovolený riadiaci znak.",
"portsList": "Webové odkazy (ports) musia byť zoznam položiek s poľami port, description a scheme.",
"portObject": "Webový odkaz ports[{index}] musí byť objekt.",
"portRequired": "Vo webovom odkaze ports[{index}] zadaj port.",
"portInteger": "Port vo webovom odkaze ports[{index}] musí byť celé číslo.",
"portRange": "Port vo webovom odkaze ports[{index}] musí byť od 1 do 65535.",
"portDuplicate": "Port {port} je pri tejto aplikácii uvedený viackrát.",
"portDescription": "Popis webového odkazu ports[{index}] obsahuje nepovolené znaky.",
"portScheme": "Protokol webového odkazu ports[{index}] musí byť http alebo https.",
"portPath": "Cesta webového odkazu ports[{index}].web_path musí byť platná cesta URL s najviac 255 znakmi.",
"portLogo": "Logo webového odkazu ports[{index}].logo_url musí byť adresa HTTP/HTTPS s najviac 512 znakmi.",
"portCategory": "Kategória webového odkazu ports[{index}].category obsahuje nepovolené znaky alebo je príliš dlhá.",
"portCustomUrl": "Vlastná adresa webového odkazu ports[{index}].custom_url musí byť adresa HTTP/HTTPS s najviac 512 znakmi.",
"tagRegexLength": "Regulárny výraz pre tagy (tag_regex) môže mať najviac 512 znakov.",
"persistPermission": "Evidenciu aplikácie sa nepodarilo uložiť. Skontroluj oprávnenia na zápis.",
"persist": "Evidenciu aplikácie sa nepodarilo uložiť.",
"noApps": "Pre tento stroj nie sú evidované žiadne aplikácie.",
"appMissing": "Záznam aplikácie „{appId}“ sa nenašiel.",
"appNotFound": "Záznam aplikácie sa nenašiel."
},
"methodOciImage": "OCI obraz (nainštaloval ProxMenux)",
"ociImageHelp": "Verzia aj dostupné aktualizácie sa určujú podľa obrazu, z ktorého bol tento kontajner vytvorený. Keďže ho nainštaloval ProxMenux, netreba nič nastavovať: nainštalovaný obraz sa porovnáva s obrazom, ktorý je dnes dostupný v jeho registri.",
"ociAppVersion": "Aplikácia",
"ociImage": "Obraz",
@@ -1585,7 +1708,7 @@
"installedRegexPlaceholder": "napr. v?(\\d+\\.\\d+\\.\\d+)",
"installedRegexAlt": "install_regex",
"regexCaptureGroupLabel": "Regex (so zachytávacou skupinou)",
"regexPlaceholderVersion": "verzia:\\s*(\\d+\\.\\d+\\.\\d+)",
"regexPlaceholderVersion": "version:\\s*(\\d+\\.\\d+\\.\\d+)",
"optionalSuffix": "(voliteľné)",
"methodNone": "Žiadny (iba odkaz)",
"methodDpkg": "balík dpkg (Debian/Ubuntu)",
@@ -1606,12 +1729,12 @@
"binaryArgsPlaceholder": "napr. --version (oddelené čiarkou)",
"filePathLabel": "Cesta k súboru",
"commandLabel": "Príkaz (argv oddelené čiarkou, bez shellu)",
"commandPlaceholder": "myapp, verzia, --json",
"commandPlaceholder": "myapp, version, --json",
"commandServerExample": "server, -v",
"containerNameLabel": "Názov kontajnera",
"containerNamePlaceholder": "napr. <your-container-name>",
"ociLabelKeyLabel": "Kľúč OCI labelu",
"pythonInterpreterLabel": "Cesta k Python interpreteru",
"ociLabelKeyLabel": "Kľúč štítka OCI",
"pythonInterpreterLabel": "Cesta k interpretu Pythonu",
"pythonInterpreterPlaceholder": "napr. /opt/<your-app>/venv/bin/python",
"pipDistLabel": "Názov distribúcie (pip balík)",
"pipDistPlaceholder": "napr. <your-pip-package>",
@@ -1651,7 +1774,7 @@
"latestFromLabel": "Najnovšia verzia z",
"upToDateBadge": "Aktuálne",
"updateAvailableBadge": "Dostupná aktualizácia",
"portDescriptionPlaceholder": "Popis (napr. Web UI, go2rtc, admin)",
"portDescriptionPlaceholder": "Popis (napr. webové rozhranie, go2rtc, správa)",
"portPortPlaceholder": "port",
"portHttp": "http",
"portHttps": "https",
@@ -1670,7 +1793,7 @@
"detectMethodDockerLabel": "štítok docker",
"detectMethodDockerExec": "docker exec",
"detectMethodCommand": "príkaz",
"binaryVersionHint": "binárna verzia",
"binaryVersionHint": "program (výstup --version)",
"systemctlHint": "systemctl show <service> -p ExecStart",
"whichHint": "ktorá <aplikácia>",
"apkInfoHint": "\" : \"informácie o apk | grep",
@@ -1721,7 +1844,7 @@
"detectorTestTitle": "Výsledok testu detektora",
"detectorTestNotSaved": "Iba koncept · neuložené",
"detectorTestNoVersion": "Nebola zistená žiadna verzia",
"detectorTestNoUpstream": "Nie je nastavený zdroj upstream",
"detectorTestNoUpstream": "Nie je nastavený zdroj dostupnej verzie",
"detectorTestFailed": "Test detektora zlyhal",
"deleteFailed": "Odstránenie zlyhalo",
"dismissFailed": "Nájdenú aplikáciu sa nepodarilo skryť",
@@ -1768,17 +1891,17 @@
"addAnotherApplication": "Registrovať ďalšiu aplikáciu",
"doneButton": "Hotovo",
"editButton": "Upraviť",
"upstreamErrorTimeout": "Časový limit siete pri kontaktovaní upstream",
"upstreamErrorTimeout": "Zdroj dostupnej verzie neodpovedal včas.",
"upstreamErrorNetwork": "Chyba siete: {detail}",
"upstreamErrorGithubRateLimit": "Bol dosiahnutý limit požiadaviek GitHubu. V časti Nastavenia → GitHub API nakonfigurujte voliteľný token alebo to skúste znova neskôr.",
"upstreamErrorRegistry": "Register obrazu sa nepodarilo kontaktovať. O niekoľko minút sa skontroluje znova.",
"upstreamErrorGeneric": "Kontrola proti prúdu zlyhala: {detail}",
"notificationsEnabled": "Upozornenia na upstream aktualizácie sú ZAPNUTÉ – kliknutím ich stlmíte",
"notificationsMuted": "Upstream upozornenia na aktualizácie MUTED – kliknutím aktivujete",
"notifyUpstreamLabel": "Upozorniť ma, keď bude k dispozícii nová upstream verzia",
"notifyUpstreamHelp": "Odošle `app_update_available` do kanálov povolených v Nastaveniach → Upozornenia. Vypnite, ak túto aplikáciu nie je možné aktualizovať na vašom boxe.",
"notificationsEnabled": "Upozornenia na nové verzie sú zapnuté — kliknutím ich stlmíš",
"notificationsMuted": "Upozornenia na nové verzie sú stlmené — kliknutím ich zapneš",
"notifyUpstreamLabel": "Upozorniť ma na novú dostupnú verziu",
"notifyUpstreamHelp": "Odošle upozornenie app_update_available do kanálov zapnutých v Nastaveniach → Upozornenia. Vypni, ak túto aplikáciu na svojom serveri nemôžeš aktualizovať.",
"excludeFromBadgeLabel": "Vylúčiť z počítadla aktualizácií LXC",
"excludeFromBadgeHelp": "Nezapočítajte túto aplikáciu do odznaku súhrnných aktualizácií na karte zoznamu LXC.Užitočné, keď ste úmyselne pripnutý ku konkrétnej verzii (požiadavka na sledovanie, zmrazenie kompatibility).Nemá vplyv na vlastný stav karty Aplikácia ani na odchádzajúce upozornenie.",
"excludeFromBadgeHelp": "Nezapočíta túto aplikáciu do počtu aktualizácií v zozname LXC. Hodí sa, ak chceš zámerne zostať pri konkrétnej verzii, napríklad kvôli kompatibilite. Stav na záložke Aplikácie aj upozornenia zostanú nezmenené.",
"ociLabelPlaceholder": "org.opencontainers.image.version",
"stackMemberTitle": "Časť aplikácie s viacerými kontajnermi",
"stackMemberBody": "Tento kontajner podporuje aplikáciu, ktorej hlavný kontajner je {primary}. Zobrazuje sa tam aplikácia, jej webové odkazy a jej verzia."
@@ -1872,7 +1995,7 @@
},
"networkInterfaces": {
"title": "Vylúčené sieťové rozhrania",
"description": "Vylúčte sieťové rozhrania, napríklad most, bond alebo fyzické sieťové karty, z kontroly stavu a upozornení. Hodí sa to pre rozhrania, ktoré sú zámerne vypnuté alebo sa nepoužívajú.",
"description": "Vylúčte sieťové rozhrania, napríklad sieťové mosty (bridge), bond alebo fyzické sieťové karty, z kontroly stavu a upozornení. Hodí sa to pre rozhrania, ktoré sú zámerne vypnuté alebo sa nepoužívajú.",
"emptyTitle": "Nenašli sa žiadne sieťové rozhrania",
"interface": "Rozhranie",
"down": "VYPNUTÉ",
@@ -2295,6 +2418,42 @@
"updateManyDescription": "Počet funkcií, ktoré sa postupne spustia: {count}.",
"legacySourceTitle": "Staršia optimalizácia - kliknutím zobrazíte zdroj",
"sourceTitle": "Kliknutím zobrazíte zdrojový kód",
"sourceDialogTitle": "Zdrojový kód: {name}",
"sourceLoading": "Načítava sa zdrojový kód…",
"sourceUnavailable": "Zdrojový kód nie je dostupný.",
"sourceLoadFailed": "Zdrojový kód sa nepodarilo načítať. Skús to znova.",
"sourceFunctionMissing": "Funkcia sa nenašla v dostupných skriptoch.",
"sourceUnknownTool": "Neznámy nástroj: {tool}",
"sourceNoMapping": "K nástroju {tool} nie je priradená funkcia.",
"sourceToolNames": {
"subscription_banner": "Odstránenie upozornenia na predplatné",
"time_sync": "Synchronizácia času",
"apt_languages": "Vynechanie jazykových balíkov APT",
"journald": "Optimalizácia journald",
"logrotate": "Optimalizácia logrotate",
"system_limits": "Zvýšenie systémových limitov",
"memory_settings": "Optimalizácia nastavení pamäte",
"zram_swap": "Profil swapu ZRAM",
"kernel_panic": "Nastavenie správania pri páde jadra",
"apt_ipv4": "Vynútenie IPv4 pre APT",
"kexec": "Rýchly reštart cez kexec",
"rpc": "Vypnutie RPC / rpcbind",
"motd": "Vlastná uvítacia správa MOTD",
"system_utils": "Systémové nástroje",
"network_optimization": "Optimalizácia siete",
"bashrc_custom": "Prispôsobenie bashrc",
"figurine": "Figurine",
"fastfetch": "Fastfetch",
"log2ram": "Log2ram (ochrana SSD)",
"zfs_autotrim": "ZFS Autotrim",
"amd_fixes": "Opravy pre procesory AMD (Ryzen/EPYC)",
"persistent_network": "Trvalé názvy sieťových rozhraní",
"vfio_iommu": "Priame priradenie zariadení (VFIO/IOMMU)",
"lvm_repair": "Oprava hlavičiek fyzických zväzkov LVM",
"repo_cleanup": "Vyčistenie repozitárov",
"proxmox_repos": "Repozitáre APT pre Proxmox",
"entropy": "Generovanie entropie (haveged)"
},
"legacy": "staršie",
"updateToolTitle": "Aktualizovať {name} na v{version}",
"availableUpdates": "Dostupné aktualizácie",
@@ -3572,7 +3731,7 @@
},
"loading": {
"title": "Načítavam údaje o sieti...",
"description": "Kontrolujem rozhrania, sieťové mosty a premávku"
"description": "Kontrolujem rozhrania, sieťové mosty (bridge) a premávku"
},
"errors": {
"serverUnavailableTitle": "Server nie je dostupný",
@@ -3586,7 +3745,7 @@
},
"sections": {
"physicalInterfaces": "Fyzické rozhrania",
"bridgeInterfaces": "Sieťové mosty",
"bridgeInterfaces": "Sieťové mosty (bridge)",
"vmLxcInterfaces": "Sieťové rozhrania VM a LXC"
},
"summary": {
@@ -3606,8 +3765,8 @@
},
"interfaceTypes": {
"physical": "Fyzické",
"bridge": "Most",
"bridges": "Mosty",
"bridge": "Sieťový most (bridge)",
"bridges": "Sieťové mosty (bridge)",
"bond": "Bond",
"vlan": "VLAN",
"virtual": "Virtuálne",
@@ -3701,7 +3860,7 @@
"inactiveTitle": "Rozhranie je neaktívne",
"inactiveDescription": "Toto rozhranie je momentálne vypnuté. Štatistiky sieťovej premávky nie sú dostupné.",
"bondConfiguration": "Nastavenie bondu",
"bridgeConfiguration": "Nastavenie mosta"
"bridgeConfiguration": "Nastavenie sieťového mosta (bridge)"
},
"flow": {
"title": "Tok siete"
@@ -4246,7 +4405,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ľúč.",
@@ -4290,6 +4449,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}",
@@ -4875,7 +5152,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.",
@@ -5387,6 +5664,7 @@
"websocketError": "Chyba WebSocket spojenia",
"connectionClosed": "Spojenie bolo zatvorené",
"completedSuccessfully": "Skript sa úspešne dokončil",
"cancelled": "Operácia bola zrušená.",
"completedWithError": "Skript skončil s chybovým kódom {code}",
"yes": "Áno",
"yourInput": "Tvoj vstup:",
@@ -5709,7 +5987,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."
},
@@ -6119,11 +6397,11 @@
}
},
"bridge_without_ports": {
"title": "Porty bridgeov",
"rationale": "Konfigurácia portov každého bridge. Bridge bez fyzického portu obsluhuje internú alebo smerovanú sieť.",
"title": "Porty sieťových mostov (bridge)",
"rationale": "Konfigurácia portov každého sieťového mosta (bridge). Sieťový most (bridge) bez fyzického portu obsluhuje internú alebo smerovanú sieť.",
"summary": {
"allConnected": "Všetkých {total} bridgeov má port",
"isolated": "{count} z {total} bridgeov nemá port",
"allConnected": "Všetkých {total} sieťových mostov (bridge) má port",
"isolated": "{count} z {total} sieťových mostov (bridge) nemá port",
"evaluationFailed": "Kontrolu sa nepodarilo vyhodnotiť"
}
}
@@ -6339,13 +6617,13 @@
"network": "Sieť",
"adapters": "Adaptéry",
"bond": "Bond",
"bridge": "Bridge",
"bridges": "Bridge",
"bridge": "Sieťový most (bridge)",
"bridges": "Sieťové mosty (bridge)",
"physicalAdapters": "Fyzické adaptéry",
"interface": "Rozhranie",
"driver": "Ovládač",
"state": "Stav",
"networkDiagramNote": "Cesta od kábla ku každému VM a LXC: fyzický adaptér, bond, ak ich zoskupuje, bridge a pripojené VM a LXC.",
"networkDiagramNote": "Cesta od kábla ku každému VM a LXC: fyzický adaptér, bond, ak ich zoskupuje, sieťový most (bridge) a pripojené VM a LXC.",
"storageAndProtection": "Úložisko a ochrana",
"storage": "Úložisko",
"content": "Obsah",
@@ -7367,35 +7645,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…",
@@ -7407,9 +7690,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",
@@ -7419,7 +7702,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",
@@ -7430,52 +7713,111 @@
"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ť."
}
},
"apiErrors": {
"http": "Požiadavka na API zlyhala: {status} {statusText}",
+375 -8
View File
@@ -1231,7 +1231,9 @@
"update": "Uppdatera",
"modify": "Ändra",
"recover": "Återställ",
"registerRestored": "Registrera igen",
"terminalTitleUpdate": "Uppdatera OCI-applikation",
"terminalTitleRecoverPrevious": "Återställ den tidigare OCI-installationen",
"terminalTitleModify": "Ändra OCI-applikation",
"terminalTitleRecover": "Återställ återställd OCI-applikation",
"terminalDescription": "Samma process körs som i ProxMenux-menyn: OCI manager Apps → Hantera installerade OCI-applikationer.",
@@ -1760,7 +1762,128 @@
"dockerEvidenceImage": "Avbild",
"dockerEvidencePort": "Publicerad TCP-port",
"dockerEvidenceProject": "Compose-projekt",
"dockerEndpointReview": "Container med publicerade TCP-portar. Bekräfta namnet och webbadressen innan du sparar. Registreringen konfigurerar inga uppdateringar."
"dockerEndpointReview": "Container med publicerade TCP-portar. Bekräfta namnet och webbadressen innan du sparar. Registreringen konfigurerar inga uppdateringar.",
"confirmRemove": "Ta bort applikationens post från fliken App? Detta avinstallerar inte applikationen.",
"packageExample": "t.ex. {package}",
"packageHelpBefore": "Fylls i automatiskt från Namn. Kontrollera med",
"packageHelpAfter": "i CT:n.",
"binaryHelpBefore": "Absolut sökväg. Hitta den med",
"binaryHelpOr": "eller",
"errors": {
"ociBusy": "en OCI-åtgärd pågår på den här containern",
"dpkgMissing": "{package} är inte installerat via dpkg",
"dpkgFailed": "dpkg-query misslyckades",
"dpkgNoVersion": "ingen version matchade i utdata från dpkg",
"apkFailed": "apk info misslyckades",
"apkNoVersion": "ingen version matchade i utdata från apk",
"fileRead": "kunde inte läsa {path}",
"fileNoMatch": "file_regex matchade inte {path}",
"binaryFailed": "anropet av binärfilen misslyckades",
"binaryNoVersion": "ingen version matchade i utdata från binärfilen",
"pythonFailed": "python -c importlib.metadata misslyckades",
"pythonNoVersion": "ingen version matchade i utdata från Python-distributionen",
"dockerImageResolve": "kunde inte lösa containerns installerade avbild",
"dockerInspectFailed": "docker inspect misslyckades",
"dockerLabelMissing": "containern saknar etiketten {label}",
"dockerLabelMutable": "docker-etiketten rapporterar {tag} (föränderlig tagg, inte en version)",
"dockerLabelNoVersion": "ingen version matchade i utdata från docker-etiketten",
"dockerExecFailed": "docker exec misslyckades",
"dockerExecNoVersion": "ingen version matchade i utdata från docker exec",
"commandEmpty": "command_argv är tom",
"commandFailed": "kommandot misslyckades",
"commandNoVersion": "ingen version matchade i kommandots utdata",
"unsupportedMethod": "metod som inte stöds: {method}",
"unknownUpstream": "okänd upstream_type: {type}",
"githubNotFound": "{repo}: hittades inte",
"githubRejected": "github avvisade förfrågan (403)",
"githubError": "github-fel {code}",
"noRelease": "inget tagg- eller releasenamn i svaret",
"tagNoMatch": "tag_regex matchade inte '{tag}'",
"httpError": "http-fel {code}",
"invalidJson": "ogiltigt JSON-svar: {detail}",
"jsonPathUnresolved": "json_path '{path}' kunde inte lösas",
"jsonEmpty": "tomt värde vid json_path",
"dockerNoTags": "inga taggar matchade (docker_hub)",
"dockerMovingOnly": "tag_regex matchade bara rörliga taggar; använd spårning av Docker-avbildens digest",
"dockerHubError": "docker hub-fel {code}",
"dockerHubJson": "ogiltig JSON från docker hub: {detail}",
"dockerHubEmpty": "docker hub returnerade inga taggar",
"payload": "payload måste vara ett JSON-objekt",
"name": "name krävs och måste vara 1–64 tecken av bokstäver/siffror/mellanslag/._+-()/",
"method": "installed_via måste vara en av: {values} eller tom",
"package": "package krävs (bokstäver/siffror/._+@:/ upp till 127 tecken)",
"filePath": "file_path krävs och måste vara en absolut sökväg",
"fileRegex": "file_regex krävs",
"fileRegexInvalid": "file_regex är inte ett giltigt reguljärt uttryck: {detail}",
"binaryPath": "binary_path krävs och måste vara en absolut sökväg",
"pythonPath": "python_path krävs och måste vara en absolut sökväg",
"distribution": "distribution krävs (namn enligt PEP 503)",
"container": "container_name krävs (Dockers namngivningsregler)",
"label": "label krävs (namngivningsregler för OCI-etiketter)",
"dockerBinary": "binary_path krävs (absolut sökväg eller enbart kommando)",
"manualVersion": "installed_version krävs för den manuella metoden",
"versionLength": "installed_version överskrider {max} tecken",
"versionControl": "installed_version innehåller ett otillåtet kontrolltecken",
"installedRegexInvalid": "installed_regex är inte ett giltigt reguljärt uttryck: {detail}",
"upstreamType": "upstream_type måste vara en av: {values}",
"tagRegexInvalid": "tag_regex är inte ett giltigt reguljärt uttryck: {detail}",
"repoRequired": "repo krävs för upstream_type=github",
"repoFormat": "repo måste vara 'owner/name'",
"githubSource": "github_source måste vara en av: {values}",
"upstreamUrlRequired": "upstream_url krävs för upstream_type=http_json",
"upstreamUrl": "upstream_url måste vara en http(s)-URL",
"upstreamUrlLength": "upstream_url överskrider {max} tecken",
"jsonPathRequired": "upstream_json_path krävs för upstream_type=http_json",
"jsonPathLength": "upstream_json_path överskrider {max} tecken",
"jsonPathCharacters": "upstream_json_path använder otillåtna tecken",
"dockerImageRequired": "docker_image krävs för upstream_type=docker_hub",
"dockerImageLength": "docker_image överskrider {max} tecken",
"dockerImageFormat": "docker_image måste följa Docker Hubs namngivning (owner/name eller name)",
"healthPath": "health_path måste vara en giltig URL-sökväg (högst 255 tecken)",
"logoUrl": "logo_url måste vara en http(s)-URL eller den här VM:ens lokalt cachade ikon",
"discoveryId": "ogiltigt discovery_id",
"helperSlug": "helper_slug måste vara en slug med gemener (bokstäver/siffror/._-)",
"updateVia": "update_via accepterar bara 'docker'",
"dockerDetector": "update_via=docker kräver en detektor av typen docker_label eller docker_exec",
"dockerConflict": "update_via=docker kan inte kombineras med {field}",
"updateCommandType": "update_command måste vara en sträng",
"updateCommandLength": "update_command överskrider {max} tecken",
"updateCommandNull": "update_command innehåller en null-byte",
"updateMethod": "update_method måste vara none, helper eller custom",
"updateCommandRequired": "update_command krävs för update_method=custom",
"updateCommandOnlyCustom": "update_command tillåts bara för update_method=custom",
"helperRequired": "en helper_slug som stöds krävs för update_method=helper",
"dockerUpdater": "Docker-delegerade appar uppdateras via sin avbild, inte via en separat appuppdaterare",
"commandRequired": "command_argv krävs (lista som inte är tom)",
"commandList": "command_argv måste vara en lista med strängar som inte är tom",
"commandCount": "command_argv accepterar högst {max} poster",
"commandEntry": "command_argv[{index}] måste vara en sträng som inte är tom",
"commandEntryLength": "command_argv[{index}] överskrider {max} tecken",
"commandEntryControl": "command_argv[{index}] innehåller ett otillåtet kontrolltecken",
"binaryArgsList": "binary_args måste vara en lista med strängar",
"binaryArgsCount": "binary_args accepterar högst {max} poster",
"binaryArgEntry": "binary_args[{index}] måste vara en sträng som inte är tom",
"binaryArgLength": "binary_args[{index}] överskrider {max} tecken",
"binaryArgControl": "binary_args[{index}] innehåller ett otillåtet kontrolltecken",
"portsList": "ports måste vara en lista med {port, description, scheme}",
"portObject": "ports[{index}] måste vara ett objekt",
"portRequired": "ports[{index}].port krävs",
"portInteger": "ports[{index}].port måste vara ett heltal",
"portRange": "ports[{index}].port måste vara 1–65535",
"portDuplicate": "porten {port} förekommer mer än en gång för den här appen",
"portDescription": "ports[{index}].description innehåller ogiltiga tecken",
"portScheme": "ports[{index}].scheme måste vara 'http' eller 'https'",
"portPath": "ports[{index}].web_path måste vara en giltig URL-sökväg (högst 255 tecken)",
"portLogo": "ports[{index}].logo_url måste vara en http(s)-URL (högst 512 tecken)",
"portCategory": "ports[{index}].category innehåller ogiltiga tecken eller är för lång",
"portCustomUrl": "ports[{index}].custom_url måste vara en http(s)-URL (högst 512 tecken)",
"tagRegexLength": "tag_regex överskrider 512 tecken",
"persistPermission": "kunde inte spara sidecar-filen (behörighet?)",
"persist": "kunde inte spara sidecar-filen",
"noApps": "inga appar registrerade för detta vmid",
"appMissing": "app_id '{appId}' hittades inte",
"appNotFound": "appen hittades inte"
}
},
"statusFilter": {
"ariaLabel": "Filtrera virtuella maskiner och containrar",
@@ -2294,6 +2417,42 @@
"updateManyDescription": "Kör om {count} efterinstallationsfunktioner i följd.",
"legacySourceTitle": "Äldre optimering – klicka för att se källan",
"sourceTitle": "Klicka för att se källkoden",
"sourceDialogTitle": "Källkod: {name}",
"sourceLoading": "Läser in källkod…",
"sourceUnavailable": "Källkoden är inte tillgänglig.",
"sourceLoadFailed": "Det gick inte att läsa in källkoden. Försök igen.",
"sourceFunctionMissing": "Funktionen hittades inte i de tillgängliga skripten.",
"sourceUnknownTool": "Okänt verktyg: {tool}",
"sourceNoMapping": "Ingen funktion är kopplad till verktyget {tool}.",
"sourceToolNames": {
"subscription_banner": "Borttagning av prenumerationsbanner",
"time_sync": "Tidssynkronisering",
"apt_languages": "Hoppa över APT-språk",
"journald": "Optimering av journald",
"logrotate": "Optimering av logrotate",
"system_limits": "Höjda systemgränser",
"memory_settings": "Optimering av minnesinställningar",
"zram_swap": "ZRAM-swapprofil",
"kernel_panic": "Konfiguration av kernel panic",
"apt_ipv4": "Tvinga IPv4 för APT",
"kexec": "kexec för snabba omstarter",
"rpc": "Inaktivera RPC / rpcbind",
"motd": "Anpassad MOTD-banner",
"system_utils": "Systemverktyg",
"network_optimization": "Nätverksoptimeringar",
"bashrc_custom": "Anpassning av bashrc",
"figurine": "Figurine",
"fastfetch": "Fastfetch",
"log2ram": "Log2ram (SSD-skydd)",
"zfs_autotrim": "ZFS Autotrim",
"amd_fixes": "Korrigeringar för AMD CPU (Ryzen/EPYC)",
"persistent_network": "Beständiga namn på nätverksgränssnitt",
"vfio_iommu": "VFIO/IOMMU-passthrough",
"lvm_repair": "Reparation av LVM PV-huvuden",
"repo_cleanup": "Rensning av arkiv",
"proxmox_repos": "Proxmox APT-arkiv",
"entropy": "Entropigenerering (haveged)"
},
"legacy": "arv",
"updateToolTitle": "Uppdatera {name} till v{version}",
"availableUpdates": "Tillgängliga uppdateringar",
@@ -4820,6 +4979,124 @@
"cancel": "Avbryt",
"save": "Spara bevarandepolicy",
"useDefault": "Använd Proxmox-standard (ingen policy för denna lagring)"
},
"apiErrors": {
"requestFailed": "Förfrågan misslyckades.",
"absolutePath": "sökvägen måste vara absolut",
"pathConfigured": "den här sökvägen är redan konfigurerad",
"pathRequired": "sökväg krävs",
"pathQuery": "frågeparametern path krävs",
"pathNotFound": "sökvägen hittades inte",
"invalidJobId": "ogiltigt jobb-ID",
"jobNotFound": "jobbet hittades inte",
"runnerMissing": "körskriptet är inte installerat",
"noRestore": "inget återställningstillstånd att avfärda",
"invalidFile": "ogiltigt filnamn",
"notFound": "hittades inte",
"logOutside": "loggsökvägen ligger utanför den tillåtna katalogen",
"targetsList": "targets måste vara en lista",
"emptyExpression": "tomt uttryck",
"invalidId": "ogiltigt ID (använd bokstäver, siffror, _ eller -)",
"backend": "backend måste vara pbs, local eller borg",
"remoteBackend": "backend måste vara pbs eller borg",
"remoteBackendQuoted": "backend måste vara 'pbs' eller 'borg'",
"profileMode": "profile_mode måste vara default eller custom",
"borgAttached": "borg kan inte kopplas (PVE vzdump skriver bara till pbs/local-lagringar)",
"borgAttachedLong": "borg är inte en giltig backend för kopplade jobb (PVE vzdump skriver bara till pbs/local-lagringar)",
"parentRequired": "pve_parent_job_id och pve_storage krävs för attached",
"absolutePaths": "paths måste vara en lista med absoluta sökvägar",
"customPath": "en anpassad profil behöver minst en sökväg",
"defaultPaths": "kunde inte lösa standardprofilens sökvägar",
"retentionObject": "retention måste vara ett objekt",
"pbsRepository": "pbs_repository krävs",
"pbsEncryption": "pbs_encrypt_mode måste vara 'none', 'new' eller 'existing'",
"pbsKeyFailed": "det gick inte att skapa PBS-krypteringsnyckelfilen",
"pbsClientMissing": "det gick inte att skapa PBS-krypteringsnyckelfilen (är proxmox-backup-client installerat?)",
"borgRepository": "borg_repo krävs",
"borgEncryption": "borg_encrypt_mode måste vara en av: none, repokey, keyfile, authenticated",
"encryption": "encrypt_mode måste vara en av: none, repokey, keyfile, authenticated",
"borgPassphrase": "borg_passphrase krävs när kryptering är aktiverad (spara den på destinationen eller skicka med en i förfrågan)",
"passphrase": "lösenfras krävs när kryptering är aktiverad",
"password": "lösenord krävs",
"deviceConfirmation": "confirm_device måste exakt matcha device (skydd mot felskrivning)",
"invalidDevice": "ogiltig enhetssökväg",
"absoluteMountpoint": "en absolut monteringspunkt krävs",
"archiveLayout": "arkivets struktur känns inte igen — ingen rootfs/ hittades",
"archiveMissing": "arkivet hittades inte",
"archiveNotFile": "arkivsökvägen är inte en vanlig fil",
"invalidName": "ogiltigt namn",
"invalidStaging": "ogiltig staging_path",
"jobRename": "jobbets namn kan inte ändras (ta bort och skapa på nytt för att byta namn)",
"absoluteKey": "key_path måste vara absolut",
"mode": "mode måste vara \"local\" eller \"ssh\"",
"nameRequired": "namn krävs (bokstäver, siffror, _ eller -)",
"noLog": "ingen logg finns ännu för det här jobbet",
"noPbsConfig": "ingen fil med manuella PBS-konfigurationer",
"outputMissing": "utdatafilen saknas",
"parserJson": "parserns utdata var inte giltig JSON",
"restoreJson": "utdata från run_restore var inte giltig JSON",
"repoRequired": "repo krävs",
"repoSnapshot": "repo_name och snapshot krävs",
"borgRepoSnapshot": "repo_name och snapshot krävs för borg",
"pbsRepoSnapshot": "repo_name och snapshot krävs för pbs",
"repoName": "repo_name krävs",
"restoreMissing": "återställningsverktygen är inte installerade på den här värden",
"serverDatastore": "server och datalager krävs",
"snapshotRequired": "snapshot krävs",
"source": "source måste vara pbs, borg eller local",
"sshPort": "ssh_port måste vara ett heltal mellan 1 och 65535",
"sshRequired": "ssh_user, ssh_host och ssh_remote_path krävs för ssh-läge",
"repositoryPath": "målet saknar arkivsökväg",
"taskExpired": "uppgiften hittades inte eller har gått ut",
"invalidExpression": "ogiltigt uttryck",
"noReport": "ingen rapport skapades",
"parseManifestFailed": "parse_manifest avslutades med en annan kod än noll",
"jobExists": "jobbet finns redan: {value}",
"pathMissing": "sökvägen finns inte på den här värden: {value}",
"deviceMissing": "enheten finns inte: {value}",
"mountpoint": "inte en monteringspunkt: {value}",
"localArchiveMissing": "lokalt arkiv hittades inte: {value}",
"borgDestination": "Borg-destinationen '{value}' hittades inte",
"pbsDestination": "PBS-destinationen '{value}' hittades inte",
"pbsPassword": "inget lösenord tillgängligt för PBS-arkivet '{value}'",
"borgRepoMissing": "Borg-arkivet \"{value}\" är inte konfigurerat",
"pbsRepoMissing": "PBS-arkivet \"{value}\" är inte konfigurerat",
"borgTargetMissing": "Borg-målet hittades inte: {value}",
"pbsTargetMissing": "PBS-arkivet hittades inte: {value}",
"pveJobMissing": "PVE vzdump-jobbet hittades inte: {value}",
"statePersist": "kan inte spara tillståndet: {value}",
"sshDirectory": "kan inte förbereda ssh-katalogen: {value}",
"logRead": "kan inte läsa loggen: {value}",
"envUpdate": "kunde inte uppdatera .env: {value}",
"pbsConfigWrite": "det gick inte att spara PBS-konfigurationen: {value}",
"persist": "det gick inte att spara: {value}",
"archiveRemove": "det gick inte att ta bort arkivet: {value}",
"start": "det gick inte att starta: {value}",
"borgTargetsUpdate": "det gick inte att uppdatera borg-targets.txt: {value}",
"pbsTargetsUpdate": "det gick inte att uppdatera pbs-manual-configs.txt: {value}",
"unitsUpdate": "det gick inte att uppdatera systemd-enheter: {value}",
"borgTargetsWrite": "det gick inte att skriva borg-targets.txt: {value}",
"jobWrite": "det gick inte att skriva jobbfiler: {value}",
"localTargetWrite": "det gick inte att skriva local-target.conf: {value}",
"unitsWrite": "det gick inte att skriva systemd-enheter: {value}",
"format": "formateringen misslyckades: {value}",
"calendar": "ogiltig on_calendar: {value}",
"mount": "monteringen misslyckades: {value}",
"parserInvocation": "anropet av parsern misslyckades: {value}",
"preflightInvocation": "anropet av preflight misslyckades: {value}",
"publicKeyMissing": "den publika nyckeln hittades inte efter generering: {value}",
"retentionInteger": "retention.{value} måste vara ett heltal",
"retentionNegative": "retention.{value} får inte vara negativt",
"keygen": "ssh-keygen misslyckades: {value}",
"systemctl": "systemctl misslyckades: {value}",
"unmount": "umount misslyckades: {value}",
"taskIncomplete": "uppgiften har tillståndet \"{value}\", inte slutförd",
"unknownKind": "okänd typ: {value}",
"unknownMode": "okänt läge \"{value}\"",
"defaultRemove": "{value} är den inbyggda standarden och kan inte tas bort",
"defaultAdd": "{value} är den inbyggda standarden; den behöver inte läggas till",
"keyImport": "Ingen PBS-nyckelfil är installerad i {value}. Importera nyckelfilen först.",
"storageMismatch": "lagringen för PVE-jobbet stämmer inte (jobbet använder {value}, payload anger {other})"
}
},
"healthStatus": {
@@ -5320,6 +5597,7 @@
"websocketError": "WebSocket-fel uppstod",
"connectionClosed": "Anslutning stängd",
"completedSuccessfully": "Skriptet har slutförts",
"cancelled": "Åtgärden avbröts.",
"completedWithError": "Skriptet avslutat med felkoden {code}",
"yes": "Ja",
"yourInput": "Din inmatning:",
@@ -7311,7 +7589,7 @@
"signInWith": "Logga in med",
"methodPassword": "Användare och lösenord",
"methodToken": "API-token",
"username": "Användare (användare@realm)",
"username": "Användare",
"password": "Lösenord",
"passwordHelp": "Lösenordet används en gång för att skapa en skrivskyddad API-token (rollen Audit) för den här Monitorn. Bara den token sparas, aldrig lösenordet. Aktivera först autentiseringen i Monitor.",
"cancel": "Avbryt",
@@ -7324,10 +7602,10 @@
"editConnection": "Anslutning att redigera",
"add": "Lägg till en ny anslutning",
"name": "Namn",
"url": "HTTPS-URL (ta med :8007 för PBS ordinarie port)",
"token_id": "API-token-ID (user@realm!token)",
"token_secret": "API-tokenhemlighet (lämna tomt för att behålla den sparade hemligheten)",
"fingerprint": "Certifikatets SHA256-fingeravtryck (valfritt)",
"url": "HTTPS-adress",
"token_id": "API-token-ID",
"token_secret": "API-tokenhemlighet",
"fingerprint": "SHA256-fingeravtryck (valfritt)",
"tlsHelp": "Lämna fingeravtrycket tomt för vanlig kontroll av certifikatutfärdare och värdnamn. För ett självsignerat certifikat kopierar du dess SHA256-fingeravtryck från PBS-konsolen eller en annan betrodd kanal. Fingeravtrycket kontrolleras innan inloggningsuppgifter skickas.",
"saved": "PBS-anslutningen har verifierats och sparats.",
"saveFailed": "Det gick inte att spara PBS-anslutningen.",
@@ -7369,7 +7647,85 @@
"estimatedFull": "Beräknad full",
"estimatedFullUnknown": "Inte tillräckligt med data ännu",
"duration": "Varaktighet",
"noJobs": "Inga synkroniserings-, verifierings- eller beskärningsscheman är konfigurerade."
"noJobs": "Inga synkroniserings-, verifierings- eller beskärningsscheman är konfigurerade.",
"errors": {
"configRead": "Det gick inte att läsa de sparade PBS-anslutningarna. Kontrollera konfigurationsfilen och behörigheterna.",
"invalidConfig": "Inställningarna för PBS-anslutningen är inte giltiga. Kontrollera formuläret och försök igen.",
"invalidField": "Ett anslutningsfält är ogiltigt eller för långt.",
"invalidUrl": "Använd en HTTPS-adress till PBS utan inbäddade inloggningsuppgifter, frågeparametrar eller fragment.",
"nameRequired": "Ange ett servernamn på högst 128 tecken.",
"tokenIdFormat": "Ange ID för PBS API-token i formatet user@realm!token.",
"tokenSecret": "Ange en giltig API-tokenhemlighet utan mellanslag eller kontrolltecken.",
"invalidTokenId": "API-token-ID får bara innehålla ASCII-tecken.",
"invalidFingerprint": "Ange certifikatets SHA256-fingeravtryck: 64 hexadecimala tecken, eventuellt avgränsade med kolon.",
"fingerprintMismatch": "PBS-certifikatet matchar inte fingeravtrycket. Inga inloggningsuppgifter skickades.",
"tokenRejected": "PBS avvisade API-token eller dess behörigheter. Kontrollera ACL:erna för både användaren och token.",
"invalidResponse": "PBS returnerade ett oväntat svar. Omdirigeringar följs inte.",
"invalidJson": "PBS returnerade ett ogiltigt JSON-dokument.",
"connectFailed": "Det gick inte att ansluta till PBS via verifierad HTTPS. Kontrollera adressen, certifikatet och tjänsten.",
"invalidId": "ID:t för PBS-anslutningen är inte giltigt. Läs in anslutningslistan igen.",
"invalidDatastores": "PBS returnerade en ogiltig lista över datalager.",
"connectionLimit": "Högst {count} PBS-anslutningar kan konfigureras.",
"username": "Ange PBS-användaren i formatet user@realm, till exempel root@pam.",
"password": "Ange PBS-användarens lösenord.",
"loginRejected": "PBS avvisade användarnamnet eller lösenordet.",
"invalidLogin": "PBS returnerade ett ogiltigt inloggningssvar.",
"tokenCreateDenied": "Den här PBS-användaren kan inte skapa API-tokens.",
"invalidToken": "PBS returnerade en ogiltig API-token.",
"auditRoleDenied": "Den här PBS-användaren kan inte tilldela rollen Audit. Logga in som root@pam eller en annan administratör.",
"notFound": "PBS-anslutningen hittades inte. Läs in anslutningslistan igen.",
"readFailed": "Det gick inte att läsa PBS-data. Kontrollera tjänsten och behörigheterna.",
"invalidList": "PBS returnerade en ogiltig datalista.",
"localUnavailable": "PBS är inte tillgänglig på den här värden.",
"authRequired": "Aktivera lokal autentisering i Monitor innan du hanterar fjärranslutningar till PBS.",
"datastoreOffline": "Datalagret är offline, i underhållsläge eller så är dess underliggande enhet inte monterad.",
"datastoreUnavailable": "Datalagrets filsystem är inte tillgängligt."
},
"taskTypes": {
"backup": "Säkerhetskopiering",
"restore": "Återställning",
"verify": "Verifiering",
"verifyGroup": "Verifiering av säkerhetskopiegrupp",
"verifySnapshot": "Verifiering av ögonblicksbild",
"prune": "Gallring",
"garbageCollection": "Skräpinsamling",
"sync": "Synkronisering",
"updateCheck": "Sök efter uppdateringar",
"terminal": "Terminal",
"createDatastore": "Skapa datalager",
"other": "Annan uppgift ({type})"
},
"states": {
"ok": "OK",
"unknown": "Okänd",
"warnings": "Varningar: {count}",
"warning": "Varning",
"error": "Fel"
},
"serviceStates": {
"active": "Aktiv",
"inactive": "Inaktiv",
"failed": "Misslyckad",
"activating": "Startar",
"deactivating": "Stoppar",
"unknown": "Okänd"
},
"durationUnits": {
"seconds": "{count} s",
"minutes": "{count} min",
"hours": "{count} h",
"days": "{count} d"
},
"urlHelp": "Ta med :8007 för PBS standardport, till exempel https://pbs.example:8007.",
"usernameHelp": "Använd user@realm, till exempel root@pam.",
"tokenIdHelp": "Använd user@realm!token, till exempel monitor@pbs!proxmenux.",
"tokenSecretHelp": "Ange hemligheten för den här API-token.",
"tokenSecretKeepHelp": "Lämna tomt för att behålla den sparade tokenhemligheten.",
"removeFailed": "Det gick inte att ta bort PBS-anslutningen.",
"savedRefreshFailed": "PBS-anslutningen sparades, men anslutningslistan kunde inte uppdateras. Läs in sidan igen innan du gör en annan ändring.",
"removedRefreshFailed": "PBS-anslutningen togs bort, men anslutningslistan kunde inte uppdateras. Läs in sidan igen.",
"technicalDetails": "Tekniska detaljer",
"datastoreType": "Datalager"
},
"monitorLinks": {
"groups": {
@@ -7408,6 +7764,17 @@
"addSelected": "Lägg till valda",
"pbsWebInterface": "PBS webbgränssnitt",
"changeAddress": "Ändra adress",
"cancel": "Avbryt"
"cancel": "Avbryt",
"errors": {
"addressRequired": "Ange adressen till Monitor.",
"invalidAddress": "Adressen måste börja med http:// eller https:// och ange en värd.",
"addressCredentials": "Adressen får inte innehålla användare eller lösenord.",
"invalidPort": "Adressens port är inte giltig.",
"invalidRequest": "Förfrågan är inte giltig. Läs in sidan igen och försök igen.",
"limit": "Högst {count} noder kan läggas till.",
"duplicate": "Den här adressen finns redan i listan.",
"notFound": "Noden hittades inte. Läs in listan igen och försök igen.",
"detectedRemove": "Endast noder som lagts till manuellt kan tas bort. Identifierade noder kan döljas."
}
}
}
+13 -6
View File
@@ -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
+15 -11
View File
@@ -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)
+15 -12
View File
@@ -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)
+44 -34
View File
@@ -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()
+6 -4
View File
@@ -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__':
+196
View File
@@ -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()
+235 -1
View File
File diff suppressed because it is too large Load Diff
+235 -1
View File
File diff suppressed because it is too large Load Diff
+234
View File
File diff suppressed because it is too large Load Diff
+234
View File
File diff suppressed because it is too large Load Diff
+234
View File
File diff suppressed because it is too large Load Diff
+1244 -1122
View File
File diff suppressed because it is too large Load Diff
+234
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -93,8 +93,8 @@ def main() -> None:
rootfs = Path(sys.argv[1])
configuration = json.loads(sys.argv[2])
path, changed = update_encoding(rootfs, configuration)
state = "updated" if changed else "already applied"
print(f"Jellyfin configuration {state}: /{path.relative_to(rootfs.resolve())}")
state = translate("Jellyfin configuration updated:") if changed else translate("Jellyfin configuration already applied:")
print(f"{state} /{path.relative_to(rootfs.resolve())}")
if __name__ == "__main__":
+4 -2
View File
@@ -20,6 +20,7 @@ import re
import subprocess
import sys
import time
from oci_ui import translate
CONFIGS = Path('/etc/pve/lxc')
HOOKS = Path('/var/lib/lxc')
@@ -121,12 +122,13 @@ def check(vmid, settle=0):
except BlockingIOError:
return
for name, version in repair(vmid, settle):
print(f'CT {vmid}: DHCP client of {name} (IPv{version}) started again', flush=True)
print(translate('CT {vmid}: DHCP client of {name} (IPv{version}) started again').format(
vmid=vmid, name=name, version=version), flush=True)
def main():
if len(sys.argv) != 2 or not (sys.argv[1].isdigit() or sys.argv[1] == '--all'):
print('usage: oci_dhcp_lease.py <vmid> | --all', file=sys.stderr)
print(translate('Usage:') + ' oci_dhcp_lease.py <vmid> | --all', file=sys.stderr)
return 2
LOCKS.mkdir(parents=True, exist_ok=True)
if sys.argv[1] == '--all':
+4 -3
View File
@@ -10,6 +10,7 @@ import sys
import time
import oci_instances as instances
from oci_ui import translate
# Names given by the OCI installers and by updates; other archives are never touched.
NAME = re.compile(r'(?:proxmenux-update-[a-z0-9]+-[0-9a-f]{64}'
@@ -82,10 +83,10 @@ def main(arguments):
command = arguments[0] if arguments else 'prune'
if command == 'prune':
for path, size in prune():
print(f'removed unused image archive: {path} ({size} bytes)')
print(translate('removed unused image archive: {path} ({size} bytes)').format(path=path, size=size))
return 0
if command not in ('list', 'remove') or not all(a.isdigit() for a in arguments[1:]):
print('usage: oci_image_cache.py [prune | list VMID... | remove VMID...]', file=sys.stderr)
print(translate('Usage:') + ' oci_image_cache.py [prune | list VMID... | remove VMID...]', file=sys.stderr)
return 2
with instances.locked(instances.ROOT):
archives = archives_of([int(a) for a in arguments[1:]])
@@ -103,5 +104,5 @@ if __name__ == '__main__':
try:
sys.exit(main(sys.argv[1:]))
except (OSError, ValueError, BlockingIOError) as error:
print(f'image cache: {error}', file=sys.stderr)
print(f"{translate('Image cache')}: {error}", file=sys.stderr)
sys.exit(1)
+1 -1
View File
@@ -284,7 +284,7 @@ def change(root, primary_id, acceleration, render_device=None, gfx_override=None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('vmid', type=int, help='main container of the application')
parser.add_argument('vmid', type=int, help=translate('main container of the application'))
parser.add_argument('--acceleration', required=True, choices=PROFILES)
parser.add_argument('--render-device')
parser.add_argument('--gfx-override')
+1 -1
View File
@@ -301,7 +301,7 @@ def main(argv=None):
diagnose = sub.add_parser('diagnose')
diagnose.add_argument('vmid', type=int)
diagnose.add_argument('--check-registry', action='store_true')
record = sub.add_parser('record', help='Internal installer operation; not legacy adoption')
record = sub.add_parser('record', help=translate('Internal installer operation; not legacy adoption'))
record.add_argument('vmid', type=int)
for flag in ('template', 'deployment', 'archive', 'digest'):
record.add_argument('--' + flag, required=True)
+2 -2
View File
@@ -1029,9 +1029,9 @@ def main():
parser.add_argument('--root', type=Path, default=instances.ROOT)
parser.add_argument('--start', action='store_true')
parser.add_argument('--host-firewall', type=int, action='append', default=[], metavar='VMID',
help='Add the host firewall rule of this container; one option for each confirmed rule')
help=translate('Add the host firewall rule of this container; one option for each confirmed rule'))
parser.add_argument('--automatic', action='store_true',
help='Only the applications that need no question: the ones the cluster keeps a record of')
help=translate('Only the applications that need no question: the ones the cluster keeps a record of'))
args = parser.parse_args()
if os.geteuid() != 0:
parser.error(translate('Root privileges are required'))
+3 -1
View File
@@ -12,6 +12,7 @@ import argparse
import re
import subprocess
import sys
from oci_ui import translate
# A model of five operators (convolution, pooling and a dense layer), enough
# to make ROCm compile and run on the GPU without downloading anything.
@@ -72,7 +73,8 @@ def main():
try:
check(args.vmid)
except RuntimeError as error:
print(error, file=sys.stderr)
message = translate('the GPU did not answer in time') if str(error) == 'the GPU did not answer in time' else str(error)
print(message, file=sys.stderr)
return 1
return 0
+2 -1
View File
@@ -18,6 +18,7 @@ import time
import oci_instances as instances
from oci_installation_state import parse_config
from oci_ui import translate
APPS = Path('/etc/proxmenux/apps')
SOCKET = Path('/run/proxmenux-monitor/oci-check.sock')
@@ -206,7 +207,7 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=instances.ROOT)
parser.add_argument('--configs', type=Path, default=Path('/etc/pve/lxc'))
parser.add_argument('--progress', action='store_true', help='Emit numeric progress for the OCI menu')
parser.add_argument('--progress', action='store_true', help=translate('Emit numeric progress for the OCI menu'))
args = parser.parse_args()
try:
if args.progress:
+6 -3
View File
@@ -19,6 +19,7 @@ import time
import oci_instances as instances
import oci_operation_notice
from oci_ui import translate
UNIT = 'proxmenux-oci-watchdog.service'
UNIT_FILE = Path('/etc/systemd/system') / UNIT
@@ -191,7 +192,8 @@ def look(states, now):
lambda seen: stop_requested(read_tasks(), vmid, seen))
data = {'app_name': name, 'vmid': vmid, 'containers': f'CT {vmid}'}
if action == 'start':
print(f'CT {vmid} ({name}) stopped on its own; starting it again (attempt {state["attempts"]})', flush=True)
print(translate('CT {vmid} ({name}) stopped on its own; starting it again (attempt {attempt})').format(
vmid=vmid, name=name, attempt=state['attempts']), flush=True)
try:
started = start(vmid)
except (OSError, subprocess.SubprocessError):
@@ -199,7 +201,8 @@ def look(states, now):
if started and notice_due(state, now):
oci_operation_notice.notify('oci_watchdog_restarted', data)
elif action == 'give-up':
print(f'CT {vmid} ({name}) could not be kept running; it is left stopped', flush=True)
print(translate('CT {vmid} ({name}) could not be kept running; it is left stopped').format(
vmid=vmid, name=name), flush=True)
oci_operation_notice.notify('oci_watchdog_failed', data)
@@ -290,7 +293,7 @@ def main():
try:
vmids = set_watchdog(instances.ROOT, args.vmid, args.state == 'on')
except BlockingIOError:
print('Another OCI operation is using the instance registry.', file=sys.stderr)
print(translate('Another OCI operation is using the instance registry.'), file=sys.stderr)
return 3
except (OSError, ValueError, KeyError) as error:
print(str(error) or type(error).__name__, file=sys.stderr)
+32 -7
View File
@@ -4,9 +4,32 @@
from __future__ import annotations
import ctypes
import json
import os
import platform
import sys
from pathlib import Path
from functools import lru_cache
@lru_cache(maxsize=1)
def _messages() -> dict:
# This helper is also installed alone in libexec: importing oci_ui would
# break that existing caller. Read the same catalog using stdlib only.
base = Path(os.environ.get('PMX_BASE_DIR', '/usr/local/share/proxmenux'))
try:
language = json.loads((base / 'config.json').read_text(encoding='utf-8')).get('language', 'en')
if language == 'en' or not isinstance(language, str) or not language:
return {}
data = json.loads((base / 'lang' / (language + '.json')).read_text(encoding='utf-8'))
return data if isinstance(data, dict) else {}
except (OSError, ValueError, AttributeError):
return {}
def translate(text: str) -> str:
value = _messages().get(text)
return value if isinstance(value, str) and value else text
AT_FDCWD = -100
@@ -41,14 +64,16 @@ def fail(step: str) -> None:
def main() -> int:
if len(sys.argv) != 5 or sys.argv[4] not in {"rw", "ro"}:
print(f"usage: {sys.argv[0]} PID SOURCE TARGET rw|ro", file=sys.stderr)
print(f"{translate('Usage:')} {sys.argv[0]} PID SOURCE TARGET rw|ro", file=sys.stderr)
return 2
machine = platform.machine().lower()
if machine not in SYSCALLS:
print(f"unsupported host architecture: {machine}", file=sys.stderr)
print(f"{translate('Unsupported host architecture:')} {machine}", file=sys.stderr)
return 2
open_tree_nr, move_mount_nr, mount_setattr_nr = SYSCALLS[machine]
pid, source, target, mode = sys.argv[1:]
# The host catalog is no longer reachable after chroot into the container.
_messages()
libc = ctypes.CDLL(None, use_errno=True)
libc.syscall.restype = ctypes.c_long
libc.setns.argtypes = (ctypes.c_int, ctypes.c_int)
@@ -60,7 +85,7 @@ def main() -> int:
ct_root = os.open(f"/proc/{pid}/root", os.O_PATH | os.O_DIRECTORY | os.O_CLOEXEC)
try:
if libc.setns(ct_ns, CLONE_NEWNS) != 0:
fail("enter container namespace")
fail(translate("enter container namespace"))
os.fchdir(ct_root)
os.chroot(".")
os.chdir("/")
@@ -71,7 +96,7 @@ def main() -> int:
OPEN_TREE_CLONE | os.O_CLOEXEC,
)
if tree < 0:
fail("clone source mount tree")
fail(translate("clone source mount tree"))
try:
if mode == "ro":
attributes = MountAttr(attr_set=MOUNT_ATTR_RDONLY)
@@ -84,9 +109,9 @@ def main() -> int:
ctypes.sizeof(attributes),
)
if result != 0:
fail("make cloned mount tree read-only")
fail(translate("make cloned mount tree read-only"))
if libc.setns(host_ns, CLONE_NEWNS) != 0:
fail("return to host namespace")
fail(translate("return to host namespace"))
os.fchdir(host_root)
os.chroot(".")
os.chdir("/")
@@ -99,7 +124,7 @@ def main() -> int:
MOVE_MOUNT_F_EMPTY_PATH,
)
if result != 0:
fail("publish mount tree")
fail(translate("publish mount tree"))
finally:
os.close(tree)
finally:
+1 -1
View File
@@ -164,7 +164,7 @@ def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("archive", type=Path)
parser.add_argument("--extracted-mib", action="store_true",
help="print the estimated unpacked size in MiB (0 when unknown)")
help=translate("print the estimated unpacked size in MiB (0 when unknown)"))
args = parser.parse_args()
if args.extracted_mib:
try:
+38 -39
View File
@@ -15,7 +15,7 @@ from . import console, images
from .catalog import Catalog
from .converter import ConversionError
from .github_source import SourceError
from .i18n import N_, source_text, translate
from .i18n import N_, diagnostic, source_text, translate
from .installer import (
ADDRESS_SHOWN,
ADVANCED_MODE,
@@ -38,7 +38,7 @@ PUBLISHERS = {"linuxserver.io": "LinuxServer", "official": N_("Official image")}
STACK_LABELS = {
"server": N_("Server"),
"application": N_("Application"),
"machine_learning": "Machine learning",
"machine_learning": N_("Machine learning"),
"database": "PostgreSQL",
"valkey": "Valkey",
"cache": "Redis",
@@ -133,7 +133,7 @@ def _service_kind(service: dict[str, Any]) -> str:
if service.get("is_main"):
return translate("Application")
if "machine-learning" in name or "machine-learning" in image:
return "Machine learning"
return translate("Machine learning")
for key, label in SERVICE_KINDS:
if key in image:
return label
@@ -267,7 +267,7 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
target = (f"{mount['size_gb']} GB {on} {mount['source']}"
if mount["type"] == "managed-volume" else mount["source"])
lines.append(f" {mount['container_path']} → {target}")
for key, label in (("media", "Library"), ("application", "Application data"), ("transfer", "Consume/export")):
for key, label in (("media", N_("Library")), ("application", N_("Application data")), ("transfer", N_("Consume/export"))):
storage = plan.get(key)
if isinstance(storage, dict) and "mode" in storage:
if storage["mode"] == "host-bind":
@@ -472,7 +472,7 @@ def _rclone_mount(catalog: Catalog, ui) -> None:
if result:
from .management import carry_records
carry_records(PROJECT_ROOT, shown=True)
console.msg_ok(f"Remote: {result['remote']}:")
console.msg_ok(f"{translate('Rclone remote')}: {result['remote']}:")
console.msg_ok(f"{translate('Read/write')}: {result['read_write_path']}")
console.msg_ok(f"{translate('Read-only')}: {result['read_only_path']}")
console.wait_for_enter(translate("Press Enter to return to the menu..."))
@@ -614,7 +614,7 @@ def interactive(catalog: Catalog) -> int:
continue
except (ConversionError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}")
ui.message(f"{translate('The operation could not be completed')}:\n\n{diagnostic(exc)}")
# ---------------------------------------------------------------- maintenance CLI
@@ -623,52 +623,52 @@ def _template_summary_text(template: dict[str, Any]) -> str:
contract = template["container_contract"]
lines = [
source_text(template["catalog_ui"]["title"]),
f"Image: {contract['image']['reference']}",
f"Status: {template['status']}",
"Ports: " + (", ".join(f"{p['container_port']}/{p['protocol']}" for p in contract["ports"]) or "none"),
"Volumes: " + (", ".join(v["container_path"] for v in contract["volumes"]) or "none"),
f"{translate('Image')}: {contract['image']['reference']}",
f"{translate('Status')}: {template['status']}",
translate("Ports") + ": " + (", ".join(f"{p['container_port']}/{p['protocol']}" for p in contract["ports"]) or translate("none")),
translate("Volumes") + ": " + (", ".join(v["container_path"] for v in contract["volumes"]) or translate("none")),
]
blockers = template["compatibility"]["untranslated_blockers"]
if blockers:
lines.append(f"Blockers: {', '.join(blockers)}")
lines.append(f"{translate('Blockers')}: {', '.join(blockers)}")
return "\n".join(lines)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="oci_manager_apps.sh",
description="ProxMenux OCI manager Apps (Technology Preview)")
description=translate("ProxMenux OCI manager Apps (Technology Preview)"))
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("sync", help="Refresh the index from the image sources")
list_parser = subparsers.add_parser("list", help="List applications")
subparsers.add_parser("sync", help=translate("Refresh the index from the image sources"))
list_parser = subparsers.add_parser("list", help=translate("List applications"))
list_parser.add_argument("--filter", default="")
generate_parser = subparsers.add_parser("generate", help="Regenerate the template of one application")
generate_parser = subparsers.add_parser("generate", help=translate("Regenerate the template of one application"))
generate_parser.add_argument("app")
generate_all_parser = subparsers.add_parser("generate-all", help="Regenerate the catalog templates")
generate_all_parser = subparsers.add_parser("generate-all", help=translate("Regenerate the catalog templates"))
generate_all_parser.add_argument("--provider", choices=["all", "linuxserver.io", "imported", "curated"],
default="all")
subparsers.add_parser("apply-icons", help="Resolve catalog icons against the jsdelivr icon sets")
show_parser = subparsers.add_parser("show", help="Show the summary of a template")
subparsers.add_parser("apply-icons", help=translate("Resolve catalog icons against the jsdelivr icon sets"))
show_parser = subparsers.add_parser("show", help=translate("Show the summary of a template"))
show_parser.add_argument("app")
open_parser = subparsers.add_parser("open", help="Open the page of an application in the menu")
open_parser = subparsers.add_parser("open", help=translate("Open the page of an application in the menu"))
open_parser.add_argument("app")
install_parser = subparsers.add_parser("install", help="Configure and install an application")
install_parser = subparsers.add_parser("install", help=translate("Configure and install an application"))
install_parser.add_argument("app")
install_parser.add_argument("--host", default="auto", help="'auto'/'local', or root@IP for development")
install_parser.add_argument("--host", default="auto", help=translate("'auto'/'local', or root@IP for development"))
install_parser.add_argument("--advanced", action="store_true")
install_parser.add_argument("--dry-run", action="store_true")
rclone_parser = subparsers.add_parser("rclone-mount", help="Enable a mount on an installed Rclone OCI")
rclone_parser = subparsers.add_parser("rclone-mount", help=translate("Enable a mount on an installed Rclone OCI"))
rclone_parser.add_argument("--host", default="auto")
rclone_parser.add_argument("--dry-run", action="store_true")
subparsers.add_parser("recover", help="Register again the OCI applications restored from a backup")
manage_parser = subparsers.add_parser("manage", help="Update, modify or recreate one installed OCI instance")
subparsers.add_parser("recover", help=translate("Register again the OCI applications restored from a backup"))
manage_parser = subparsers.add_parser("manage", help=translate("Update, modify or recreate one installed OCI instance"))
manage_parser.add_argument("vmid", type=int)
manage_parser.add_argument("--action", choices=("update", "modify", "recreate"), required=True)
manage_parser.add_argument("--keep-backup", metavar="STORAGE",
help="Keep the backup taken before the update in this Proxmox storage")
help=translate("Keep the backup taken before the update in this Proxmox storage"))
manage_parser.add_argument("--unattended", action="store_true",
help="Scheduled run: no questions; stops where a person has to decide")
help=translate("Scheduled run: no questions; stops where a person has to decide"))
manage_parser.add_argument("--acknowledge-external-data", action="store_true",
help="Host directories are not reverted by the backup (confirmed beforehand)")
help=translate("Host directories are not reverted by the backup (confirmed beforehand)"))
manage_parser.add_argument("--min-image-age-days", type=int, default=0)
return parser
@@ -681,15 +681,15 @@ def main(argv: list[str] | None = None) -> int:
return interactive(catalog)
if args.command == "sync":
payload = catalog.sync_index()
print(f"Index updated: {len(payload['applications'])} candidate applications")
print(translate('Index updated: {count} candidate applications').format(count=len(payload['applications'])))
return 0
if args.command == "list":
query = args.filter.casefold()
for item in catalog.load_index()["applications"]:
if not item.get("hidden", False) and query in item["id"].casefold():
candidate = item.get("automatic_install_candidate")
status = ("installable" if candidate else "pending adaptation" if candidate is False
else item.get("template_status") or "not generated")
status = (translate("installable") if candidate else translate("pending adaptation") if candidate is False
else item.get("template_status") or translate("not generated"))
print(f"{item['id']:<30} {_display_architectures(item):<12} {publisher(item):<14} {status}")
return 0
if args.command == "generate":
@@ -702,13 +702,12 @@ def main(argv: list[str] | None = None) -> int:
print(f"\r[{current:3}/{total}] {marker:<5} {app_id:<32}", end="", flush=True)
report = catalog.generate_all(progress=progress, provider=args.provider)
print(f"\nGenerated: {report['generated_count']}; failed: {report['failed_count']}; "
f"family: {args.provider}")
print('\n' + translate('Generated: {generated}; failed: {failed}; family: {provider}').format(
generated=report['generated_count'], failed=report['failed_count'], provider=args.provider))
return 0 if not report["failed"] else 2
if args.command == "apply-icons":
report = catalog.apply_icons()
print(f"Icons resolved: {report['resolved']}; cleared: {report['cleared']}; "
f"unchanged: {report['unchanged']}; with a theme variant: {report['themed']}")
print(translate('Icons resolved: {resolved}; cleared: {cleared}; unchanged: {unchanged}; with a theme variant: {themed}').format(**report))
return 0
if args.command == "show":
print(_template_summary_text(catalog.compose(args.app)))
@@ -728,14 +727,14 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "install":
template = catalog.compose(args.app)
if not template["compatibility"]["automatic_install_candidate"]:
raise InstallError("Installation blocked: " + ", ".join(template["compatibility"]["untranslated_blockers"]))
raise InstallError(translate("Installation blocked:") + " " + ", ".join(template["compatibility"]["untranslated_blockers"]))
notice = external_services_notice(template)
if notice:
print(notice + "\n")
deployment = build_deployment(template, None, ADVANCED_MODE if args.advanced else DEFAULT_MODE)
print(_deployment_summary_text(template, deployment))
if not args.dry_run and input("\nInstall? [y/N]: ").strip().casefold() not in {"y", "yes"}:
print("Installation cancelled.")
if not args.dry_run and input('\n' + translate('Install? [y/N]:') + ' ').strip().casefold() not in {"y", "yes"}:
print(translate("Installation cancelled."))
return 0
result = run_remote_install(PROJECT_ROOT, template, deployment, args.host, args.dry_run)
if result:
@@ -764,7 +763,7 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps(deployment, ensure_ascii=False, indent=2))
result = run_remote_rclone_mount(PROJECT_ROOT, template, deployment, args.host, args.dry_run)
if result:
print(f"Read/write: {result['read_write_path']}\nRead-only: {result['read_only_path']}")
print(f"{translate('Read/write')}: {result['read_write_path']}\n{translate('Read-only')}: {result['read_only_path']}")
return 0
return 1
except (KeyboardInterrupt, UserCancelled):
@@ -773,7 +772,7 @@ def main(argv: list[str] | None = None) -> int:
return 130
except (ConversionError, SourceError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
print(f"ERROR: {exc}", file=sys.stderr)
print(f"ERROR: {diagnostic(exc)}", file=sys.stderr)
return 1
+52 -12
View File
@@ -21,7 +21,7 @@ from .cli import install_template
from .i18n import source_text
from .installer import ADVANCED_MODE, DEFAULT_MODE
from .converter import ConversionError
from .i18n import translate
from .i18n import N_, translate
from .registry_auth import RegistryError, refuses_access, validate_image
from .ui import UserCancelled
@@ -533,16 +533,16 @@ def read_definition(ui) -> tuple[str, str]:
BLOCKER_TEXTS = {
'multi-service-compose': 'it describes more than one image',
'native-multi-lxc-orchestrator-not-yet-implemented': 'it describes more than one image',
'top-level-configs': 'it uses Compose configs, which have no equivalent here',
'devices-format': 'the devices it asks for are not written in a way that can be read',
'healthcheck-format': 'its health check is not written in a way that can be read',
'stop-grace-period-format': 'its stop timeout is not written in a way that can be read',
'shm-size-format': 'its shared memory size is not written in a way that can be read',
'ulimits-format-or-resource': 'the resource limits it asks for cannot be applied',
'mem-limit-format-or-below-proxmox-minimum': 'the memory limit it asks for cannot be applied',
'mem-limit-deploy-consistency-review': 'it asks for two different memory limits',
'multi-service-compose': N_('it describes more than one image'),
'native-multi-lxc-orchestrator-not-yet-implemented': N_('it describes more than one image'),
'top-level-configs': N_('it uses Compose configs, which have no equivalent here'),
'devices-format': N_('the devices it asks for are not written in a way that can be read'),
'healthcheck-format': N_('its health check is not written in a way that can be read'),
'stop-grace-period-format': N_('its stop timeout is not written in a way that can be read'),
'shm-size-format': N_('its shared memory size is not written in a way that can be read'),
'ulimits-format-or-resource': N_('the resource limits it asks for cannot be applied'),
'mem-limit-format-or-below-proxmox-minimum': N_('the memory limit it asks for cannot be applied'),
'mem-limit-deploy-consistency-review': N_('it asks for two different memory limits'),
}
@@ -730,7 +730,7 @@ def _name(ui, template: dict[str, Any]) -> str:
return name
def explore(ui) -> None:
def _explore(ui) -> None:
"""Reads a Compose file, a docker run command, an image reference or an
oci-compose, reports what ProxMenux would install from it and installs it."""
text, origin = read_definition(ui)
@@ -784,3 +784,43 @@ def explore(ui) -> None:
else:
name = _name(ui, template)
install_template(ui, template, name, mode)
def conversion_message(error: ConversionError) -> str:
"""Render legacy converter diagnostics at the custom-image UI boundary.
Converter exceptions are also consumed by catalog tools. Keep their
original text, and any diagnostic data, outside this localized UI.
"""
text = str(error)
exact = {
'environment must be a list or a mapping': N_('Compose environment must be a list or a mapping.'),
'volumes must be a list': N_('Compose volumes must be a list.'),
'ports must be a list': N_('Compose ports must be a list.'),
'The CasaOS Compose file has no services': N_('The Compose file has no services.'),
'The CasaOS document is not a Compose mapping': N_('The Compose document must be a mapping.'),
'The Compose file has no x-casaos metadata': N_('The Compose file has no x-casaos metadata.'),
'x-casaos.main no identifica un servicio valido': N_('x-casaos.main must name an existing service.'),
'The main CasaOS service declares no image': N_('The main Compose service has no image.'),
}
prefixes = {
'Ruta de volumen no valida:': N_('Invalid Compose volume path:'),
'Puerto no valido:': N_('Invalid Compose port:'),
'Cannot normalise the CasaOS identifier:': N_('Invalid application identifier:'),
'Docker Compose CasaOS no valido:': N_('The Compose file is not valid YAML:'),
}
if text in exact:
translated = translate(exact[text])
return text if translated == exact[text] else translated
for prefix, source in prefixes.items():
if text.startswith(prefix):
translated = translate(source)
return text if translated == source else translated + text[len(prefix):]
return text
def explore(ui) -> None:
try:
_explore(ui)
except ConversionError as error:
raise ConversionError(conversion_message(error)) from error
+63
View File
@@ -7,6 +7,7 @@ is the source language: a missing translation returns the English text.
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import Any
@@ -46,6 +47,68 @@ def translate(text: str) -> str:
return _cache.get(text) or text
def diagnostic(error: Any) -> str:
"""Translate legacy diagnostics only when presenting them to a person.
The catalog's exception types and original messages remain stable for
internal callers; identifiers are inserted after the translation lookup.
"""
text = str(error)
for pattern, source in (
(r"No template is available for '(.*)'", N_("No template is available for '{app_id}'")),
(r"The application '(.*)' is not in the index", N_("The application '{app_id}' is not in the index")),
(r"The application '(.*)' does not come from LinuxServer", N_("The application '{app_id}' does not come from LinuxServer")),
):
match = re.fullmatch(pattern, text)
if match:
return translate(source).format(app_id=match[1])
exact = {
'The CasaOS Compose file has no services': N_('The Compose file has no services.'),
'The Compose file has no services': N_('The Compose file has no services.'),
'x-casaos.main no identifica un servicio valido': N_('x-casaos.main must name an existing service.'),
'The main CasaOS service declares no image': N_('The main Compose service has no image.'),
'El servicio principal no declara una imagen': N_('The main Compose service has no image.'),
'El servicio no declara una imagen': N_('The service declares no image:'),
'The CasaOS document is not a Compose mapping': N_('The Compose document must be a mapping.'),
'The Compose file has no x-casaos metadata': N_('The Compose file has no x-casaos metadata.'),
'environment must be a list or a mapping': N_('Compose environment must be a list or a mapping.'),
'volumes must be a list': N_('Compose volumes must be a list.'),
'ports must be a list': N_('Compose ports must be a list.'),
'The README has no recognisable docker-compose section': N_('The README has no recognisable docker-compose section'),
'La seccion docker-compose no contiene un bloque de codigo': N_('The docker-compose section contains no code block'),
'The docker-compose block is not closed': N_('The docker-compose block is not closed'),
'The block found does not look like a Docker Compose file': N_('The block found does not look like a Docker Compose file'),
'No se puede identificar la imagen principal del Compose': N_('The main Compose service cannot be identified unambiguously'),
'No se puede identificar de forma univoca el servicio principal del Compose': N_('The main Compose service cannot be identified unambiguously'),
'python3-jsonschema is required to generate templates': N_('python3-jsonschema is required to generate templates'),
'Bulk generation requires GITHUB_TOKEN to obtain an immutable revision per application': N_('Bulk generation requires GITHUB_TOKEN to obtain an immutable revision per application'),
'The ProxMenux application catalogue is not a list': N_('The ProxMenux application catalogue is not a list'),
'GitHub returned a truncated tree for the CasaOS catalogue': N_('GitHub returned a truncated tree for the CasaOS catalogue'),
'No Compose files were found in the CasaOS catalogue': N_('No Compose files were found in the CasaOS catalogue'),
}
if text in exact:
return translate(exact[text])
for prefix, source in (
('Cannot normalise the CasaOS identifier:', N_('Invalid application identifier:')),
('Cannot normalise the identifier:', N_('Invalid application identifier:')),
('Docker Compose CasaOS no valido:', N_('The Compose file is not valid YAML:')),
('Docker Compose no valido:', N_('The Compose file is not valid YAML:')),
('La plantilla generada no cumple el esquema:', N_('The generated template does not match the schema:')),
):
if text.startswith(prefix):
return translate(source) + text[len(prefix):]
match = re.fullmatch(r'GitHub returned HTTP 403 for (.+)\. Set GITHUB_TOKEN to raise the API rate limit\.', text)
if match:
return translate('GitHub returned HTTP 403 for {url}. Set GITHUB_TOKEN to raise the API rate limit.').format(url=match[1])
match = re.fullmatch(r'GitHub returned HTTP ([0-9]{3}) (?:for|para) (.+)\.', text)
if match:
return translate('GitHub returned HTTP {status} for {url}.').format(status=match[1], url=match[2])
match = re.fullmatch(r'No se pudo acceder a (.+) despues de 3 intentos: (.*)', text, re.S)
if match:
return translate('Could not reach {url} after 3 attempts: {reason}').format(url=match[1], reason=match[2])
return text
def source_text(value: Any) -> str:
"""The English a catalog field was written in.
+33 -14
View File
@@ -19,6 +19,16 @@ STATUS_LABELS = {'installed': N_('installed'), 'failed': N_('failed'), 'updating
'assembling': N_('assembling')}
class CancelledOperation:
"""An explicit user decline; remains false for menu and batch callers."""
def __bool__(self):
return False
CANCELLED = CancelledOperation()
def public_row(record, decision):
return {'vmid': record['vmid'], 'hostname': record.get('deployment', {}).get('hostname',
f'OCI {record["vmid"]}'),
@@ -219,7 +229,7 @@ def offer_recovery(project, ui):
f"{translate('Until they are registered again they cannot be updated or managed from here. The recovery checks first what prevents their registration; an application with such a problem is not registered. A Rclone mount that has to be enabled again does not prevent it, but then the applications are not started automatically.')}"
f"\n\n{translate('Recover them now?')}")
if not ui.confirm(text, default=True):
return
return CANCELLED
command = [sys.executable, str(project / 'remote/oci_restore_recovery.py'), 'recover']
for rule in _restored_firewall_rules(project):
if ui.confirm(translate('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.').format(
@@ -242,7 +252,9 @@ def direct_recovery(project):
if not restored_applications(project):
ui.message(translate('No restored OCI application is waiting to be recovered.'), translate('Restored OCI applications'))
return EXIT_DONE
offer_recovery(project, ui)
completed = offer_recovery(project, ui)
if completed is CANCELLED:
return EXIT_CANCELLED
return EXIT_DONE if not restored_applications(project) else EXIT_FAILED
@@ -458,12 +470,14 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=(), heading=No
proposal = None
if action == 'modify':
from .recreation import edit_recreation
proposal = edit_recreation(record, ui)
proposal = edit_recreation(record, ui, cancelled_result=CANCELLED)
if proposal is CANCELLED:
return CANCELLED
if proposal is None:
return False
elif not ui.review(translate('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If the update fails after the container was changed, the backup is restored. An update that stops halfway is recovered from the OCI management menu.'),
elif not ui.review(translate('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.'),
translate('Update OCI'), question=translate('Update now?'), default=True):
return False
return CANCELLED
# Opening/cancelling a confirmation needs no disk or storage probes.
# Both accepted operations retain this check before the backend starts.
if not _work_backup_ready(project, ui, row['vmid']):
@@ -498,11 +512,11 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=(), heading=No
action = ui.choose(translate('Interrupted operation'), [('status', translate('View status')),
('recover', translate('Recover the previous installation'))], 'status')
if action is None:
return False
return CANCELLED
if action == 'recover' and not ui.review(
translate('If the container had already been changed, its previous native backup is restored. Shared host directories are not reverted. The disks of the failed attempt are removed when the cleanup succeeds.'),
translate('Recover OCI'), question=translate('Recover now?'), default=True):
return False
return CANCELLED
return _run_lifecycle([sys.executable, str(project / 'remote/oci_instance_transaction.py'),
action, str(row['vmid'])],
translate('Recover OCI') if action == 'recover' else translate('OCI management'))
@@ -552,13 +566,15 @@ def _removal_summary(project, vmid):
bridge = oci_remove.private_bridge(primary)
text = []
if len(members) > 1 and vmid == primary_id:
text += [f"{application} {translate('runs in')} {len(members)} {translate('containers')}. "
f"{translate('All of them are targeted for removal.')}", '']
sentence = translate('{application} runs in {count} containers.').format(
application=application, count=len(members))
text += [f"{sentence} {translate('All of them are targeted for removal.')}", '']
elif len(members) > 1:
alone = translate('It cannot be removed on its own, because the application would stop '
'working: continuing targets the whole application for removal.')
text += [f"CT {vmid} {translate('is one of the')} {len(members)} "
f"{translate('containers of')} {application}. {alone}", '']
sentence = translate('CT {vmid} is one of the {count} containers of {application}.').format(
vmid=vmid, count=len(members), application=application)
text += [f"{sentence} {alone}", '']
text += [translate('Containers targeted for removal:'), *lines, '',
translate('Container data targeted for deletion:'), *volumes]
if bridge and not oci_remove.bridge_in_use(bridge, set(members)):
@@ -642,7 +658,7 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
'the OCI management menu.')
.format(count=len(members), vmid=primary_id), translate('Recreate OCI stack'),
question=translate('Recreate the whole stack?'), default=False):
return False
return CANCELLED
else:
if not ui.review(translate('All {count} containers of the application are updated together (main CT: {vmid}). '
'If there are new versions, all images are downloaded and verified, the application '
@@ -650,11 +666,11 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
'fails after a container was changed, the backups are restored. An operation that stops '
'halfway is recovered from the OCI management menu.').format(count=len(members), vmid=primary_id),
translate('Update OCI stack'), question=translate('Update the whole stack?'), default=True):
return False
return CANCELLED
else:
if not ui.review(translate('A coordinated operation has a saved journal. Continuing attempts to recover the previous stack where needed, or finish cleanup for a completed operation. Recovery or cleanup can fail.'), translate('Recover OCI stack'),
question=translate('Recover or complete the operation?'), default=True):
return False
return CANCELLED
import json
members = json.loads(Path(pending).read_text())['plan']['members']
if not pending and not _work_backup_ready(project, ui, primary_id):
@@ -707,6 +723,7 @@ class UnattendedUI:
# Exit codes of `manage`, read by ProxMenux Monitor.
EXIT_DONE, EXIT_FAILED, EXIT_NOT_OCI, EXIT_BUSY, EXIT_NEEDS_REVIEW, EXIT_TOO_RECENT = 0, 1, 2, 3, 4, 5
EXIT_CANCELLED = 6 # Explicit interactive cancellation; never a successful update.
def _image_too_recent(project, vmid, min_age_days):
@@ -765,4 +782,6 @@ def direct_management(project, vmid, action, lifecycle_args=(), unattended=False
return EXIT_BUSY
if unattended and ui.declined:
return EXIT_NEEDS_REVIEW
if completed is CANCELLED and not unattended:
return EXIT_CANCELLED
return EXIT_DONE if completed else EXIT_FAILED
+29 -5
View File
@@ -3,7 +3,7 @@ import copy
import re
from pathlib import Path, PurePosixPath
from .i18n import translate
from .i18n import N_, translate
from .ui import BacktrackUI, RestartWizard, UserCancelled
@@ -212,8 +212,8 @@ def edit_section(candidate, ui, setting):
wizard.close()
def edit_recreation(record, ui):
"""Keep a draft across independently editable settings; apply it once."""
def edit_recreation(record, ui, *, cancelled_result=None):
"""Keep a draft; optionally distinguish cancellation from an unchanged review."""
from .cli import _deployment_summary_text
candidate = copy.deepcopy(record)
selected = 'cores'
@@ -227,7 +227,7 @@ def edit_recreation(record, ui):
title=translate('Modify OCI'), show_tags=False)
if selected is None or selected == 'discard':
if not pending or ui.confirm(translate('Discard unapplied changes?'), False):
return None
return cancelled_result
selected = 'apply'
elif selected == 'apply':
if not pending:
@@ -243,8 +243,29 @@ def edit_recreation(record, ui):
candidate = edit_section(candidate, ui, selected)
def _catalog_message(error):
"""Localize legacy catalog errors here, preserving the catalog API."""
text = str(error)
patterns = (
(r"No template is available for '(.*)'", N_("No template is available for '{app_id}'")),
(r"The application '(.*)' is not in the index", N_("The application '{app_id}' is not in the index")),
)
for pattern, source in patterns:
match = re.fullmatch(pattern, text)
if match:
translated = translate(source)
return text if translated == source else translated.format(app_id=match[1])
prefix = 'La plantilla generada no cumple el esquema:'
if text.startswith(prefix):
source = N_('The generated template does not match the schema:')
translated = translate(source)
return text if translated == source else translated + text[len(prefix):]
return text
def refresh_template(candidate, ui):
from .catalog import Catalog
from .converter import ConversionError
old = candidate.get('template', {})
template_id = old.get('id', '')
if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', template_id):
@@ -258,7 +279,10 @@ def refresh_template(candidate, ui):
if len(matching) != 1:
return
name = matching[0]['id']
latest = catalog.compose(name)
try:
latest = catalog.compose(name)
except ConversionError as error:
raise ConversionError(_catalog_message(error)) from error
if latest == old:
return
if not ui.confirm(translate('Apply options from the current catalog template? New required paths and settings may be requested. Review the resulting configuration before recreating the CT.'), True):
+3 -3
View File
@@ -365,13 +365,13 @@ def build_stack(template, ui, mode='advanced'):
service_name = group['service']
if service_name not in envs:
raise StackError(f"{translate('Unknown credential service:')} {service_name}")
if not quiet.confirm(f"{translate('Configure')} {group['label']} ({translate('optional')})", False):
if not quiet.confirm(f"{translate('Configure')} {translate(group['label'])} ({translate('optional')})", False):
continue
for field in group['fields']:
if field['name'] not in envs[service_name] or envs[service_name][field['name']] != '':
raise StackError(f"{translate('External field not reserved:')} {field['name']}")
value = (ui.password(field['label'], required=True) if field.get('sensitive',True)
else ui.ask(field['label'], required=True))
value = (ui.password(translate(field['label']), required=True) if field.get('sensitive',True)
else ui.ask(translate(field['label']), required=True))
if not value or any(c in value for c in '\r\n'):
raise StackError(translate('External credential is empty or spans multiple lines'))
envs[service_name][field['name']] = value
+13 -1
View File
@@ -3,6 +3,7 @@ transcoding and its recognition: the CPU, or each usable GPU of the host for
both or for only one of them."""
from pathlib import Path
import json
import sys
import unittest
from unittest.mock import patch
@@ -165,7 +166,18 @@ class ImmichAccelerationTests(unittest.TestCase):
def test_the_name_of_the_machine_learning_container_is_not_translated(self, *_):
script = (ROOT / "remote/install_immich_stack.sh").read_text()
self.assertNotIn('translate "Machine learning"', script)
self.assertNotIn('translate("Machine learning")', (ROOT / "src/proxmenux_oci/cli.py").read_text())
from proxmenux_oci.cli import _service_kind
from proxmenux_oci import i18n
service = {'name': 'immich-machine-learning', 'image': 'ghcr.io/immich-app/immich-machine-learning:release'}
original = dict(service)
with patch.object(i18n, 'language', return_value='sk'), patch.object(i18n, '_cache', json.loads((ROOT.parent / 'lang/sk.json').read_text())):
self.assertEqual(_service_kind(service), 'Strojové učenie')
_, _, plan = self.plan(NOTHING, DEFAULT_MODE)
# Spanish keeps the English name of the service.
with patch.object(i18n, 'language', return_value='es'), patch.object(i18n, '_cache', json.loads((ROOT.parent / 'lang/es.json').read_text())):
self.assertEqual(_service_kind(service), 'Machine learning')
self.assertIn('machine_learning', plan)
self.assertEqual(service, original)
if __name__ == "__main__":
+1 -1
View File
@@ -185,7 +185,7 @@ class ModifyDispatchTests(unittest.TestCase):
patch.object(management, '_run_lifecycle', side_effect=lifecycle):
return management.manage_instance(ROOT, ui or Mock(), row, action='modify')
def test_cancel_does_not_start_a_lifecycle_transaction(self):
def test_legacy_none_result_does_not_start_a_lifecycle_transaction(self):
lifecycle = Mock()
self.assertFalse(self.call_manager(None, lifecycle))
lifecycle.assert_not_called()
+216
View File
@@ -0,0 +1,216 @@
"""Single-container editor wording, real helper paths and no-apply boundaries."""
import copy
import json
from pathlib import Path
import subprocess
import unittest
from unittest.mock import patch
from test_modify_menu import MenuUI, record
from proxmenux_oci import i18n, installer, management, recreation
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.cli import _deployment_summary_text
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.ui import UserCancelled
ROOT = Path(__file__).resolve().parents[2]
class EditorUI(MenuUI):
def info(self, text):
self.events.append(('info', text))
def password(self, text, **kwargs):
self.events.append(('password', text))
return next(self.answers)
class SlovakEditorTests(unittest.TestCase):
def setUp(self):
translations = json.loads((ROOT / 'lang/sk.json').read_text())
for mock in (patch.object(i18n, '_language', 'sk'),
patch.object(i18n, '_cache', translations)):
mock.start()
self.addCleanup(mock.stop)
self.forbidden = []
# The actual editor/helpers run, but any external action fails the test.
for target, name in ((management, '_run_lifecycle'), (subprocess, 'run'),
(subprocess, 'Popen'), (subprocess, 'check_output'),
(Path, 'write_text'), (Path, 'write_bytes')):
mock = patch.object(target, name, side_effect=AssertionError('editor performed an external action'))
self.forbidden.append(mock.start())
self.addCleanup(mock.stop)
self.original = record()
self.saved = copy.deepcopy(self.original)
def tearDown(self):
self.assertEqual(self.original, self.saved)
for mock in self.forbidden:
mock.assert_not_called()
def text(self, ui):
return '\n'.join(str(e[1:]) for e in ui.events)
def test_cpu_minimum_then_invalid_number_can_be_corrected_and_discarded(self):
ui = EditorUI(['cores', 'discard'], ['0', 'two', '3'], confirms=[True])
self.assertIsNone(recreation.edit_recreation(self.original, ui))
text = self.text(ui)
self.assertIn('Počet jadier CPU', text)
self.assertIn('Počet jadier: minimum 1', text)
self.assertIn('Hodnota musí byť číslo:', text)
self.assertIn('Zadajte hodnotu znova.', text)
self.assertIn('Zahodiť nepoužité zmeny?', text)
self.assertNotIn('private-test-secret', text)
def test_ram_boundary_and_declined_review_do_not_apply_a_proposal(self):
ui = EditorUI(['memory', 'apply', 'discard'], ['127', '128'],
reviews=[False], confirms=[True])
self.assertIsNone(recreation.edit_recreation(self.original, ui))
self.assertIn('RAM v MiB: minimum 128', self.text(ui))
summary = next(e[1] for e in ui.events if e[0] == 'review')
self.assertIn('128 MB RAM', summary)
self.assertIn('rootfs 8 GB na local-zfs', summary)
self.assertIn('vyrovnávacia pamäť obrazov na', summary)
self.assertIn('neprivilegovaný LXC', summary)
self.assertNotIn('private-test-secret', summary)
def test_no_change_and_cancelled_section_keep_the_existing_record(self):
for choices, answers in ((['apply'], []), (['cores', 'discard'], [UserCancelled()])):
with self.subTest(choices=choices):
ui = EditorUI(choices, answers)
self.assertIsNone(recreation.edit_recreation(self.original, ui))
self.assertFalse(any(e[0] == 'review' for e in ui.events))
def test_environment_duplicate_is_an_error_not_an_instruction_to_duplicate(self):
self.original['deployment']['environment'] *= 2
self.saved = copy.deepcopy(self.original)
ui = EditorUI([], ['PASSWORD', UserCancelled()], confirms=[True])
result = recreation.edit_section(self.original, ui, 'environment')
self.assertIs(result, self.original)
self.assertIn('Premenná je v definícii kontajnera uvedená viackrát; pred úpravou ju skontrolujte', self.text(ui))
def test_invalid_variable_name_and_value_are_rejected_before_draft_is_returned(self):
for name, value, expected in (('INVALID NAME', None, 'Neplatný názov premennej'),
('VISIBLE', 'bad\x00value', 'Hodnota obsahuje nepodporovaný znak')):
with self.subTest(name=name):
answers = [name] + ([value] if value is not None else []) + [UserCancelled()]
ui = EditorUI([], answers, confirms=[True, False])
self.assertIs(recreation.edit_section(self.original, ui, 'environment'), self.original)
self.assertIn(expected, self.text(ui))
def test_new_secret_prompt_and_summary_do_not_expose_the_value(self):
candidate = copy.deepcopy(self.original)
ui = EditorUI([], ['NEW_SECRET', 'fixture-new-secret'], confirms=[True, True, False])
recreation.edit_setting(candidate, ui, 'environment')
self.assertIn(('password', 'Nová hodnota pre NEW_SECRET'), ui.events)
self.assertNotIn('fixture-new-secret', self.text(ui))
self.assertNotIn('fixture-new-secret', _deployment_summary_text(candidate['template'], candidate['deployment']))
def test_storage_picker_reports_free_space_not_customs_duty(self):
ui = EditorUI(['local-zfs'])
rows = [{'storage': name, 'type': 'zfspool', 'avail': 8 * 1024**3}
for name in ('local-zfs', 'other')]
with patch.object(installer.host, 'storages', return_value=rows):
self.assertEqual(installer.ask_storage(ui, 'Úložisko', 'rootdir', 'local-zfs'), 'local-zfs')
self.assertIn('8 GB voľné', self.text(ui))
def test_shared_host_path_prompt_changes_only_the_draft(self):
candidate = copy.deepcopy(self.original)
candidate['template']['proxmox']['installer_profile']['shared_directories'] = {
'container_root': '/shares'}
ui = EditorUI(['host-bind'], ['media', '/fixture/nonexistent'], confirms=[True, False])
with patch.object(installer.os.path, 'isdir', return_value=False):
recreation.edit_setting(candidate, ui, 'shares')
self.assertIn('Cesta na hostiteľovi pre /shares/media', self.text(ui))
self.assertEqual(candidate['deployment']['mounts'][-1]['source'], '/fixture/nonexistent')
def test_extra_path_cannot_cover_system_directories(self):
ui = EditorUI(['host-bind'], ['/etc', '/fixture/host', UserCancelled()],
confirms=[True, False])
self.assertIs(recreation.edit_section(self.original, ui, 'paths'), self.original)
self.assertIn('Vlastná cesta nemôže skryť systémové adresáre', self.text(ui))
def test_network_gateway_explains_outbound_route(self):
ui = EditorUI(['static'], ['vmbr0', '192.0.2.42/24', ''], confirms=[])
with patch.object(installer.host, 'bridges', return_value=[]), \
patch.object(installer.host, 'address_answers', return_value=False):
recreation.edit_setting(copy.deepcopy(self.original), ui, 'network')
self.assertIn('Brána IPv4 (prázdne = bez odchádzajúcej trasy)', self.text(ui))
def test_incompatible_docker_mods_are_localized_without_gpu_or_image_probe(self):
self.original['template']['proxmox']['installer_profile']['hardware_acceleration'] = {
'profiles': [{'id': 'gpu', 'environment': [{'name': 'DOCKER_MODS', 'value': 'mod'}]}]}
self.saved = copy.deepcopy(self.original)
ui = EditorUI([])
self.assertIs(recreation.edit_section(self.original, ui, 'acceleration'), self.original)
self.assertIn('Docker Mods sú dostupné iba pre kompatibilné obrazy LinuxServer', self.text(ui))
def test_duplicate_usb_device_is_rejected_without_attaching_it(self):
self.original['deployment']['devices'] = [{'host_path': '/dev/ttyACM0'}]
self.saved = copy.deepcopy(self.original)
ui = EditorUI(['usb'], ['/dev/ttyACM0', UserCancelled()], confirms=[True])
with patch.object(installer.host, 'usb_devices', return_value=[]):
self.assertIs(recreation.edit_section(self.original, ui, 'devices'), self.original)
self.assertIn('Toto zariadenie je už pripojené', self.text(ui))
def catalog_refresh(self, error=None, latest=None):
candidate = copy.deepcopy(self.original)
candidate['template']['container_contract']['image']['repository'] = 'example/app'
ui = EditorUI(['managed-volume'], ['local-zfs', '4'], confirms=[True])
index = {'applications': [{'id': 'example', 'template_id': 'example-app'}]}
with patch.object(Catalog, 'load_index', return_value=index), \
patch.object(Catalog, 'compose', side_effect=error, return_value=latest):
recreation.refresh_template(candidate, ui)
return candidate, ui
def test_required_catalog_path_has_a_clear_persistence_prompt(self):
latest = copy.deepcopy(self.original['template'])
latest['container_contract']['image']['repository'] = 'example/app'
latest['container_contract']['volumes'] = [{'id': 'new', 'container_path': '/new-data'}]
candidate, ui = self.catalog_refresh(latest=latest)
self.assertIn('Aktuálna šablóna vyžaduje nové trvalé úložisko pre cestu: /new-data', self.text(ui))
self.assertIn('Nové úložisko bude prázdne; existujúce údaje sa doň automaticky neprenesú.', self.text(ui))
self.assertIn('Trvalé úložisko pre /new-data', self.text(ui))
self.assertEqual(candidate['deployment']['mounts'][-1]['container_path'], '/new-data')
def test_template_identity_change_is_a_localized_migration_error(self):
latest = copy.deepcopy(self.original['template'])
latest['container_contract']['image']['repository'] = 'different/app'
with self.assertRaisesRegex(ValueError, 'Aktuálna šablóna mení identitu šablóny alebo repozitár obrazu; je potrebná výslovná migrácia'):
self.catalog_refresh(latest=latest)
def test_real_catalog_missing_file_error_is_localized_only_in_editor(self):
index = {'applications': [{'id': 'example', 'template_id': 'example-app'}]}
item = dict(index['applications'][0], template_family='curated-profile', curated_path='absent-template.json')
with patch.object(Catalog, 'load_index', return_value=index), patch.object(Catalog, 'find_item', return_value=item):
with self.assertRaisesRegex(ConversionError, "No template is available for 'example'"):
Catalog(ROOT / 'oci').compose('example')
with self.assertRaisesRegex(ConversionError, "Pre aplikáciu 'example' nie je dostupná šablóna"):
recreation.refresh_template(copy.deepcopy(self.original), EditorUI([]))
def test_real_schema_diagnostic_preserves_field_paths_and_exception_type(self):
with self.assertRaises(ConversionError) as raw:
Catalog(ROOT / 'oci').validate({})
self.assertTrue(str(raw.exception).startswith('La plantilla generada no cumple el esquema:'))
details = str(raw.exception).split(':', 1)[1]
with self.assertRaises(ConversionError) as localized:
self.catalog_refresh(error=raw.exception)
self.assertEqual(str(localized.exception), 'Vygenerovaná šablóna nezodpovedá schéme:' + details)
def test_missing_index_entry_preserves_the_application_identifier(self):
with self.assertRaisesRegex(ConversionError, "Aplikácia 'example' nie je v indexe"):
self.catalog_refresh(error=ConversionError("The application 'example' is not in the index"))
def test_english_missing_translation_and_unknown_diagnostics_are_preserved(self):
for message in ("No template is available for 'example'", "The application 'example' is not in the index",
'La plantilla generada no cumple el esquema: field: required', 'unknown technical failure'):
for language, cache in (('en', {}), ('sk', {})):
with self.subTest(message=message, language=language), \
patch.object(i18n, '_language', language), patch.object(i18n, '_cache', cache):
with self.assertRaises(ConversionError) as error:
self.catalog_refresh(error=ConversionError(message))
self.assertEqual(str(error.exception), message)
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -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"
}
}
+49 -11
View File
@@ -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
+2 -2
View File
@@ -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
+5 -5
View File
@@ -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"
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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,132 @@
// Real transport templates and actual fetchApi, with no network/guest access.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const {execFileSync} = require('node:child_process')
const root = path.resolve(__dirname, '../..')
const app = path.join(root, 'AppImage')
const ts = require(path.join(app, 'node_modules/typescript'))
function load(relative, extra = {}) {
const compiled = ts.transpileModule(fs.readFileSync(path.join(app, relative), 'utf8'), {
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020}, reportDiagnostics: true,
})
assert.deepEqual(compiled.diagnostics, [])
const context = {exports: {}, process: {env: {}}, console: {error() {}}, ...extra}
vm.runInNewContext(compiled.outputText, context)
return context.exports
}
const {localizeAppEditorError: localize} = load('lib/lxc-app-editor-errors.ts')
const catalogs = Object.fromEntries(['en', 'sk', 'de'].map(locale => [locale,
JSON.parse(fs.readFileSync(path.join(app, `messages/${locale}/common.json`)))]))
const translate = locale => (key, params = {}) => {
const lookup = data => key.split('.').reduce((item, segment) => item?.[segment], data)
const template = lookup(catalogs[locale]) ?? lookup(catalogs.en)
assert.equal(typeof template, 'string', 'Missing translation ' + key)
return template.replace(/\{(\w+)\}/g, (all, key) => params[key] ?? all)
}
// A locale without these keys must show the English backend text.
catalogs.missing = {}
const en = translate('en'), sk = translate('sk')
// Enumerate only pure validator ASTs; importing lxc_apps would access host state.
const contract = JSON.parse(execFileSync('python3', ['-c', String.raw`
import ast,json,re,typing,sys
tree=ast.parse(open(sys.argv[1]).read())
names={'_err','_validate_command_argv','_validate_binary_args','_validate_ports','validate_config'}
ns={'re':re,'Any':typing.Any,'Optional':typing.Optional}
for node in tree.body:
if isinstance(node,ast.Assign) and len(node.targets)==1 and isinstance(node.targets[0],ast.Name) and node.targets[0].id.startswith('_'):
try: value=ast.literal_eval(node.value)
except (ValueError,TypeError):
if not (isinstance(node.value,ast.Call) and isinstance(node.value.func,ast.Attribute) and isinstance(node.value.func.value,ast.Name) and node.value.func.value.id=='re' and node.value.func.attr=='compile'): continue
args=[getattr(re,a.attr) if isinstance(a,ast.Attribute) and isinstance(a.value,ast.Name) and a.value.id=='re' else ast.literal_eval(a) for a in node.value.args]
value=re.compile(*args)
ns[node.targets[0].id]=value
functions=[n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name in names]
exec(compile(ast.Module(body=functions,type_ignores=[]),'<pure validators>','exec'),ns)
messages=set()
context={**ns,'i':3,'p':8080,'field':'repo','e':'missing ], position 4\npattern: v?(test'}
for function in functions:
for n in ast.walk(function):
if isinstance(n,ast.Call) and isinstance(n.func,ast.Name) and n.func.id=='_err' and n.args and isinstance(n.args[0],(ast.Constant,ast.JoinedStr)):
messages.add(eval(compile(ast.Expression(n.args[0]),'<contract>','eval'),context))
cases=[[],{}, {'name':'Test','installed_via':'file','file_path':'relative'},
{'name':'Test','installed_via':'file','file_path':'/fiction/version','file_regex':'['},
{'name':'Test','installed_via':'command','command_argv':['test','bad\narg']},
{'name':'Test','ports':[{'port':8080},{'port':8080}]},
{'name':'Test','installed_via':'manual'}]
errors=[]
for case in cases:
ok,error=ns['validate_config'](case)
assert not ok
errors.append(error)
print(json.dumps({'templates':sorted(messages),'actual':errors}))
`, path.join(app, 'scripts/lxc_apps.py')], {encoding: 'utf8'}))
assert.equal(contract.templates.length, 69, 'Update the contract inventory when validation changes')
for (const message of [...contract.templates, ...contract.actual]) {
assert.equal(localize(message, en), message, 'EN must preserve the exact backend message')
assert.notEqual(localize(message, sk), message, 'Untranslated backend validation: ' + message)
assert.equal(localize(message, translate('missing')), message, 'Missing locale falls back to EN')
}
// Every supported message, including detector errors, preserves interpolated data.
const enErrors = catalogs.en.vmLxc.appEditor.errors, skErrors = catalogs.sk.vmLxc.appEditor.errors
assert.deepEqual(Object.keys(enErrors).sort(), Object.keys(skErrors).sort())
for (const [key, template] of Object.entries(enErrors)) {
const params = Object.fromEntries([...template.matchAll(/\{(\w+)\}/g)].map(([, name]) => [name,
['index','port','max','code'].includes(name) ? '17' : name === 'repo' ? 'owner/project' : "detail /opt/app --version {literal} <tag>\nnext line"]))
const message = en(`vmLxc.appEditor.errors.${key}`, params)
assert.equal(localize(message, en), message)
assert.equal(localize(message, sk), sk(`vmLxc.appEditor.errors.${key}`, params), key)
}
assert.equal(localize('binary_args[4] exceeds 256 chars', sk), 'Argument binary_args[4] môže mať najviac 256 znakov.')
assert.equal(localize('tag_regex is not a valid regex: [bad]\nposition 4', sk), 'Regulárny výraz pre tagy (tag_regex) je neplatný: [bad]\nposition 4')
for (const message of ['unknown /opt/app --version', 'myapp: not found', 'sh: myapp: not found', '<script>alert(1)</script>', 'file_regex is required extra', 'prefix file_regex is required', 'binary_args[-1] exceeds 4 chars']) {
assert.equal(localize(message, sk), message, 'Unknown detail must remain intact')
}
for (const empty of [null, undefined, '']) assert.equal(localize(empty, sk), '')
assert.equal(catalogs.sk.vmLxc.appEditor.commandPlaceholder, catalogs.en.vmLxc.appEditor.commandPlaceholder)
assert.equal(catalogs.sk.vmLxc.appEditor.regexPlaceholderVersion, catalogs.en.vmLxc.appEditor.regexPlaceholderVersion)
async function main() {
const {fixture, flatten, text} = require('./test_native_app_discovery_ui.cjs')
const recorded = {id: 'fiction', name: 'Fixture', installed_via: 'file', file_path: 'relative', ports: [], state: {}}
for (const locale of ['en', 'sk']) {
const t = translate(locale)
const f = fixture({apps: [recorded], translate: t, apiError: 'file_path is required and must be an absolute path'})
f.render(); await f.settle()
const mode = flatten(f.tree()).find(n => n.type === 'button' && text(n).trim() === t('vmLxc.appEditor.editButton'))
assert.ok(mode); mode.props.onClick(); f.render()
const action = key => flatten(f.tree()).find(n => n.type === 'button' && text(n).trim() === t('vmLxc.appEditor.' + key))
await action('removeButton').props.onClick(); f.render()
assert.deepEqual(f.confirmPrompts, [t('vmLxc.appEditor.confirmRemove')])
assert.equal(f.calls.filter(c => c.method === 'DELETE').length, 0)
assert.equal(f.cachedWrites.length, 0, 'decline leaves cache untouched')
await action('editFieldsButton').props.onClick(); f.render()
await f.button('saveButton').props.onClick(); f.render()
assert.ok(text(f.tree()).includes(t('vmLxc.appEditor.errors.filePath')))
assert.equal(flatten(f.tree()).find(n => n.props?.id === 'app-file-path').props.value, 'relative')
assert.equal(f.calls.filter(c => c.method === 'PUT').length, 1)
assert.equal(f.cachedWrites.length, 0, 'failed save preserves cached record')
await f.button('testDetectorButton').props.onClick(); f.render()
assert.ok(text(f.tree()).includes(t('vmLxc.appEditor.errors.filePath')))
const before = f.calls.filter(c => c.method && c.method !== 'GET').length
f.button('cancelButton').props.onClick(); f.render()
assert.equal(f.calls.filter(c => c.method && c.method !== 'GET').length, before)
assert.ok(text(f.tree()).includes('Fixture'))
}
let requests = 0
const backend = 'file_path is required and must be an absolute path'
const {fetchApi} = load('lib/api-config.ts', {fetch: async () => {
requests++
return {ok: false, status: 400, statusText: 'BAD REQUEST', headers: {get: () => 'application/json'}, json: async () => ({error: backend})}
}})
for (const [method, route] of [['POST','/api/vms/999999/apps'], ['PUT','/api/vms/999999/apps/fake'], ['POST','/api/vms/999999/apps/test']]) {
try {await fetchApi(route, {method, body: '{}'}); assert.fail('Expected validation failure')}
catch (error) {
assert.equal(error.message, backend)
assert.equal(localize(error.message, sk), 'Zadaj úplnú cestu k súboru (file_path), začínajúcu lomkou /.')
}
}
assert.equal(requests, 3)
console.log(`PASS app editor: ${contract.templates.length} backend validation templates, ${Object.keys(enErrors).length} EN/SK rules, real fetchApi POST/PUT/test fixtures; external calls 0`)
}
main().catch(error => {console.error(error); process.exitCode = 1})
@@ -23,9 +23,10 @@ const candidate = {
evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [],
}
function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []} = {}) {
function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = [], translate,
confirmAccepted = false, apiError = null, detectorResult = null} = {}) {
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [], memoStates = []
const calls = [], cachedWrites = []
const calls = [], cachedWrites = [], confirmPrompts = []
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
let dockerStatus = "complete", dockerCached = {vmid:101,status:"not_scanned",candidates:[],stale:false}
@@ -56,6 +57,8 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []
}
const api = async (url, options = {}) => {
calls.push({url, ...options})
if (apiError && options.method && options.method !== 'GET') throw new Error(apiError)
if (detectorResult && url.endsWith('/apps/test')) return detectorResult
if (url.endsWith('/logo-discovery')) {
if (iconGate) await iconGate
if (iconError) throw iconError
@@ -96,12 +99,20 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []
}
const exports = {}
vm.runInNewContext(compiled.outputText, {exports, console, setTimeout, clearTimeout, AbortController,
confirm: prompt => {confirmPrompts.push(prompt); return confirmAccepted},
require: name => {
if (name === 'react') return react
if (name === 'react/jsx-runtime') return {jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})}
if (name.includes('api-config')) return {fetchApi: api}
if (name.includes('lxc-app-editor-errors')) {
const context = {exports: {}}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, 'lib/lxc-app-editor-errors.ts'), 'utf8'), {
compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020},
}).outputText, context)
return context.exports
}
if (name.includes('lxc-apps-cache')) return cache
if (name.includes('i18n/provider')) return {useT: () => (key, values) => key + (values ? JSON.stringify(values) : '')}
if (name.includes('i18n/provider')) return {useT: () => translate || ((key, values) => key + (values ? JSON.stringify(values) : ''))}
if (name.includes('category-color')) return {useIsLightTheme: () => false, categoryChipStyle: () => ({})}
if (name.includes('category-label')) return {getCategoryLabel: value => value}
return new Proxy({}, {get: (_, key) => key})
@@ -136,11 +147,12 @@ function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []
}
const settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()}
const button = key => {
const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key))
const label = translate ? translate('vmLxc.appEditor.' + key) : 'vmLxc.appEditor.' + key
const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes(label))
assert.ok(match, 'Missing button ' + key)
return match
}
return {render, renderParent, settle, calls, cachedWrites, button, tree: () => tree, bundleReads: () => bundleReads,
return {render, renderParent, settle, calls, cachedWrites, confirmPrompts, button, tree: () => tree, bundleReads: () => bundleReads,
holdIcon: () => {iconGate = new Promise(resolve => {resolveIcon = resolve})},
resolveIcon: () => resolveIcon(), setIconResult: value => {iconResult = value},
setIconError: value => {iconError = value},
+103
View File
@@ -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; });
+133
View File
@@ -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;});
+157
View File
@@ -0,0 +1,157 @@
// Execute the real WebSocket handlers and the OCI caller's actual close callback.
const assert = require('node:assert/strict'), vm = require('node:vm');
const {ts,read,compile,translator,catalogs} = require('./backup_i18n_seam.cjs');
const moduleScope = {exports:{}};
vm.runInNewContext(compile(read('lib/script-completion.ts')), moduleScope);
const completion = moduleScope.exports;
const source = ts.createSourceFile('modal.tsx', read('components/script-terminal-modal.tsx'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const handlers = []; let messageCallback,reconnectCallback;
function walk(node) {
if (ts.isBinaryExpression(node) && node.left.getText(source) === 'ws.onmessage') handlers.push(node.right);
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'getCompletionMessage') messageCallback = node.initializer.arguments[0];
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'attemptReconnect') reconnectCallback = node.initializer.arguments[0];
ts.forEachChild(node, walk);
}
walk(source);
assert.equal(handlers.length,2,'initial and reconnect handlers');
assert.ok(messageCallback);
function executable(node, file, scope) {
const local = {...scope,exports:{}};
vm.runInNewContext(compile('exports.fn = '+node.getText(file)+';'),local);
return local.exports.fn;
}
function fixture(handler, locale, optedIn=true) {
const writes=[], callbacks=[], state={closed:0};
const t=translator(locale), terminal={write:s=>writes.push(s),writeln:s=>writes.push(s)};
const shared={...completion, cancelledExitCode:optedIn?6:undefined,t,
completedSuccessfullyMessage:undefined,completedWithErrorMessage:undefined};
const getCompletionMessage=executable(messageCallback,source,shared);
const scope={...shared,getCompletionMessage,term:terminal,termRef:{current:terminal},
completionReceivedRef:{current:false},onCompleteRef:{current:code=>callbacks.push(code)},
setIsComplete:v=>state.complete=v,ws:{readyState:1,close:()=>state.closed++},WebSocket:{OPEN:1},
setIsWaitingNextInteraction:()=>{},waitingTimeoutRef:{current:null},clearTimeout:()=>{},
setCurrentInteraction:()=>{}};
return {receive:executable(handler,source,scope),writes,callbacks,state,t,scope};
}
let scenarios=0;
for (const handler of handlers) for (const locale of ['en','sk']) for (const frame of ['raw','json']) {
for (const [code,optedIn,kind] of [[6,true,'cancelled'],[6,false,'error'],[1,true,'error'],[0,true,'success'],[-1,true,'error']]) {
const f=fixture(handler,locale,optedIn);
const data=frame==='raw'?`\r\n[Script exited with code ${code}]\r\n`:JSON.stringify({type:'script_complete',exit_code:code});
f.receive({data});
assert.deepEqual(f.callbacks,[code]); assert.equal(f.state.closed,1);
const rendered=f.writes.join('');
if(kind==='cancelled') {
assert.ok(rendered.includes(f.t('scriptTerminal.cancelled')));
assert.ok(rendered.includes('\x1b[36m'));
assert.ok(rendered.startsWith('\x1bc'),'cancel resets the obsolete dialog in the queued output');
assert.ok(!rendered.includes('Script exited with code'));
assert.ok(!rendered.includes('\x1b[31m'));
assert.ok(!rendered.includes(f.t('scriptTerminal.completedSuccessfully')));
} else {
assert.ok(!rendered.includes('\x1bc'),'success/errors and non-opted-in scripts keep their logs');
assert.ok(rendered.includes(kind==='success'?f.t('scriptTerminal.completedSuccessfully'):f.t('scriptTerminal.completedWithError',{code})));
}
// Worker sends both forms; neither a second frame nor a heartbeat may complete twice.
f.receive({data:JSON.stringify({type:'script_complete',exit_code:code})});
f.receive({data:'{"type":"pong"}'});
assert.deepEqual(f.callbacks,[code]);assert.equal(f.state.closed,1);scenarios++;
}
for(const payload of [{type:'script_complete'},{type:'script_complete',exit_code:'invalid'}]) {
const f=fixture(handler,locale);f.receive({data:JSON.stringify(payload)});
assert.ok(f.writes.join('').includes('\x1b[31m'));
assert.ok(!f.writes.join('').includes(f.t('scriptTerminal.cancelled')));scenarios++;
}
}
for(const locale of Object.keys(catalogs)) assert.ok(translator(locale)('scriptTerminal.cancelled')!=='scriptTerminal.cancelled');
// The native CLI and the explicitly opted-in caller share the documented code6 contract.
const fs=require('node:fs');
assert.match(fs.readFileSync('oci/src/proxmenux_oci/management.py','utf8'),/^EXIT_CANCELLED = 6 /m);
assert.equal(completion.OCI_CANCELLED_EXIT_CODE,6);
const parent=ts.createSourceFile('vms.tsx',read('components/virtual-machines.tsx'),ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX);
let ociElement,reset,pendingRecoveryClick,registrationClick,registrationLabel;
function find(node) {
if(ts.isJsxSelfClosingElement(node) && node.tagName.getText(parent)==='ScriptTerminalModal' &&
node.attributes.properties.some(p=>p.name?.getText(parent)==='scriptPath' && p.initializer?.text?.endsWith('/oci/manage_instance.sh'))) ociElement=node;
if(ts.isCallExpression(node) && node.expression.getText(parent)==='useEffect' && node.arguments[0]?.getText(parent).includes('ociCompletionCodeRef.current = undefined')) reset=node.arguments[0];
if(ts.isJsxElement(node) && node.openingElement.tagName.getText(parent)==='Button') {
const click=node.openingElement.attributes.properties.find(p=>p.name?.getText(parent)==='onClick')?.initializer?.expression;
if(click?.getText(parent).includes('setOciAction(') && click.getText(parent).includes('action: "update"')) pendingRecoveryClick=click;
if(click?.getText(parent).includes('setOciAction(') && click.getText(parent).includes('action: "recover"')) {
registrationClick=click;
registrationLabel=node.children.find(child=>ts.isJsxExpression(child)&&child.expression)?.expression;
}
}
ts.forEachChild(node,find);
}
find(parent);assert.ok(ociElement);assert.ok(reset);assert.ok(pendingRecoveryClick);assert.ok(registrationClick);assert.ok(registrationLabel);
const attribute=name=>ociElement.attributes.properties.find(p=>p.name?.getText(parent)===name)?.initializer?.expression;
assert.equal(attribute('cancelledExitCode').getText(parent),'OCI_CANCELLED_EXIT_CODE');
(async()=>{
for(const locale of ['en','sk']) {
const scope={t:translator(locale),selectedVM:{vmid:110},applyBackup:false,applyBackupStorage:'',selectedBackupStorage:''};
scope.setOciAction=value=>scope.ociAction=value;
executable(registrationClick,parent,scope)();
assert.equal(scope.ociAction.action,'recover');assert.equal(scope.ociAction.vmid,110);
assert.equal(executable(registrationLabel,parent,scope),locale==='sk'?'Znova zaregistrovať':'Register again');
assert.equal(executable(attribute('title'),parent,scope),locale==='sk'?
'Zaregistrovať OCI aplikácie obnovené zo zálohy':'Register restored OCI applications');
scope.selectedVM={vmid:111};
assert.equal(executable(attribute('params'),parent,scope).VMID,'110','registration preserves the clicked target context');
assert.equal(executable(attribute('params'),parent,scope).ACTION,'recover','registration retains its existing global CLI action');
assert.equal(scope.t('vmLxc.ociUpdates.recover'),locale==='sk'?'Obnoviť':'Recover');
}
for(const locale of ['en','sk']) for(const pending of [false,true]) {
const scope={t:translator(locale), selectedVM:{vmid:110}, ociInstance:{pending},
applyBackup:false,applyBackupStorage:'',selectedBackupStorage:''};
scope.setOciAction=value=>scope.ociAction=value;
executable(pendingRecoveryClick,parent,scope)();
assert.equal(scope.ociAction.vmid,110);
assert.equal(scope.ociAction.action,'update','pending recovery retains the existing manage contract');
// Background refresh or another selected guest must not rename this open operation.
scope.ociInstance={pending:!pending};scope.selectedVM={vmid:999};
const title=executable(attribute('title'),parent,scope);
assert.equal(title,scope.t(pending?'vmLxc.ociUpdates.terminalTitleRecoverPrevious':'vmLxc.ociUpdates.terminalTitleUpdate'));
assert.equal(title,pending?(locale==='sk'?'Obnoviť predchádzajúcu OCI inštaláciu':'Recover the previous OCI installation'):
(locale==='sk'?'Aktualizovať OCI aplikáciu':'Update OCI application'));
const params=executable(attribute('params'),parent,scope);
assert.equal(params.ACTION,'update');assert.equal(params.VMID,'110');
assert.equal(params.KEEP_BACKUP,'');
}
for(const action of ['modify','recover']) {
const scope={t:translator('sk'),ociAction:{vmid:110,action,recoverPrevious:true}};
assert.equal(executable(attribute('title'),parent,scope),scope.t(action==='recover'?
'vmLxc.ociUpdates.terminalTitleRecover':'vmLxc.ociUpdates.terminalTitleModify'),
'registration and editing retain their own titles');
}
for(const frame of ['raw','json']) {
const f=fixture(handlers[0],'sk'),timers=[];
const old={readyState:3,onclose:()=>f.callbacks.push(-1),onerror:()=>{},onmessage:()=>{},close(){this.onclose?.({})}};
const ref={current:old};
class Socket{static OPEN=1;constructor(){this.readyState=1}close(){this.onclose?.({})}}
const scope={...f.scope,isOpen:true,isComplete:false,reconnectAttemptsRef:{current:0},reconnectTimeoutRef:{current:null},
wsRef:ref,WebSocket:Socket,sessionIdRef:{current:'fixture'},paramsRef:{current:{}},keepAliveIntervalRef:{current:null},
scriptPath:'/fixture/oci',setConnectionStatus:()=>{},getScriptWebSocketUrl:()=> 'ws://fixture',getTicketedWsUrl:async url=>url,
setTimeout:fn=>{timers.push(fn);return 1},clearTimeout:()=>{}};
executable(reconnectCallback,source,scope)();await timers[0]();
assert.equal(old.onclose,null,'replaced connection cannot emit an unknown result');
assert.equal(old.onmessage,null,'late result from replaced connection cannot win');
ref.current.onmessage({data:frame==='raw'?'\r\n[Script exited with code 6]\r\n':JSON.stringify({type:'script_complete',exit_code:6})});
assert.deepEqual(f.callbacks,[6]);
}
for(const action of ['update','modify']) for(const code of [6,0,1,undefined]) {
const calls=[],row={vmid:110,action},saved=JSON.stringify(row),ref={current:undefined};
const scope={...completion,ociAction:row,ociCompletionCodeRef:ref,
setOciAction:value=>calls.push(['close',value]),fetchApi:(url,options)=>{calls.push(['fetch',url,options]);return Promise.resolve({});},
invalidateLxcApps:vmid=>calls.push(['invalidate',vmid]),vmModalCacheRef:{current:{mountPoints:new Map()}},
fetchOciInstance:vmid=>calls.push(['instance',vmid]),fetchMountPoints:vmid=>calls.push(['mounts',vmid]),mutate:()=>calls.push(['mutate'])};
executable(attribute('onComplete'),parent,scope)(code);
executable(attribute('onClose'),parent,scope)();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(JSON.stringify(row),saved);
if(code===6) assert.deepEqual(calls,[['close',null]],'cancel has zero registry/API/cache calls');
else assert.equal(calls.filter(c=>c[0]==='fetch'&&c[2].method==='POST').length,1,'preserve refresh after non-cancel');
executable(reset,parent,scope)();assert.equal(ref.current,undefined,'next modal cannot inherit cancellation');
}
console.log(`PASS: ${scenarios} real terminal frame scenarios; actual OCI close callbacks, code6 scoping, reset and zero cancel probes`);
})().catch(error=>{console.error(error);process.exitCode=1});
+114
View File
@@ -0,0 +1,114 @@
// Actual Settings handlers and real fetchApi; all responses are inert fixtures.
const assert=require('node:assert/strict'),fs=require('node:fs'),path=require('node:path'),vm=require('node:vm')
const {execFileSync}=require('node:child_process')
const root=path.resolve(__dirname,'..'),app=path.join(root,'AppImage')
const ts=require(path.join(app,'node_modules/typescript'))
const catalogs=Object.fromEntries(['en','sk','de'].map(locale=>[locale,JSON.parse(fs.readFileSync(path.join(app,`messages/${locale}/common.json`)))]))
// A locale without these keys must show the English name.
catalogs.missing={}
const translate=locale=>(key,params={})=>{
const find=data=>key.split('.').reduce((item,name)=>item?.[name],data)
return (find(catalogs[locale])??find(catalogs.en)??key).replace(/\{(\w+)\}/g,(all,key)=>params[key]??all)
}
const flatten=node=>!node||typeof node!=='object'?[]:[node,...[node.props?.children].flat(Infinity).flatMap(flatten)]
const text=node=>node==null||node===false?'':typeof node!=='object'?String(node):[node.props?.children].flat(Infinity).map(text).join(' ')
const compile=source=>ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020,jsx:ts.JsxEmit.ReactJSX}}).outputText
const source=process.argv.includes('--baseline')?execFileSync('git',['show','e826680c:AppImage/components/settings.tsx'],{cwd:root,encoding:'utf8'}):fs.readFileSync(path.join(app,'components/settings.tsx'),'utf8')
const formatter={exports:{}};vm.runInNewContext(compile(fs.readFileSync(path.join(app,'lib/optimization-source-errors.ts'),'utf8')),formatter)
const descriptionsPath=path.join(app,'lib/optimization-descriptions.ts')
const descriptions={exports:{},require:name=>({default:require(path.resolve(app,'lib',name))})};if(fs.existsSync(descriptionsPath))vm.runInNewContext(compile(fs.readFileSync(descriptionsPath,'utf8')),descriptions)
const tools=['one','two'].map(key=>({key,name:'Demo '+key,enabled:true,has_source:true,version:'1.0',available_version:'1.1'}))
const sourceBody={success:true,function:'demo',script:'demo.sh',source:'demo() {\n echo "<tag>& $HOME"\n}\n',deprecated:false}
function fixture(locale='sk',catalog=tools){
let cursor=0,states=[],refs=[],effects=[],memo=[],tree,language=locale,reply=()=>sourceBody
const calls=[],events={},timers=[],copies=[];let fallbackCopies=0
const t=(key,params)=>translate(language)(key,params)
const react={
useState(value){const i=cursor++;if(!(i in states))states[i]=typeof value==='function'?value():value;return[states[i],v=>{states[i]=typeof v==='function'?v(states[i]):v}]},
useRef(value){const i=cursor++;return refs[i]??=( {current:value})},
useEffect(fn,deps){const i=cursor++,old=memo[i];if(!old||deps?.some((v,n)=>v!==old.deps?.[n]))effects.push(()=>{old?.cleanup?.();memo[i]={deps,cleanup:fn()}})},
}
const response=(body,status=200)=>({ok:status<400,status,statusText:'fixture',headers:{get:()=> 'application/json'},json:async()=>body})
const fetch=async(url,options={})=>{
const endpoint=String(url).replace(/^https?:\/\/[^/]+/,'');calls.push({endpoint,method:options.method||'GET'})
assert.equal(options.method||'GET','GET','Source viewer may never mutate')
if(endpoint.includes('/tool-source/')){const result=await reply(endpoint);if(result instanceof Error)throw result;return result?.http?response(result.body,result.http):response(result)}
if(endpoint.endsWith('/installed-tools'))return response({success:true,installed_tools:catalog,updates_available_count:0})
return response({categories:[],storages:[],interfaces:[],dismissed:[],success:true,candidates:[],configured:false})
}
const api={exports:{},fetch,process:{env:{}},console:{error(){}}};vm.runInNewContext(compile(fs.readFileSync(path.join(app,'lib/api-config.ts'),'utf8')),api)
const add=(name,handler)=>{(events[name]??=new Set()).add(handler)},remove=(name,handler)=>events[name]?.delete(handler)
const win={addEventListener:add,removeEventListener:remove,setTimeout:fn=>{timers.push(fn);return timers.length},isSecureContext:true}
const doc={addEventListener:add,removeEventListener:remove,body:{appendChild(){},removeChild(){}},createElement:()=>({style:{},focus(){},select(){}}),execCommand:()=>{fallbackCopies++;return true}}
const clipboard={writeText:async value=>copies.push(value)}
const context={exports:{},window:win,document:doc,navigator:{clipboard},console:{error(){}},setTimeout:win.setTimeout,
require:name=>{
if(name==='react')return react
if(name==='react/jsx-runtime')return{jsx:(type,props)=>({type,props}),jsxs:(type,props)=>({type,props})}
if(name.includes('optimization-source-errors'))return formatter.exports
if(name.includes('optimization-descriptions'))return descriptions.exports
if(name.includes('api-config'))return api.exports
if(name.includes('i18n/provider'))return{SUPPORTED_LANGUAGES:[],useI18n:()=>({t,language,setLanguage:value=>language=value})}
if(name.includes('use-platform'))return{useIsPbs:()=>false}
if(name.includes('format-network'))return{getNetworkUnit:()=> 'Bytes'}
return new Proxy({},{get:(_,key)=>key})
}}
vm.runInNewContext(compile(source),context)
const render=()=>{cursor=0;effects=[];tree=context.exports.Settings();for(const effect of effects)effect();return tree}
const settle=async()=>{for(let i=0;i<24;i++)await Promise.resolve();render()}
// Rows show the localized tool name when the catalog has one.
const label=name=>{const fallback=catalog.find(tool=>tool.key===name)?.name||'Demo '+name;return descriptions.exports.optimizationToolName?descriptions.exports.optimizationToolName(name,fallback,t):fallback}
const tool=name=>flatten(tree).find(node=>node.props?.title===t('settings.optimizations.sourceTitle')&&text(node).includes(label(name)))
const dialog=()=>flatten(tree).find(node=>node.props?.role==='dialog')
const close=()=>flatten(tree).find(node=>node.props?.['aria-label']===t('actions.close')).props.onClick()
return{render,settle,tool,dialog,close,calls,copies,timers,tree:()=>tree,setReply:value=>reply=value,t,
language:value=>{language=value;render()},escape:()=>events.keydown?.forEach(fn=>fn({key:'Escape'})),
unmount:()=>memo.forEach(effect=>effect?.cleanup?.()),clipboard,win,fallbackCopies:()=>fallbackCopies}
}
async function main(){
if(process.argv.includes('--baseline')){
const f=fixture();f.render();await f.settle();f.setReply(()=>({http:404,body:{success:false,error:'Function not found in available scripts'}}));await f.tool('one').props.onClick();f.render();
assert.ok(text(f.tree()).includes('Failed to load source code'));assert.ok(!text(f.tree()).includes(f.t('settings.optimizations.sourceFunctionMissing')))
console.log('REPRODUCED baseline: real Settings + fetchApi 404 displays English Failed to load source code');return
}
for(const locale of ['en','sk','de']){
const f=fixture(locale);f.render();await f.settle()
for(const [reply,key,param]of [
[{success:false},'sourceUnavailable'],[null,'sourceUnavailable'],[{success:'yes',source:'invalid'},'sourceUnavailable'],[{success:true,source:''},'sourceUnavailable'],[{success:true,source:' '},'sourceUnavailable'],[{success:true,source:17},'sourceUnavailable'],
[{http:404,body:{success:false,error:'Function not found in available scripts'}},'sourceFunctionMissing'],
[{http:404,body:{success:false,error:'Unknown tool: demo'}},'sourceUnknownTool',{tool:'demo'}],
[{http:404,body:{success:false,error:'No function mapping for demo'}},'sourceNoMapping',{tool:'demo'}],
[{success:false,error:'Source code not available'},'sourceUnavailable'],[new Error('network unreachable'),'sourceLoadFailed']]){
f.setReply(()=>reply);await f.tool('one').props.onClick();f.render();assert.ok(text(f.dialog()).includes(f.t('settings.optimizations.'+key,param)),key)
}
f.setReply(()=>({http:500,body:{success:false,error:'PermissionError /opt/demo <unsafe>&'}}));await f.tool('one').props.onClick();f.render();assert.ok(text(f.dialog()).includes('PermissionError /opt/demo <unsafe>&'))
f.language('sk');assert.ok(text(f.dialog()).includes(translate('sk')('settings.optimizations.sourceLoadFailed')))
f.setReply(()=>sourceBody);await f.tool('one').props.onClick();f.render();
const backdrop=flatten(f.tree()).find(node=>node.props?.className?.startsWith('fixed inset-0')&&flatten(node).some(child=>child.props?.role==='dialog'))
assert.equal(backdrop.props.style.margin,0,'parent space-y must not offset the fixed backdrop')
const pre=flatten(f.dialog()).find(node=>node.type==='pre');assert.ok(pre.props.dangerouslySetInnerHTML.__html.includes('&lt;'));assert.ok(!pre.props.dangerouslySetInnerHTML.__html.includes('<tag>'))
const copy=flatten(f.dialog()).find(node=>node.props?.title===f.t('actions.copyToClipboard'));await copy.props.onClick();f.render();assert.equal(f.copies.at(-1),sourceBody.source)
f.win.isSecureContext=false;await copy.props.onClick();assert.equal(f.fallbackCopies(),1)
const before=f.calls.length;f.close();f.render();assert.equal(f.dialog(),undefined);assert.equal(f.calls.length,before)
}
const names=catalogs.en.settings.optimizations.sourceToolNames
const metadata=JSON.parse(execFileSync('python3',['-c',"import ast,json,sys;t=ast.parse(open(sys.argv[1]).read());n=next(n for n in t.body if isinstance(n,ast.Assign) and isinstance(n.targets[0],ast.Name) and n.targets[0].id=='TOOL_METADATA');print(json.dumps({k:v['name'] for k,v in ast.literal_eval(n.value).items()}))",path.join(app,'scripts/flask_proxmenux_routes.py')],{encoding:'utf8'}))
assert.deepEqual(names,metadata,'source title names must match real backend metadata')
for(const [key,name]of Object.entries(names)){
assert.equal(formatter.exports.optimizationSourceName(key,name,translate('en')),name)
assert.equal(formatter.exports.optimizationSourceName(key,name,translate('sk')),catalogs.sk.settings.optimizations.sourceToolNames[key])
assert.equal(formatter.exports.optimizationSourceName(key,name,translate('missing')),name)
}
assert.equal(Object.keys(names).length,27)
assert.equal(formatter.exports.optimizationSourceName('future','Future tool',translate('sk')),'Future tool')
const known=fixture('sk',[{...tools[0],key:'time_sync',name:'Time Synchronization'}]);known.render();await known.settle();await known.tool('time_sync').props.onClick();known.render();assert.ok(text(known.dialog()).includes('Synchronizácia času'));known.language('en');assert.ok(text(known.dialog()).includes('Time Synchronization'))
const f=fixture();f.render();await f.settle();let resolveOld;f.setReply(()=>new Promise(resolve=>resolveOld=resolve));const old=f.tool('one').props.onClick();f.render();assert.ok(flatten(f.dialog()).some(node=>node.props?.role==='status'))
f.close();f.render();resolveOld(sourceBody);await old;f.render();assert.equal(f.dialog(),undefined,'late success never reopens closed viewer')
let resolveStale;f.setReply(()=>new Promise(resolve=>resolveStale=resolve));const stale=f.tool('one').props.onClick();f.setReply(()=>({...sourceBody,source:'two() { :; }'}));await f.tool('two').props.onClick();f.render();resolveStale({http:404,body:{error:'Function not found in available scripts'}});await stale;f.render();assert.ok(text(f.dialog()).includes('Demo two'));assert.equal(flatten(f.dialog()).some(node=>node.props?.role==='alert'),false)
const before=f.calls.length;f.escape();f.render();assert.equal(f.dialog(),undefined);assert.equal(f.calls.length,before)
f.setReply(()=>sourceBody);await f.tool('one').props.onClick();f.render();let copyResolve;f.clipboard.writeText=()=>new Promise(resolve=>copyResolve=resolve);const copying=flatten(f.dialog()).find(node=>node.props?.title===f.t('actions.copyToClipboard')).props.onClick();f.close();f.render();copyResolve();await copying;f.render();assert.equal(f.dialog(),undefined)
let unmountedResolve;f.setReply(()=>new Promise(resolve=>unmountedResolve=resolve));const unmounted=f.tool('one').props.onClick();f.render();const snapshot=text(f.tree());f.unmount();unmountedResolve(sourceBody);await unmounted;f.render();assert.equal(text(f.tree()),snapshot,'response after unmount cannot update modal state')
assert.equal(f.calls.filter(call=>call.method!=='GET').length,0);assert.equal(f.calls.filter(call=>/scan|update|script-runner/.test(call.endpoint)).length,0)
console.log('PASS source viewer: actual Settings/fetchApi, EN/SK/fallback, 404/500/network/malformed/empty, exact copy, late requests and close/Esc; mutations/scan/update 0')
}
main().catch(error=>{console.error(error);process.exitCode=1})
+154
View File
@@ -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};