diff --git a/.github/scripts/tests/fixtures/command_descriptions.json b/.github/scripts/tests/fixtures/command_descriptions.json index e93eab7e..eddce3ad 100644 --- a/.github/scripts/tests/fixtures/command_descriptions.json +++ b/.github/scripts/tests/fixtures/command_descriptions.json @@ -126,7 +126,7 @@ "Dateieigentümer ändern", "Tar.gz-Archiv extrahieren", "Tar.gz-Archiv erstellen", - "Festplattennutzung anzeigen", + "Laufwerksnutzung anzeigen", "Verzeichnisgrößen anzeigen", "Speichernutzung anzeigen", "Laufende Prozesse anzeigen", diff --git a/.github/scripts/tests/test_cloudinit_launcher.py b/.github/scripts/tests/test_cloudinit_launcher.py new file mode 100644 index 00000000..11fd534e --- /dev/null +++ b/.github/scripts/tests/test_cloudinit_launcher.py @@ -0,0 +1,140 @@ +"""Exercise the Cloud-Init menu without network access or guest creation.""" + +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[3] +SELECTOR = ROOT / "scripts/vm/select_linux_iso.sh" + + +class CloudInitLauncherTests(unittest.TestCase): + def run_menu(self, selection="2", body=None, curl_exit=0, dialog_exit=0, + installer_exit=0, stdin="\n"): + source = SELECTOR.read_text(encoding="utf-8") + match = re.search(r"(?ms)^function select_linux_cloudinit\(\) \{.*?^\}", source) + assert match is not None, "Cloud-Init menu function not found" + with tempfile.TemporaryDirectory(prefix="cloudinit-launcher-") as temporary: + work = Path(temporary) + (work / "menus").mkdir() + (work / "menus/create_vm_menu.sh").write_text( + 'printf "menu-returned\\n" >> "$ACTION_LOG"\n', encoding="utf-8") + environment = os.environ.copy() + environment.update({ + "LOCAL_SCRIPTS": str(work), "SELECTION": selection, + "DIALOG_EXIT": str(dialog_exit), "CURL_EXIT": str(curl_exit), + "INSTALLER_EXIT": str(installer_exit), + "CURL_BODY": body if body is not None else + 'printf "helper-executed\\n" >> "$ACTION_LOG"\nexit "$INSTALLER_EXIT"\n', + "ACTION_LOG": str(work / "actions"), + "CURL_ARGS": str(work / "curl-args"), + "DIALOG_LOG": str(work / "dialog-args"), + }) + prelude = r''' +translate() { printf '%s' "$1"; } +msg_success() { printf 'continue-prompt\n' >> "$ACTION_LOG"; } +whiptail() { printf 'website-prompt\n' >> "$ACTION_LOG"; } +dialog() { + printf '%s\n' "$@" >> "$DIALOG_LOG" + if [[ " $* " == *" --menu "* ]]; then + printf '%s' "$SELECTION" >&2 + return "$DIALOG_EXIT" + fi +} +curl() { + printf '%s\n' "$@" > "$CURL_ARGS" + printf '%s' "$CURL_BODY" + return "$CURL_EXIT" +} +''' + result = subprocess.run( + ["bash", "--noprofile", "--norc", "-c", + prelude + match.group() + "\nselect_linux_cloudinit\n"], + input=stdin, capture_output=True, text=True, env=environment, + timeout=5) + def contents(name): + path = work / name + return path.read_text(encoding="utf-8") if path.exists() else "" + return result, contents("curl-args"), contents("dialog-args"), contents("actions") + + def test_debian_uses_the_maintained_generic_installer(self): + result, curl_args, _, actions = self.run_menu() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(curl_args.splitlines()[-1], + "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/debian-vm.sh") + self.assertIn("helper-executed", actions) + + + def test_ubuntu_uses_one_generic_entry_without_obsolete_release_labels(self): + result, curl_args, menu, actions = self.run_menu(selection="3") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(curl_args.splitlines()[-1], + "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu-vm.sh") + self.assertIn("helper-executed", actions) + self.assertEqual(menu.count("(Cloud-Init automated)"), 3) + for obsolete in ("Debian 13", "Debian 12", "Ubuntu 22.04", "Ubuntu 24.04", + "Ubuntu 24.10", "Ubuntu 25.04"): + self.assertNotIn(obsolete, menu) + + + def test_failed_or_empty_download_never_runs_a_script(self): + for body, exit_code in (("404: Not Found", 22), + ('printf "helper-executed\\n" >> "$ACTION_LOG"', 28), + ("", 0), (" \n\t", 0)): + with self.subTest(body=body, exit_code=exit_code): + result, _, messages, actions = self.run_menu(body=body, curl_exit=exit_code) + self.assertNotIn("helper-executed", actions) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Error: Failed to download the script.", messages) + self.assertNotIn("menu-returned", actions) + + + def test_installer_failure_returns_its_status_without_success_navigation(self): + result, _, _, actions = self.run_menu(installer_exit=17) + self.assertEqual(result.returncode, 17, result.stderr) + self.assertIn("helper-executed", actions) + self.assertNotIn("continue-prompt", actions) + self.assertNotIn("website-prompt", actions) + self.assertNotIn("menu-returned", actions) + + + def test_arch_and_successful_menu_navigation_are_preserved(self): + result, curl_args, _, actions = self.run_menu(selection="1") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(curl_args.splitlines()[-1], + "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/archlinux-vm.sh") + self.assertEqual(actions.splitlines(), + ["helper-executed", "continue-prompt", "website-prompt", "menu-returned"]) + arguments = curl_args.splitlines() + self.assertIn("-fsSL", arguments) + self.assertEqual(arguments[arguments.index("--connect-timeout") + 1], "5") + self.assertEqual(arguments[arguments.index("--max-time") + 1], "60") + + def test_return_and_cancel_do_not_download_or_execute(self): + for selection, status, expected in (("4", 0, 1), ("2", 1, 0), ("2", 255, 0)): + with self.subTest(selection=selection, status=status): + result, curl_args, _, actions = self.run_menu( + selection=selection, dialog_exit=status) + self.assertEqual(result.returncode, expected, result.stderr) + self.assertEqual(curl_args, "") + self.assertEqual(actions, "") + + def test_unknown_selection_is_rejected(self): + result, curl_args, _, actions = self.run_menu(selection="999") + self.assertNotEqual(result.returncode, 0) + self.assertEqual(curl_args, "") + self.assertEqual(actions, "") + + def test_installer_keeps_interactive_standard_input(self): + body = 'IFS= read -r answer; printf "input:%s\\n" "$answer" >> "$ACTION_LOG"\n' + result, _, _, actions = self.run_menu(body=body, stdin="answer with spaces\n\n") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("input:answer with spaces\n", actions) + self.assertIn("menu-returned", actions) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index e7339492..ec35c3b2 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -212,10 +212,28 @@ class CommandDescriptionsTests(unittest.TestCase): # New LXC observation labels intentionally ship only EN/SK. # Model the bot's future output in temporary catalogs, just # like the other pending keys above; never rewrite locales. + messages["scriptTerminal"].setdefault("endOfInput", english["scriptTerminal"]["endOfInput"]) + for key in ("noShell", "enterFailed"): + messages["lxcTerminal"]["errors"].setdefault(key, english["lxcTerminal"]["errors"][key]) + # The notification history panel texts await the bot as well. + for key, value in english["settings"]["notifications"]["history"].items(): + messages["settings"]["notifications"].setdefault("history", {}).setdefault(key, value) for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown", - "checkStale", "checkStaleHelp"): + "checkStale", "checkStaleHelp", "checkBeforeUpdate", + "startBeforeCheck", "notCheckedYet", + "checkStillRunning", "refreshCheckHelp", + "checkInProgress", "checkSystemPackages", "runCustomCommand", + "installedVersionUnverified", "versionStatusUnknown"): messages["vmLxc"]["updates"].setdefault( key, english["vmLxc"]["updates"][key]) + for key in ("startPending", "shutdownPending", "rebootPending", "stopPending"): + messages["vmLxc"]["actions"].setdefault( + key, english["vmLxc"]["actions"][key]) + for key in ("osDockerScope", "dockerImageMethod", "unverifiedTargetsHelp"): + messages["vmLxc"]["bulkUpdate"].setdefault( + key, english["vmLxc"]["bulkUpdate"][key]) + messages["vmLxc"]["errors"].setdefault( + "controlNotConfirmed", english["vmLxc"]["errors"]["controlNotConfirmed"]) path.write_text(json.dumps(messages, ensure_ascii=False)) before = {p: p.read_bytes() for p in root.glob("*/common.json")} argv = [str(SCRIPT), "--source", str(root / "en/common.json"), diff --git a/.github/scripts/tests/test_notification_history_panel.py b/.github/scripts/tests/test_notification_history_panel.py new file mode 100644 index 00000000..8399ec4b --- /dev/null +++ b/.github/scripts/tests/test_notification_history_panel.py @@ -0,0 +1,49 @@ +"""The notification settings show the delivery history the Monitor records, +the daily digest runs included, and never offer to erase it: Audit & Report +reads the same history to tell whether each channel delivers.""" +import json +from pathlib import Path +import re +import unittest + +ROOT = Path(__file__).resolve().parents[3] +PAGE = ROOT / 'AppImage/components/notification-settings.tsx' +KEYS = ('title', 'description', 'empty', 'loadFailed', 'sent', 'notSent', + 'severityInfo', 'severityWarning', 'severityCritical') + + +class NotificationHistoryPanelTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.page = PAGE.read_text(encoding='utf-8') + + def test_the_panel_opens_and_lists_the_history(self): + self.assertIn('setShowHistory(open => !open)', self.page) + self.assertIn('history.map(entry =>', self.page) + self.assertIn('t("settings.notifications.history.title")', self.page) + + def test_the_history_is_read_without_a_success_flag(self): + loader = re.search(r'const loadHistory = useCallback\(async \(\) => \{(.*?)\n \}, \[\]\)', self.page, re.S) + self.assertIsNotNone(loader) + self.assertIn('Array.isArray(data.history)', loader.group(1)) + self.assertNotIn('data.success', loader.group(1)) + + def test_a_digest_run_and_a_failed_delivery_show_their_message(self): + self.assertIn('entry.event_type === "digest" ? entry.message', self.page) + self.assertIn('!sent ? entry.error_message', self.page) + + def test_the_page_never_erases_the_history(self): + self.assertNotIn('handleClearHistory', self.page) + self.assertIsNone(re.search(r'notifications/history["\'`][^)]*method:\s*["\']DELETE', self.page)) + + def test_the_texts_exist_in_english_and_spanish(self): + for language in ('en', 'es'): + catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8')) + history = catalog['settings']['notifications']['history'] + for key in KEYS: + with self.subTest(language=language, key=key): + self.assertTrue(history.get(key)) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/tests/test_oci_pve_version_gate.py b/.github/scripts/tests/test_oci_pve_version_gate.py new file mode 100644 index 00000000..80703fd6 --- /dev/null +++ b/.github/scripts/tests/test_oci_pve_version_gate.py @@ -0,0 +1,46 @@ +"""OCI manager Apps opens only on Proxmox VE 9.1 or later, the first release +that runs OCI images as LXC containers.""" +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[3] +LAUNCHER = ROOT / 'scripts/oci/oci_manager_apps.sh' + + +class OciPveVersionGateTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + text = LAUNCHER.read_text(encoding='utf-8') + cls.function = re.search(r'^pve_supports_oci\(\) \{\n.*?^\}\n', text, re.M | re.S).group(0) + cls.text = text + + def supported(self, version): + with tempfile.TemporaryDirectory() as folder: + fake = Path(folder, 'pveversion') + # Without Proxmox the command is missing: a stand-in that fails hides the host's own. + fake.write_text('#!/bin/sh\nexit 127\n' if version is None else + f'#!/bin/sh\necho "pve-manager/{version}/b9984c6d90a4bd80 (running kernel: 6.17)"\n') + fake.chmod(0o755) + result = subprocess.run(['bash', '-c', f'PATH={folder}:/usr/bin:/bin\n{self.function}pve_supports_oci'], + capture_output=True, text=True) + return result.returncode == 0 + + def test_versions_from_9_1_open_it(self): + for version in ('9.1.0', '9.1.4', '9.2.2', '10.0.1'): + with self.subTest(version=version): + self.assertTrue(self.supported(version)) + + def test_older_versions_and_hosts_without_proxmox_do_not(self): + for version in ('8.4.1', '9.0.11', None): + with self.subTest(version=version): + self.assertFalse(self.supported(version)) + + def test_it_is_checked_before_anything_is_installed(self): + self.assertLess(self.text.index('if ! pve_supports_oci; then'), self.text.index('ensure_oci_dependencies || exit 1')) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/tests/test_oci_selection_setup_wording.py b/.github/scripts/tests/test_oci_selection_setup_wording.py index af2b9d38..9d35d7d5 100644 --- a/.github/scripts/tests/test_oci_selection_setup_wording.py +++ b/.github/scripts/tests/test_oci_selection_setup_wording.py @@ -7,7 +7,7 @@ from pathlib import Path import subprocess from types import SimpleNamespace from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch ROOT = Path(__file__).resolve().parents[3] MENU = ROOT / 'oci/src/proxmenux_oci/management.py' @@ -48,6 +48,42 @@ class SelectionSetupWording(TestCase): ui.message.assert_called_once_with(EMPTY, 'OCI management') ui.choose.assert_not_called() + def _menu(self, inventories, selections): + rows = iter(inventories) + ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections)) + refresh = Mock(return_value={}) + manage = Mock(return_value=True) + fn = extracted('_interactive_management', os=SimpleNamespace(geteuid=lambda: 0), + shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows), + _clean_orphans=lambda project: None, + recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None, + carry_records=refresh, manage_instance=manage, menu_size=lambda: 10, + STATUS_LABELS={}, translate=lambda s: s) + fn(Path('/fixture'), ui) + return ui, refresh, manage + + def test_the_list_comes_back_after_an_operation_without_refreshing_every_copy(self): + row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} + ui, refresh, manage = self._menu([[row], [row]], ['101', None]) + self.assertEqual(ui.choose.call_count, 2) + manage.assert_called_once() + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True) + + def test_removing_the_last_application_returns_without_a_message(self): + row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} + ui, _, manage = self._menu([[row], []], ['101']) + manage.assert_called_once() + ui.message.assert_not_called() + + def test_the_watchdog_refreshes_the_copy_of_the_containers_it_changed(self): + refresh = Mock(return_value={}) + watchdog = SimpleNamespace(set_watchdog=Mock(side_effect=[[101, 102]])) + fn = extracted('set_watchdog', sys=SimpleNamespace(path=[]), carry_records=refresh, + instances=SimpleNamespace(ROOT=Path('/fixture'))) + with patch.dict('sys.modules', {'oci_watchdog': watchdog}): + self.assertTrue(fn(Path('/fixture'), [102], True)) + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, vmids=[101, 102]) + def test_selector_filters_missing_guest_without_erasing_saved_contract(self): class Entry: name = '101' diff --git a/.github/scripts/tests/test_terminal_ctrl_keys.py b/.github/scripts/tests/test_terminal_ctrl_keys.py new file mode 100644 index 00000000..823b2168 --- /dev/null +++ b/.github/scripts/tests/test_terminal_ctrl_keys.py @@ -0,0 +1,31 @@ +"""Every web terminal of the Monitor can send Ctrl+D from its Ctrl menu, so +a pasted text can be finished from a phone or a tablet.""" +import json +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[3] +TERMINALS = { + 'terminal-panel': 'sendSequence', + 'script-terminal-modal': 'sendCommand', + 'lxc-terminal-modal': 'sendKey', +} + + +class TerminalCtrlKeysTests(unittest.TestCase): + def test_each_terminal_sends_ctrl_d(self): + for name, send in TERMINALS.items(): + source = (ROOT / f'AppImage/components/{name}.tsx').read_text(encoding='utf-8') + with self.subTest(terminal=name): + self.assertIn(f'onSelect={{() => {send}("\\x04")}}', source) + self.assertIn('t("scriptTerminal.endOfInput")', source) + + def test_the_label_exists_in_english_and_spanish(self): + for language in ('en', 'es'): + catalog = json.loads((ROOT / f'AppImage/messages/{language}/common.json').read_text(encoding='utf-8')) + with self.subTest(language=language): + self.assertTrue(catalog['scriptTerminal'].get('endOfInput')) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/scripts/tests/test_vm_iso_download.py b/.github/scripts/tests/test_vm_iso_download.py new file mode 100644 index 00000000..8c859386 --- /dev/null +++ b/.github/scripts/tests/test_vm_iso_download.py @@ -0,0 +1,275 @@ +"""The VM wizard must not accept failed ISO downloads as installation media. + +Run its function logic against a loopback HTTP fixture. Redirect the fixed +native ISO directory only in the loaded test copy and stub Proxmox/UI services. +No product script is sourced, no guest operation runs, and no public network +or real ISO storage is accessed. +""" +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import os +from pathlib import Path +import re +import shlex +import shutil +import subprocess +import tempfile +import threading +import unittest + +ROOT = Path(__file__).resolve().parents[3] +CREATOR = ROOT / "scripts/vm/vm_creator.sh" +PAYLOAD = b"fixture installation media\x00\x01\n" +REAL_WGET = shutil.which("wget") + + +class MediaHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/missing.iso": + self.send_response(404) + self.end_headers() + return + if self.path == "/partial.iso": + self.send_response(200) + self.send_header("Content-Length", "1024") + self.end_headers() + self.wfile.write(b"partial") + self.close_connection = True + return + body = b"" if self.path == "/empty.iso" else PAYLOAD + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.send_header("Content-Disposition", 'attachment; filename="remote-name.iso"') + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + +class VmIsoDownloadTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if REAL_WGET is None: + raise RuntimeError("These offline tests require wget, as the VM wizard does") + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), MediaHandler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.http = f"http://127.0.0.1:{cls.server.server_port}" + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.thread.join() + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="vm-iso-download-") + self.addCleanup(self.temp.cleanup) + self.directory = Path(self.temp.name) + self.iso = self.directory / "installation media.iso" + self.qm_log = self.directory / "qm.log" + self.wget_log = self.directory / "wget.log" + self.bin = self.directory / "bin" + self.bin.mkdir() + # Route only the optional fixed VirtIO/SourceForge endpoints to our + # fixture. Wget still performs the actual HTTP transfer and file I/O. + assert REAL_WGET is not None + wrapper = self.bin / "wget" + wrapper.write_text( + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$*" >> "$WGET_LOG"\n' + 'args=("$@")\n' + 'for i in "${!args[@]}"; do\n' + ' case "${args[$i]}" in\n' + ' https://fedorapeople.org/*) args[$i]="$VIRTIO_FIXTURE_URL";;\n' + ' https://sourceforge.net/*) args[$i]="$HTTP_FIXTURE/success.iso";;\n' + ' esac\n' + ' if [[ "${args[$i]}" == http://* || "${args[$i]}" == https://* ]]; then\n' + ' [[ "${args[$i]}" == "$HTTP_FIXTURE"/* ]] || exit 98\n' + ' fi\n' + 'done\n' + 'for i in "${!args[@]}"; do\n' + ' if [[ "${args[$i]}" == -O ]]; then\n' + ' case "${args[$((i+1))]}" in "$FIXTURE_DIR"/*) ;; *) exit 97;; esac\n' + ' fi\n' + 'done\n' + f"exec {shlex.quote(REAL_WGET)} --no-config --no-proxy --tries=1 --timeout=1 " + '"${args[@]}"\n' + ) + wrapper.chmod(0o755) + + def run_wizard(self, route="/success.iso", windows=False, overwrite=True, url=None, + caller_iso_dir=None): + source = CREATOR.read_text() + functions = "\n\n".join(re.findall(r"^function \w+\(\) \{\n.*?^\}", source, re.M | re.S)) + self.assertIn("function create_vm()", functions) + # Only relocate this external filesystem anchor. Keep all predicates, + # transfers and promotion logic unchanged, without a production hook. + self.assertIn("/var/lib/vz/template/iso", functions) + functions = functions.replace("/var/lib/vz/template/iso", "${FIXTURE_DIR}") + prelude = r''' + translate() { printf '%s' "$1"; } + msg_ok() { printf 'OK:%s\n' "$*"; } + msg_error() { printf 'ERROR:%s\n' "$*"; } + msg_warn() { printf 'WARN:%s\n' "$*"; } + msg_info() { :; } + msg_success() { :; } + qm() { printf '%q ' "$@" >> "$QM_LOG"; printf '\n' >> "$QM_LOG"; } + pvesm() { printf 'Name Type Status\nfixture dir active\n'; } + select_storage_target() { printf fixture; } + # No next menu, start, or guest-facing process is launched. + bash() { :; } + whiptail() { + if [[ " $* " == *' --yesno '* ]]; then + return "$OVERWRITE_STATUS" + fi + if [[ ! -f "$DIALOG_ONCE" ]]; then + : > "$DIALOG_ONCE" + printf '1' >&2 + return 0 + fi + return 1 + } + IMPORT_DISKS=(); PASSTHROUGH_DISKS=(); VIRTUAL_DISKS=(); CONTROLLER_NVME_PCIS=() + VMID=99999; HN=fixture; CORE_COUNT=1; RAM_SIZE=512; BRG=fixture + MAC='02:00:00:00:00:01'; VLAN=''; MTU=''; BIOS_TYPE=''; CPU_TYPE='' + MACHINE=''; SERIAL_PORT=''; START_VM=no; WIZARD_ADD_GPU=no + TAB=''; GN=''; CL='' + create_vm + ''' + env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ["PATH"], + ISO_PATH=str(self.iso), ISO_URL=url if url is not None else self.http + route, + ISO_VOLID="local:iso/installation media.iso", + OS_TYPE="2" if windows else "3", + QM_LOG=str(self.qm_log), WGET_LOG=str(self.wget_log), + DIALOG_ONCE=str(self.directory / "dialog-once"), FIXTURE_DIR=str(self.directory), + OVERWRITE_STATUS="0" if overwrite else "1", HTTP_FIXTURE=self.http, + VIRTIO_FIXTURE_URL=self.http + route) + env.pop("ISO_DIR", None) + if caller_iso_dir is not None: + env["ISO_DIR"] = str(caller_iso_dir) + return subprocess.run(["bash", "--noprofile", "--norc", "-c", functions + prelude], + env=env, stdin=subprocess.DEVNULL, capture_output=True, + text=True, timeout=15) + + def qm_calls(self): + return self.qm_log.read_text() if self.qm_log.exists() else "" + + def test_http_failure_stops_before_creating_a_guest(self): + result = self.run_wizard("/missing.iso") + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.qm_calls(), "") + self.assertNotIn("OK:ISO image downloaded", result.stdout) + self.assertIn("ERROR:Failed to download ISO image", result.stdout) + + def test_partial_transfer_is_not_published_as_cached_media(self): + result = self.run_wizard("/partial.iso") + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.qm_calls(), "") + self.assertFalse(self.iso.exists(), "A failed transfer must not become reusable media") + + def test_an_empty_cached_download_is_retried(self): + self.iso.write_bytes(b"") + result = self.run_wizard() + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.iso.read_bytes(), PAYLOAD) + self.assertIn("create ", self.qm_calls()) + + def test_virtio_download_keeps_native_storage_despite_caller_environment(self): + self.iso.write_bytes(PAYLOAD) + unrelated = self.directory / "unrelated directory" + unrelated.mkdir() + result = self.run_wizard(windows=True, caller_iso_dir=unrelated) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + driver = self.directory / "virtio-win.iso" + self.assertTrue(driver.is_file(), result.stdout + result.stderr) + self.assertEqual(driver.read_bytes(), PAYLOAD) + self.assertIn("-ide3 ", self.qm_calls()) + self.assertEqual(list(unrelated.iterdir()), []) + + def test_failed_virtio_fetch_is_not_selected_for_attachment(self): + self.iso.write_bytes(PAYLOAD) + result = self.run_wizard("/missing.iso", windows=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertNotIn("-ide3 ", self.qm_calls()) + self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout) + self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout) + self.assertFalse((self.directory / "virtio-win.iso").exists()) + + + def test_successful_virtio_replacement_preserves_existing_permissions(self): + self.iso.write_bytes(PAYLOAD) + driver = self.directory / "virtio-win.iso" + driver.write_bytes(b"previous driver") + driver.chmod(0o640) + result = self.run_wizard(windows=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(driver.read_bytes(), PAYLOAD) + self.assertEqual(driver.stat().st_mode & 0o777, 0o640) + + def test_empty_http_success_is_rejected(self): + result = self.run_wizard("/empty.iso") + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.qm_calls(), "") + self.assertFalse(self.iso.exists()) + self.assertEqual({p.name for p in self.directory.iterdir()}, {"bin", "wget.log"}) + + def test_existing_nonempty_media_is_reused_without_fetching(self): + self.iso.write_bytes(b"existing installation media") + result = self.run_wizard("/missing.iso") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.iso.read_bytes(), b"existing installation media") + self.assertFalse(self.wget_log.exists()) + self.assertIn("-ide2 ", self.qm_calls()) + + def test_sourceforge_download_keeps_the_selected_destination_name(self): + result = self.run_wizard(url="https://sourceforge.net/projects/fixture/iso/download") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.iso.read_bytes(), PAYLOAD) + self.assertFalse((self.directory / "remote-name.iso").exists()) + self.assertEqual({p.name for p in self.directory.iterdir()}, + {"bin", "wget.log", "qm.log", self.iso.name}) + + def test_failed_virtio_replacement_preserves_previous_media(self): + self.iso.write_bytes(PAYLOAD) + driver = self.directory / "virtio-win.iso" + driver.write_bytes(b"previous driver") + driver.chmod(0o640) + result = self.run_wizard("/partial.iso", windows=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(driver.read_bytes(), b"previous driver") + self.assertEqual(driver.stat().st_mode & 0o777, 0o640) + self.assertNotIn("-ide3 ", self.qm_calls()) + self.assertNotIn("OK:VirtIO driver ISO downloaded successfully.", result.stdout) + + def test_declining_virtio_replacement_reuses_previous_media(self): + self.iso.write_bytes(PAYLOAD) + driver = self.directory / "virtio-win.iso" + driver.write_bytes(b"previous driver") + result = self.run_wizard("/missing.iso", windows=True, overwrite=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(driver.read_bytes(), b"previous driver") + self.assertFalse(self.wget_log.exists()) + self.assertIn("-ide3 ", self.qm_calls()) + + def test_empty_cached_virtio_media_is_retried_not_attached(self): + self.iso.write_bytes(PAYLOAD) + driver = self.directory / "virtio-win.iso" + driver.write_bytes(b"") + result = self.run_wizard("/missing.iso", windows=True, overwrite=False) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue(self.wget_log.exists()) + self.assertNotIn("-ide3 ", self.qm_calls()) + self.assertIn("ERROR:Failed to download VirtIO driver ISO.", result.stdout) + + def test_a_directory_cannot_be_published_as_media(self): + self.iso.mkdir() + result = self.run_wizard() + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(self.qm_calls(), "") + self.assertTrue(self.iso.is_dir()) + self.assertEqual(list(self.iso.iterdir()), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_vm_iso_sources.py b/.github/scripts/tests/test_vm_iso_sources.py new file mode 100644 index 00000000..8eb9e46a --- /dev/null +++ b/.github/scripts/tests/test_vm_iso_sources.py @@ -0,0 +1,217 @@ +"""Offline VM source regressions; execute bounded real functions, never selectors. + +Fixtures are reduced official metadata excerpts captured 2026-10-16. Network +commands are shell stubs: no ISO downloads, utils sourcing, or host changes. +""" +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[3] +LINUX = ROOT / "scripts/vm/select_linux_iso.sh" +NAS = ROOT / "scripts/vm/select_nas_iso.sh" +FIELDS = ("ISO_NAME", "ISO_TYPE", "ISO_URL", "ISO_FILE", "ISO_PATH", "HN") + + +def extract_function(path, name): + """Only top-level function definitions with an unindented closing brace.""" + match = re.search( + rf"^function {re.escape(name)}\(\) \{{\n.*?^\}}(?=\n|$)", + path.read_text(), re.MULTILINE | re.DOTALL, + ) + if match is None: + raise AssertionError(f"Missing bounded function: {name}") + return match.group(0) + + +class IsoSourceTests(unittest.TestCase): + def run_selector(self, path, function, *, menu_name="", metadata="", + metadata_url="", fetch_status=0, has_curl=True): + with tempfile.TemporaryDirectory(prefix="vm-iso-sources-") as directory: + temp = Path(directory) + fixture = temp / "metadata.html" + fixture.write_text(metadata) + trace = temp / "fetches.txt" + trace.touch() + iso_dir = temp / "iso" + iso_dir.mkdir() + env = os.environ.copy() + env.update(ISO_DIR=str(iso_dir), MENU_NAME=menu_name, + METADATA_FILE=str(fixture), METADATA_URL=metadata_url, + FETCH_STATUS=str(fetch_status), FETCH_TRACE=str(trace)) + helpers = "\n".join( + extract_function(NAS, name) + for name in ("_has_curl", "_latest_version_from_lines") + ) + script = helpers + r''' +translate() { printf '%s' "$1"; } +curl() { + local url="${@: -1}" + printf '%s\n' "$url" >> "$FETCH_TRACE" + [[ "$url" == "$METADATA_URL" ]] || return 97 + [[ "$FETCH_STATUS" == 0 ]] || return "$FETCH_STATUS" + cat "$METADATA_FILE" +} +wget() { return 97; } +dialog() { + local arg previous="" name + for arg in "$@"; do + name="${arg%%│*}" + name="${name%"${name##*[![:space:]]}"}" + if [[ "$name" == "$MENU_NAME" ]]; then + printf '%s' "$previous" >&2 + return 0 + fi + previous="$arg" + done + return 1 +} +''' + if not has_curl: + script += "\n_has_curl() { return 1; }\n" + script += extract_function(path, function) + script += f"\n{function} || exit $?\n" + script += 'printf \'%s\\0\' ' + " ".join( + f'"${{{field}:-}}"' for field in FIELDS + ) + result = subprocess.run( + ["bash", "--noprofile", "--norc", "-c", script], + env=env, capture_output=True, timeout=10, + ) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + values = result.stdout.decode().split("\0") + self.assertEqual(len(values), len(FIELDS) + 1, result.stdout) + selected = dict(zip(FIELDS, values)) + self.assertEqual(selected["ISO_PATH"], str(iso_dir / selected["ISO_FILE"])) + return selected, trace.read_text().splitlines() + + def test_debian13_dvd_uses_fixed_official_archive(self): + selected, calls = self.run_selector( + LINUX, "select_linux_iso_official", menu_name="Debian 13", + ) + self.assertEqual(selected["ISO_URL"], + "https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-dvd/" + "debian-13.6.0-amd64-DVD-1.iso") + self.assertEqual(selected["ISO_TYPE"], "Desktop") + self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-DVD-1.iso") + self.assertEqual(calls, []) + + def test_debian13_netinst_uses_fixed_official_archive(self): + selected, calls = self.run_selector( + LINUX, "select_linux_iso_official", menu_name="Debian 13 Netinst", + ) + self.assertEqual(selected["ISO_URL"], + "https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-cd/" + "debian-13.6.0-amd64-netinst.iso") + self.assertEqual(selected["ISO_TYPE"], "CLI") + self.assertEqual(selected["ISO_FILE"], "debian-13.6.0-amd64-netinst.iso") + self.assertEqual(calls, []) + + def test_omv_metadata_unavailable_uses_verified_fallback(self): + url = "https://sourceforge.net/projects/openmediavault/files/iso/" + for has_curl, fetch_status, metadata in ( + (False, 0, ""), (True, 22, ""), + (True, 0, "No release links"), + ): + with self.subTest(has_curl=has_curl, fetch_status=fetch_status): + selected, calls = self.run_selector( + NAS, "resolve_omv_iso", metadata_url=url, has_curl=has_curl, + fetch_status=fetch_status, metadata=metadata, + ) + self.assertEqual(selected["ISO_URL"], + url + "8.3.1/openmediavault_8.3.1-amd64.iso/download") + self.assertEqual(selected["ISO_NAME"], "OpenMediaVault 8.3.1") + self.assertEqual(selected["ISO_FILE"], "openmediavault_8.3.1-amd64.iso") + self.assertEqual(calls, [url] if has_curl else []) + + def test_rockstor_resolves_official_landing_without_directory_listing(self): + # Exact installer anchor excerpt from https://rockstor.com/dls/index.html. + url = "https://rockstor.com/dls/index.html" + metadata = ( + '' + 'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso
' + ) + selected, calls = self.run_selector( + NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata, + ) + self.assertEqual(selected["ISO_URL"], + "https://rockstor.com/downloads/installer/leap/15.6/x86_64/" + "Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso") + self.assertEqual(selected["ISO_FILE"], + "Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso") + self.assertEqual(selected["HN"], "Rockstor") + self.assertEqual(calls, [url]) + + def test_rockstor_metadata_unavailable_uses_verified_fallback(self): + url = "https://rockstor.com/dls/index.html" + for has_curl, fetch_status, metadata in ( + (False, 0, ""), (True, 22, ""), + (True, 0, "No installer links"), + ): + with self.subTest(has_curl=has_curl, fetch_status=fetch_status): + selected, calls = self.run_selector( + NAS, "resolve_rockstor_iso", metadata_url=url, has_curl=has_curl, + fetch_status=fetch_status, metadata=metadata, + ) + self.assertEqual(selected["ISO_URL"], + "https://rockstor.com/downloads/installer/leap/15.6/x86_64/" + "Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso") + self.assertEqual(selected["ISO_NAME"], "Rockstor") + self.assertEqual(selected["ISO_FILE"], + "Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso") + self.assertEqual(calls, [url] if has_curl else []) + + def test_omv_current_metadata_resolution_is_preserved(self): + # Reduced release links from the official SourceForge ISO index. + url = "https://sourceforge.net/projects/openmediavault/files/iso/" + metadata = ( + '8.3.1\n' + '7.4.17' + ) + selected, calls = self.run_selector( + NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata, + ) + self.assertEqual(selected["ISO_URL"], + url + "8.3.1/openmediavault_8.3.1-amd64.iso/download") + self.assertEqual(calls, [url]) + + def test_omv_detected_release_takes_precedence_over_fallback(self): + url = "https://sourceforge.net/projects/openmediavault/files/iso/" + # Synthetic ordering case: must execute the real version parser. + metadata = ('new' + 'old') + selected, calls = self.run_selector( + NAS, "resolve_omv_iso", metadata_url=url, metadata=metadata, + ) + self.assertEqual(selected["ISO_URL"], + url + "8.10.0/openmediavault_8.10.0-amd64.iso/download") + self.assertEqual(calls, [url]) + + def test_rockstor_retains_generic_x86_64_leap156_edition(self): + url = "https://rockstor.com/dls/index.html" + # Synthetic ordering / edition cases; not claims of published images. + metadata = "\n".join(( + "Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso", + "Rockstor-Leap15.6-generic.x86_64-5.9.0-0.install.iso", + "Rockstor-Leap15.6-generic.aarch64-9.0.0-0.install.iso", + "Rockstor-Leap16.0-generic.x86_64-9.0.0-0.install.iso", + "Rockstor-Leap15.5-generic.x86_64-9.0.0-0.install.iso", + )) + selected, calls = self.run_selector( + NAS, "resolve_rockstor_iso", metadata_url=url, metadata=metadata, + ) + self.assertEqual(selected["ISO_URL"], + "https://rockstor.com/downloads/installer/leap/15.6/x86_64/" + "Rockstor-Leap15.6-generic.x86_64-5.10.0-0.install.iso") + self.assertEqual(calls, [url]) + + +if __name__ == "__main__": + unittest.main() diff --git a/AppImage/ProxMenux-1.2.6.2-beta.AppImage b/AppImage/ProxMenux-1.2.6.2-beta.AppImage index 1d8b7ed9..f8c72490 100755 Binary files a/AppImage/ProxMenux-1.2.6.2-beta.AppImage and b/AppImage/ProxMenux-1.2.6.2-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 48e022f1..5a19cd72 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -90a46ab219d193ee635165ae1761f1c3806f0f0be2fc4434907ea6e8d2c86414 ProxMenux-1.2.6.2-beta.AppImage +79ba79ea553e9ba473b33c8614f4dae5f684c960390f42272dc282c902627020 ProxMenux-1.2.6.2-beta.AppImage diff --git a/AppImage/components/lxc-terminal-modal.tsx b/AppImage/components/lxc-terminal-modal.tsx index 3afc8042..06b94886 100644 --- a/AppImage/components/lxc-terminal-modal.tsx +++ b/AppImage/components/lxc-terminal-modal.tsx @@ -382,6 +382,19 @@ export function LxcTerminalModal({ return } + + // The host prompt came back: pct enter could not enter the + // container. Show why and close, so the session is never left + // on the host shell under the container's name. + const reason = afterPctEnter.split(/\r?\n/).slice(0, -1) + .map((line) => line.trim()).filter(Boolean).join("\r\n") + if (reason) term.writeln(`\x1b[31m${reason}\x1b[0m`) + term.writeln(`\r\n\x1b[33m${/Failed to exec/.test(reason) + ? t("lxcTerminal.errors.noShell") + : t("lxcTerminal.errors.enterFailed")}\x1b[0m`) + isInsideLxcRef.current = true + ws.close() + return } } } else { @@ -688,6 +701,10 @@ export function LxcTerminalModal({ Ctrl+C {t("scriptTerminal.cancelInterrupt")} + sendKey("\x04")}> + Ctrl+D + {t("scriptTerminal.endOfInput")} + sendKey("\x18")}> Ctrl+X {t("scriptTerminal.exitNano")} diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index 621743a2..ac9733f1 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -18,7 +18,7 @@ import { Trash2, ChevronRight, TestTube2, Mail, Webhook, Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload, Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb, - Moon, Newspaper + Moon, Newspaper, History, ChevronDown } from "lucide-react" interface ChannelConfig { @@ -109,7 +109,8 @@ interface HistoryEntry { title: string severity: string sent_at: string - success: boolean + success: boolean | number + message?: string | null error_message: string | null } @@ -331,7 +332,8 @@ export function NotificationSettings() { const { resolvedTheme } = useTheme() const [config, setConfig] = useState(DEFAULT_CONFIG) const [status, setStatus] = useState(null) - const [history, setHistory] = useState([]) + const [history, setHistory] = useState(null) + const [historyError, setHistoryError] = useState(false) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) @@ -452,12 +454,12 @@ export function NotificationSettings() { const loadHistory = useCallback(async () => { try { - const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20") - if (data.success) { - setHistory(data.history || []) - } + const data = await fetchApi<{ history?: HistoryEntry[]; error?: string }>("/api/notifications/history?limit=20") + if (data.error || !Array.isArray(data.history)) throw new Error("history unavailable") + setHistory(data.history) + setHistoryError(false) } catch { - // Ignore + setHistoryError(true) } }, []) @@ -1097,15 +1099,6 @@ export function NotificationSettings() { } } - const handleClearHistory = async () => { - try { - await fetchApi("/api/notifications/history", { method: "DELETE" }) - setHistory([]) - } catch { - // Ignore - } - } - // Maps each eye-button local key to the body shape the backend // expects for /api/notifications/reveal-secret. Centralised here so // the JSX call site stays a one-liner — `toggleSecret(key)`. @@ -2761,6 +2754,75 @@ export function NotificationSettings() { )} + {/* ── Notification history ── */} +
+ + {showHistory && ( + historyError ? ( +

{t("settings.notifications.history.loadFailed")}

+ ) : history === null ? ( +
+ ) : history.length === 0 ? ( +

{t("settings.notifications.history.empty")}

+ ) : ( +
+ {history.map(entry => { + const sent = Boolean(entry.success) + const severity = (entry.severity || "").toLowerCase() + const detail = !sent ? entry.error_message : entry.event_type === "digest" ? entry.message : null + return ( +
+ {sent ? ( + + ) : ( + + )} +
+ {entry.title || entry.event_type} + + {entry.channel ? entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1) : ""} · {new Date(entry.sent_at).toLocaleString()} + + {detail && ( +

{detail}

+ )} +
+ {(severity === "critical" || severity === "warning" || severity === "info") && ( + + {severity === "critical" + ? t("settings.notifications.history.severityCritical") + : severity === "warning" + ? t("settings.notifications.history.severityWarning") + : t("settings.notifications.history.severityInfo")} + + )} +
+ ) + })} +
+ ) + )} +
)} diff --git a/AppImage/components/release-notes-modal.tsx b/AppImage/components/release-notes-modal.tsx index 52b956cb..c8caa849 100644 --- a/AppImage/components/release-notes-modal.tsx +++ b/AppImage/components/release-notes-modal.tsx @@ -307,7 +307,7 @@ const CURRENT_VERSION_FEATURES = [ { icon: , key: "releaseNotes.currentFeatures.ociManager", - text: "OCI manager Apps (beta) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.", + text: "OCI manager Apps (Technology Preview) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.", }, { icon: , diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index 605ed58a..c993281d 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -918,6 +918,10 @@ const initMessage = { Ctrl+C {t("scriptTerminal.cancelInterrupt")}
+ sendCommand("\x04")}> + Ctrl+D + {t("scriptTerminal.endOfInput")} + sendCommand("\x18")}> Ctrl+X {t("scriptTerminal.exitNano")} diff --git a/AppImage/components/terminal-panel.tsx b/AppImage/components/terminal-panel.tsx index 13ba67d3..d65ac01c 100644 --- a/AppImage/components/terminal-panel.tsx +++ b/AppImage/components/terminal-panel.tsx @@ -1038,6 +1038,10 @@ const handleClose = () => { Ctrl+C {t("scriptTerminal.cancelInterrupt")} + sendSequence("\x04")}> + Ctrl+D + {t("scriptTerminal.endOfInput")} + sendSequence("\x18")}> Ctrl+X {t("scriptTerminal.exitNano")} diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index a0660d18..87d1bf07 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -4,6 +4,7 @@ import type React from "react" import { useState, useMemo, useEffect, useRef } from "react" import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache" +import { getUpdateActionButtonClass } from "../lib/update-action-style" import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" @@ -27,8 +28,9 @@ import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor" import { formatStorage } from "../lib/utils" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" +import { waitForVmControlTask } from "../lib/vm-control" import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search" -import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation" +import { refreshCheckLxcUpdates, getLxcOsUpdateState, isLxcUpdateStale } from "../lib/lxc-update-observation" import DOMPurify from "dompurify" import { marked } from "marked" import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider" @@ -871,6 +873,8 @@ export function VirtualMachines() { const lifecycleCacheRevisionsRef = useRef(new Map()) const [dockerInventoryRefreshingVmid, setDockerInventoryRefreshingVmid] = useState(null) const [controlLoading, setControlLoading] = useState(false) + const controlRequestRef = useRef(false) + const [controlAction, setControlAction] = useState<{ vmid: number; action: string } | null>(null) // Destructive control confirmation. `Force Stop` and `Reboot` skip the OS // shutdown sequence and can corrupt running guests; gate them behind a // typed-VMID match prompt to prevent misclicks. See audit Tier 2 #17. @@ -917,6 +921,7 @@ export function VirtualMachines() { // (or after a 15 s safety timeout). const [updatesRefreshing, setUpdatesRefreshing] = useState(false) const [osCheckingVmid, setOsCheckingVmid] = useState(null) + const osCheckRequestRef = useRef(null) const [osCheckError, setOsCheckError] = useState(null) const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null) const [updatesBaselineCount, setUpdatesBaselineCount] = useState(null) @@ -1231,6 +1236,12 @@ export function VirtualMachines() { useEffect(() => { if (!updatesRefreshing) return if (!selectedVM) return + if (!selectedVM.update_check || selectedVM.update_check.error) { + setUpdatesResult(null) + setUpdatesRefreshing(false) + setUpdatesBaselineCount(null) + return + } const currentCount = selectedVM.update_check?.count ?? 0 if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) { const applied = Math.max(0, updatesBaselineCount - currentCount) @@ -1250,7 +1261,8 @@ export function VirtualMachines() { const safety = setTimeout(() => { const currentCount = selectedVMRef.current?.update_check?.count ?? 0 const baseline = updatesBaselineCountRef.current - setUpdatesResult({ + const observation = selectedVMRef.current?.update_check + setUpdatesResult(!observation || observation.error ? null : { pendingAfter: currentCount, appliedCount: Math.max(0, (baseline ?? 0) - currentCount), }) @@ -1788,12 +1800,16 @@ export function VirtualMachines() { } const handleVMControl = async (vmid: number, action: string) => { + if (controlRequestRef.current) return + controlRequestRef.current = true + setControlAction({ vmid, action }) setControlLoading(true) try { - await fetchApi(`/api/vms/${vmid}/control`, { + const accepted = await fetchApi<{ task: string }>(`/api/vms/${vmid}/control`, { method: "POST", body: JSON.stringify({ action }), }) + const completed = await waitForVmControlTask(vmid, action, accepted.task) // Any control action can change what the guest reports: a stop // hides runtime-only fields, a start may bring a new config that @@ -1809,16 +1825,37 @@ export function VirtualMachines() { cache.firewall.delete(vmid) invalidateLxcApps(vmid) - mutate() - setSelectedVM(null) - setVMDetails(null) + // Publish the confirmed result as an SWR mutation before revalidating. + // SWR then discards list requests started before this completion. + try { + await mutate(current => current?.map(vm => vm.vmid === vmid + ? { ...vm, status: completed.guest_status as VMData["status"] } : vm), { revalidate: false }) + await mutate() + } catch { /* Polling will retry; control already succeeded. */ } + // Keep the user's tab/scroll and last complete detail while revalidating. + // Never resurrect a modal the user closed, or overwrite another guest. + if (selectedVMRef.current?.vmid === vmid) { + setSelectedVM(current => current?.vmid === vmid + ? { ...current, status: completed.guest_status as VMData["status"] } : current) + try { + const details = await fetchApi(`/api/vms/${vmid}`) + cache.details.set(vmid, details) + if (selectedVMRef.current?.vmid === vmid) setVMDetails(details) + } catch { + // The control succeeded; detail polling will retry independently. + } + } } catch (error) { console.error(`Failed to ${action} VM ${vmid}:`, error) // Same UX issue as handleCreateBackup: a silent console.error left the // user looking at a "Stop"/"Start" button that just never reacted. - const msg = error instanceof Error ? error.message : t("vmLxc.errors.unknown") + const msg = error instanceof Error + ? (error.message === "controlNotConfirmed" ? t("vmLxc.errors.controlNotConfirmed") : error.message) + : t("vmLxc.errors.unknown") alert(t("vmLxc.errors.controlFailed", { action, vmid, message: msg })) } finally { + controlRequestRef.current = false + setControlAction(null) setControlLoading(false) } } @@ -1983,6 +2020,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const [applyRestart, setApplyRestart] = useState(false) const [applyStartedAt, setApplyStartedAt] = useState(0) const [applyRunId, setApplyRunId] = useState("") + const [applyCheckedOs, setApplyCheckedOs] = useState(false) const [applyTargetIds, setApplyTargetIds] = useState([]) const [applyTargetLabels, setApplyTargetLabels] = useState([]) // Extra state carried alongside applyTarget when the App branch is @@ -2029,6 +2067,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // detected via the community-scripts pattern. All loaded once when // the user opens the Updates tab of a specific LXC. const [scheduleLoaded, setScheduleLoaded] = useState(null) + const scheduleLoadRequestRef = useRef(0) const [scheduleEnabled, setScheduleEnabled] = useState(false) const [scheduleCron, setScheduleCron] = useState("0 3 * * *") const [schedulePreset, setSchedulePreset] = useState("daily-3am") @@ -2073,6 +2112,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // with scheduled updates: changing a one-click manual selection must never // rewrite the cron automation configured in Options. const [bulkLoaded, setBulkLoaded] = useState(null) + const bulkLoadRequestRef = useRef(0) const [bulkConfigured, setBulkConfigured] = useState(false) const [bulkTargets, setBulkTargets] = useState(["os"]) const [bulkPersistedTargets, setBulkPersistedTargets] = useState(["os"]) @@ -2107,9 +2147,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ? s.targets.map((value: any) => String(value)) : ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])])) setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0) - if (s.backup !== undefined) setApplyBackup(!!s.backup) - if (s.backup_storage) setApplyBackupStorage(s.backup_storage) - if (s.restart !== undefined) setApplyRestart(!!s.restart) + setApplyBackup(!!s.backup) + setApplyBackupStorage(typeof s.backup_storage === "string" ? s.backup_storage : "") + setApplyRestart(!!s.restart) setScheduleLastRunAt(s.last_run_at || null) setScheduleLastRunStatus(s.last_run_status || null) setScheduleLastRunReason(s.last_run_reason || null) @@ -2122,30 +2162,46 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { } const loadSchedule = async (vmid: number) => { + if (selectedVMRef.current?.vmid !== vmid) return + const requestId = ++scheduleLoadRequestRef.current + const isCurrent = () => selectedVMRef.current?.vmid === vmid + && scheduleLoadRequestRef.current === requestId setScheduleError(null) // Seed from ref cache so tab-switching to Updates isn't a "Loading…" // moment when the payload was already fetched this session. const cachedSched = vmModalCacheRef.current.schedule.get(vmid) - if (cachedSched) applySchedulePayload(cachedSched) + // Missing options are this guest's defaults, never the previous guest's. + applySchedulePayload(cachedSched || {}) try { const s: any = await fetchApi(`/api/vms/${vmid}/schedule`) - if (s && typeof s === "object") { - vmModalCacheRef.current.schedule.set(vmid, s) - applySchedulePayload(s) - } + if (!isCurrent()) return + if (!s || typeof s !== "object" || Array.isArray(s)) + throw new Error(t("vmLxc.scheduled.loadFailed")) + vmModalCacheRef.current.schedule.set(vmid, s) + applySchedulePayload(s) } catch (e: any) { - setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed")) + if (isCurrent()) setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed")) } finally { - setScheduleLoaded(vmid) + if (isCurrent()) setScheduleLoaded(vmid) } } const loadBulkUpdate = async (vmid: number) => { + if (selectedVMRef.current?.vmid !== vmid) return + const requestId = ++bulkLoadRequestRef.current + const isCurrent = () => selectedVMRef.current?.vmid === vmid + && bulkLoadRequestRef.current === requestId setBulkError(null) setBulkLoaded(null) setBulkEditMode(false) + setBulkConfigured(false) + setBulkTargets(["os"]) + setBulkPersistedTargets(["os"]) try { const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`) + if (!isCurrent()) return + if (!config || typeof config !== "object" || Array.isArray(config)) + throw new Error(t("vmLxc.bulkUpdate.loadFailed")) const targets = Array.isArray(config?.targets) ? Array.from(new Set(config.targets.map((value: any) => String(value)))) as string[] : [] @@ -2155,12 +2211,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setBulkTargets(next) setBulkPersistedTargets(next) } catch (e: any) { + if (!isCurrent()) return setBulkConfigured(false) setBulkTargets(["os"]) setBulkPersistedTargets(["os"]) setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed")) } finally { - setBulkLoaded(vmid) + if (isCurrent()) setBulkLoaded(vmid) } } @@ -2560,8 +2617,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const openApplyTerminal = ( vmid: number, target: "os" | "app" | "both", - opts?: { updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] }, + opts?: { checkedOs?: boolean; updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] }, ) => { + const guest = selectedVMRef.current + if (target === "os" || target === "both") { + if (osCheckingVmid !== null || osCheckRequestRef.current !== null || updatesRefreshing || (opts?.checkedOs && osCheckError)) return + const state = guest?.vmid === vmid ? getLxcOsUpdateState(guest.update_check) : "unknown" + if (state === "unknown") { + setOsCheckError(t("vmLxc.updates.checkBeforeUpdate")) + return + } + // A combined plan can still update apps when OS packages are current. + if (target === "os" && state === "current") return + } + // The explicit update button is the user's confirmation. The runner + // owns the temporary start, backup and restoration of a stopped guest. const fallbackTargets = target === "os" ? ["os"] : target === "both" @@ -2584,6 +2654,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setApplyVmid(vmid) setApplyTarget(target) setApplyRunId(runId) + setApplyCheckedOs(opts?.checkedOs === true) setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets) setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels) setApplyUpdateCommand(opts?.updateCommand || "") @@ -2680,10 +2751,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // false "N package pending" every time a registered app has an // upstream bump. const checkOsUpdates = async (vmid: number) => { + if (osCheckRequestRef.current !== null || applyOpen || controlLoading) return + osCheckRequestRef.current = vmid setOsCheckingVmid(vmid) setOsCheckError(null) try { - await checkLxcUpdates(vmid) + const observation = await refreshCheckLxcUpdates(vmid) + await mutate((current) => Array.isArray(current) + ? current.map(vm => vm.vmid === vmid ? { ...vm, update_check: observation } : vm) + : current, { revalidate: false }) + setSelectedVM(current => current?.vmid === vmid ? { ...current, update_check: observation } : current) const details = await fetchApi(`/api/vms/${vmid}`) vmModalCacheRef.current.details.set(vmid, details) if (selectedVMRef.current?.vmid === vmid) setVMDetails(details) @@ -2692,9 +2769,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { if (selectedVMRef.current?.vmid === vmid) { setOsCheckError(error.status === 429 ? t("vmLxc.updates.checkCooldown", { seconds: error.body?.retry_after ?? 60 }) - : error.message || t("vmLxc.updates.osStatusUnavailable")) + : error.message === "checkStillRunning" + ? t("vmLxc.updates.checkStillRunning") + : t("vmLxc.updates.osStatusUnavailable")) } } finally { + osCheckRequestRef.current = null setOsCheckingVmid(null) } } @@ -5707,7 +5787,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { !selectedVM.update_check?.is_oci_lxc && (() => { const uc = selectedVM.update_check const osUpdateStale = isLxcUpdateStale(uc) - const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale + const osUpdateStatusKnown = getLxcOsUpdateState(uc) !== "unknown" const hasOsUpdates = osUpdateStatusKnown && !!uc.available const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker") const dockerRegistered = !!dockerAppWatch @@ -5863,6 +5943,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { return { id: `app:${app.id}`, label: app.name || t("vmLxc.updates.applicationDefaultName"), + methodLabel: app.update_command?.trim() + ? t("vmLxc.updates.customCommandTitle") + : t("vmLxc.updates.helperMethod"), detail: "", logoUrl: webLinkLogo || app.logo_url?.trim() || "", } @@ -5877,6 +5960,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ? [{ id: "docker-engine", label: "Docker Engine", + methodLabel: t("vmLxc.scheduled.dockerEngineTarget"), detail: "", logoUrl: "", }] @@ -5884,6 +5968,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ...dockerUpdateUnits.map((unit) => ({ id: unit.id, label: unit.display_name || unit.primary_reference || unit.primary_service || unit.id, + methodLabel: unit.kind === "compose" + ? "Docker Compose" + : t("vmLxc.bulkUpdate.dockerImageMethod"), detail: (unit.dependent_services || []).length ? t("vmLxc.bulkUpdate.includesDependencies", { names: (unit.dependent_services || []).join(", "), @@ -5891,7 +5978,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { : "", logoUrl: unit.logo_url || "", })), - ] + ].map((choice, _index, choices) => ({ + ...choice, + // Keep distinct action IDs; names alone never prove + // that a custom updater and an image are one action. + label: choices.filter((other) => + other.label.trim().toLowerCase() === choice.label.trim().toLowerCase() + ).length > 1 ? `${choice.label} — ${choice.methodLabel}` : choice.label, + })) const bulkChoices = [{ id: "os", label: t("vmLxc.bulkUpdate.osTarget"), @@ -5935,7 +6029,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // has been checked and is current, otherwise unknown. const selectedBulkUpdateStates = bulkTargets.map((target) => { if (target === "os") { - if (!uc || uc.error) return null + if (!osUpdateStatusKnown) return null return hasOsUpdates } if (target === "docker-engine") { @@ -5960,6 +6054,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { return null }) const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true) + const bulkHasUnknownTargets = selectedBulkUpdateStates.some((state) => state === null) const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0 && selectedBulkUpdateStates.every((state) => state === false) const toggleBulkChoice = (id: string, checked: boolean) => { @@ -5968,9 +6063,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ? Array.from(new Set(["os", ...current.filter((value) => value !== "os"), id])) : current.filter((value) => value !== id)) } - const pendingBtnCls = "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" - const upToDateBtnCls = "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300" - const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground" + const pendingBtnCls = getUpdateActionButtonClass(true, false) + const upToDateBtnCls = getUpdateActionButtonClass(false, true) return ( <> @@ -5982,14 +6076,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

{t("vmLxc.updates.osPackagesTitle")}

+ {selectedVM.status !== "running" && ( +

+ {t("vmLxc.updates.startBeforeCheck")} +

+ )}
{t("vmLxc.updates.lastCheckedPrefix")}{" "} {uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"} @@ -5998,7 +6097,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )}
{osCheckError &&

{osCheckError}

} - {osUpdateStale && !uc?.error ? ( + {osCheckingVmid === selectedVM.vmid ? ( +
+ + {t("vmLxc.updates.checkInProgress")} +
+ ) : !uc?.last_check && !uc?.error ? ( +

{t("vmLxc.updates.notCheckedYet")}

+ ) : osUpdateStale && !uc?.error ? (

{t("vmLxc.updates.checkStaleHelp")}

) : !osUpdateStatusKnown ? (
{ {t("vmLxc.updates.noOsUpdatesPending")}
)} -
+ {osUpdateStatusKnown && osCheckingVmid !== selectedVM.vmid &&
+ {hasOsUpdates ? ( -
+ ) : ( + + )} +
} {/* Docker has three independent lifecycles: @@ -6200,11 +6311,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { dockerRefresh: true, }, )} - className={`${dockerEngineHasUpdate - ? pendingBtnCls - : dockerEngineUpToDate - ? upToDateBtnCls - : neutralBtnCls} flex-shrink-0`} + className={`${getUpdateActionButtonClass(dockerEngineHasUpdate, dockerEngineUpToDate)} flex-shrink-0`} > {dockerEngineHasUpdate && } {!dockerEngineHasUpdate && !dockerEngineUpToDate && } @@ -6480,7 +6587,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ) : helperTracksVersion ? (

- {t("vmLxc.updates.versionTrackingPending")} + {t("vmLxc.updates.versionStatusUnknown")}

) : helperExists ? (

@@ -6503,7 +6610,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // still force the helper's own // update script. const noState = !hasUpd && !upToD - const cls = hasUpd ? pendingBtnCls : upToD ? upToDateBtnCls : neutralBtnCls + const cls = getUpdateActionButtonClass(hasUpd, upToD) const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater") return (

@@ -6639,7 +6746,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : null ) : ( -
{t("vmLxc.updates.versionTrackingPendingShort")}
+
{t("vmLxc.updates.versionStatusUnknown")}
)} ) : tracksVersion ? ( @@ -6651,7 +6758,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {t("vmLxc.updates.dockerImagesSubheading")} - : t("vmLxc.updates.versionTrackingPendingShort")} + : aw.installed_via === "manual" + ? t("vmLxc.updates.installedVersionUnverified") + : t("vmLxc.updates.versionStatusUnknown")} ) : hasCmd ? (
@@ -6673,7 +6782,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { targetLabels: [aw.name || t("vmLxc.updates.applicationDefaultName")], }, )} - className={hasUpdate ? pendingBtnCls : upToDate ? upToDateBtnCls : neutralBtnCls} + className={getUpdateActionButtonClass(hasUpdate, upToDate)} > {hasUpdate && } {!hasUpdate && !upToDate && } @@ -6681,7 +6790,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ? t("vmLxc.updates.applyUpdate") : upToDate ? t("vmLxc.updates.upToDate") - : t("vmLxc.updates.runUpdater")} + : aw.update_method === "helper" + ? t("vmLxc.updates.runUpdater") + : t("vmLxc.updates.runCustomCommand")}
) : ( @@ -6719,6 +6830,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

{t("vmLxc.bulkUpdate.description")}

+ {dockerRegistered && ( +

+ {t("vmLxc.bulkUpdate.osDockerScope")} +

+ )} {!bulkEditMode && ( ) @@ -7916,7 +8036,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setApplyVmid(null) }} onComplete={handleApplyComplete} - scriptPath="/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh" + scriptPath={applyCheckedOs + ? "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh" + : "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"} scriptName="lxc_apply_updates" title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })} completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")} diff --git a/AppImage/lib/lxc-update-observation.ts b/AppImage/lib/lxc-update-observation.ts index 6b19d02d..8a00d565 100644 --- a/AppImage/lib/lxc-update-observation.ts +++ b/AppImage/lib/lxc-update-observation.ts @@ -4,6 +4,17 @@ interface UpdateObservation { last_check?: string | null stale?: boolean stale_after_seconds?: number + available?: boolean + count?: number | null + error?: string | null +} + +export function getLxcOsUpdateState(observation?: UpdateObservation, now = Date.now()): "unknown" | "pending" | "current" { + if (isLxcUpdateStale(observation, now) || observation?.error + || typeof observation?.available !== "boolean" + || !Number.isInteger(observation.count) || (observation.count ?? -1) < 0 + || observation.available !== ((observation.count ?? 0) > 0)) return "unknown" + return observation.available ? "pending" : "current" } export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now()): boolean { @@ -19,3 +30,18 @@ export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now export function checkLxcUpdates(vmid: number): Promise { return fetchApi(`/api/vms/${vmid}/updates/check`, { method: "POST" }) } + +// Explicit action: refresh indexes and check an already-running CT. The +// backend owns the job even if the browser closes or navigates away. +export async function refreshCheckLxcUpdates(vmid: number): Promise { + const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" }) + const deadline = Date.now() + 8 * 60_000 + while (Date.now() < deadline) { + const result = await fetchApi<{ state: string; error?: string; update_check?: T }>( + `/api/vms/${vmid}/updates/refresh-check/${job.job_id}`) + if (result.state === "failed") throw new Error("osStatusUnavailable") + if (result.state === "complete") return result.update_check as T + await new Promise(resolve => setTimeout(resolve, 1000)) + } + throw new Error("checkStillRunning") +} diff --git a/AppImage/lib/update-action-style.ts b/AppImage/lib/update-action-style.ts new file mode 100644 index 00000000..ea0a51d1 --- /dev/null +++ b/AppImage/lib/update-action-style.ts @@ -0,0 +1,7 @@ +// Use the dashboard's existing update colors for every executable action. +// Version availability remains separate from action styling and enablement. +export function getUpdateActionButtonClass(hasUpdate: boolean, isCurrent: boolean): string { + return !hasUpdate && isCurrent + ? "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300" + : "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" +} diff --git a/AppImage/lib/vm-control.ts b/AppImage/lib/vm-control.ts new file mode 100644 index 00000000..2d250a5c --- /dev/null +++ b/AppImage/lib/vm-control.ts @@ -0,0 +1,32 @@ +import { fetchApi } from './api-config' + +// A successful control POST accepts an asynchronous Proxmox task. In +// particular, an unchanged "running" status cannot confirm a reboot. +export async function waitForVmControlTask(vmid: number, action: string, task: string) { + if (!task) throw new Error('controlNotConfirmed') + const expected = action === 'shutdown' || action === 'stop' ? 'stopped' : 'running' + const deadline = Date.now() + 180_000 + let readFailures = 0 + while (Date.now() < deadline) { + let result: { status?: string; exitstatus?: string; guest_status?: string } + try { + result = await fetchApi(`/api/vms/${vmid}/control-task/${encodeURIComponent(task)}`, { + signal: AbortSignal.timeout(10_000), + }) + readFailures = 0 + } catch (error) { + if (++readFailures >= 3) throw new Error('controlNotConfirmed') + await new Promise(resolve => setTimeout(resolve, 1000)) + continue + } + if (result?.status === 'stopped') { + if (!result.exitstatus) throw new Error('controlNotConfirmed') + if (result.exitstatus !== 'OK') throw new Error(result.exitstatus) + if (result.guest_status === expected) return result + } else if (result?.status !== 'running') { + throw new Error('controlNotConfirmed') + } + await new Promise(resolve => setTimeout(resolve, 1000)) + } + throw new Error('controlNotConfirmed') +} diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index f93d7a9b..4093529a 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -18,7 +18,7 @@ "refresh": "Aktualisieren", "toggleTheme": "Thema umschalten", "openUserMenu": "Benutzermenü öffnen", - "cancel": "Stornieren", + "cancel": "Abbrechen", "save": "Speichern", "edit": "Bearbeiten", "remove": "Entfernen", @@ -32,16 +32,16 @@ "navigation": { "overview": "Überblick", "apps": "Apps", - "storage": "Lagerung", + "storage": "Speicher", "network": "Netzwerk", "virtualMachines": "VMs und LXCs", "hardware": "Hardware", - "backup": "Sicherung", + "backup": "Backup", "terminal": "Terminal", "systemLogs": "Systemprotokolle", "security": "Sicherheit", "settings": "Einstellungen", - "about": "Um", + "about": "Über", "profile": "Profil", "node": "Knoten", "admin": "Admin", @@ -86,22 +86,22 @@ "networkMetricsUnavailable": "Netzwerkmetriken sind noch nicht verfügbar", "noNetworkMetrics": "Keine Netzwerkmetriken verfügbar", "cpuUsage": "CPU-Auslastung", - "cpuUsageLoadAverage": "CPU-Auslastung und durchschnittliche Auslastung", + "cpuUsageLoadAverage": "CPU-Auslastung und Durchschnitt", "cpuPercent": "CPU %", - "loadAverage": "Ladedurchschn", + "loadAverage": "Ø Auslastung", "loadAxis": "Laden", - "memory": "Erinnerung", - "memoryUsage": "Speichernutzung", + "memory": "Arbeitsspeicher", + "memoryUsage": "Arbeitsspeichernutzung", "zram": "ZRAM", "zramCapacity": "Kapazität", "zramSwapUsed": "Verwendeter Swap", "zramAlgorithm": "Algorithmus", "zramCompressionRatio": "Kompressionsverhältnis", - "zramPhysicalMemory": "Verwendeter physischer Speicher", + "zramPhysicalMemory": "Verwendeter Arbeitsspeicher", "user": "Benutzer", "system": "System", "cores": "Kerne", - "used": "Gebraucht", + "used": "Verwendet", "cached": "Zwischengespeichert", "total": "Gesamt", "free": "Frei", @@ -115,9 +115,9 @@ "storageOverview": "Speicherübersicht", "totalNodeCapacity": "Gesamtknotenkapazität:", "totalCapacity": "Gesamtkapazität:", - "physicalDisks": "Physische Festplatten:", - "diskSingular": "Scheibe", - "diskPlural": "Festplatten", + "physicalDisks": "Physische Laufwerke:", + "diskSingular": "Laufwerk", + "diskPlural": "Laufwerke", "vmLxcStorage": "VM/LXC-Speicher", "available": "Verfügbar", "storageVolumeSingular": "Speichervolumen", @@ -127,9 +127,9 @@ "storageDataUnavailable": "Speicherdaten nicht verfügbar", "networkOverview": "Netzwerkübersicht", "activeInterfaces": "Aktive Schnittstellen:", - "received": "Erhalten:", + "received": "Empfangen:", "sent": "Gesendet:", - "receivedShort": "Erhalten", + "receivedShort": "Enpfangen", "sentShort": "Gesendet", "networkDataUnavailable": "Netzwerkdaten nicht verfügbar", "systemInformation": "Systeminformationen", @@ -155,7 +155,7 @@ "year": "1 Jahr" }, "stats": { - "avg": "avg", + "avg": "Ø", "max": "max", "min": "min" }, @@ -164,39 +164,39 @@ "serverUnavailableDescription": "Es kann keine Verbindung zum Monitor-Server hergestellt werden. Stellen Sie sicher, dass der Dienst ausgeführt wird, und versuchen Sie es erneut." }, "runningCountOne": "{count} läuft", - "runningCountFew": "{count} läuft", - "runningCountMany": "{count} läuft" + "runningCountFew": "{count} laufen", + "runningCountMany": "{count} laufen" }, "storage": { "loadingTitle": "Speicherdaten werden geladen...", - "loadingDescription": "Scannen von Festplatten, Partitionen und Speicherpools", + "loadingDescription": "Scannen von Laufwerke, Partitionen und Speicherpools", "loadingError": "Fehler beim Laden der Speicherdaten: {error}", "storageUsed": "Verwendeter Speicher", "localUsed": "Lokal verwendet", - "remoteUsed": "Fernbedienung verwendet", - "physicalDisks": "Physische Festplatten", + "remoteUsed": "Remote verwendet", + "physicalDisks": "Physische Laufwerke", "proxmoxStorage": "Proxmox-Speicher", - "remoteMounts": "Fernmontagen", + "remoteMounts": "Remote-Mounts", "externalStorageUsb": "Externer Speicher (USB)", - "physicalDisksSmart": "Physische Festplatten und SMART-Status", + "physicalDisksSmart": "Physische Laufwerke und SMART-Status", "zfsPools": "ZFS-Pools", "local": "Lokal", - "remote": "Fernbedienung", + "remote": "Remote", "free": "Frei", - "used": "Gebraucht", + "used": "Verwendet", "total": "Gesamt", "available": "Verfügbar", "size": "Größe", "allocated": "Zugewiesen", "none": "Keiner", "noRemoteStorage": "Kein Remote-Speicher", - "diskSingular": "Scheibe", - "diskPlural": "Festplatten", + "diskSingular": "Laufwerk", + "diskPlural": "Laufwerke", "allHealthy": "alle gesund", "criticalCount": "{count} kritisch", "warningCount": "{count} Warnung", "remoteMount": "Remote-Montage", - "remoteMountShort": "Fernbedienung", + "remoteMountShort": "Remote", "excluded": "ausgeschlossen", "notMonitored": "nicht überwacht", "namespaceRestricted": "Namespace-beschränkt", @@ -228,26 +228,26 @@ "serialShort": "Seriennummer:", "viewDetails": "Details anzeigen", "diskDetails": "Datenträgerdetails: /dev/{name}", - "physicalDisk": "Physische Festplatte", + "physicalDisk": "Physische Laufwerk", "overview": "Überblick", "smart": "SMART", - "history": "Geschichte", + "history": "Verlauf", "schedule": "Zeitplan", "serialNumber": "Seriennummer", "healthStatus": "Gesundheitszustand", - "wearLifetime": "Tragen und lebenslang", + "wearLifetime": "Auslastung während der Haltbarkeit", "dataWritten": "Daten geschrieben", - "life": "Leben", - "lifeRemaining": "Verbleibendes Leben", + "life": "Haltbarkeit", + "lifeRemaining": "Verbleibende Haltbarkeit", "realTest": "Echter Test", "wear": { - "label": "Tragen", + "label": "Auslastung", "percentageUsed": "Prozentsatz der Nutzung", "mediaWearout": "Medienverschleiß", "wearLevel": "Verschleißgrad", - "lifeUsed": "Leben genutzt" + "lifeUsed": "Halbarkeit genutzt" }, - "estimatedLife": "Schätzung: Leben", + "estimatedLife": "Schätzung: Haltbarkeit", "noWearDetected": "Noch kein Verschleiß festgestellt", "months": "Monate", "years": "Jahre", @@ -268,10 +268,10 @@ "reallocatedSectors": "Neu zugewiesene Sektoren", "pendingSectors": "Ausstehende Sektoren", "temperature": "Temperatur", - "noSmartData": "Für diese Festplatte sind keine SMART-Testdaten verfügbar.", + "noSmartData": "Für dieses Laufwerk sind keine SMART-Testdaten verfügbar.", "smartDataHint": "Führen Sie auf der Registerkarte „SMART-Test“ einen SMART-Test durch, um detaillierte Gesundheitsinformationen zu erhalten.", "observations": "Beobachtungen", - "observationsDescription": "Die folgenden Beobachtungen wurden für diese Scheibe aufgezeichnet:", + "observationsDescription": "Die folgenden Beobachtungen wurden für dieses Laufwerk aufgezeichnet:", "loadingObservations": "Beobachtungen werden geladen...", "firstSeen": "Erste", "lastSeen": "Zuletzt", @@ -304,14 +304,14 @@ "smartTest": { "loading": "SMART-Daten werden geladen...", "toolsMissing": "SMART-Tools sind nicht installiert", - "nvmeToolRequired": "nvme-cli ist erforderlich, um SMART-Tests auf NVMe-Festplatten auszuführen.", - "smartctlRequired": "smartmontools ist erforderlich, um SMART-Tests auf dieser Festplatte auszuführen.", + "nvmeToolRequired": "nvme-cli ist erforderlich, um SMART-Tests auf NVMe-Laufwerke auszuführen.", + "smartctlRequired": "smartmontools ist erforderlich, um SMART-Tests auf dieses Laufwerks auszuführen.", "installingTools": "SMART-Tools werden installiert...", - "installTools": "Installieren Sie SMART-Tools", + "installTools": "SMART-Tools installieren", "installFailed": "Die Installation ist fehlgeschlagen", "installFailedManual": "Die Installation ist fehlgeschlagen. Versuchen Sie es manuell: apt-get install smartmontools nvme-cli", "installToolsManual": "Tools konnten nicht installiert werden. Versuchen Sie es manuell: apt-get install smartmontools nvme-cli", - "runTest": "Führen Sie den SMART-Test durch", + "runTest": "SMART-Test durchführen", "shortTest": "Kurztest", "longTest": "Langer Test (1-4 Stunden)", "extendedTest": "Erweiterter Test", @@ -321,7 +321,7 @@ "extended": "Erweitert", "testInProgress": "Der {type}-Test läuft", "testWait": "Bitte warten Sie, bis der Test abgeschlossen ist. Die Tasten werden automatisch entsperrt.", - "noProgress": "Die Firmware dieser Festplatte meldet keinen Fortschritt. Der Test läuft im Hintergrund.", + "noProgress": "Die Firmware dieses Laufwerks meldet keinen Fortschritt. Der Test läuft im Hintergrund.", "lastTest": "Letzter Test", "nvmeMetrics": "NVMe-Gesundheitsmetriken", "sasMetrics": "SAS/SCSI-Zustandsmetriken", @@ -344,7 +344,7 @@ "historyTab": { "loading": "Testverlauf wird geladen...", "empty": "Keine Testhistorie", - "emptyHint": "Führen Sie einen SMART-Test durch, um den Erstellungsverlauf für diese Festplatte zu starten.", + "emptyHint": "Führen Sie einen SMART-Test durch, um den Erstellungsverlauf für dieses Laufwerk zu starten.", "title": "Testverlauf", "latest": "letzte", "today": "Heute", @@ -360,25 +360,25 @@ "printPdf": "Drucken / Als PDF speichern", "printHintMac": "Verwenden Sie Cmd+P, um als PDF zu speichern", "printHintCtrl": "Verwenden Sie Strg+P, um als PDF zu speichern", - "subtitle": "ProxMenux Monitor – Analyse des Festplattenzustands", + "subtitle": "ProxMenux Monitor – Analyse Laufwerkszustands", "htmlLang": "de", "date": "Datum", "device": "Gerät", "executiveSummary": "Zusammenfassung", "smartStatus": "SMART-Status", - "healthAssessment": "Bewertung des Festplattenzustands", + "healthAssessment": "Bewertung des Laufwerkszustands", "passedUpper": "BESTANDEN", - "testAgeWarning": "Dieser Bericht basiert auf einem SMART-Test, der vor {days} Tagen ({date}) durchgeführt wurde. Möglicherweise hat sich der Zustand der Festplatte seitdem geändert. Wir empfehlen, einen neuen SMART-Test durchzuführen, um aktuelle Ergebnisse zu erhalten.", - "healthyAssessment": "Diese Festplatte arbeitet innerhalb normaler Parameter. Alle SMART-Attribute liegen innerhalb akzeptabler Schwellenwerte. Die Festplatte ist seit ca. {uptime} eingeschaltet und arbeitet derzeit mit {temperature}. {sectors}", - "failedAssessment": "Dieser Datenträger hat einen SMART-Zustandsfehler gemeldet. Es besteht sofortiger Handlungsbedarf. Sichern Sie alle wichtigen Daten und planen Sie den Austausch der Festplatte.", + "testAgeWarning": "Dieser Bericht basiert auf einem SMART-Test, der vor {days} Tagen ({date}) durchgeführt wurde. Möglicherweise hat sich der Zustand des Laufwerks seitdem geändert. Wir empfehlen, einen neuen SMART-Test durchzuführen, um aktuelle Ergebnisse zu erhalten.", + "healthyAssessment": "Dieses Laufwerk arbeitet innerhalb normaler Parameter. Alle SMART-Attribute liegen innerhalb akzeptabler Schwellenwerte. Das Laufwerk ist seit ca. {uptime} eingeschaltet und arbeitet derzeit mit {temperature}. {sectors}", + "failedAssessment": "Dieser Datenträger hat einen SMART-Zustandsfehler gemeldet. Es besteht sofortiger Handlungsbedarf. Sichern Sie alle wichtigen Daten und planen Sie den Austausch des Laufwerks.", "noBadSectors": "Es wurden keine fehlerhaften Sektoren erkannt.", "reallocatedSectors": "{count} neu zugewiesene(r) Sektor(en) erkannt – sorgfältig überwachen.", - "healthyMeaningTitle": "Was bedeutet das? Ihre Festplatte ist fehlerfrei!", + "healthyMeaningTitle": "Was bedeutet das? Ihr Laufwerk ist fehlerfrei!", "attentionTitle": "ACHTUNG ERFORDERLICH: Probleme erkannt", "monitorTitle": "Einige Probleme müssen überwacht werden", - "healthyMeaning": "Vereinfacht ausgedrückt: Diese Festplatte funktioniert ordnungsgemäß. Sie können es normal weiter verwenden. Wir empfehlen, regelmäßige SMART-Tests (monatlich) durchzuführen, um etwaige Probleme frühzeitig zu erkennen.", - "criticalMeaning": "Vereinfacht ausgedrückt: Diese Festplatte weist Probleme auf, die zu Datenverlust führen können. Sichern Sie Ihre wichtigen Dateien sofort und erwägen Sie, die Festplatte bald auszutauschen.", - "warningMeaning": "Vereinfacht ausgedrückt: Die Scheibe funktioniert, weist aber einige Gebrauchsspuren auf. Es ist noch nicht kritisch, aber überwachen Sie es genau und halten Sie Ihre Backups auf dem neuesten Stand.", + "healthyMeaning": "Vereinfacht ausgedrückt: Dieses Laufwerk funktioniert ordnungsgemäß. Sie können es normal weiter verwenden. Wir empfehlen, regelmäßige SMART-Tests (monatlich) durchzuführen, um etwaige Probleme frühzeitig zu erkennen.", + "criticalMeaning": "Vereinfacht ausgedrückt: Dieses Laufwerk weist Probleme auf, die zu Datenverlust führen können. Sichern Sie Ihre wichtigen Dateien sofort und erwägen Sie, das Laufwerk bald auszutauschen.", + "warningMeaning": "Vereinfacht ausgedrückt: Das Laufwerk funktioniert, weist aber einige Gebrauchsspuren auf. Es ist noch nicht kritisch, aber überwachen Sie es genau und halten Sie Ihre Backups auf dem neuesten Stand.", "issuesFound": "Probleme gefunden", "criticalStatus": "Kritisch – erfordert sofortige Aufmerksamkeit", "warningStatus": "Warnung – sollte überwacht werden", @@ -389,24 +389,24 @@ "rawMessage": "Rohe Nachricht", "firstSeen": "Zum ersten Mal gesehen", "lastSeen": "Zuletzt gesehen", - "observationsIntro": "Die folgenden Ereignisse wurden für diese Festplatte erkannt und protokolliert. Diese Beobachtungen können auf potenzielle Probleme hinweisen, die Aufmerksamkeit erfordern.", + "observationsIntro": "Die folgenden Ereignisse wurden für dieses Laufwerk erkannt und protokolliert. Diese Beobachtungen können auf potenzielle Probleme hinweisen, die Aufmerksamkeit erfordern.", "temperatureHistory": "Temperaturverlauf", "temperatureSamples": "{count} Beispiele", "temperatureBands": "Bänder: Gelb = Warnung (>= {warn}°C), Rot = Kritisch (>= {hot}°C). Alle 60er Jahre probiert.", "observationSummary": "{unique} einzigartig, {total} insgesamt", "observationsTitle": "Beobachtungen und Ereignisse ({count} aufgezeichnet, {total} Vorkommnisse insgesamt)", "outdatedTestTitle": "Veraltete Testdaten ({days} Tage alt)", - "diskInformation": "Festplatteninformationen", + "diskInformation": "Laufwerksinformationen", "attributeCount": "{total} insgesamt", "attributeCountWithWarnings": "{total} insgesamt, {warnings} Warnung(en)", "entriesCount": "{count} Einträge", "fullSelfTestHistory": "Vollständiger Selbsttestverlauf ({count} Einträge)", - "firmwareNoSelfTestLog": "Die Firmware dieser Festplatte führt kein internes Selbsttestprotokoll. Testergebnisse werden von ProxMenux Monitor verfolgt.", - "ssdWearNote": "Schätzungen zur SSD-Lebensdauer basieren auf vom Hersteller gemeldeten Verschleißindikatoren. Die tatsächliche Lebensdauer kann je nach Arbeitsbelastung und Nutzungsmuster variieren.", + "firmwareNoSelfTestLog": "Die Firmware dieses Laufwerks führt kein internes Selbsttestprotokoll. Testergebnisse werden von ProxMenux Monitor verfolgt.", + "ssdWearNote": "Schätzungen zur SSD-Haltbarkeit basieren auf vom Hersteller gemeldeten Verschleißindikatoren. Die tatsächliche Haltbarkeit kann je nach Arbeitsbelastung und Nutzungsmuster variieren.", "labels": { "model": "Modell", "serial": "Seriell", - "estimatedLife": "Geschätzte verbleibende Lebensdauer", + "estimatedLife": "Geschätzte verbleibende Haltbarkeit", "totalDataWritten": "Gesamtzahl der geschriebenen Daten", "availableSpare": "Verfügbar als Ersatzteil", "reportGenerated": "Bericht generiert", @@ -430,13 +430,13 @@ "pendingSectors": "Ausstehende Sektoren", "crcErrors": "CRC-Fehler", "grownDefects": "Gewachsene Defekte", - "lifeRemaining": "VERBLEIBENDES LEBEN", + "lifeRemaining": "VERBLEIBENDE HALTBARKEIT", "estimated": "Geschätzt", "usageStatistics": "NUTZUNGSSTATISTIK", - "percentageUsed": "Prozentsatz der Nutzung", + "percentageUsed": "Prozentsatz verwendet", "dataWritten": "Daten geschrieben", "source": "Quelle", - "ssdLifeIndicator": "SSD-Lebensanzeige", + "ssdLifeIndicator": "SSD-Haltbarkeitsanzeige", "wearLevel": "Verschleißgrad", "powerOnHours": "Betriebsstunden", "smartStatus": "SMART-Status", @@ -462,7 +462,7 @@ "sections": { "nvmeWearLifetime": "NVMe Wear & Lifetime", "extendedNvmeHealth": "Erweiterte NVMe-Gesundheit", - "ssdWearLifetime": "SSD-Verschleiß und Lebensdauer", + "ssdWearLifetime": "SSD-Verschleiß und Haltbarkeit", "nvmeHealthMetrics": "NVMe-Gesundheitsmetriken", "sasHealthMetrics": "SAS/SCSI-Gesundheitsmetriken", "smartAttributes": "SMART-Attribute", @@ -588,13 +588,13 @@ }, "attributeExplanations": { "criticalWarning": "Aktive Alarmflags vom NVMe-Controller. Jeder Wert ungleich Null erfordert eine sofortige Untersuchung.", - "nvmeTemperature": "Vom Controller gemeldete Verbundtemperatur. Anhaltend hohe Temperaturen führen zu thermischer Drosselung und verkürzen die NAND-Lebensdauer.", + "nvmeTemperature": "Vom Controller gemeldete Verbundtemperatur. Anhaltend hohe Temperaturen führen zu thermischer Drosselung und verkürzen die NAND-Haltbarkeit.", "temperatureSensor1": "Primärer Temperatursensor, normalerweise der NAND-Flash. Am repräsentativsten für Flash-Gesundheit.", "temperatureSensor2": "Sekundärsensor, oft der Controller, geht kaputt. Wird normalerweise heißer als Sensor 1.", "temperatureSensor3": "Tertiärsensor, falls vorhanden. Der Standort variiert je nach Hersteller.", "availableSpare": "Prozentsatz der verbleibenden NAND-Ersatzblöcke für den Austausch fehlerhafter Blöcke. Unterhalb des Schwellenwerts wird eine Warnung ausgelöst.", "availableSpareThreshold": "Vom Hersteller festgelegtes Minimum für verfügbares Ersatzteil. Darunter meldet das Laufwerk eine kritische Warnung.", - "percentageUsed": "Schätzung der verbrauchten Lebensdauer des Laufwerks basierend auf den tatsächlichen Schreibzyklen im Vergleich zu den Nenn-Schreibzyklen. 100 % bedeutet, dass das Nenn-TBW erreicht wurde; Das Laufwerk funktioniert möglicherweise darüber hinaus weiter.", + "percentageUsed": "Schätzung der verbrauchten Haltbarkeit des Laufwerks basierend auf den tatsächlichen Schreibzyklen im Vergleich zu den Nenn-Schreibzyklen. 100 % bedeutet, dass das Nenn-TBW erreicht wurde; Das Laufwerk funktioniert möglicherweise darüber hinaus weiter.", "mediaErrors": "Nicht behebbare Lese-/Schreibfehler auf NAND-Flash. Jeder Wert ungleich Null weist auf eine dauerhafte Zellschädigung hin. Wachsende Anzahl bedeutet baldigen Austausch.", "mediaIntegrityErrors": "Vom Controller erkannte nicht behebbare Fehler. Ein Wert ungleich Null bedeutet das Risiko einer Datenkorruption.", "unsafeShutdowns": "Stromverluste ohne ordnungsgemäße Spülung oder Abschaltung. Sehr hohe Werte bergen das Risiko einer Metadatenbeschädigung und Firmware-Problemen.", @@ -614,7 +614,7 @@ "thermalManagementT2TransCount": "Häufigkeit, mit der das Laufwerk stark thermisch gedrosselt wurde (T2). Erhebliche Auswirkungen auf die Leistung.", "thermalManagementT1TotalTime": "Gesamtzahl der Sekunden, die bei leichter thermischer Drosselung verbracht wurden. Zeigt anhaltende Kühlprobleme an.", "thermalManagementT2TotalTime": "Gesamtzahl der Sekunden, die mit starker thermischer Drosselung verbracht wurden. Starker Leistungsabfall.", - "rawReadErrorRate": "Hardware-Lesefehler erkannt. Hohe Rohwerte auf Festplatten von Seagate/Samsung sind normal, da deren proprietäre Formel WERT wichtiger macht als Rohwerte.", + "rawReadErrorRate": "Hardware-Lesefehler erkannt. Hohe Rohwerte auf Laufwerken von Seagate/Samsung sind normal, da deren proprietäre Formel WERT wichtiger macht als Rohwerte.", "writeErrorRate": "Bei Schreibvorgängen sind Fehler aufgetreten. Eine steigende Zahl kann auf Probleme mit dem Kopf oder den Medien hinweisen.", "multiZoneErrorRate": "Fehler beim Schreiben in Mehrzonenregionen. Herstellerspezifisch; ein steigender Trend ist besorgniserregend.", "softReadErrorRate": "Lesefehler werden durch die Firmware ohne Datenverlust korrigiert. Hohe Werte können auf eine Verschlechterung der Medienqualität hinweisen.", @@ -622,21 +622,21 @@ "reportedUncorrect": "Fehler, die ECC nicht beheben konnte. Jeder Wert ungleich Null bedeutet, dass Daten verloren gingen oder nicht lesbar waren.", "reportedUncorrectableErrors": "Fehler, die ECC nicht beheben konnte. Ein Wert ungleich Null bedeutet Datenverlustrisiko.", "reallocatedSectorCount": "Fehlerhafte Sektoren werden durch Ersatzsektoren aus dem Reservepool ersetzt. Eine wachsende Anzahl bedeutet eine Verschlechterung des Laufwerks.", - "retiredBlockCount": "NAND-Blöcke wurden aufgrund von Verschleiß oder Ausfall ausgemustert (SSD). Ähnlich wie die Anzahl der neu zugewiesenen Sektoren für Festplatten.", + "retiredBlockCount": "NAND-Blöcke wurden aufgrund von Verschleiß oder Ausfall ausgemustert (SSD). Ähnlich wie die Anzahl der neu zugewiesenen Sektoren für Laufwerke.", "reallocatedEventCount": "Anzahl der durchgeführten Neuzuordnungsvorgänge. Jedes Ereignis bedeutet, dass ein fehlerhafter Sektor ersetzt wurde.", "currentPendingSector": "Instabile Sektoren warten darauf, beim nächsten Schreibvorgang neu zugeordnet zu werden. Sie können aufgelöst oder dauerhaft neu zugewiesen werden.", "pendingSectors": "Sektoren, die darauf warten, neu zugeordnet zu werden. Sie können aufgelöst oder neu zugewiesen werden.", "offlineUncorrectable": "Sektoren, die beim Offline-Scan fehlgeschlagen sind und nicht korrigiert werden konnten. Weist auf einen möglichen Datenverlust hin.", "offlineUncorrectableSectorCount": "Beim Hintergrundscan wurden nicht korrigierbare Sektoren gefunden. Daten zu diesen Sektoren gehen verloren.", - "sataTemperature": "Aktuelle Laufwerkstemperatur. Anhaltend hohe Temperaturen beschleunigen den Verschleiß und verkürzen die Lebensdauer.", - "temperatureCelsius": "Aktuelle Laufwerkstemperatur in Celsius. Festplatten sollten normalerweise unter 45 °C bleiben; SSDs unter 60°C.", + "sataTemperature": "Aktuelle Laufwerkstemperatur. Anhaltend hohe Temperaturen beschleunigen den Verschleiß und verkürzen die Haltbarkeit.", + "temperatureCelsius": "Aktuelle Laufwerkstemperatur in Celsius. Laufwerke sollten normalerweise unter 45 °C bleiben; SSDs unter 60°C.", "airflowTemperatureCel": "Vom Luftstromsensor gemessene Temperatur. Normalerweise etwas niedriger als der Haupttemperatursensor.", "temperatureCase": "Temperatur des Antriebsgehäuses. Nützlich zur Überwachung der Gehäusebelüftung.", "temperatureInternal": "Interner Temperatursensor. Möglicherweise ist die Temperatur höher als die Gehäusetemperatur.", "sataPowerOnHours": "Gesamtanzahl der Stunden, in denen das Laufwerk eingeschaltet war. Wird verwendet, um das Alter abzuschätzen und Ersatz zu planen.", "powerOnHoursAndMsec": "Gesamteinschaltzeit mit Millisekundengenauigkeit.", "powerCycleCount": "Gesamtzahl der vollständigen Ein-/Ausschaltzyklen. Häufiges Radfahren belastet die Elektronik.", - "powerOffRetractCount": "Mal wurden die Köpfe wegen Stromausfall (Festplatte) eingefahren. Hohe Werte weisen auf eine instabile Stromversorgung hin.", + "powerOffRetractCount": "Mal wurden die Köpfe wegen Stromausfall (Laufwerk) eingefahren. Hohe Werte weisen auf eine instabile Stromversorgung hin.", "unexpectedPowerLossCt": "Unerwartete Leistungsverluste (SSD). Kann zu einer Beschädigung der Metadaten führen, wenn der Schreibcache aktiv war.", "unsafeShutdownCount": "Stromausfälle ohne ordnungsgemäßes Herunterfahren (SSD). Bei hohen Werten besteht die Gefahr einer Firmware-Beschädigung.", "startStopCount": "Start-/Stoppzyklen des Spindelmotors (HDD). Jeder Zyklus verursacht mechanischen Verschleiß.", @@ -650,7 +650,7 @@ "headFlyingHours": "Stundenlang wurden die Lese-/Schreibköpfe über den Platten (HDD) positioniert.", "highFlyWrites": "Schreibt, wo der Kopf höher flog als erwartet (HDD). Daten werden möglicherweise nicht korrekt geschrieben.", "gSenseErrorRate": "Vom Beschleunigungsmesser (HDD) erkannte Stoß- oder Vibrationsereignisse. Hohe Werte deuten auf eine körperliche Störung hin.", - "diskShift": "Entfernung, um die sich die Festplatte von ihrer ursprünglichen Position (HDD) verschoben hat. Temperatur- oder stoßbedingt.", + "diskShift": "Entfernung, um die sich das Laufwerk von ihrer ursprünglichen Position (HDD) verschoben hat. Temperatur- oder stoßbedingt.", "loadedHours": "Stundenlange Arbeit mit vollen Köpfen über den Plattentellern (HDD).", "loadInTime": "Zeitpunkt des Kopfladevorgangs. Herstellerspezifische Diagnosemetrik.", "torqueAmplificationCount": "Manchmal benötigte der Antrieb zusätzliches Drehmoment, um hochzudrehen. Kann auf Haftreibung oder motorische Probleme hinweisen.", @@ -668,10 +668,10 @@ "endToEndErrorDetectionCount": "Anzahl der Paritätsfehler im Datenpfad. Ein Wert ungleich Null weist auf Controller-Probleme hin.", "wearLevelingCount": "Durchschnittliche Löschzyklen pro NAND-Block (SSD). Ein niedrigerer WERT bedeutet mehr verbrauchten Verschleiß.", "wearRangeDelta": "Unterschied zwischen am meisten abgenutzten und am wenigsten abgenutzten Blöcken (SSD). Hohe Werte weisen auf ungleichmäßige Abnutzung hin.", - "mediaWearoutIndicator": "Geschätzte verbleibende Lebensdauer der Intel SSD. Beginnt bei 100 und sinkt auf 0, wenn die Ausdauer verbraucht wird.", - "ssdLifeLeft": "Geschätzter Prozentsatz der verbleibenden SSD-Lebensdauer basierend auf dem NAND-Verschleiß.", - "percentLifetimeRemain": "Geschätzter Prozentsatz der verbleibenden Lebensdauer. 100 bedeutet neu; 0 bedeutet Ende der Nennlebensdauer.", - "percentLifetimeUsed": "Prozentsatz der verbrauchten Nennausdauer. Kehrwert der prozentualen verbleibenden Lebensdauer.", + "mediaWearoutIndicator": "Geschätzte verbleibende Haltbarkeit der Intel SSD. Beginnt bei 100 und sinkt auf 0, wenn die Ausdauer verbraucht wird.", + "ssdLifeLeft": "Geschätzter Prozentsatz der verbleibenden SSD-Haltbarkeit basierend auf dem NAND-Verschleiß.", + "percentLifetimeRemain": "Geschätzter Prozentsatz der verbleibenden Haltbarkeit. 100 bedeutet neu; 0 bedeutet Ende der Nenn-Haltbarkeit.", + "percentLifetimeUsed": "Prozentsatz der verbrauchten Nennausdauer. Kehrwert der prozentualen verbleibenden Haltbarkeit.", "availableReservedSpace": "Verbleibende Ersatzblöcke in Prozent (SSD). Niedrige Werte verringern die Fähigkeit des Laufwerks, fehlerhafte Blöcke zu verarbeiten.", "usedReservedBlockCount": "Reserveblöcke, die für den Austausch fehlerhafter Blöcke (SSD) verbraucht werden. Ein wachsender Wert bedeutet Altern.", "unusedReservedBlockCount": "Es stehen noch Reserveblöcke für den Bad-Block-Austausch (SSD) zur Verfügung. Null bedeutet, dass kein Austausch fehlerhafter Blöcke mehr möglich ist.", @@ -683,8 +683,8 @@ "runtimeBadBlock": "Während des normalen Betriebs (SSD) entdeckte fehlerhafte Blöcke. Eine steigende Zahl bedeutet, dass der Blitz abgenutzt ist.", "totalLbasWritten": "Gesamtzahl der geschriebenen logischen Blockadressen. Für das Gesamtdatenvolumen multiplizieren Sie es mit 512 Byte.", "totalLbasRead": "Gesamtzahl der gelesenen logischen Blockadressen. Nützlich zur Berechnung der täglichen Arbeitsbelastung.", - "lifetimeWritesGib": "Gesamte Datenmenge, die während der Lebensdauer des Laufwerks in GiB geschrieben wurde.", - "lifetimeReadsGib": "Gesamte Datenmenge, die während der Lebensdauer des Laufwerks in GiB gelesen wurde.", + "lifetimeWritesGib": "Gesamte Datenmenge, die während der Haltbarkeit des Laufwerks in GiB geschrieben wurde.", + "lifetimeReadsGib": "Gesamte Datenmenge, die während der Haltbarkeit des Laufwerks in GiB gelesen wurde.", "totalWritesGib": "Gesamtdaten in GiB geschrieben. Zur Schätzung der Ausdauer mit der TBW-Bewertung vergleichen.", "totalReadsGib": "Gesamte gelesene Daten in GiB.", "nandWritesGib": "Raw NAND schreibt in GiB. Aufgrund der Schreibverstärkung höher als Host-Schreibvorgänge.", @@ -695,7 +695,7 @@ "nandGbWrittenTlc": "Gesamte in TLC-NAND-Zellen geschriebene Daten in GB. Beinhaltet den Overhead für die Schreibverstärkung.", "nandGibWritten": "Gesamte NAND-Schreibvorgänge in GiB. Höher als Host-Schreibvorgänge aufgrund von Schreibverstärkung und Speicherbereinigung.", "averageBlockEraseCount": "Durchschnittliche Anzahl von Löschzyklen pro NAND-Block (SSD). Laufwerke sind normalerweise für Zyklen von 3.000 bis 100.000 ausgelegt.", - "averageEraseCount": "Durchschnittliche Löschzyklen pro Block. Zur Schätzung der verbleibenden Lebensdauer mit der Nennlebensdauer vergleichen.", + "averageEraseCount": "Durchschnittliche Löschzyklen pro Block. Zur Schätzung der verbleibenden Haltbarkeit mit der Nenn-Haltbarkeit vergleichen.", "maxEraseCount": "Maximale Löschzyklen für jeden einzelnen Block. Eine große Lücke im Durchschnitt weist auf ungleichmäßigen Verschleiß hin.", "totalEraseCount": "Summe aller Löschzyklen über alle Blöcke hinweg. Gesamtindikator für das NAND-Schreibvolumen.", "powerLossCapTest": "Ergebnis des Leistungsverlustschutz-Kondensator-Selbsttests (SSD). Fehlgeschlagen bedeutet, dass bei einem Stromausfall das Risiko eines Datenverlusts besteht.", @@ -705,7 +705,7 @@ "ssdProgramFailCount": "Gesamtzahl der Schreibfehler auf der SSD. Zeigt Probleme mit der Zuverlässigkeit der Flash-Zellen an.", "throughputPerformance": "Gesamtdurchsatzleistungsbewertung (HDD). Sinkende Werte deuten auf eine Verschlechterung hin.", "unknownAttribute": "Herstellerspezifisches Attribut, das im SMART-Standard nicht definiert ist. Überprüfen Sie die Herstellerdokumentation.", - "freeFallSensor": "Freifall-Ereignisse erkannt (Laptop-Festplatte). Die Köpfe sind geparkt, um Schäden bei Stürzen zu vermeiden.", + "freeFallSensor": "Freifall-Ereignisse erkannt (Laptop-Laufwerk). Die Köpfe sind geparkt, um Schäden bei Stürzen zu vermeiden.", "grownDefectList": "Sektoren wurden aufgrund von während des Betriebs festgestellten Mängeln neu zugeordnet. Entspricht neu zugewiesenen Sektoren auf SATA. Eine wachsende Anzahl bedeutet eine Verschlechterung des Laufwerks.", "readErrorsCorrected": "Lesefehler von ECC korrigiert. Normal für Unternehmenslaufwerke unter hoher Auslastung; Nur unkorrigierte Fehler sind kritisch.", "readEccFast": "Fehler werden durch schnelles On-the-Fly-ECC während Lesevorgängen korrigiert. Normal bei SAS-Laufwerken.", @@ -719,28 +719,28 @@ "verifyUncorrectedErrors": "Überprüfen Sie Fehler, die nicht behoben werden konnten. Ein Wert ungleich Null weist auf eine Verschlechterung des Mediums hin.", "nonMediumErrors": "Controller- oder Busfehler, die nichts mit dem Medium selbst zu tun haben. Ein hoher Wert kann auf Probleme mit der Rückwandplatine oder dem Kabel hinweisen.", "sasTemperature": "Aktuelle Laufwerkstemperatur. Enterprise-SAS-Laufwerke vertragen unter Dauerlast bis zu 55–60 °C.", - "sasPowerOnHours": "Gesamtstunden, in denen das Laufwerk eingeschaltet war. Enterprise-Festplatten sind für den 24/7-Betrieb ausgelegt.", + "sasPowerOnHours": "Gesamtstunden, in denen das Laufwerk eingeschaltet war. Enterprise-Laufwerke sind für den 24/7-Betrieb ausgelegt.", "startStopCycles": "Start-/Stoppzyklen des Motors. Enterprise-SAS-Laufwerke sind für mehr als 50.000 Zyklen ausgelegt.", - "loadUnloadCycles": "Lade-/Entladezyklen des Kopfes. Enterprise-Festplatten sind für mehr als 600.000 Zyklen ausgelegt.", + "loadUnloadCycles": "Lade-/Entladezyklen des Kopfes. Enterprise-Laufwerke sind für mehr als 600.000 Zyklen ausgelegt.", "backgroundScanStatus": "Status des SCSI-Hintergrundmedienscans. Läuft kontinuierlich, um Oberflächenfehler zu erkennen." }, "recommendations": { - "healthyTitle": "Die Festplatte ist fehlerfrei", + "healthyTitle": "Das Laufwerk ist fehlerfrei", "healthyText": "Alle SMART-Attribute liegen im Normalbereich. Setzen Sie die regelmäßige Überwachung fort.", - "criticalTitle": "Kritisch: Problem mit dem Festplattenzustand erkannt", - "criticalText": "SMART hat ein Gesundheitsproblem gemeldet. Sichern Sie sofort alle Daten und planen Sie den Austausch der Festplatte.", + "criticalTitle": "Kritisch: Problem mit dem Laufwerkszustand erkannt", + "criticalText": "SMART hat ein Gesundheitsproblem gemeldet. Sichern Sie sofort alle Daten und planen Sie den Austausch des Laufwerks.", "reallocatedTitle": "Neu zugewiesene Sektoren erkannt ({count})", - "reallocatedText": "Die Festplatte weist fehlerhafte Sektoren auf, die neu zugeordnet wurden. Überwachen Sie es genau und erwägen Sie einen Austausch, wenn die Anzahl zunimmt.", + "reallocatedText": "Das Laufwerk weist fehlerhafte Sektoren auf, die neu zugeordnet wurden. Überwachen Sie es genau und erwägen Sie einen Austausch, wenn die Anzahl zunimmt.", "pendingTitle": "Ausstehende Sektoren ({count})", "pendingText": "Einige Sektoren warten auf eine Neuzuteilung. Dies kann auf einen drohenden Ausfall hinweisen.", "highTemperature": "Hohe Temperatur ({temperature}°C)", - "hotHdd": "Die Festplatte läuft heiß. Verbessern Sie den Luftstrom im Gehäuse oder sorgen Sie für zusätzliche Kühlung.", + "hotHdd": "Das Laufwerk läuft heiß. Verbessern Sie den Luftstrom im Gehäuse oder sorgen Sie für zusätzliche Kühlung.", "hotSsd": "Die SSD läuft heiß. Überprüfen Sie den Luftstrom um das Laufwerk.", "hotNvme": "Das NVMe ist überhitzt. Erwägen Sie den Einbau eines Kühlkörpers oder die Verbesserung der Gehäuseluftzirkulation.", "maintenanceTitle": "Regelmäßige Wartung", "maintenanceText": "Planen Sie jeden Monat einen erweiterten SMART-Test ein, um Probleme frühzeitig zu erkennen.", "backupTitle": "Backup-Strategie", - "backupText": "Sichern Sie wichtige Daten regelmäßig, unabhängig vom Zustand der Festplatte.", + "backupText": "Sichern Sie wichtige Daten regelmäßig, unabhängig vom Zustand des Laufwerks.", "nvmeWarningTitle": "Kritische NVMe-Warnung aktiv (0x{value})", "nvmeWarningText": "Der NVMe-Controller hat eine Warnung ausgelöst. Sichern Sie die Daten sofort und untersuchen Sie sie weiter.", "nvmeMediaTitle": "NVMe-Medienfehler erkannt ({count})", @@ -750,14 +750,14 @@ "seagateTitle": "Seagate Raw_Read_Error_Rate – Normales Verhalten", "seagateText": "Seagate-Laufwerke melden sehr große Rohwerte für Attribut Nr. 1. Dies wird erwartet; Es zählt nur der normalisierte Wert und dieser sollte bei 100 bleiben.", "smrTitle": "SMR-Laufwerk erkannt – Schreibbeschränkungen", - "smrText": "SMR-Festplatten schreiben zufällige Daten langsamer und können bei hoher gemischter Arbeitslast zum Stillstand kommen. Sie eignen sich für Backups und Archive, werden jedoch nicht als primärer Proxmox-Speicher oder ZFS-vdevs empfohlen.", + "smrText": "SMR-Laufwerke schreiben zufällige Daten langsamer und können bei hoher gemischter Arbeitslast zum Stillstand kommen. Sie eignen sich für Backups und Archive, werden jedoch nicht als primärer Proxmox-Speicher oder ZFS-vdevs empfohlen.", "passedTitle": "Überprüfen Sie die gemeldeten Ergebnisse", - "passedText": "Der Status „PASSED“ kann gleichzeitig mit Warnungen oder aufgezeichneten Fehlern bestehen.Überprüfen Sie die Berichtsdetails, führen Sie die regelmäßige Überwachung fort und führen Sie aktuelle Backups durch." + "passedText": "Der Status „PASSED“ kann gleichzeitig mit Warnungen oder aufgezeichneten Fehlern bestehen. Überprüfen Sie die Berichtsdetails, führen Sie die regelmäßige Überwachung fort und führen Sie aktuelle Backups durch." }, - "passedAssessment": "Der angegebene SMART-Status ist PASSED.Dieser Status allein stellt nicht sicher, dass jedes Attribut innerhalb des zulässigen Bereichs liegt oder dass die Festplatte fehlerfrei ist.Angezeigte Einschaltzeit: {uptime}.Angezeigte Temperatur: {temperature}.{sectors}", - "noReallocatedSectorsReported": "Der angegebene Wert für den neu zugewiesenen Sektor ist Null oder nicht verfügbar.Dies beweist nicht, dass es keine fehlerhaften Sektoren gibt.", + "passedAssessment": "Der angegebene SMART-Status ist PASSED. Dieser Status allein stellt nicht sicher, dass jedes Attribut innerhalb des zulässigen Bereichs liegt oder dass das Laufwerk fehlerfrei ist. Angezeigte Einschaltzeit: {uptime}. Angezeigte Temperatur: {temperature}.{sectors}", + "noReallocatedSectorsReported": "Der angegebene Wert für den neu zugewiesenen Sektor ist Null oder nicht verfügbar. Dies beweist nicht, dass es keine fehlerhaften Sektoren gibt.", "passedMeaningTitle": "Was bedeutet der Status „PASSED“?", - "passedMeaning": "PASSED ist der Gesamtstatus dieses Berichts, kein Beweis für einen abgeschlossenen Selbsttest oder eine Garantie gegen Fehlschläge.Überprüfen Sie die Attribute, Fehlerprotokolle und Selbsttestergebnisse und bewahren Sie aktuelle Backups auf." + "passedMeaning": "PASSED ist der Gesamtstatus dieses Berichts, kein Beweis für einen abgeschlossenen Selbsttest oder eine Garantie gegen Fehlschläge. Überprüfen Sie die Attribute, Fehlerprotokolle und Selbsttestergebnisse und bewahren Sie aktuelle Backups auf." }, "ataErrors": { "idnf": "Sektoradresse nicht gefunden – möglicherweise fehlerhafter Sektor oder Kabelproblem", @@ -778,8 +778,8 @@ "enabled": "Ermöglicht", "disabled": "Deaktiviert", "configured": "Konfigurierte Zeitpläne", - "disks": "Festplatten", - "allDisks": "alle Festplatten", + "disks": "Laufwerke", + "allDisks": "alle Laufwerke", "keepResults": "Behalten Sie {count} Ergebnisse", "empty": "Keine geplanten Tests konfiguriert", "emptyHint": "Erstellen Sie einen Zeitplan, um SMART-Tests automatisch auszuführen.", @@ -801,7 +801,7 @@ "keepAll": "Behalten Sie alle", "saving": "Sparen...", "save": "Zeitplan speichern", - "cancel": "Stornieren", + "cancel": "Abbrechen", "add": "Zeitplan hinzufügen", "note": "Geplante Tests werden automatisch über Cron ausgeführt. Die Ergebnisse werden im SMART-Verlauf gespeichert.", "days": { @@ -815,15 +815,15 @@ } }, "savedSmartData": "SMART-Daten gespeichert", - "idle": "Idle", - "idleTitle": "Nicht lesen, während nichts es benutzt, also kann es sich drehen. Seine Temperatur wird erneut angezeigt, sobald die Scheibe in Gebrauch ist.", - "diskExcluded": "Ausgenommen", - "diskExcludedTitle": "Ausgenommen von periodischen Lesevorgängen in Einstellungen.", - "temperatureNoDisks": "No disks", - "temperatureNormal": "Temperaturen normal", - "temperatureHighCount": "Hohe Temperaturen: {count}", - "temperatureCriticalCount": "Kritische Temperaturen: {count}", - "temperatureUnavailable": "Temperaturen nicht verfügbar", + "idle": "Leerlauf", + "idleTitle": "Solange keine Lesezugriffe stattfinden, wird das Laufwerk nicht genutzt. Es wird in den Ruhezustand versetzt. Sobald es wieder verwendet wird, wird die Temperatur wieder angezeigt.", + "diskExcluded": "Ausgeschlossen", + "diskExcludedTitle": "Ausgeschlossen von periodischen Lesevorgängen in Einstellungen.", + "temperatureNoDisks": "Keine Laufwerke", + "temperatureNormal": "Temperatur normal", + "temperatureHighCount": "Hohe Temperatur: {count}", + "temperatureCriticalCount": "Kritische Temperatur: {count}", + "temperatureUnavailable": "Temperatur nicht verfügbar", "temperatureIncomplete": "Temperaturdaten unvollständig", "temperatureUnavailableCount": "Nicht verfügbar: {count}" }, @@ -855,15 +855,15 @@ }, "processes": { "topByCpu": "Top-Prozesse nach CPU", - "topByMemory": "Top-Prozesse von Memory", - "cpuDescription": "Aktuelle CPU-Nutzung pro Prozess als Bruchteil der gesamten CPU des Hosts – gleiche Skala wie auf der Karte „CPU-Nutzung“ oben. Wird im geöffneten Zustand alle 3 Sekunden aktualisiert.", + "topByMemory": "Top-Prozesse nach Speicher", + "cpuDescription": "Aktuelle CPU-Auslastung pro Prozess als Bruchteil der gesamten CPU des Hosts – gleiche Skala wie auf der Karte „CPU-Auslastung“ oben. Wird im geöffneten Zustand alle 3 Sekunden aktualisiert.", "memoryDescription": "Aktueller residenter Speicher pro Prozess. Wird im geöffneten Zustand alle 3 Sekunden aktualisiert.", - "filterPlaceholder": "Filtern Sie nach Befehlszeile, Benutzer oder PID...", + "filterPlaceholder": "Filtern Sie nach Befehl, Benutzer oder PID...", "pid": "PID", "user": "Benutzer", "command": "Befehl", "cpuPercent": "CPU %", - "memory": "Erinnerung", + "memory": "Arbeitsspeicher", "memPercent": "Speicher %", "noMatches": "Keine Prozesse passen zum Filter", "captured": "Erfasst {time}", @@ -871,7 +871,7 @@ "matchesCount": "{count} Übereinstimmungen von {total} Prozessen", "topCount": "Top {shown} von {total} Prozessen", "loadFailed": "Prozesse konnten nicht abgerufen werden", - "lifetimeAverageTitle": "Durchschnittlicher CPU-Prozentsatz über die gesamte Lebensdauer dieses Prozesses – nützlich, um Baselines mit langer Laufzeit im Leerlauf zu finden", + "lifetimeAverageTitle": "Durchschnittlicher CPU-Prozentsatz über die gesamte Haltbarkeit dieses Prozesses – nützlich, um Baselines mit langer Laufzeit im Leerlauf zu finden", "averageShort": "avg" }, "processInfo": { @@ -888,7 +888,7 @@ "overview": "Überblick", "resources": "Ressourcen", "command": "Befehl", - "lifetime": "Lebensdauer" + "lifetime": "Haltbarkeit" }, "labels": { "state": "Zustand", @@ -898,7 +898,7 @@ "user": "Benutzer", "group": "Gruppe", "cpu": "CPU", - "memory": "Erinnerung", + "memory": "Arbeitsspeicher", "residentRss": "Bewohner (RSS)", "virtualSize": "Virtuelle Größe", "swap": "Swap", @@ -914,7 +914,7 @@ "states": { "running": "Läuft", "sleeping": "Schlafen", - "diskWait": "Festplatte warten", + "diskWait": "Laufwerk warten", "zombie": "Zombie", "stopped": "Angehalten", "tracingStop": "Suchstopp", @@ -931,11 +931,11 @@ "totalVmLxc": "Gesamtzahl der VMs und LXCs", "totalCpuAllocated": "Gesamte zugewiesene CPU", "totalMemory": "Gesamtspeicher", - "totalDisk": "Gesamte Festplatte", + "totalDisk": "Gesamtes Laufwerk", "running": "läuft", "stopped": "gestoppt", "vms": "VMs", - "used": "Gebraucht", + "used": "Verwendet", "configured": "Konfiguriert", "inUse": "Im Einsatz", "allocated": "Zuordnen", @@ -946,7 +946,7 @@ "empty": "Keine virtuellen Maschinen gefunden", "uptime": "Betriebszeit: {uptime}", "cpuUsage": "CPU-Auslastung", - "memory": "Erinnerung", + "memory": "Arbeitsspeicher", "disk": "Scheibe", "diskUsage": "Festplattennutzung", "diskIo": "Festplatten-E/A", @@ -966,8 +966,8 @@ "notesDecodeError": "Fehler beim Dekodieren von Notizen. Bitte bearbeiten, um das Problem zu beheben.", "noNotes": "Noch keine Notizen. Klicken Sie auf Bearbeiten, um Notizen hinzuzufügen.", "containerConfiguration": "Containerkonfiguration", - "mountedFromContainer": "Von innen im Container montiert", - "openTerminal": "Öffnen Sie das Terminal", + "mountedFromContainer": "Im Container gemounted", + "openTerminal": "Terminal öffnen", "coreCount": { "one": "{count} Kern", "few": "{count} Kerne", @@ -978,9 +978,9 @@ }, "details": { "sourceHost": "Quelle (Host)", - "mountedAtCt": "Montiert bei (CT)", + "mountedAtCt": "Gemounted bei (CT)", "total": "Gesamt", - "used": "Gebraucht", + "used": "Verwendet", "available": "Verfügbar", "swap": "Swap", "privilegeLevel": "Berechtigungsstufe", @@ -1009,28 +1009,28 @@ "pciPassthrough": "PCI-Passthrough", "usbDevices": "USB-Geräte", "serialPorts": "Serielle Ports", - "bridge": "Brücke", + "bridge": "Bridge", "enabled": "Ermöglicht", "disabled": "Deaktiviert", "on": "An", "off": "aus", "included": "enthalten", "excluded": "ausgeschlossen", - "storage": "Lagerung", + "storage": "Speicher", "rootFilesystem": "Root-Dateisystem", - "efiDisk": "EFI-Festplatte", + "efiDisk": "EFI-Laufwerk", "tpmState": "TPM-Status", "mountPoint": "Einhängepunkt", "mountPointIndexed": "Mountpunkt {index}", "network": "Netzwerk", "networkInterface": "Netzwerkschnittstelle {index}", - "hostInterface": "Host: {interface}", - "gateway": "Tor", + "hostInterface": "Gastgeber: {interface}", + "gateway": "Gateway", "gatewayV6": "Gateway v6", "type": "Typ", "rateLimit": "Ratenbegrenzung", "firewall": "Firewall", - "backup": "Sicherung", + "backup": "Backup", "replicate": "Replizieren", "volume": "Volumen", "path": "Weg", @@ -1043,18 +1043,18 @@ "mountTypes": { "pveVolume": "PVE-Volumen", "pveStorageBind": "aus dem PVE-Speicher binden", - "hostBind": "Vom Host binden", + "hostBind": "Vom Host gemounted", "adHoc": "Ad-hoc innerhalb von CT" }, "mountStatus": { "stale": "abgestanden", - "notMounted": "nicht montiert", - "hostDetached": "Host getrennt", + "notMounted": "nicht gemounted", + "hostDetached": "Gastgeber getrennt", "readOnly": "schreibgeschützt", "stopped": "gestoppt", - "mounted": "montiert" + "mounted": "gemounted" }, - "startOnBoot": "Beim Booten beginnen", + "startOnBoot": "Beim Booten starten", "watchdog": "Watchdog", "watchdogHelp": "Startet diese Anwendung automatisch neu, wenn sie abstürzt.", "tags": "Tags", @@ -1073,7 +1073,7 @@ }, "tabs": { "status": "Status", - "mounts": "Reittiere", + "mounts": "Mounts", "backups": "Backups", "updates": "Aktualisierungen", "firewall": "Firewall", @@ -1081,11 +1081,15 @@ "logs": "Logs" }, "actions": { - "start": "Start", - "shutdown": "Abschalten", + "start": "Starten", + "shutdown": "Herunterfahren", "reboot": "Neustart", "forceStop": "Stopp erzwingen", - "working": "Arbeiten..." + "working": "Wird ausgeführt...", + "startPending": "Wird gestartet...", + "shutdownPending": "Wird heruntergefahren...", + "rebootPending": "Wird neu gestartet...", + "stopPending": "Wird gestoppt..." }, "confirm": { "forceStopTitle": "Stopp erzwingen", @@ -1127,20 +1131,21 @@ }, "errors": { "unknown": "Unbekannter Fehler", - "backupStartFailed": "Sicherung konnte nicht gestartet werden: {message}", + "backupStartFailed": "Backup konnte nicht gestartet werden: {message}", "controlFailed": "Fehler bei {action} VM {vmid}: {message}", "saveNotesFailed": "Fehler beim Speichern der Notizen. Bitte versuchen Sie es erneut.", - "appNotFound": "Diese Anwendung ist nicht mehr verfügbar. Aktualisieren Sie die Seite und versuchen Sie es erneut.", + "appNotFound": "Diese App ist nicht mehr verfügbar. Aktualisieren Sie die Seite und versuchen Sie es erneut.", "saveCustomCommandFailed": "Der benutzerdefinierte Aktualisierungsbefehl konnte nicht gespeichert werden: {message}", "removeCustomCommandConfirm": "Den benutzerdefinierten Aktualisierungsbefehl für „{name}“ entfernen?", "removeCustomCommandFailed": "Der benutzerdefinierte Aktualisierungsbefehl konnte nicht entfernt werden: {message}", - "hideNoticeFailed": "Dieser Hinweis konnte nicht ausgeblendet werden: {message}" + "hideNoticeFailed": "Dieser Hinweis konnte nicht ausgeblendet werden: {message}", + "controlNotConfirmed": "Der Vorgang wurde nicht als abgeschlossen bestätigt. Überprüfen Sie den Gaststatus, bevor Sie es erneut versuchen." }, "backupModal": { - "title": "Sicherung {type} {vmid} ({name})", - "description": "Konfigurieren Sie Sicherungsoptionen für diesen {type}", - "storage": "Lagerung", - "selectStorage": "Speicher auswählen", + "title": "Backup {type} {vmid} ({name})", + "description": "Konfigurieren Sie Backup-Optionen für diesen {type}", + "storage": "Storage", + "selectStorage": "Storage auswählen", "free": "frei", "mode": "Modus", "notification": "Benachrichtigung", @@ -1154,7 +1159,7 @@ "notes": "Notizen", "variables": "Variablen: {{cluster}}, {{guestname}}, {{node}}, {{vmid}}", "creating": "Erstellen...", - "submit": "Sicherung", + "submit": "Backup", "typeContainer": "Container", "typeVirtualMachine": "virtuelle Maschine", "modes": { @@ -1181,14 +1186,14 @@ "consoleLog": { "title": "Konsolenausgabe", "description": "Standardausgabe und Fehlerausgabe des Hauptprozesses des Images.", - "stopped": "Der Container ist gestoppt; das Log enthält die Ausgabe seiner vorherigen Läufe.", + "stopped": "Der Container ist gestoppt. Das Log enthält die Ausgabe seines vorherigen Betriebs.", "lastLines": "Letzte {count} Zeilen", "follow": "Folgen", "pause": "Pausieren", "refresh": "Aktualisieren", "sinceStart": "Seit dem letzten Start", "allHistory": "Gesamter Verlauf", - "startedAt": "Container gestartet · {date}", + "startedAt": "Container gestartet: {date}", "download": "Herunterladen", "filter": "Zeilen filtern", "loading": "Konsolenausgabe wird geladen...", @@ -1200,13 +1205,13 @@ "notAvailableHint": "Das Konsolen-Log wird eingerichtet, wenn ProxMenux den Container aus seinem OCI-Image installiert." }, "ociUpdates": { - "notTracked": "Wird die Anwendung in der App-Registerkarte registriert, wird ihr Image hier verfolgt.", + "notTracked": "Wird die App in der App-Registerkarte registriert, wird ihr Image hier verfolgt.", "installedImage": "Installiertes Image", "newImage": "Neues Image {image} verfügbar", "stackNote": "Teil einer Multi-Container-Anwendung (Haupt-CT {primary}, {count} Container): Das Update umfasst alle.", "memberNote": "Dieser Container gehört zu einer Multi-Container-Anwendung mit dem Haupt-CT {primary} und wird nicht einzeln aktualisiert. Die Aktualisierung erfolgt über den Haupt-CT, der alle {count} Container gemeinsam aktualisiert.", "openPrimary": "Haupt-CT öffnen", - "pendingNote": "Ein Vorgang an diesem Container wurde unterbrochen; Wiederherstellen öffnet seine Wiederherstellung.", + "pendingNote": "Ein Vorgang an diesem Container wurde unterbrochen. Wiederherstellen öffnet seine Wiederherstellung.", "update": "Aktualisieren", "modify": "Ändern", "recover": "Wiederherstellen", @@ -1216,23 +1221,23 @@ "terminalDescription": "Es läuft derselbe Vorgang wie im ProxMenux-Menü: OCI manager Apps → Installierte OCI-Anwendungen verwalten.", "keepBackup": "Das vor dem Update erstellte Backup behalten", "noKeepBackup": "Das vor dem Update erstellte Backup wird nicht behalten", - "keepBackupHelp": "Jedes Update sichert den Container, bevor sein Image ersetzt wird, um ihn wiederherzustellen, falls das Update fehlschlägt. Mit dieser Option wird dieses Backup im gewählten Speicher behalten; auf Proxmox Backup Server wird vor dem Update ein Backup geschrieben.", + "keepBackupHelp": "Jedes Update sichert den Container, bevor sein Image ersetzt wird, um ihn wiederherzustellen, falls das Update fehlschlägt. Mit dieser Option wird dieses Backup im gewählten Speicher behalten. Auf Proxmox Backup Server wird vor dem Update ein Backup geschrieben.", "scheduleHelp": "Nur das Image wird aktualisiert, und nur wenn seine Registry ein neues veröffentlicht.", "releaseDelayHelp": "Ein neues Image wird erst installiert, wenn es mindestens so alt ist." }, "options": { "title": "Optionen", - "snapshotBefore": "Schnappschuss vor der Bewerbung", + "snapshotBefore": "Schnappschuss vor der Anwendung", "snapshotOn": "– am {storage}", - "noSnapshot": "Kein Schnappschuss vor der Bewerbung", - "restartAfter": "Starten Sie den Behälter nach dem Auftragen neu", - "noRestart": "Kein Neustart nach der Bewerbung", + "noSnapshot": "Kein Schnappschuss vor der Anwendung", + "restartAfter": "Neustart des Containers nach der Anwendung", + "noRestart": "Kein Neustart des Containers nach der Anwendung", "snapshotLabel": "Snapshot des Containers vor der Anwendung (vzdump)", "appliesToBoth": "Gilt für manuelle und geplante Ausführungen.", "backupStorage": "Backup-Speicher", "pickStorage": "Wählen Sie einen Speicher", "editButton": "Bearbeiten", - "cancelButton": "Stornieren", + "cancelButton": "Abbrechen", "saveButton": "Speichern", "storageAuto": "Auto" }, @@ -1268,7 +1273,7 @@ "releaseDelayDays": "{days} Tag(e) warten", "releaseDelayHelp": "Gilt nur für ausgewählte Apps mit Versionsüberwachung. Deren ausstehende Versionen müssen dieses Alter erreicht haben; Apps ohne Versionsüberwachung führen ihren Updater bei jedem Zeitplan aus.", "notScheduled": "Nicht geplant", - "frequency": "Frequenz", + "frequency": "Intervall", "cronExpression": "Cron-Ausdruck", "cronPlaceholder": "z. B. 0 3 * * *", "whatToUpdate": "Was soll aktualisiert werden?", @@ -1281,7 +1286,7 @@ "noTargets": "Keine Ziele ausgewählt", "selectAtLeastOne": "Wählen Sie mindestens ein Aktualisierungsziel aus.", "deleteButton": "Zeitplan löschen", - "deleteConfirm": "Geplante Updates für diesen Container entfernen? Die Standardeinstellungen (Sicherung + Neustart) werden beibehalten.", + "deleteConfirm": "Geplante Updates für diesen Container entfernen? Die Standardeinstellungen (Backup + Neustart) werden beibehalten.", "loadFailed": "Die geplanten Updates konnten nicht geladen werden.", "saveFailed": "Die geplanten Updates konnten nicht gespeichert werden.", "deleteFailed": "Die geplanten Updates konnten nicht entfernt werden.", @@ -1302,7 +1307,7 @@ "cronChip": { "detected": "Host-Cron erkannt", "variantTteck": "Tteck (Vermächtnis)", - "variantCustom": "Brauch", + "variantCustom": "Benutzerdefiniert", "variantCommunityScripts": "Community-Skripte", "scopeAptApk": "apt/apk auf allen CTs", "scopeUnknown": "Umfang unbekannt" @@ -1326,35 +1331,35 @@ "osStatusUnavailable": "Der Status der Betriebssystemaktualisierungen konnte nicht ermittelt werden.", "installedByHelperPrefix": "Installiert von", "helperScriptsName": "Proxmox VE Helper-Scripts", - "helperCommandEditHint": "Wenn du diesen Aufruf bearbeitest, wird er als benutzerdefinierter Befehl gespeichert.", - "customOnlineDescription": "Oder verwende ein vom Entwickler bereitgestelltes Online-Skript:", - "customOnlineExampleNote": "Ersetze die Beispiel-URL durch die URL des offiziellen Skripts deiner Anwendung.", + "helperCommandEditHint": "Wenn Sie diesen Aufruf bearbeiten, wird er als benutzerdefinierter Befehl gespeichert.", + "customOnlineDescription": "Oder verwenden Sie ein vom Entwickler bereitgestelltes Online-Skript:", + "customOnlineExampleNote": "Ersetzen Sie die Beispiel-URL durch die URL des offiziellen Skripts der App.", "customExamplesHeading": "Beispiele zum Anpassen:", - "updaterChoiceHint": "Wähle eine Aktualisierungsmethode und speichere sie.", + "updaterChoiceHint": "Wählen Sie eine Aktualisierungsmethode und speichern Sie sie.", "updaterMethodLabel": "Aktualisierungsmethode", "helperMethod": "Helper-Scripts", "customMethod": "Eigener Befehl", "helperMethodHelp": "Über Helper-Scripts", - "customMethodHelp": "Über eigene Befehle", + "customMethodHelp": "Über Eigene Befehle", "helperDetectedChoice": "Helper-Scripts-Updater für diese Anwendung erkannt.", "helperUnavailableChoice": "Der ausgewählte Helper-Scripts-Updater ist nicht mehr verfügbar.", "disableUpdater": "Deaktivieren", "disableUpdaterConfirm": "Aktualisierungsmethode für {name} deaktivieren? Gespeicherte Pläne und Zeitpläne behalten ihre Auswahl, führen diese Anwendung aber erst wieder aus, wenn eine Methode konfiguriert ist.", - "noUpdaterSelected": "Du hast keine Aktualisierungsmethode ausgewählt.", + "noUpdaterSelected": "Sie haben keine Aktualisierungsmethode ausgewählt.", "helperMethodDescription": "ProxMenux führt den erkannten Updater in diesem LXC aus und zeigt dessen Protokoll und Ergebnis. Der Updater wird von Proxmox VE Helper-Scripts gepflegt.", - "customMethodDescription": "Du kannst den Updater der Anwendung, einen Paketmanager oder eine neue Binärdatei verwenden. Der Befehl wird in diesem LXC mit Administratorrechten ausgeführt.", - "updaterInstructions": "Lies die Hinweise des Updaters.", + "customMethodDescription": "Sie können den Updater der Anwendung, einen Paketmanager oder eine neue Binärdatei verwenden. Der Befehl wird in diesem LXC mit Administratorrechten ausgeführt.", + "updaterInstructions": "Lesen Sie die Hinweise des Updaters.", "helperDocumentation": "Aktualisierungsdokumentation", "helperSource": "Anwendungsskript", - "customMethodExample": "Passe Namen, Pfade und Dienst an deine Anwendung an. Nur der gespeicherte Befehl wird ausgeführt.", + "customMethodExample": "Passen Sie Namen, Pfade und Dienst an Ihre App an. Nur der gespeicherte Befehl wird ausgeführt.", "helperCommandLabel": "Befehl innerhalb des LXC", "helperCommandFallback": "Wenn wget nicht verfügbar ist, lädt ProxMenux dasselbe Skript mit curl -fsSL herunter.", "customScriptTitle": "Aktualisierungsskript", - "customScriptDescription": "Führe ein bereits im LXC vorhandenes Aktualisierungsskript der Anwendung aus.", + "customScriptDescription": "Führen Sie ein bereits im LXC vorhandenes Aktualisierungsskript der Anwendung aus.", "customPackageTitle": "Debian/Ubuntu-Paket", - "customPackageDescription": "Ersetze my-package durch das Paket, das du aktualisieren möchtest.", + "customPackageDescription": "Ersetzen Sie my-package durch das Paket, das Sie aktualisieren möchten.", "customBinaryTitle": "Binärdatei", - "customBinaryDescription": "Lade zuerst die offizielle Binärdatei für die richtige Version und Architektur nach /tmp/my-app.new herunter und überprüfe sie. Dieses Beispiel sichert die bisherige Binärdatei und startet den zugehörigen Dienst neu.", + "customBinaryDescription": "Laden Sie zuerst die offizielle Binärdatei für die richtige Version und Architektur nach /tmp/my-app.new herunter und überprüfen Sie sie. Dieses Beispiel sichert die bisherige Binärdatei und startet den zugehörigen Dienst neu.", "helperUpdatesRun": "Installiert von Proxmox Helper-Scripts.", "installedPrefix": "installiert", "upstreamAvailable": "Version {version} verfügbar", @@ -1401,7 +1406,7 @@ "customCommandLabel": "Benutzerdefinierter Update-Befehl", "customCommandPlaceholder": "z. B. cd /opt/ && git pull && systemctl restart ", "removeButton": "Entfernen", - "cancelButton": "Stornieren", + "cancelButton": "Abbrechen", "saveButton": "Speichern", "versionTrackingPendingShort": "Versionsverfolgung steht aus.", "versionTrackingNotConfigured": "Die Versionsüberwachung ist nicht konfiguriert.", @@ -1453,13 +1458,23 @@ "postApplyNothingPending": "Nichts ausstehend – alles ist auf dem neuesten Stand.", "postApplyPartial": "{pending} Paket(e) stehen nach der Ausführung noch aus.", "postApplyPartialSubline": "{applied} angewendet. Einige Aktualisierungen wurden nicht abgeschlossen – sehen Sie sich die Terminalausgabe oben an.", - "imagePinned": "Angeheftet an einen Digest", - "imagePinnedTitle": "Dieser Container führt das genaue Bild seiner Digest-Namen aus. Ein neueres Tag ändert es nicht; das Bearbeiten der Referenz in seiner Konfiguration tut es.", + "imagePinned": "An einen Digest angeheftet", + "imagePinnedTitle": "Dieser Container führt das genaue Bild seiner Digest-Namen aus. Ein neueres Tag ändert es nicht. Das Bearbeiten der Referenz in seiner Konfiguration tut es.", "checkNow": "Jetzt prüfen", "checkReadOnlyHelp": "Liest das aktuelle Betriebssystem und die ausstehenden Pakete aus den vorhandenen Indizes. Keine Installation und keine Aktualisierung der Paketindizes.", "checkCooldown": "Eine Prüfung läuft bereits oder wurde kürzlich angefordert. Versuchen Sie es in {seconds} Sekunden erneut.", "checkStale": "Prüfung veraltet", - "checkStaleHelp": "Dieses Ergebnis ist älter als 48 Stunden oder hat kein gültiges Prüfdatum. Führen Sie eine neue Prüfung durch, bevor Sie sich darauf verlassen." + "checkStaleHelp": "Dieses Ergebnis ist älter als 48 Stunden oder hat kein gültiges Prüfdatum. Führen Sie eine neue Prüfung durch, bevor Sie sich darauf verlassen.", + "checkSystemPackages": "Systempakete prüfen", + "checkInProgress": "Überprüfung läuft", + "refreshCheckHelp": "Aktualisiert Paketindizes und prüft ausstehende Aktualisierungen, während der Container ausgeführt wird. Es ist nichts installiert.", + "notCheckedYet": "Updates wurden noch nicht überprüft.", + "checkStillRunning": "Die Prüfung läuft noch. Das Ergebnis wird nach Abschluss gespeichert.", + "checkBeforeUpdate": "Überprüfen Sie zuerst die Updates", + "startBeforeCheck": "Starten Sie den Container und suchen Sie nach Updates. Kein Abzeichen bedeutet nicht, dass es keine Updates gibt.", + "runCustomCommand": "Benutzerdefinierten Befehl ausführen", + "installedVersionUnverified": "Die installierte Version ist nicht bestätigt. Die Verfügbarkeit des Updates konnte nicht bestätigt werden.", + "versionStatusUnknown": "Die Verfügbarkeit des Updates ist nicht bestätigt. Versionsinformationen sind nicht verfügbar oder unvollständig." }, "bulkUpdate": { "title": "Sammelaktualisierung", @@ -1484,12 +1499,15 @@ "saveFailed": "Die Sammelaktualisierungskonfiguration konnte nicht gespeichert werden.", "deleteFailed": "Die Sammelaktualisierungskonfiguration konnte nicht entfernt werden.", "planFailed": "Die Sammelaktualisierung konnte nicht vorbereitet werden. Prüfen Sie die nicht verfügbaren Methoden.", - "loadFailed": "Die Massenaktualisierungskonfiguration konnte nicht geladen werden." + "loadFailed": "Die Massenaktualisierungskonfiguration konnte nicht geladen werden.", + "osDockerScope": "Betriebssystem-Updates umfassen auch Docker, wenn es als Systempaket installiert ist.", + "unverifiedTargetsHelp": "Für einige ausgewählte Elemente ist nicht bestätigt, ob ein Update verfügbar ist. Beim Start werden die gewählten Update-Methoden auch ohne bestätigte neue Version ausgeführt.", + "dockerImageMethod": "Docker-Image" }, "appEditor": { "methodOciImage": "OCI-Image (von ProxMenux installiert)", "ociImageHelp": "Version und Updates werden aus dem Image gelesen, aus dem dieser Container erstellt wurde. ProxMenux hat ihn installiert, daher gibt es nichts zu konfigurieren: Das installierte Image wird mit dem verglichen, das seine Registry heute veröffentlicht.", - "ociAppVersion": "Anwendung", + "ociAppVersion": "App", "ociImage": "Image", "ociNewImage": "Neues Image", "ociImageCurrent": "Version", @@ -1501,7 +1519,7 @@ "upstreamDelegatedTitle": "Die verfügbare Version stammt aus dem Docker-Image", "upstreamDelegatedHelp": "Diese Anwendung läuft in einem Container. Die verfügbare Version wird aus dessen Image ermittelt, ohne separate Abfrage oder doppelte Benachrichtigungen. Aktualisieren Sie das Image im Tab Aktualisierungen.", "closePanel": "Panel schließen", - "cancelButton": "Stornieren", + "cancelButton": "Abbrechen", "saveButton": "Speichern", "hideButton": "Verstecken", "hidePermanentlyTooltip": "Diese Erkennung dauerhaft ausblenden", @@ -1524,7 +1542,7 @@ "installedViaLabel": "Installiert über", "installedVersionLabel": "Installierte Version", "installedVersionRegexLabel": "Regex der installierten Version (Erfassungsgruppe)", - "installedRegexPlaceholder": "z. B. v?(\\d+\\.\\d+\\.\\d+)", + "installedRegexPlaceholder": "z.B. v?(\\d+\\.\\d+\\.\\d+)", "installedRegexAlt": "Installierter_regex", "regexCaptureGroupLabel": "Regex (mit Capture-Gruppe)", "regexPlaceholderVersion": "Version:\\s*(\\d+\\.\\d+\\.\\d+)", @@ -1586,7 +1604,7 @@ "dockerMovingTag": "beweglicher Tag", "dockerMovingTagHelp": "Bewegliche Tags enthalten keine Version. Stattdessen über den Digest bei Docker-Image-Updates verfolgen.", "tagRegexLabel": "Tag-Regex (mit Capture-Gruppe)", - "tagRegexPlaceholder": "z. B. v?(\\d+\\.\\d+\\.\\d+)", + "tagRegexPlaceholder": "z.B. v?(\\d+\\.\\d+\\.\\d+)", "tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)", "installedStatus": "Installiert", "checkingStatus": "Überprüfung…", @@ -1594,13 +1612,13 @@ "upToDateBadge": "Auf dem neuesten Stand", "updateAvailableBadge": "Update verfügbar", "portDescriptionPlaceholder": "Beschreibung (z. B. Web-UI, go2rtc, admin)", - "portPortPlaceholder": "Hafen", + "portPortPlaceholder": "Port", "portHttp": "http", "portHttps": "https", "portLogoLabel": "Logo-URL für diesen Link (optional)", "portLogoPlaceholder": "z. B. https://example.com/logo.webp", - "portCategoryNone": "No category", - "portCategoryAddNew": "+ Add new category…", + "portCategoryNone": "Keine Kategorie", + "portCategoryAddNew": "+ Neue Kategorie hinzufügen…", "removePortTooltip": "Port entfernen", "detectMethodDpkg": "dpkg ·", "detectMethodApk": "apk ·", @@ -1617,9 +1635,9 @@ "hiddenDetectionsHelpSingular": "Sie haben {count} versteckte Erkennung. Stellen Sie eine wieder her, um sie wiederherzustellen, oder registrieren Sie manuell eine benutzerdefinierte App.", "hiddenDetectionsHelpPlural": "Sie haben {count} versteckte Erkennungen. Stellen Sie eine wieder her, um sie wiederherzustellen, oder registrieren Sie manuell eine benutzerdefinierte App.", "registerCustom": "Registrieren Sie eine benutzerdefinierte App", - "noAppsTitle": "Keine Bewerbungen registriert", - "noAppsBody": "Registrieren Sie die Anwendungen in diesem Container, um Weblinks und optional die Verfolgung verfügbarer Versionen mit Benachrichtigungen zu neuen Releases zu erhalten.", - "registerApplication": "Anwendung registrieren", + "noAppsTitle": "Keine Apps registriert", + "noAppsBody": "Registrieren Sie die Apps in diesem Container, um Weblinks und optional die Verfolgung verfügbarer Versionen mit Benachrichtigungen zu neuen Releases zu erhalten.", + "registerApplication": "App registrieren", "searchApplications": "Apps suchen", "searchingApplications": "Apps werden gesucht…", "noApplicationsDetected": "Keine Apps erkannt.", @@ -1691,13 +1709,13 @@ "upstreamErrorGeneric": "Upstream-Prüfung fehlgeschlagen: {detail}", "notificationsEnabled": "Upstream-Update-Benachrichtigungen EIN – zum Stummschalten klicken", "notificationsMuted": "Upstream-Update-Benachrichtigungen stummgeschaltet – zum Aktivieren klicken", - "notifyUpstreamLabel": "Benachrichtigen Sie mich, wenn eine neue Upstream-Version verfügbar ist", + "notifyUpstreamLabel": "Benachrichtigen, wenn eine neue Upstream-Version verfügbar ist", "notifyUpstreamHelp": "Sendet „app_update_available“ an die Kanäle, die in Einstellungen → Benachrichtigungen aktiviert sind. Deaktivieren Sie diese Option, wenn diese App auf Ihrer Box nicht aktualisiert werden kann.", "excludeFromBadgeLabel": "Vom LXC-Aktualisierungszähler ausschließen", - "excludeFromBadgeHelp": "Zählen Sie diese App nicht im Abzeichen „Aggregate Updates“ auf der LXC-Listenkarte. Nützlich, wenn Sie absichtlich an eine bestimmte Version gebunden sind (Tracker-Anforderung, Kompatibilitätsstopp).Hat keinen Einfluss auf den eigenen Status der App-Registerkarte oder die ausgehende Benachrichtigung.", + "excludeFromBadgeHelp": "Zählen Sie diese App nicht im Abzeichen „Aggregate Updates“ auf der LXC-Listenkarte. Nützlich, wenn Sie absichtlich an eine bestimmte Version gebunden sind (Tracker-Anforderung, Kompatibilitätsstopp). Hat keinen Einfluss auf den eigenen Status der App-Registerkarte oder die ausgehende Benachrichtigung.", "ociLabelPlaceholder": "org.opencontainers.image.version", - "portCategoryPlaceholder": "Kategorie für das Apps-Dashboard (optional)", - "portCategoryCustomPlaceholder": "Geben Sie eine Kategorie ein und drücken Sie Enter (Esc to cancel)", + "portCategoryPlaceholder": "Kategorie für das Anwendungsdashboard (optional)", + "portCategoryCustomPlaceholder": "Geben Sie eine Kategorie ein und drücken Sie Enter (Esc zum Abbrechen)", "portCustomUrlPlaceholder": "Benutzerdefinierte URL (z. B. https://vault.example.com) überschreibt IP:port", "stackMemberTitle": "Teil einer Multi-Container-Anwendung", "stackMemberBody": "Dieser Container gehört zu einer Anwendung, deren Hauptcontainer {primary} ist. Die Anwendung, ihre Weblinks und ihre Version werden dort angezeigt." @@ -1705,8 +1723,8 @@ "statusFilter": { "ariaLabel": "Virtuelle Maschinen und Container filtern", "all": "Alle", - "running": "Läuft", - "stopped": "Gestoppt", + "running": "Laufende", + "stopped": "Gestoppte", "empty": "Keine virtuellen Maschinen oder Container mit dem Status „{status}“" }, "filters": { @@ -1721,7 +1739,7 @@ "noUpdates": "Kein LXC mit verfügbaren Updates.", "typeSynonyms": { "lxc": "container", - "qemu": "vm virtuelle maschine virtuelle maschinen" + "qemu": "vm virtuelle Maschine virtuelle Maschinen" } } }, @@ -1733,10 +1751,10 @@ "alerts": "Warnungen" }, "interfaceLanguage": { - "title": "Schnittstellensprache", + "title": "Anzeigesprache", "description": "Wählen Sie die Sprache für das Monitor-Dashboard.", "label": "Dashboard-Sprache", - "fallbackNote": "Automatische Übersetzung mit Google Translate. Melde Fehler in den Issues des Projekts oder schicke einen PR mit der Korrektur.", + "fallbackNote": "Automatische Übersetzung mit Google Translate. Melden Sie Fehler in den Issues des Projekts oder schicken Sie einen PR mit der Korrektur.", "statusComplete": "vollständig", "statusPartial": "teilweise", "statusNeedsTranslation": "Community-Übersetzung erforderlich" @@ -1749,7 +1767,7 @@ "bits": "Bits" }, "healthMonitor": { - "title": "Gesundheitsmonitor", + "title": "Health-Monitor", "description": "Konfigurieren Sie für jede Kategorie, wie lange verworfene Warnungen ausgeblendet bleiben. Änderungen gelten für bestehende und zukünftige abgewiesene Warnungen.", "category": "Kategorie", "suppressionDuration": "Unterdrückungsdauer", @@ -1757,14 +1775,14 @@ "permanentNotice": "Benachrichtigungen für {category} bleiben nach der Entlassung dauerhaft verborgen.", "temperatureSafetyNotice": "Kritische CPU-Temperaturwarnungen scheinen immer noch zum Schutz der Hardware beizutragen.", "longSuppressionNotice": "Lange Unterdrückungsperiode. Verworfene Benachrichtigungen in dieser Kategorie bleiben für lange Zeit verborgen.", - "footerNote": "Diese Einstellungen gelten, wenn Sie eine Warnung vom Health Monitor verwerfen. Kritische CPU-Temperaturwarnungen werden immer angezeigt, um Ihre Hardware zu schützen.", + "footerNote": "Diese Einstellungen gelten, wenn Sie eine Warnung vom Health-Monitor verwerfen. Kritische CPU-Temperaturwarnungen werden immer angezeigt, um Ihre Hardware zu schützen.", "activeSuppressions": "Aktive Unterdrückung", - "activeSuppressionsDescription": "Warnungen, die Sie im Gesundheitsmonitor ausgeblendet haben. Dauerhafte Entlassungen können nur hier rückgängig gemacht werden. Für die Bearbeitung ist der Health Monitor erforderlich", - "noActiveSuppressions": "Keine aktiven Unterdrückungen. Abgewiesene Warnungen vom Health Monitor werden hier angezeigt.", + "activeSuppressionsDescription": "Warnungen, die Sie im Health-Monitor ausgeblendet haben. Dauerhafte Entlassungen können nur hier rückgängig gemacht werden. Für die Bearbeitung ist der Health-Monitor erforderlich", + "noActiveSuppressions": "Keine aktiven Unterdrückungen. Abgewiesene Warnungen vom Health-Monitor werden hier angezeigt.", "permanent": "Dauerhaft", "daysRemaining": "{count}d übrig", "hoursRemaining": "{count}h verbleibend", - "reEnableDisabledTitle": "Aktivieren Sie den Health Monitor-Bearbeitungsmodus, um ihn erneut zu aktivieren", + "reEnableDisabledTitle": "Aktivieren Sie den Health-Monitor-Bearbeitungsmodus, um ihn erneut zu aktivieren", "reEnableQueuedTitle": "Wiederaktivierung abbrechen; es wird beim Speichern nicht angewendet", "reEnableTitle": "Stellen Sie diese Warnung in die Warteschlange, um sie beim Speichern erneut zu aktivieren", "reEnable": "Wieder aktivieren", @@ -1782,14 +1800,14 @@ "1w": "1 Woche", "1m": "1 Monat", "1y": "1 Jahr", - "custom": "Brauch", + "custom": "Benutzerdefiniert", "permanent": "Dauerhaft" }, "categories": { "cpu": "CPU-Auslastung und Temperatur", "memory": "Arbeitsspeicher & Swap", - "storage": "Aufbewahrungshalterungen und Platz", - "disk": "Festplatten-E/A und Fehler", + "storage": "Speicherlaufwerke und Platz", + "disk": "Laufwerks-E/A und Fehler", "network": "Netzwerkschnittstellen", "vms": "VMs und Container", "services": "PVE-Dienste", @@ -1804,10 +1822,10 @@ "description": "Schließen Sie Remote-Speicher (PBS, NFS, CIFS usw.) von der Gesundheitsüberwachung und den Benachrichtigungen aus. Verwenden Sie dies für Speicher, die absichtlich offline sind oder nur über eingeschränkten API-Zugriff verfügen.", "emptyTitle": "Keine Remote-Speicher erkannt", "emptyDescription": "PBS, NFS, CIFS und andere Remote-Speicher werden hier angezeigt, wenn sie konfiguriert sind", - "storage": "Lagerung", + "storage": "Speicher", "offline": "Offline oder nicht verfügbar", "namespaceRestricted": "Erreichbar; Durch ACL ausgeblendete Datenspeichergröße", - "healthHelp": "Im ausgeschalteten Zustand löst dieser Speicher keine Warnungen oder kritischen Alarme im Health Monitor aus.", + "healthHelp": "Im ausgeschalteten Zustand löst dieser Speicher keine Warnungen oder kritischen Alarme im Health-Monitor aus.", "alertsHelp": "Bei AUS werden für diesen Speicher keine Benachrichtigungen gesendet." }, "networkInterfaces": { @@ -1815,10 +1833,10 @@ "description": "Schließen Sie Netzwerkschnittstellen (Bridges, Bonds, physische NICs) von der Zustandsüberwachung und den Benachrichtigungen aus. Verwenden Sie dies für Schnittstellen, die absichtlich deaktiviert oder nicht verwendet werden.", "emptyTitle": "Keine Netzwerkschnittstellen erkannt", "interface": "Schnittstelle", - "down": "RUNTER", + "down": "INAKTIV", "excluded": "Ausgeschlossen", "noIp": "Keine IP", - "healthHelp": "Im ausgeschalteten Zustand löst diese Schnittstelle keine Warnungen oder kritischen Alarme im Health Monitor aus.", + "healthHelp": "Im ausgeschalteten Zustand löst diese Schnittstelle keine Warnungen oder kritischen Alarme im Health-Monitor aus.", "alertsHelp": "Bei AUS werden für diese Schnittstelle keine Benachrichtigungen gesendet." }, "snippets": { @@ -1889,7 +1907,7 @@ "label": "Ressourcen" }, "storage": { - "label": "Lagerung" + "label": "Speicher" }, "network": { "label": "Netzwerk" @@ -1904,7 +1922,7 @@ "label": "Dienstleistungen" }, "health": { - "label": "Gesundheitsmonitor" + "label": "Health-Monitor" }, "updates": { "label": "Aktualisierungen" @@ -1929,13 +1947,13 @@ "vm_start": "VM gestartet", "vm_start_warning": "VM gestartet (Warnungen)", "vm_stop": "VM gestoppt", - "vm_shutdown": "VM heruntergefahren", + "vm_shutdown": "VM runtergefahren", "vm_fail": "VM ist fehlgeschlagen", "vm_restart": "VM neu gestartet", "ct_start": "CT gestartet", "ct_start_warning": "CT gestartet (Warnungen)", "ct_stop": "Die CT wurde gestoppt", - "ct_shutdown": "CT abgeschaltet", + "ct_shutdown": "CT runtergefahren", "ct_restart": "CT neu gestartet", "ct_fail": "CT ist fehlgeschlagen", "migration_start": "Die Migration hat begonnen", @@ -1944,20 +1962,20 @@ "migration_fail": "Die Migration ist fehlgeschlagen", "replication_fail": "Die Replikation ist fehlgeschlagen", "replication_complete": "Replikation abgeschlossen", - "backup_start": "Sicherung gestartet", - "backup_complete": "Sicherung abgeschlossen", - "backup_warning": "Sicherung mit Warnungen abgeschlossen", - "backup_fail": "Die Sicherung ist fehlgeschlagen", + "backup_start": "Backup gestartet", + "backup_complete": "Backup abgeschlossen", + "backup_warning": "Backup mit Warnungen abgeschlossen", + "backup_fail": "Das Backup ist fehlgeschlagen", "host_backup_start": "Host-Backup gestartet", - "host_backup_complete": "Host-Sicherung abgeschlossen", - "host_backup_fail": "Host-Sicherung fehlgeschlagen", + "host_backup_complete": "Host-Backup abgeschlossen", + "host_backup_fail": "Host-Backup fehlgeschlagen", "snapshot_complete": "Snapshot erstellt", "snapshot_fail": "Snapshot ist fehlgeschlagen", "cpu_high": "Hohe CPU-Auslastung", "ram_high": "Hoher Speicherverbrauch", "temp_high": "Hohe Temperatur", "disk_space_low": "Geringer Speicherplatz", - "disk_io_error": "Festplattenfehler/E/A-Fehler", + "disk_io_error": "Laufwerksfehler/E/A-Fehler", "storage_unavailable": "Speicher nicht verfügbar", "smart_test_complete": "SMART-Test abgeschlossen", "smart_test_failed": "Der SMART-Test ist fehlgeschlagen", @@ -1973,7 +1991,7 @@ "user_permission_change": "Benutzerberechtigung geändert", "burst_auth_fail": "Häufung von Authentifizierungsfehlern", "burst_ip_block": "Häufung von IP-Sperren", - "burst_disk_io": "Häufung von Festplatten-E/A-Fehlern", + "burst_disk_io": "Häufung von Laufwerks-E/A-Fehlern", "burst_cluster": "Cluster-Verbindung flattert", "burst_service_fail": "Mehrere Dienstausfälle", "burst_system": "Mehrere Systemprobleme", @@ -1982,7 +2000,7 @@ "node_disconnect": "Knoten getrennt", "node_reconnect": "Knoten wieder verbunden", "system_startup": "Systemstartbericht", - "system_shutdown": "System wird heruntergefahren", + "system_shutdown": "System wird runtergefahren", "system_reboot": "Systemneustart", "system_restore_completed": "Host-Wiederherstellung abgeschlossen", "system_problem": "Systemproblem erkannt", @@ -2022,7 +2040,7 @@ "oci_modify_completed": "OCI-Anwendung geändert", "oci_modify_failed": "Änderung der OCI-Anwendung fehlgeschlagen", "oci_watchdog_restarted": "OCI-Anwendung vom Watchdog neu gestartet", - "oci_watchdog_failed": "OCI-Anwendung vom Watchdog gestoppt belassen", + "oci_watchdog_failed": "OCI-Anwendung vom Watchdog gestoppt", "app_update_available": "App-Update verfügbar", "lxc_update_applied": "LXC Update angewendet", "docker_stack_update_available": "Docker Updates verfügbar" @@ -2199,10 +2217,21 @@ "gettingStartedDescription": "Eine Beispielaufforderung steht bereit, um Ihnen den Einstieg zu erleichtern. Sie können es anpassen, exportieren, um es mit anderen zu teilen, oder eine Eingabeaufforderung aus der Community importieren.", "loadExample": "Beispiel laden", "startFromScratch": "Fangen Sie bei Null an" + }, + "history": { + "title": "Benachrichtigungsverlauf", + "description": "Die letzten 20 gesendeten Benachrichtigungen, einschließlich jeder Ausführung des täglichen Digests, mit dem jeweiligen Ergebnis.", + "empty": "Es wurden noch keine Benachrichtigungen gesendet.", + "loadFailed": "Der Benachrichtigungsverlauf konnte nicht geladen werden.", + "sent": "Gesendet", + "notSent": "Nicht gesendet", + "severityInfo": "Info", + "severityWarning": "Warnung", + "severityCritical": "Kritisch" } }, "optimizations": { - "title": "ProxMenux Optimizations", + "title": "ProxMenux-Optimierungen", "description": "Über ProxMenux installierte Systemoptimierungen und Dienstprogramme", "emptyTitle": "Es sind noch keine ProxMenux-Optimierungen installiert", "emptyDescription": "Führen Sie ProxMenux aus, um Systemoptimierungen zu konfigurieren", @@ -2224,8 +2253,8 @@ "updateSelected": "Update ausgewählt" }, "healthThresholds": { - "title": "Schwellenwerte für den Gesundheitsmonitor", - "description": "Health Monitor und Benachrichtigungen reagieren, wenn diese Grenzwerte überschritten werden. Ziehen Sie den gelben Griff für die Warnstufe und den roten Griff für die kritische Stufe. Von der Empfehlung abweichende Werte werden blau dargestellt; Bewegen Sie den Mauszeiger über ein Handle, um die Empfehlung anzuzeigen, oder verwenden Sie „Zurücksetzen“, um sie wiederherzustellen.", + "title": "Schwellenwerte für den Health-Monitor", + "description": "Health-Monitor und Benachrichtigungen reagieren, wenn diese Grenzwerte überschritten werden. Ziehen Sie den gelben Griff für die Warnstufe und den roten Griff für die kritische Stufe. Von der Empfehlung abweichende Werte werden blau dargestellt; Bewegen Sie den Mauszeiger über ein Handle, um die Empfehlung anzuzeigen, oder verwenden Sie „Zurücksetzen“, um sie wiederherzustellen.", "loadFailed": "Schwellenwerte konnten nicht geladen werden.", "invalidValue": "Ungültiger Wert für {key}: muss eine Zahl sein", "resetFailed": "Zurücksetzen fehlgeschlagen", @@ -2253,7 +2282,7 @@ "title": "CPU-Temperatur" }, "disk_temperature": { - "title": "Festplattentemperatur", + "title": "Laufwerkstemperatur", "description": "Schwellenwerte pro Klasse. Gleiche Einheiten (°C), unterschiedliche Standardwerte, da jede Klasse einen anderen Bereich toleriert." }, "host_storage": { @@ -2262,7 +2291,7 @@ }, "lxc_rootfs": { "title": "Speicherplatz – LXC rootfs", - "description": "Root-Festplatte pro Container, bewertet anhand der Rootfs-Größe von PVE." + "description": "Root-Laufwerk pro Container, bewertet anhand der Rootfs-Größe von PVE." }, "lxc_mount": { "title": "LXC-Montagepunkte", @@ -2270,7 +2299,7 @@ }, "pve_storage": { "title": "PVE-Speicherkapazität", - "description": "PVE-Speicher im Blockstil: LVM, LVM-Thin, ZFS-Pool, RBD/Ceph, PBS. Speicher im Dateisystemstil wird bereits durch Host-Festplattenschwellenwerte abgedeckt." + "description": "PVE-Speicher im Blockstil: LVM, LVM-Thin, ZFS-Pool, RBD/Ceph, PBS. Speicher im Dateisystemstil wird bereits durch Host-Laufwerksschwellenwerte abgedeckt." }, "zfs_pool": { "title": "ZFS-Poolkapazität", @@ -2290,16 +2319,16 @@ "customActive": "Benutzerdefinierte Navigationsreihenfolge aktiv." }, "diskExclusions": { - "title": "Ausschlüsse von Datenträgern", - "description": "Festplatten, die nie nach einem Zeitplan gelesen werden, so dass sie auf ihrem eigenen Timer herunterfahren können.", - "empty": "Keine physischen Festplatten gefunden.", - "disk": "Scheibe", + "title": "Ausschlüsse von Laufwerken", + "description": "Laufwerke, die nie nach einem Zeitplan gelesen werden, so dass sie auf ihrem eigenen Timer herunterfahren können.", + "empty": "Keine physischen Laufwerke gefunden.", + "disk": "Laufwerk", "periodicReads": "Regelmäßige Ablesungen", - "excluded": "Ausgenommen", + "excluded": "Ausgeschlossen", "notConnected": "nicht verbunden", - "saveFailed": "Konnte die Diskausschlüsse nicht retten.", - "help": "Eine ausgeschlossene Festplatte erhält keine geplante Temperatur oder SMART liest - nicht einmal eine Power-Mode-Abfrage -, so dass sie völlig in Ruhe gelassen wird. Das Öffnen seiner SMART-Details liest es immer noch.", - "idleHelp": "Mechanische Festplatten ohne Aktivität werden bereits automatisch in Ruhe gelassen, bis etwas sie wieder verwendet." + "saveFailed": "Konnte die Laufwerksausschlüsse nicht retten.", + "help": "Für ein ausgeschlossenes Laufwerk werden weder Temperatur noch SMART-Werte planmäßig ausgelesen, nicht einmal der Energiestatus wird abgefragt, sodass es völlig in Ruhe gelassen wird. Beim Aufrufen der SMART-Details werden diese immer noch gelsesn.", + "idleHelp": "Mechanische Laufwerke ohne Aktivität werden bereits automatisch in Ruhe gelassen, bis sie wieder verwendet werden." } }, "login": { @@ -2308,7 +2337,7 @@ "password": "Passwort", "usernamePlaceholder": "Geben Sie Ihren Benutzernamen ein", "passwordPlaceholder": "Geben Sie Ihr Passwort ein", - "rememberMe": "Anmeldedaten merken", + "rememberMe": "Erinnere dich an mich", "missingCredentials": "Bitte geben Sie Benutzernamen und Passwort ein", "missingTotp": "Bitte geben Sie Ihren 2FA-Code ein", "invalidCredentials": "Falscher Benutzername oder Passwort", @@ -2359,7 +2388,7 @@ "avatar": "Avatar", "change": "Ändern", "chooseImage": "Bild auswählen", - "clear": "Klar", + "clear": "Löschen", "avatarHint": "PNG, JPEG, WebP oder GIF · bis zu 2 MB · Quadrat vor dem Zuschneiden für beste Ergebnisse.", "settingUp": "Einrichten...", "setupAuthentication": "Authentifizierung einrichten", @@ -2378,7 +2407,7 @@ "enabledLower": "ermöglicht", "disabledLower": "deaktiviert", "clusterLower": "Cluster", - "hostLower": "Host", + "hostLower": "Gastgeber", "remove": "Entfernen", "allow": "Erlauben", "delete": "Löschen", @@ -2397,7 +2426,7 @@ "none": "Keiner", "external": "Extern", "up": "Hoch", - "down": "Runter" + "down": "Inaktiv" }, "errors": { "deleteReportNoFiles": "Es wurden keine Lynis-Berichts- oder Protokolldateien zum Entfernen gefunden.", @@ -2531,7 +2560,7 @@ "httpNoSsl": "Das Dashboard verwendet einfaches HTTP.", "usingCertificate": "Verwendung des Zertifikats {source}.", "proxmoxHost": "Proxmox-Host", - "custom": "Brauch", + "custom": "Benutzerdefiniert", "unencryptedHttp": "Der Datenverkehr wird über HTTP nicht verschlüsselt.", "activeCertificate": "Aktives Zertifikat", "cert": "Zertifikat", @@ -2547,7 +2576,7 @@ "expires": "Läuft ab", "selfSignedWarning": "Bei selbstsignierten Zertifikaten wird möglicherweise eine Browserwarnung angezeigt. Das ist bei lokalen Proxmox-Setups normal.", "configuring": "Konfigurieren...", - "useProxmoxCertificate": "Verwenden Sie das Proxmox-Zertifikat", + "useProxmoxCertificate": "Das Proxmox-Zertifikat verwenden", "noProxmoxCertificate": "Es wurde kein Proxmox-Zertifikat erkannt.", "useCustomCertificate": "Benutzerdefiniertes Zertifikat verwenden", "customPaths": "Benutzerdefinierte Zertifikatpfade", @@ -2594,12 +2623,12 @@ "loading": "Token werden geladen...", "empty": "Noch keine API-Tokens.", "scope": { - "label": "Token permissions", - "readOnly": "Read-only", - "fullAdmin": "Full admin", - "readOnlyHint": "Lesen Sie Metriken und Status. Empfohlen für Dashboards und Integrationen.", + "label": "Token-Berechtigung", + "readOnly": "Nur lesen", + "fullAdmin": "Administrator", + "readOnlyHint": "Liest Metriken und Status. Empfohlen für Dashboards und Integrationen.", "fullAdminHint": "Volle Kontrolle, wie Ihre eigene Sitzung.", - "fullAdminWarning": "Ein Full-Admin-Token kann alles tun, was Sie können: den Host ausschalten oder neu starten, Updates ausführen und ein Terminal öffnen. Teilen Sie es nur mit vollständig vertrauenswürdigen Integrationen." + "fullAdminWarning": "Ein Administrator-Token kann alles tun, was Sie können: den Host ausschalten oder neu starten, Updates ausführen und ein Terminal öffnen. Teilen Sie es nur mit vollständig vertrauenswürdigen Integrationen." } }, "firewall": { @@ -2626,7 +2655,7 @@ "drop": "Fallen", "reject": "Ablehnen", "cluster": "Cluster", - "host": "Host", + "host": "Gastgeber", "rules": "{count} Regeln", "addRule": "Regel hinzufügen", "newRule": "Neue Regel", @@ -2724,7 +2753,7 @@ "noBannedIps": "Derzeit sind keine IP-Adressen blockiert.", "noJails": "Keine Schutzmaßnahmen gefunden.", "noActivity": "Noch keine Fail2Ban-Aktivität.", - "installationTitle": "Installieren Sie Fail2Ban", + "installationTitle": "Fail2Ban installieren", "installationDescription": "Das Terminal installiert Fail2Ban und konfiguriert den Anmeldeschutz.", "uninstallConfirmTitle": "Fail2Ban deinstallieren?", "uninstallConfirmDescription": "Dadurch werden das Fail2Ban-Paket und seine von Monitor erstellten Schutzdateien entfernt.", @@ -2795,7 +2824,7 @@ "featureVulnerabilities": "bekannte Schwachstellen und riskante Einstellungen", "featureCompliance": "gemeinsame Sicherheitskontrollen", "featureGithub": "übersichtlicher Bericht, den Sie ausdrucken oder speichern können", - "install": "Installieren Sie Lynis", + "install": "Lynis installieren", "installedDescription": "Lynis ist installiert und bereit, diesen Host zu prüfen.", "lastScan": "Letzter Scan", "hardeningIndex": "Härteindex", @@ -2839,7 +2868,7 @@ "lowPriority": "Niedrige Priorität", "runningAudit": "Audit läuft...", "runAudit": "Audit durchführen", - "installationTitle": "Installieren Sie Lynis", + "installationTitle": "Lynis installieren", "installationDescription": "Das Terminal installiert Lynis und bereitet die Berichtsunterstützung vor.", "uninstallConfirmTitle": "Lynis deinstallieren?", "uninstallConfirmDescription": "Lynis deinstallieren und den Prüfbericht sowie die Protokolle entfernen.", @@ -3021,7 +3050,7 @@ "gbAvailable": "{amount} GB verfügbar", "configurationSummary": "Konfigurationszusammenfassung", "hostname": "Hostname", - "storage": "Lagerung", + "storage": "Speicher", "accessMode": "Zugriffsmodus", "accessModes": { "hostOnly": "Nur Proxmox", @@ -3129,7 +3158,7 @@ "notInstalled": { "subtitle": "Das Tailscale-Gateway ist noch nicht bereitgestellt.", "description": "Stellen Sie es bereit, um ProxMenux und Proxmox sicher von Ihrem Tailnet aus zu erreichen, ohne öffentliche Ports zu öffnen.", - "deploy": "Stellen Sie Secure Gateway bereit" + "deploy": "Secure Gateway bereitstellen" }, "schema": { "auth_key": { @@ -3288,7 +3317,7 @@ "node": "Knoten", "user": "Benutzer", "unit": "Einheit", - "host": "Host", + "host": "Gastgeber", "timestamp": "Zeitstempel", "type": "Typ", "systemdUnit": "Systemd-Einheit", @@ -3296,7 +3325,7 @@ "duration": "Dauer", "startTime": "Startzeit", "endTime": "Endzeit", - "storage": "Lagerung", + "storage": "Speicher", "storageType": "Speichertyp", "size": "Größe", "created": "Erstellt", @@ -3320,8 +3349,8 @@ "logTitle": "Protokolldetails", "logDescription": "Vollständige Informationen zu diesem Protokolleintrag", "eventTitle": "Veranstaltungsdetails", - "eventDescription": "Vollständige Informationen zu dieser Veranstaltung", - "backupTitle": "Sicherungsdetails", + "eventDescription": "Vollständige Informationen zu diesem Ereignis", + "backupTitle": "Backup-Details", "backupDescription": "Vollständige Informationen zu diesem Backup", "notificationTitle": "Benachrichtigungsdetails", "notificationDescription": "Vollständige Informationen zu dieser Benachrichtigung" @@ -3335,11 +3364,11 @@ }, "onboarding": { "dialogTitle": "ProxMenux-Onboarding", - "previous": "Vorherige", + "previous": "Zurück", "skip": "Überspringen", - "next": "Nächste", - "getStarted": "Legen Sie los!", - "dontShowAgain": "Zeige das nicht noch einmal", + "next": "Weiter", + "getStarted": "Loslegen!", + "dontShowAgain": "Nicht noch einmal zeigen", "goToSlide": "Gehen Sie zu Folie {number}", "slides": { "welcome": { @@ -3352,7 +3381,7 @@ }, "storage": { "title": "Speicherverwaltung", - "description": "Sehen Sie den Status Ihrer Festplatten und Ihres Speichers: Kapazität, Nutzung, SMART-Zustand, Temperatur und Leistung für jedes Gerät." + "description": "Sehen Sie den Status Ihrer Laufwerke und Ihres Speichers: Kapazität, Nutzung, SMART-Zustand, Temperatur und Leistung für jedes Gerät." }, "network": { "title": "Netzwerkmetriken", @@ -3364,7 +3393,7 @@ }, "hardware": { "title": "Hardwareinformationen", - "description": "Zeigen Sie Details zur Serverhardware an: CPU, RAM, GPU, Festplatten, Netzwerk, USV und mehr, einschließlich Modellen, Seriennummern und Status." + "description": "Zeigen Sie Details zur Serverhardware an: CPU, RAM, GPU, Laufwerke, Netzwerk, USV und mehr, einschließlich Modellen, Seriennummern und Status." }, "logs": { "title": "Systemprotokolle", @@ -3410,7 +3439,7 @@ "webhookHttps": "Proxmox-Benachrichtigungen erreichen den Monitor auch mit aktiviertem HTTPS — sie werden über eine nur lokale Adresse zugestellt und scheitern nicht mehr an einem Zertifikatsfehler.", "persistentLogs": "Eine beendete Häufung von Log-Fehlern wird nicht mehr als dauerhaft gemeldet: Ein Muster muss 15 Minuten lang weiter auftreten, und seine Warnung schließt sich von selbst (gemeldet von @Joshua1264).", "mountsLanAddress": "Mountpunkte auf LVM-thin und anderem Blockspeicher zeigen ihre Belegung, und Multi-Container-Anwendungen öffnen sich unter ihrer LAN-Adresse.", - "ociManager": "OCI manager Apps (Beta) – ein neuer Eintrag im ProxMenux-Menü installiert Anwendungen aus ihren offiziellen Container-Images als native Proxmox-LXC, ohne Docker-Engine. Der Katalog umfasst über 360 Anwendungen.", + "ociManager": "OCI manager Apps (Technology Preview) – ein neuer Eintrag im ProxMenux-Menü installiert Anwendungen aus ihren offiziellen Container-Images als native Proxmox-LXC, ohne Docker-Engine. Der Katalog umfasst über 360 Anwendungen.", "ociInMonitor": "OCI-Anwendungen im Monitor – die App-Registerkarte erkennt sie, die Updates-Registerkarte aktualisiert und ändert sie wie das Menü, und eine Logs-Registerkarte verfolgt die Konsolenausgabe der Anwendung live.", "ociAcceleration": "Hardwarebeschleunigung für OCI-Anwendungen – das Installationsprogramm bietet nur die Profile an, die der Host ausführen kann: NVIDIA-, Intel- und AMD-GPUs und für Frigate eine Intel-NPU oder eine Coral. Das NVIDIA Container Toolkit wird mit dem Treiber installiert.", "ociRecovery": "Eine OCI-Anwendung reist mit ihrem Backup – auf diesem oder einem anderen Host wiederhergestellt, wird sie erneut registriert und verwaltet, und in einem Cluster startet sie auf dem Knoten, auf den sie verschoben wird.", @@ -3427,7 +3456,7 @@ }, "loading": { "title": "Netzwerkdaten werden geladen...", - "description": "Scannen von Schnittstellen, Brücken und Verkehr" + "description": "Scannen von Schnittstellen, Bridges und Verkehr" }, "errors": { "serverUnavailableTitle": "Server ist nicht verfügbar", @@ -3452,17 +3481,17 @@ "healthy": "Gesund", "warning": "Warnung", "critical": "Kritisch", - "up": "Hoch", - "down": "Runter" + "up": "Aktiv", + "down": "Inaktiv" }, "duplex": { "full": "Vollduplex", "half": "Halbduplex" }, "interfaceTypes": { - "physical": "Körperlich", - "bridge": "Brücke", - "bridges": "Brücken", + "physical": "Physisch", + "bridge": "Bridge", + "bridges": "Bridges", "bond": "Bindung", "vlan": "VLAN", "virtual": "Virtuell", @@ -3480,14 +3509,14 @@ "bytesSent": "Gesendete Bytes", "current": "Aktuell", "domain": "Domain", - "down": "Runter", - "drops": "Tropfen", - "dropsIn": "Verworfen beim Empfang", - "dropsOut": "Verworfen beim Senden", + "down": "Inaktiv", + "drops": "Drops", + "dropsIn": "Eing. Drops", + "dropsOut": "Ausg. Drops", "duplex": "Duplex", "errors": "Fehler", - "errorsIn": "Fehler beim Empfang", - "errorsOut": "Fehler beim Senden", + "errorsIn": "Fehler in", + "errorsOut": "Fehler raus", "hostname": "Hostname", "interfaceName": "Schnittstellenname", "ipAddress": "IP-Adresse", @@ -3505,21 +3534,21 @@ "packetsSent": "Pakete gesendet", "physicalInterface": "Physische Schnittstelle", "primarySlave": "Primärer Sklave", - "received": "Erhalten", + "received": "Empfangen", "sent": "Gesendet", "slaveInterfaces": "Slave-Schnittstellen", "speed": "Geschwindigkeit", "status": "Status", "time": "Zeit", "type": "Typ", - "up": "Hoch", + "up": "Aktiv", "virtualMemberInterfaces": "Virtuelle Mitgliederschnittstellen", "vmLxcName": "VM/LXC-Name" }, "roles": { "active": "aktiv", - "standby": "Standby", - "down": "runter" + "standby": "standby", + "down": "inaktiv" }, "empty": { "noVmLxc": "Kein VM/LXC", @@ -3581,7 +3610,7 @@ }, "targets": { "gateway": "Gateway (Router)", - "gatewayShort": "Tor", + "gatewayShort": "Gateway", "cloudflare": "Cloudflare (1.1.1.1)", "cloudflareShort": "Wolkenflare", "google": "Google DNS (8.8.8.8)", @@ -3676,7 +3705,7 @@ "coralTpu": "Coral TPU / AI-Beschleuniger", "powerConsumption": "Stromverbrauch", "powerSupplies": "Netzteile", - "systemFans": "Systemfans", + "systemFans": "Systemlüfter", "upsStatus": "UPS-Status", "statusOverview": "Statusübersicht", "batteryInformation": "Batterieinformationen", @@ -3694,7 +3723,7 @@ "gpu": "{count} GPU", "devices": "{count} Geräte", "psus": "{count} Netzteile", - "fans": "{count} Fans", + "fans": "{count} Lüfter", "ups": "{count} USV", "interfaces": "{count} Schnittstellen" }, @@ -3710,7 +3739,7 @@ "size": "Größe", "type": "Typ", "speed": "Geschwindigkeit", - "vendor": "Verkäufer", + "vendor": "Hersteller", "pciSlot": "PCI-Steckplatz", "driver": "Treiber", "kernelModule": "Kernel-Modul", @@ -3720,13 +3749,13 @@ "switchMode": "Modus wechseln", "currentDriver": "Aktueller Fahrer", "consumer": "Verbraucher", - "graphicsClock": "Grafikuhr", - "memoryClock": "Erinnerungsuhr", + "graphicsClock": "Grafik-Taktrate", + "memoryClock": "Speicher-Taktrate", "powerDraw": "Stromaufnahme", "temperature": "Temperatur", - "memory": "Erinnerung", + "memory": "Speicher", "total": "Gesamt", - "used": "Gebraucht", + "used": "Verwendet", "free": "Frei", "memoryUtilization": "Speichernutzung", "connection": "Verbindung", @@ -3741,13 +3770,13 @@ "hardwareWarnings": "Hardwarewarnungen", "currentDraw": "Aktuelle Auslosung", "currentOutput": "Aktueller Ausgang", - "remote": "Fernbedienung", + "remote": "Remote", "batteryCharge": "Batterieladung", "load": "Laden", "runtime": "Laufzeit", "inputVoltage": "Eingangsspannung", "status": "Status", - "host": "Host", + "host": "Gastgeber", "chargeLevel": "Ladezustand", "runtimeRemaining": "Verbleibende Laufzeit", "batteryVoltage": "Batteriespannung", @@ -3819,9 +3848,9 @@ "ethernet": "Ethernet", "wireless": "W-lan", "wirelessController": "Drahtloser Controller", - "wifi": "W-lan", - "storage": "Lagerung", - "storageController": "Speichercontroller", + "wifi": "W-LAN", + "storage": "Speicher", + "storageController": "Storage-Controller", "hid": "HID", "vendorSpecific": "Anbieterspezifisch", "communications": "Kommunikation", @@ -3838,8 +3867,8 @@ "sriovMetricsDescription": "Die folgenden Metriken spiegeln die physische Funktion wider (aggregiert über {count} VFs).", "updatingEverySeconds": "Aktualisierung alle {seconds} Sekunden", "realTimeMetrics": "Echtzeit-Metriken", - "engineUtilizationTotal": "Motorauslastung (Gesamt)", - "engineUtilization": "Motorauslastung", + "engineUtilizationTotal": "Geräteauslastung (Gesamt)", + "engineUtilization": "Geräteauslastung", "activeProcesses": "Aktive Prozesse ({count})", "noActiveProcesses": "Keine aktiven Prozesse, die die GPU nutzen", "virtualFunctions": "Virtuelle Funktionen", @@ -3869,10 +3898,10 @@ "changePending": "Änderung steht aus..." }, "actions": { - "installNvidiaDrivers": "Installieren Sie NVIDIA-Treiber", - "installAmdGpuTools": "Installieren Sie die AMD GPU Tools", - "installIntelGpuTools": "Installieren Sie die Intel GPU Tools", - "installCoralDrivers": "Installieren Sie Coral TPU-Treiber", + "installNvidiaDrivers": "NVIDIA-Treiber installieren", + "installAmdGpuTools": "Die AMD GPU Tools installieren", + "installIntelGpuTools": "Die Intel GPU Tools installieren", + "installCoralDrivers": "Coral TPU-Treiber installieren", "installDrivers": "Treiber installieren" }, "coral": { @@ -3911,13 +3940,13 @@ }, "backup": { "actions": { - "addAnother": "Fügen Sie einen weiteren hinzu", + "addAnother": "Weiteren hinzufügen", "addPath": "Pfad hinzufügen", "all": "Alle", "apply": "Anwenden", "back": "Zurück", "change": "Ändern", - "clear": "Klar", + "clear": "Löschen", "delete": "Löschen", "disable": "Deaktivieren", "download": "Herunterladen", @@ -3927,8 +3956,8 @@ "generateKey": "Schlüssel generieren", "import": "Import", "importKeyfile": "Schlüsseldatei importieren", - "mount": "Montieren", - "next": "Nächste", + "mount": "Mounten", + "next": "Weiter", "recoverKeyfile": "Schlüsseldatei wiederherstellen", "regenerate": "Regenerieren", "restore": "Wiederherstellen", @@ -3944,12 +3973,12 @@ "archives": { "archive": "Archiv", "at": "bei", - "backup": "Sicherung", + "backup": "Backup", "backupTimeLabel": "Backup-Zeit", "deleteBackendTitle": "Löschen Sie dieses Backup", "deleteBorgDescription": "Dadurch wird das Borg-Archiv aus dem Repository entfernt.", "deleteBorgTitle": "Borg-Archiv löschen", - "deleteLocalDescription": "Dadurch wird das lokale Sicherungsarchiv von der Festplatte entfernt.", + "deleteLocalDescription": "Dadurch wird das lokale Backup-Archiv vom Laufwerk entfernt.", "deleteLocalTitle": "Lokales Archiv löschen", "deletePbsDescription": "Dadurch wird der PBS-Snapshot aus dem Datenspeicher entfernt.", "deletePbsTitle": "PBS-Snapshot löschen", @@ -3957,17 +3986,17 @@ "descriptionAfter": "", "descriptionBefore": "Durchsuchen Sie die gefundenen Backups und stellen Sie sie wieder her", "downloadTitle": "Laden Sie dieses Backup herunter", - "emptyAfter": "Backups noch nicht.", - "emptyBefore": "NEIN", + "emptyAfter": "Backups.", + "emptyBefore": "Keine", "encrypted": "Verschlüsselt", "encryptedTitle": "Dieses Backup ist verschlüsselt", "filesInBackup": "Dateien im Backup", "group": "Gruppe", - "host": "Host", + "host": "Gastgeber", "importKeyToDownloadTitle": "Importieren Sie vor dem Herunterladen die passende Schlüsseldatei", "importKeyToRestoreTitle": "Importieren Sie vor der Wiederherstellung die passende Schlüsseldatei", "importKeyToViewTitle": "Importieren Sie die passende Schlüsseldatei, bevor Sie Inhalte anzeigen", - "inspectTitle": "Überprüfen Sie die Sicherung", + "inspectTitle": "Überprüfen Sie das Backup", "job": "Arbeit", "loadFailed": "Backups konnten nicht geladen werden", "packedSizeLabel": "Packmaß", @@ -3975,15 +4004,15 @@ "restoreTitle": "Von diesem Backup wiederherstellen", "startingExport": "Download wird vorbereitet...", "title": "Backups", - "viewContentsTitle": "Sicherungsinhalte anzeigen", - "emptyMessage": "Keine Sicherungsarchive gefunden.", + "viewContentsTitle": "Backup-Inhalte anzeigen", + "emptyMessage": "Keine BAckup-Archive gefunden.", "hiddenOtherNodes": "Backups anderer Knoten werden nicht angezeigt: {count}.", "scopeAllNodes": "Alle Knoten", "scopeAriaLabel": "Backups zum Anzeigen", "scopeShowAll": "Alle anzeigen", "scopeThisNode": "Dieser Knoten", "formerJob": "Ehemaliger Auftrag", - "formerJobTitle": "Der Auftrag, der diese Sicherung erstellt hat, ist auf diesem Knoten nicht mehr vorhanden. Die Sicherung bleibt bis zur Löschung erhalten." + "formerJobTitle": "Der Auftrag, der dieses Backup erstellt hat, ist auf diesem Knoten nicht mehr vorhanden. Die Sicherung bleibt bis zur Löschung erhalten." }, "backends": { "borgDescription": "Borg-Repository für deduplizierte Host-Backups.", @@ -4013,7 +4042,7 @@ "filesTruncated": "· Liste bei 5000 abgeschnitten – öffnen Sie den Snapshot manuell, um den Rest anzuzeigen", "filtered": "gefiltert", "manifest": "Manifest", - "manifestMissingBefore": "Diese Sicherung enthält manifest.json nicht. Die folgenden Metadatendateien enthalten entsprechende Informationen:", + "manifestMissingBefore": "Dieses Backup enthält manifest.json nicht. Die folgenden Metadatendateien enthalten entsprechende Informationen:", "metadataFiles": "Metadatendateien", "network": "Netzwerk", "noDrivers": "Keine Treiber, die neu installiert werden müssen.", @@ -4023,7 +4052,7 @@ "restorePlan": "Plan wiederherstellen", "restorePlanUnavailable": "Wiederherstellungsplan nicht verfügbar: {error}", "rollbackPlan": "Rollback-Plan", - "storage": "Lagerung", + "storage": "Speicher", "title": "Inhalte sichern", "totalFiles": "(insgesamt {count})" }, @@ -4032,7 +4061,7 @@ "attachedWarning": "Dieser Job ist an einem PVE-Aufbewahrungshaken befestigt. Durch das Löschen wird auch der Monitor-Hook aus diesem Speicher entfernt.", "description": "Die Timer- und Monitor-Metadaten für diesen Job werden entfernt.", "timerWarning": "Der systemd-Timer wird entfernt. Bereits erstellte Backups bleiben an ihrem Zielort.", - "title": "Sicherungsauftrag löschen" + "title": "Backup-Auftrag löschen" }, "destinations": { "absolutePathHelp": "Verwenden Sie auf diesem Host einen absoluten Pfad.", @@ -4101,10 +4130,10 @@ "unmountTitle": "Hängen Sie dieses Ziel aus", "whereIsBorgRepo": "Wo ist das Borg-Repository?", "backupsKeptMessage": "Vorhandene Backup-Archive werden nicht gelöscht.", - "localAttachHelpMessage": "Mounten Sie zuerst USB-Laufwerke.Lassen Sie das Feld leer, um den PVE-Speicherpfad + /dump zu verwenden (Fallback: /var/lib/vz/dump).", + "localAttachHelpMessage": "Mounten Sie zuerst USB-Laufwerke. Lassen Sie das Feld leer, um den PVE-Speicherpfad + /dump zu verwenden (Fallback: /var/lib/vz/dump).", "sshAuthorizedKeyHelp": "Überprüfen Sie die folgende Zeile, bevor Sie sie zu ~/.ssh/authorized_keys für {user} auf dem Remote-Host hinzufügen.", - "sshKeyHelpMessage": "Pfad des privaten Schlüssels auf diesem Host.Verwenden Sie {action}, überprüfen Sie dann die angezeigte Public-Key-Zeile und fügen Sie sie zu ~/.ssh/authorized_keys des Remote-Benutzers hinzu.", - "sshKeySetupHelp": "Erstellt einen Schlüssel im angegebenen lokalen Pfad, wenn keiner vorhanden ist;andernfalls liest es seinen öffentlichen Schlüssel.Auf dem Remote-Host ist kein Schlüssel installiert.", + "sshKeyHelpMessage": "Pfad des privaten Schlüssels auf diesem Host. Verwenden Sie {action}, überprüfen Sie dann die angezeigte Public-Key-Zeile und fügen Sie sie zu ~/.ssh/authorized_keys des Remote-Benutzers hinzu.", + "sshKeySetupHelp": "Erstellt einen Schlüssel im angegebenen lokalen Pfad, wenn keiner vorhanden ist. Andernfalls liest es seinen öffentlichen Schlüssel. Auf dem Remote-Host ist kein Schlüssel installiert.", "sshKeySetupTitle": "SSH-Schlüsseleinrichtung", "sshUserHelpMessage": "Remote-Konto, das für den Zugriff auf das Borg-Repository verwendet wird, zum Beispiel borg." }, @@ -4143,7 +4172,7 @@ "savePassphraseNow": "Speichern Sie die Passphrase für geplante Ausführungen", "unrecoverable": "Ohne den Schlüssel können verschlüsselte Backups nicht wiederhergestellt werden.", "usesSharedKeyfileAt": "Verwendet eine gemeinsame Schlüsseldatei unter", - "pbsHelp": "Monitor verwendet {keyfile} mit {option} für verschlüsselte PBS-Sicherungen und -Wiederherstellungen.Die Schlüsseldatei wird im Modus {mode} gespeichert.Bewahren Sie eine sichere Kopie außerhalb dieses Hosts auf." + "pbsHelp": "Monitor verwendet {keyfile} mit {option} für verschlüsselte PBS-Sicherungen und -Wiederherstellungen. Die Schlüsseldatei wird im Modus {mode} gespeichert. Bewahren Sie eine sichere Kopie außerhalb dieses Hosts auf." }, "errors": { "backendNoStagingPath": "Das Backend hat keinen Staging-Pfad zurückgegeben.", @@ -4168,10 +4197,10 @@ "absolutePathHintBefore": "Verwenden Sie den vollständigen absoluten Pfad.", "absolutePathHintMiddle": "ist nicht dasselbe wie", "descriptionAfter": "(manuell oder geplant, Standard- oder benutzerdefiniertes Profil) oben in der Standardprofilliste. Nützlich für App-Daten, benutzerdefinierte Konfigurationsordner und ähnliche Pfade. ({count} konfiguriert)", - "descriptionBefore": "Hier hinzugefügte Pfade sind in enthalten", + "descriptionBefore": "Hier hinzugefügte Pfade sind enthalten in", "empty": "Noch keine benutzerdefinierten Pfade – Backups verwenden nur das Standardprofil.", - "everyBackup": "jedes Backup", - "missingOnDisk": "fehlt auf der Festplatte", + "everyBackup": "jedem Backup", + "missingOnDisk": "fehlt auf dem Laufwerk", "removeTitle": "Entfernen Sie diesen benutzerdefinierten Pfad", "title": "Benutzerdefinierte Pfade" }, @@ -4207,7 +4236,7 @@ "label": "Etikett", "localDestination": "Lokales Ziel", "lvmVgs": "LVM-VGs", - "memory": "Erinnerung", + "memory": "Speicher", "minuteOfHour": "Minute der Stunde", "mode": "Modus", "name": "Name", @@ -4232,7 +4261,7 @@ "repository": "Repository", "repositoryLabel": "Repository", "repositoryPath": "Repository-Pfad", - "retentionLabel": "Zurückbehaltung", + "retentionLabel": "Aufbewahrung", "roles": "Rollen", "schedule": "Zeitplan", "scheduleLabel": "Zeitplan", @@ -4246,11 +4275,11 @@ "sshKeyPath": "SSH-Schlüsselpfad", "sshUser": "SSH-Benutzer", "status": "Status", - "storageLabel": "Lagerung", + "storageLabel": "Speicher", "target": "Ziel", "timeOfDay": "Uhrzeit", "type": "Typ", - "used": "Gebraucht", + "used": "Verwendet", "username": "Benutzername", "vmsLxcs": "VMs/LXCs", "when": "Wann", @@ -4288,23 +4317,23 @@ "loadingJob": "Job wird geladen...", "manualOneShot": "manuell / One-Shot", "manualOneShotDescription": "One-Shot-Backup – zum Zeitpunkt des Auslösers erfasst. Kann nicht erneut ausgeführt oder bearbeitet werden.", - "neverRun": "nie ausgeführt", + "neverRun": "niemals laufen", "newScheduledJob": "Neuer geplanter Auftrag", - "newScheduledJobDescription": "Erstellen Sie einen wiederkehrenden Host-Sicherungsauftrag.", + "newScheduledJobDescription": "Erstellen Sie einen wiederkehrenden Host-Backup-Aauftrag.", "nextRun": "Nächster Lauf", "nextRunLabel": "Nächster Lauf", "nextRunTitle": "Nächster geplanter Lauf", - "noCompatiblePveJob": "Kein kompatibler PVE-Sicherungsjob gefunden", + "noCompatiblePveJob": "Kein kompatibler PVE-Backupjob gefunden", "noCompatiblePveJobDescriptionAfter": "Erste.", - "noCompatiblePveJobDescriptionBefore": "Erstellen oder aktivieren Sie einen PVE-Sicherungsjob für", + "noCompatiblePveJobDescriptionBefore": "Erstellen oder aktivieren Sie einen PVE-Backupjob für", "noCompatiblePveJobDescriptionMiddle": "In", "notAvailableForBorg": "Nicht verfügbar für Borg", "openJobTitle": "Stellendetails öffnen", "parentPveJobHelpAfter": "läuft erfolgreich.", "parentPveJobHelpBefore": "Der Monitor wird nach diesem PVE-Job ausgeführt", - "pbsGroupHelp": "Dies wird zur PBS-Sicherungsgruppe.", + "pbsGroupHelp": "Dies wird zur PBS-Backup-Gruppe.", "pickParentPveJob": "Wählen Sie den übergeordneten PVE-Job", - "retentionTitle": "Zurückbehaltung", + "retentionTitle": "Aufbewahrung", "runAttachedTitle": "Jetzt einen Ad-hoc-Lauf auslösen (der PVE-Timer behält seinen eigenen Zeitplan)", "runNowTitle": "Lösen Sie diesen Job jetzt aus", "running": "Läuft...", @@ -4341,14 +4370,14 @@ "uploadTitle": "Schlüsseldatei hochladen", "useThisKey": "Benutzen Sie diesen Schlüssel", "willFail": "wird scheitern", - "pveKeyDescription": "PVE-Speicher {storage} hat eine Schlüsseldatei unter {path}.Sie können es in Monitor importieren." + "pveKeyDescription": "PVE-Speicher {storage} hat eine Schlüsseldatei unter {path}. Sie können es in Monitor importieren." }, "keyfileError": { "currentlyInstalled": "Derzeit installiert", "description": "Dieses Backup ist verschlüsselt und benötigt die passende PBS-Schlüsseldatei.", "importFrom": "Importieren Sie die passende Schlüsseldatei von", "importPath": "Backup → Ziele → PBS-Schlüsseldatei", - "requiredManifest": "Für die Sicherung erforderlich", + "requiredManifest": "Für das Backup erforderlich", "retryHint": "und versuchen Sie es erneut.", "title": "Passende Schlüsseldatei erforderlich" }, @@ -4365,14 +4394,14 @@ "currentPbsUploadStays": "Vorhandene PBS-Wiederherstellungs-Uploads bleiben erhalten.", "downloadKeyfile": "Schlüsseldatei herunterladen", "hideOptions": "Optionen ausblenden", - "keepLocalDescriptionAfter": "und bewahren Sie Ihre eigene Sicherungskopie an einem anderen Ort auf.", + "keepLocalDescriptionAfter": "und bewahren Sie Ihre eigene Backup-Kopie an einem anderen Ort auf.", "keepLocalDescriptionBefore": "Behalten Sie den Schlüssel nur auf diesem Host", "keepLocalOnly": "Nur lokal verwenden", "keepOffsiteCopy": "Bewahren Sie eine externe Kopie auf", "keyfileInstalled": "Schlüsseldatei installiert", "startUploading": "Beginnen Sie mit dem Hochladen von Wiederherstellungsblobs auf PBS", "stopUploading": "Stoppen Sie das Hochladen von Wiederherstellungs-Blobs", - "thisBackupReusesKeyfile": "Bei dieser Sicherung wird die gemeinsam genutzte PBS-Schlüsseldatei wiederverwendet.", + "thisBackupReusesKeyfile": "Bei diesem Backup wird die gemeinsam genutzte PBS-Schlüsseldatei wiederverwendet.", "title": "Schlüsseldateiverwaltung", "updatePassphrase": "Passphrase aktualisieren", "uploadNo": "Nein, behalten Sie es nur lokal bei", @@ -4406,7 +4435,7 @@ "gpus": "GPUs", "interfaceCount": "{count} Schnittstelle(n)", "lxcsAtBackup": "LXCs im Backup", - "memory": "Erinnerung", + "memory": "Speicher", "nics": "Netzwerkkarten", "pathCount": "{count} Pfade", "pathsArchived": "Pfade archiviert", @@ -4417,13 +4446,13 @@ }, "manual": { "description": "Führen Sie jetzt ein One-Shot-Host-Backup durch.", - "inProgress": "Manuelle Sicherung läuft", + "inProgress": "Manuelles Backup läuft", "oneShotDescription": "One-Shot-Backups werden als eingefrorene Jobs aufbewahrt, sodass Sie ihr Protokoll später überprüfen können.", "reopenLogTitle": "Protokoll erneut öffnen", - "run": "Ausführen", + "run": "Starten", "runBackup": "Führen Sie ein Backup aus", "runOneShotBackup": "Führen Sie ein One-Shot-Backup durch", - "title": "Manuelle Sicherung", + "title": "Manuelles Backup", "viewProgress": "Fortschritt anzeigen" }, "paths": { @@ -4477,7 +4506,7 @@ "zfsPoolsCount": "{count} ZFS-Pools" }, "profile": { - "custom": "Brauch", + "custom": "Benutzerdefiniert", "customDescription": "Wählen Sie genau aus, welche Pfade einbezogen werden sollen.", "default": "Standard", "defaultDescription": "Empfohlene Hostkonfiguration und ProxMenux-Daten.", @@ -4508,12 +4537,12 @@ "crossKernelDescription": "Sowohl die vollständige als auch die benutzerdefinierte Wiederherstellung überspringen automatisch Kernel-/Boot-gebundene Pfade, um eine Kernel-Panic beim nächsten Start zu vermeiden. Alles andere (VMs, LXCs, Netzwerk, Komponenten, benutzerdefinierte Pfade) wird normal wiederhergestellt.", "crossKernelTitle": "Kernelübergreifende Wiederherstellung – nur sichere Teilmenge", "customByPaths": "Benutzerdefiniert nach Pfaden", - "customByPathsDescription": "Wählen Sie genau aus, welche Pfade wiederhergestellt werden sollen. Listet auf, was tatsächlich in dieser Sicherung enthalten ist (Standardprofil + Ihre benutzerdefinierten Pfade).", + "customByPathsDescription": "Wählen Sie genau aus, welche Pfade wiederhergestellt werden sollen. Listet auf, was tatsächlich in diesem Backup enthalten ist (Standardprofil + Ihre benutzerdefinierten Pfade).", "customByPathsTitle": "Benutzerdefinierte Wiederherstellung nach Pfaden", "customPickDescriptionAfter": "env var – derselbe Downstream-Code wie die TUI. Für einen Neustart erforderliche Pfade (Kernel, Module, fstab usw.) werden vom Bash-Flow erkannt und Sie können sie von dort aus für den nächsten Start planen.", "customPickDescriptionBefore": "Wählen Sie die wiederherzustellenden Pfade aus. Ihre Auswahl-Feeds", "customPickDescriptionMiddle": "über die", - "destructiveTitle": "Durch die vollständige Wiederherstellung wird Folgendes ENTFERNT (erstellt nach der Sicherung):", + "destructiveTitle": "Durch die vollständige Wiederherstellung wird Folgendes ENTFERNT (erstellt nach dem Backup):", "noPathsMatch": "Keine Pfade entsprechen dem Filter.", "noRestorablePaths": "Dieses Backup enthält keine wiederherstellbaren Pfade – hier ist nur die vollständige Wiederherstellung sinnvoll.", "olderThanTargetKernel": "ist älter als der Zielkernel", @@ -4535,17 +4564,17 @@ "retention": { "daily": "Täglich", "hourly": "Stündlich", - "keepDaily": "Täglich aufbewahren", - "keepHourly": "Stündlich aufbewahren", - "keepLast": "Bleiben Sie zuletzt", - "keepMonthly": "Monatlich aufbewahren", - "keepWeekly": "Wöchentlich aufbewahren", - "keepYearly": "Jährlich aufbewahren", - "last": "Zuletzt", + "keepDaily": "Tägliche aufbewahren", + "keepHourly": "Stündliche aufbewahren", + "keepLast": "Anzahl aufbewahren", + "keepMonthly": "Monatliche aufbewahren", + "keepWeekly": "Wöchentliche aufbewahren", + "keepYearly": "Jährliche aufbewahren", + "last": "Letzte", "monthly": "Monatlich", "none": "Keiner", "noneAccumulate": "Keine Aufbewahrungsbeschränkungen – Backups sammeln sich an, bis sie manuell entfernt werden.", - "title": "Zurückbehaltung", + "title": "Aufbewahrung", "weekly": "Wöchentlich", "yearly": "Jährlich", "zeroDisables": "0 deaktiviert dieses Limit." @@ -4555,7 +4584,7 @@ "configsIncluded": "Im Backup enthaltene Konfigurationen", "lxcConfigs": "LXC-Konfigurationen:", "noDifferences": "Keine Host-Statusunterschiede festgestellt – der Backup-Status stimmt mit dem aktuellen Host überein (oder das Backup enthielt /etc/pve nicht).", - "pveOnlyHint": "Nur die /etc/pve-Konfiguration wird wiederhergestellt – Festplatten-Images bleiben dort, wo sie gespeichert sind.", + "pveOnlyHint": "Nur die /etc/pve-Konfiguration wird wiederhergestellt – Laufwerke-Images bleiben dort, wo sie gespeichert sind.", "vmConfigs": "VM-Konfigurationen:" }, "schedule": { @@ -4576,7 +4605,7 @@ "hourlyHelpBefore": "Im Moment", "hourlyHelpMiddle": "der Timer", "hourlyOption": "Stündlich", - "howToSchedule": "Wie soll es ausgeführt werden?", + "howToSchedule": "Wie soll es laufen?", "janFirst": "Jedes Jahr am 1. Januar", "modeHelp": "Wählen Sie einen einfachen Zeitplan oder geben Sie einen systemd-OnCalendar-Ausdruck ein.", "monthlyOption": "Monatlich", @@ -4612,24 +4641,24 @@ "drivesDetected": "{count} Laufwerke erkannt", "empty": "Keine USB-Laufwerke erkannt. Schließen Sie eines an und aktualisieren Sie es. Der Monitor scannt automatisch alle 30 Sekunden.", "formatAndUse": "Formatieren und verwenden", - "formatDescription": "Dadurch wird die ausgewählte Festplatte gelöscht und eine einzelne ext4-Partition erstellt.", + "formatDescription": "Dadurch wird das ausgewählte Laufwerk gelöscht und eine einzelne ext4-Partition erstellt.", "formatDrive": "Laufwerk formatieren", - "formatDriveQuestion": "Diese Festplatte löschen und formatieren?", - "formatTitle": "Löschen Sie diese Festplatte und formatieren Sie sie als ext4", - "formatWarning": "Alles auf der Festplatte wird zerstört. Dies kann nicht rückgängig gemacht werden.", - "mountAndUse": "Montieren und verwenden", - "mounted": "Montiert", + "formatDriveQuestion": "Dieses Laufwerk löschen und formatieren?", + "formatTitle": "Löschen Sie dieses Laufwerk und formatieren Sie sie als ext4", + "formatWarning": "Alles auf dem Laufwerk wird zerstört. Dies kann nicht rückgängig gemacht werden.", + "mountAndUse": "Mounten und verwenden", + "mounted": "Gemounted", "noneDetected": "Keine USB-Laufwerke erkannt.", - "repartitionHint": "Die Festplatte wird neu partitioniert (GPT + einzelne ext4-Partition) und automatisch gemountet.", + "repartitionHint": "Das Laufwerk wird neu partitioniert (GPT + einzelne ext4-Partition) und automatisch gemountet.", "scanning": "USB-Laufwerke scannen...", "stateEmpty": "kein Dateisystem", - "stateMounted": "montiert", - "stateUnmounted": "nicht montiert", + "stateMounted": "gemounted", + "stateUnmounted": "nicht gemounted", "typeDeviceExactly": "Geben Sie den Gerätepfad GENAU ein, um zu bestätigen:", "typeDeviceToConfirm": "Geben Sie zur Bestätigung den Gerätepfad ein", "unformatted": "Unformatiert", - "unmountConfirm": "{path} aushängen? Jeder Sicherungsauftrag, der auf diesen Pfad verweist, schlägt fehl, bis Sie ihn erneut bereitstellen.", - "unmounted": "Unmontiert", + "unmountConfirm": "{path} aushängen? Jeder Backup-Auftrag, der auf diesen Pfad verweist, schlägt fehl, bis Sie ihn erneut bereitstellen.", + "unmounted": "Unmounted", "uuidTitle": "UUID: {uuid}", "wipeAndFormat": "Löschen und formatieren" }, @@ -4747,8 +4776,8 @@ "categories": { "cpu": "CPU-Auslastung und Temperatur", "memory": "Arbeitsspeicher & Swap", - "storage": "Aufbewahrungshalterungen und Platz", - "disks": "Festplatten-E/A und Fehler", + "storage": "Speicher-Mounts und Platz", + "disks": "Laufwerks-E/A und Fehler", "network": "Netzwerkschnittstellen", "vms": "VMs und Container", "services": "PVE-Dienste", @@ -4801,7 +4830,7 @@ "certificateValid": "Das Zertifikat ist gültig", "clusterDetected": "Cluster erkannt (corosync.conf ist vorhanden)", "active": "Aktiv", - "up": "HOCH", + "up": "AKTIV", "kernelUpToDate": "Kernel/PVE ist auf dem neuesten Stand", "proxmoxUpToDate": "Proxmox VE ist auf dem neuesten Stand", "noSecurityUpdates": "Es stehen keine Sicherheitsupdates aus", @@ -4939,7 +4968,7 @@ } }, "pwaInstall": { - "title": "Installieren Sie ProxMenux Monitor", + "title": "ProxMenux Monitor installieren", "iosDescription": "Fügen Sie den Monitor für einen schnellen Zugriff zu Ihrem Startbildschirm hinzu.", "androidDescription": "Fügen Sie den Monitor als App hinzu, damit er wie eine normale Anwendung geöffnet wird.", "addToHomeScreen": "Zum Startbildschirm hinzufügen", @@ -4977,7 +5006,7 @@ "charts": { "cpu": "CPU-Auslastung", "memory": "Speichernutzung", - "diskIo": "Festplatten-E/A", + "diskIo": "Laufwerks-E/A", "networkIo": "Netzwerk-E/A" }, "series": { @@ -4992,7 +5021,9 @@ "lxcTerminal": { "title": "Terminal: {name} (ID: {id})", "errors": { - "invalidVmid": "Ungültige VMID – Ausführung von pct enter wird abgelehnt" + "invalidVmid": "Ungültige VMID – Ausführung von pct enter wird abgelehnt", + "noShell": "Das Image dieses Containers hat keine Shell und kann daher kein Terminal öffnen. Die Ausgabe steht auf der Registerkarte „Logs“.", + "enterFailed": "In den Container konnte nicht gewechselt werden." }, "commands": { "listFiles": "Listen Sie alle Dateien mit Details auf", @@ -5000,7 +5031,7 @@ "displayFile": "Dateiinhalte anzeigen", "searchPattern": "Suchen Sie nach einem Muster in einer Datei", "findFiles": "Suchen Sie Dateien nach Namen", - "diskUsage": "Festplattennutzung anzeigen", + "diskUsage": "Laufwerksnutzung anzeigen", "directorySizes": "Verzeichnisgrößen anzeigen", "memoryUsage": "Speichernutzung anzeigen", "runningProcesses": "Laufende Prozesse anzeigen", @@ -5058,9 +5089,9 @@ "rollback": { "empty": "Es gibt auf diesem Host keine Einträge, die im wiederhergestellten Backup fehlten.", "showCleanup": "Manuelle Bereinigungsbefehle anzeigen", - "description": "Diese Einträge sind auf diesem Host vorhanden, waren aber nicht in der wiederhergestellten Sicherung enthalten. Überprüfen Sie sie, bevor Sie etwas entfernen.", + "description": "Diese Einträge sind auf diesem Host vorhanden, waren aber nicht im wiederhergestellten Backup enthalten. Überprüfen Sie sie, bevor Sie etwas entfernen.", "vms": "Nach dem Backup erstellte VMs", - "lxcs": "LXCs, die nach der Sicherung erstellt wurden", + "lxcs": "LXCs, die nach dem Backup erstellt wurden", "components": "Nach dem Backup installierte Komponenten", "uninstallComponentCommand": "# {name} manuell über ProxMenux → Hardware & GPU deinstallieren" }, @@ -5076,9 +5107,9 @@ "importedForced": "Importiert (erzwungen, ausländische Host-ID)", "importedForcedHelp": "Die neue Host-ID wurde auf das Pool-Label geschrieben – der nächste Start sollte sauber importiert werden.", "skippedPartial": "Übersprungen (einige Datenträger fehlen)", - "skippedPartialHelp": "Einige vdev-Festplatten wurden unter /dev/disk/by-id nicht gefunden. Der Pool wurde nicht importiert, um einen beeinträchtigten automatischen Import zu vermeiden. Reparieren Sie die Festplatten oder importieren Sie sie manuell mit zpool import.", + "skippedPartialHelp": "Einige vdev-Laufwerke wurden unter /dev/disk/by-id nicht gefunden. Der Pool wurde nicht importiert, um einen beeinträchtigten automatischen Import zu vermeiden. Reparieren Sie die Laufwerke oder importieren Sie sie manuell mit zpool import.", "skippedMissing": "Übersprungen (keine Datenträger vorhanden)", - "skippedMissingHelp": "Keine der Festplatten dieses Pools befindet sich auf diesem Host. Verschieben Sie die Festplatten hierher oder importieren Sie den Pool von einem anderen Host.", + "skippedMissingHelp": "Keine der Laufwerke dieses Pools befindet sich auf diesem Host. Verschieben Sie die Laufwerke hierher oder importieren Sie den Pool von einem anderen Host.", "importFailed": "Der Import ist fehlgeschlagen", "importFailedHelp": "ZFS lehnte den Import auch mit -f ab. Überprüfen Sie den Zpool-Import und das Protokoll unten.", "logPath": "Protokoll: {path}" @@ -5095,7 +5126,7 @@ }, "actions": { "details": "Einzelheiten", - "history": "Geschichte", + "history": "Verlauf", "dismiss": "Zurückweisen" }, "summary": { @@ -5108,17 +5139,17 @@ "terminal": { "connected": "Verbunden", "disconnected": "Getrennt", - "terminalCount": "{count} / 4 Klemmen", + "terminalCount": "{count} / 4 Terminals", "stackedLayout": "Gestapelte Ansicht (Zeilen)", "gridLayout": "2×2-Rasteransicht", "new": "Neu", "search": "Suchen", - "clear": "Klar", - "searchCommands": "Suchbefehle", + "clear": "Löschen", + "searchCommands": "Syntaxsuche", "onlineSource": "Online – mit der cheat.sh-API", "offlineSource": "Offline – mit lokalen Befehlen", "searchDescription": "Suchen Sie nach Linux- und Proxmox-Befehlen", - "searchPlaceholder": "Suchbefehle… (z. B. tar, docker, qm, systemctl)", + "searchPlaceholder": "Syntaxsuche (z. B. tar, docker, qm, systemctl)", "searchingCheatSh": "Suche in „cheat.sh“…", "poweredByCheatSh": "Unterstützt von cheat.sh", "send": "Schicken", @@ -5176,7 +5207,7 @@ "Dateieigentümer ändern", "Tar.gz-Archiv extrahieren", "Tar.gz-Archiv erstellen", - "Festplattennutzung anzeigen", + "Laufwerksnutzung anzeigen", "Verzeichnisgrößen anzeigen", "Speichernutzung anzeigen", "Laufende Prozesse anzeigen", @@ -5225,21 +5256,22 @@ "yes": "Ja", "yourInput": "Ihr Beitrag:", "submit": "Einreichen", - "ok": "OK" + "ok": "OK", + "endOfInput": "Ende der Eingabe" }, "apps": { - "searchPlaceholder": "Search app or CT…", - "searchAriaLabel": "Search", - "filterAll": "All categories", - "filterAriaLabel": "Filter by category", - "sortAriaLabel": "Sort by", + "searchPlaceholder": "Nach App or CT suchen…", + "searchAriaLabel": "Suchen", + "filterAll": "Alle Kategorien", + "filterAriaLabel": "Nach Kategorie filtern", + "sortAriaLabel": "Sortieren nach", "sortName": "Name", "sortId": "ID", - "sortCategory": "Category", - "countOne": "1 app", - "countMany": "{n} apps", - "uncategorized": "Uncategorized", - "openAriaLabel": "Open {name} in a new tab", + "sortCategory": "Kategorie", + "countOne": "1 App", + "countMany": "{n} Apps", + "uncategorized": "Unkategorisiert", + "openAriaLabel": "{name} in einbem neuen Tab öffnen", "openGuestAriaLabel": "{name} öffnen ({type} {id})", "customLinkAdd": "Add link", "editModeToggle": "Edit", @@ -5250,17 +5282,17 @@ "openUpdatesAria": "Registerkarte Updates von {name} öffnen", "openUpdatesTitle": "Update verfügbar — Registerkarte Updates öffnen", "customLinkName": "Name", - "customLinkNamePlaceholder": "e.g. My app", + "customLinkNamePlaceholder": "z.B. Meine App", "customLinkUrl": "URL", "customLinkLogo": "Logo URL (optional)", - "customLinkCategory": "Category (optional)", - "customLinkBinding": "Bound to", - "customLinkSave": "Save", - "customLinkCreate": "Create", - "customLinkCancel": "Cancel", - "customLinkDelete": "Delete", - "customLinkSaveError": "Save failed", - "customLinkDeleteError": "Delete failed", + "customLinkCategory": "Kategorie (optional)", + "customLinkBinding": "gebunden an", + "customLinkSave": "Speichern", + "customLinkCreate": "Erstellen", + "customLinkCancel": "Abbrechen", + "customLinkDelete": "Löschen", + "customLinkSaveError": "Speichern fehlgeschlagen", + "customLinkDeleteError": "Löschen fehlgeschlagen", "categoryLabels": { "arrSuite": "*Arr Suite", "aiCodingDevTools": "AI / Coding & Dev-Tools", @@ -5447,41 +5479,41 @@ "readOnlyScope": "Die Bewertung überprüft die Hosteinstellungen und den Zustand. Es kann Berichte und Protokolle schreiben; Boot-Statusprüfungen können EFI-Systempartitionen vorübergehend bereitstellen." }, "title": "Audit & Report", - "loading": "Loading assessment…", - "run": "Run assessment", - "running": "Assessing…", - "lastRun": "Last assessed on {when}", - "stale": "{days} days ago", + "loading": "Lade Bewertung…", + "run": "Bewertung starten", + "running": "Zugriff", + "lastRun": "Letzte Bewerung: {when}", + "stale": "vor {days} Tagen", "unverifiedChecks": "Nicht erhoben: {checks}. Jede nennt in ihrer Evidenz, was sie nicht lesen konnte.", - "affectedCount": "{count} affected", + "affectedCount": "{count} betroffen", "reviewOn": "Überprüfen am: {when}", "reviewDue": "Zur Überprüfung fällig — die Entscheidung gilt weiter", "reviewDueNotice": "{count} akzeptierte Entscheidung(en) stehen zur Überprüfung an.", "states": { - "fail": "Failed", - "warn": "Warning", - "accepted": "Accepted risk", - "pass": "Passed", - "not_applicable": "Not applicable", + "fail": "Fehlgeschlagen", + "warn": "Warnung", + "accepted": "Akzeptables Risiko", + "pass": "Bestanden", + "not_applicable": "Nicht anwendbar", "unknown": "Nicht geprüft" }, "areas": { - "all": "All", + "all": "Alles", "system": "System", - "storage": "Storage", - "network": "Network", - "security": "Security", + "storage": "Speicher", + "network": "Netzwerk", + "security": "Sicherheit", "backup": "Backup", - "guests": "Guests", + "guests": "Gäste", "hardware": "Hardware" }, "detail": { - "why": "Context", + "why": "Kontext", "whatItMeans": "Was das bedeutet", "whatToDo": "Was zu tun ist", - "evidence": "Evidence", - "affected": "Affected", - "acceptedRisk": "Accepted risk", + "evidence": "Beweis", + "affected": "Betroffen", + "acceptedRisk": "Akzeptables Risiko", "sources": "Quellen und Erfassungszeitpunkte" }, "errors": { @@ -5490,7 +5522,7 @@ "checks": { "backup": { "guest_coverage": { - "title": "Backup coverage", + "title": "Backup-Abdeckung", "rationale": "Aktivierte Sicherungsaufträge auf diesem Knoten, die von jedem ausgewählten Gäste und die davon ausgeschlossenen Gastdaten. Eine konfigurierte Abdeckung belegt nicht, dass eine verwendbare Sicherung existiert. Ob ein nicht ausgewählter Gast geschützt werden sollte, ergibt sich aus der erklärten Richtlinie.", "summary": { "noJobs": "Für die {total} Gäste dieses Knotens ist kein Sicherungsauftrag definiert", @@ -5991,62 +6023,62 @@ "OK": "OK" }, "viewSwitch": { - "assessment": "Assessment", - "inventory": "Inventory", + "assessment": "Bewertung", + "inventory": "Inventar", "policy": "Richtlinie", "changes": "Änderungen", - "ariaLabel": "Wechsel zwischen Assessment und Inventar" + "ariaLabel": "Wechsel zwischen Bewertung und Inventar" }, "inventory": { - "loading": "Loading inventory…", - "collectedAt": "Composed on {when}", - "unresolved": "path not resolved", - "noUplink": "no uplink", - "identity": "Node identity", - "node": "Node", + "loading": "Lacde Inventar…", + "collectedAt": "Zusammengestellt am {when}", + "unresolved": "Pfad nicht aufgelöst", + "noUplink": "Kein Uplink", + "identity": "Knoten-Identität", + "node": "Knoten", "pveVersion": "Proxmox VE", "kernel": "Kernel", - "subscription": "Subscription", + "subscription": "Abonnement", "cluster": "Cluster", - "standalone": "Not in a cluster", + "standalone": "Nicht in einem Cluster", "hardware": "Hardware", "system": "System", - "serial": "Serial number", + "serial": "Seriennummer", "bios": "BIOS", - "cpu": "Processor", + "cpu": "Prozessor", "topology": "Layout", - "cpuLayout": "{sockets} socket(s) × {cores} cores = {threads} threads", - "virtualisation": "Virtualisation", - "memory": "Memory", - "iommuGroups": "IOMMU groups", - "network": "Network", - "storage": "Storage", + "cpuLayout": "{sockets} Sockel × {cores} Kerne = {threads} Threads", + "virtualisation": "Virtualisierung", + "memory": "Speicher", + "iommuGroups": "IOMMU-Gruppen", + "network": "Netzwerk", + "storage": "Speicher", "name": "Name", - "type": "Type", - "content": "Content", - "shared": "Shared", - "yes": "Yes", - "no": "No", - "guests": "Guests", - "ostype": "Operating system", - "onboot": "Starts with host", + "type": "Typ", + "content": "Inhalt", + "shared": "Geteilt", + "yes": "Ja", + "no": "Nein", + "guests": "Gäste", + "ostype": "Betriebssystem", + "onboot": "Start mit Host", "tags": "Tags", - "privilege": "Privilege", - "privileged": "Privileged", - "unprivileged": "Unprivileged", + "privilege": "Privilegien", + "privileged": "Privilegiert", + "unprivileged": "Unprivilegiert", "features": "Features", - "agent": "Guest agent", - "cpuModel": "CPU model", - "disks": "Disks", - "interfaces": "Network interfaces", + "agent": "Gast-Agent", + "cpuModel": "CPU-Modell", + "disks": "Laufwerke", + "interfaces": "Netzwerkschnittstellen", "protection": "Backup", "passthrough": "Passthrough", - "noBackup": "No backup", - "applications": "Applications", - "versionUnknown": "version not detected", - "passthroughTitle": "PCI passthrough", - "iommuGroup": "IOMMU group {group}", - "proxmenux": "ProxMenux optimizations", + "noBackup": "Kein backup", + "applications": "Anwendungen", + "versionUnknown": "Version nicht erkannt", + "passthroughTitle": "PCI-Passthrough", + "iommuGroup": "IOMMU-Gruppe {group}", + "proxmenux": "ProxMenux-Optimierungen", "latency": "Netzwerklatenz", "subscriptionStatus": { "notfound": "Kein Abonnement vorhanden", @@ -6076,13 +6108,13 @@ "title": "Auditbericht", "subtitle": "Struktur, Konfiguration und Bewertung von {node}", "generated": "Erstellt", - "assessed": "Assessed", - "runId": "Assessment reference", - "print": "Print or save as PDF", - "summaryByArea": "Summary by area", - "area": "Area", - "inventoryAnnex": "Inventory annex", - "scopeTitle": "Scope of this report", + "assessed": "Bewertet", + "runId": "Bewertungsreferenz", + "print": "Al PDF speichern oder drucken", + "summaryByArea": "Zusammenfassung nach Thema", + "area": "Thema", + "inventoryAnnex": "Inventaranhang", + "scopeTitle": "Umfang dieses Berichts", "building": "Bericht wird zusammengestellt…", "node": "Knoten", "profile": "Profil", @@ -6123,7 +6155,7 @@ "board": "Board", "processor": "Prozessor", "topology": "Sockel × Kerne / Threads", - "memory": "Speicher", + "memory": "Arbeitsspeicher", "cores": "Kerne", "threads": "Threads", "memoryModules": "Speichermodule", @@ -6399,7 +6431,7 @@ "readOnlyNotice": "Die Bewertung überprüft die Hosteinstellungen und den Zustand. Es kann Berichte und Protokolle schreiben; Boot-Statusprüfungen können EFI-Systempartitionen vorübergehend bereitstellen.", "noActionNeeded": "Nichts zu tun. Dieser Check hat herausgefunden, was er erwartet.", "couldNotEvaluate": "Diese Überprüfung konnte nicht ausgewertet werden, so dass sie nichts meldet. Die Beweise dokumentieren, was sie nicht lesen konnten.", - "neverRun": "Dieser Gastgeber wurde noch nicht bewertet.", + "neverRun": "Dieser Host wurde noch nicht bewertet.", "noFindings": "Keine Ergebnisse stimmen mit dem aktuellen Filter überein.", "acceptedNotice": "{count} akzeptierte Risiken, die auf diesem Host aufgezeichnet wurden.", "summaryFallback": "Die Überprüfung konnte nicht ausgewertet werden" @@ -7059,7 +7091,7 @@ "vm_ct": "VM a CT", "backup": "Backup", "resources": "Ressourcen", - "storage": "Lagerung", + "storage": "Speicher", "network": "Netzwerk", "security": "Sicherheit", "cluster": "Cluster", @@ -7108,7 +7140,7 @@ "vm_ct": "Virtuelle Maschine / Container", "backup": "Backup & Snapshot", "resources": "Systemressourcen", - "storage": "Lagerung", + "storage": "Speicher", "network": "Netzwerk", "security": "Sicherheit", "cluster": "Cluster", @@ -7125,7 +7157,7 @@ "action": "Aktion", "targetNode": "Zielknoten", "reason": "Grund", - "storage": "Lagerung", + "storage": "Speicher", "status": "Status", "size": "Größe", "duration": "Dauer", @@ -7134,7 +7166,7 @@ "currentValue": "Aktueller Wert", "threshold": "Schwelle", "cpuCores": "CPU-Kerne", - "memory": "Erinnerung", + "memory": "Arbeitsspeicher", "temperature": "Temperatur", "mountPoint": "Mount Point", "usage": "Verwendung", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 0719aeb9..ca7e536d 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1106,7 +1106,11 @@ "shutdown": "Shutdown", "reboot": "Reboot", "forceStop": "Force Stop", - "working": "Working..." + "working": "Working...", + "startPending": "Starting...", + "shutdownPending": "Shutting down...", + "rebootPending": "Restarting...", + "stopPending": "Stopping..." }, "confirm": { "forceStopTitle": "Force Stop", @@ -1150,6 +1154,7 @@ "unknown": "Unknown error", "backupStartFailed": "Failed to start backup: {message}", "controlFailed": "Failed to {action} VM {vmid}: {message}", + "controlNotConfirmed": "The operation has not been confirmed complete. Check the guest status before trying again.", "saveNotesFailed": "Error saving notes. Please try again.", "appNotFound": "This application is no longer available. Refresh the page and try again.", "saveCustomCommandFailed": "Could not save the custom update command: {message}", @@ -1338,7 +1343,14 @@ }, "updates": { "osPackagesTitle": "OS packages", - "checkNow": "Check now", + "checkNow": "Check updates", + "checkSystemPackages": "Check system packages", + "checkInProgress": "Check in progress", + "refreshCheckHelp": "Refreshes package indexes and checks pending updates while the container is running. Nothing is installed.", + "notCheckedYet": "Updates have not been checked yet.", + "checkStillRunning": "The check is still running. Its result will be saved when it finishes.", + "checkBeforeUpdate": "Check updates first", + "startBeforeCheck": "Start the container, then check for updates. No badge does not mean there are no updates.", "checkReadOnlyHelp": "Read current OS and pending packages from existing indexes. No installation or package index refresh.", "checkCooldown": "Check already running or recently requested. Try again in {seconds} seconds.", "checkStale": "Check outdated", @@ -1386,6 +1398,9 @@ "applyUpdate": "Apply update", "upToDate": "Up to date", "runUpdater": "Run updater", + "runCustomCommand": "Run custom command", + "installedVersionUnverified": "Installed version is unverified. Update availability could not be confirmed.", + "versionStatusUnknown": "Update availability is unverified. Version information is unavailable or incomplete.", "terminalTitle": "Apply updates — CT {vmid}", "terminalDescriptionOs": "Applying OS package updates inside the container...", "terminalDescriptionApp": "Running the application updater inside the container...", @@ -1489,9 +1504,12 @@ "notConfigured": "Not configured.", "configure": "Configure", "edit": "Edit", - "apply": "Apply updates", + "apply": "Start bulk update", "osTarget": "OS", "osRequired": "Required for a bulk update.", + "osDockerScope": "OS updates also include Docker when it is installed as a system package.", + "unverifiedTargetsHelp": "Update availability for some selected items is unverified. Starting will run the selected update methods even without a confirmed new version.", + "dockerImageMethod": "Docker image", "includesDependencies": "Also includes declared dependencies: {names}", "noMethod": "No executable update method is available.", "staleTarget": "This selection is no longer available. Remove it before saving.", @@ -2182,6 +2200,17 @@ "localPveApi": "The address cannot point to the local PVE API (localhost:8006/8007).", "metadataIp": "The link-local metadata IP cannot be used as a Gotify endpoint." }, + "history": { + "title": "Notification history", + "description": "The last 20 notifications sent, including each run of the daily digest, with the result of each one.", + "empty": "No notifications have been sent yet.", + "loadFailed": "The notification history could not be loaded.", + "sent": "Sent", + "notSent": "Not sent", + "severityInfo": "Info", + "severityWarning": "Warning", + "severityCritical": "Critical" + }, "customPromptInfo": { "title": "Custom prompt mode", "description": "Create your own AI prompt for ProxMenux Monitor notifications", @@ -3409,7 +3438,7 @@ "webhookHttps": "Proxmox notifications reach the Monitor with HTTPS enabled — they are delivered on a local-only address and no longer fail with a certificate error.", "persistentLogs": "A burst of log errors that ended is no longer reported as persistent: a pattern has to keep appearing for 15 minutes, and its warning clears on its own (reported by @Joshua1264).", "mountsLanAddress": "Mount points on LVM-thin and other block storage show their usage, and multi-container applications open at their LAN address.", - "ociManager": "OCI manager Apps (beta) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.", + "ociManager": "OCI manager Apps (Technology Preview) — a new entry in the ProxMenux menu installs applications from their official container images as native Proxmox LXC, with no Docker engine. The catalog carries over 360 applications.", "ociInMonitor": "OCI applications in the Monitor — the App tab recognises them, the Updates tab updates and modifies them as the menu does, and a Logs tab follows the console output of the application live.", "ociAcceleration": "Hardware acceleration for OCI applications — the installer offers only the profiles the host can run: NVIDIA, Intel and AMD GPUs and, for Frigate, an Intel NPU or a Coral. The NVIDIA Container Toolkit installs with the driver.", "ociRecovery": "An OCI application travels with its backup — restored on this host or on another one, it is registered and managed again, and in a cluster it starts on whichever node it moves to.", @@ -4991,7 +5020,9 @@ "lxcTerminal": { "title": "Terminal: {name} (ID: {id})", "errors": { - "invalidVmid": "Invalid VMID — refusing to execute pct enter" + "invalidVmid": "Invalid VMID — refusing to execute pct enter", + "noShell": "This container's image has no shell, so it cannot open a terminal. Its output is in the Logs tab.", + "enterFailed": "The container could not be entered." }, "commands": { "listFiles": "List all files with details", @@ -5206,6 +5237,7 @@ "processing": "Processing...", "controlSequences": "Control Sequences", "cancelInterrupt": "Cancel/Interrupt", + "endOfInput": "End of input", "exitNano": "Exit (nano)", "searchHistory": "Search history", "clipboard": "Clipboard", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 135107ea..1d5308a1 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1085,7 +1085,11 @@ "shutdown": "Apagar", "reboot": "Reiniciar", "forceStop": "Parar", - "working": "Procesando..." + "working": "Procesando...", + "startPending": "Iniciando...", + "shutdownPending": "Apagando...", + "rebootPending": "Reiniciando...", + "stopPending": "Parando..." }, "confirm": { "forceStopTitle": "Parar", @@ -1134,7 +1138,8 @@ "saveCustomCommandFailed": "No se pudo guardar el comando de actualización personalizado: {message}", "removeCustomCommandConfirm": "¿Eliminar el comando de actualización personalizado para \"{name}\"?", "removeCustomCommandFailed": "No se pudo eliminar el comando de actualización personalizado: {message}", - "hideNoticeFailed": "No se pudo ocultar este aviso: {message}" + "hideNoticeFailed": "No se pudo ocultar este aviso: {message}", + "controlNotConfirmed": "No se ha confirmado que la operación haya terminado. Compruebe el estado de la VM o del contenedor antes de volver a intentarlo." }, "backupModal": { "title": "Backup de {type} {vmid} ({name})", @@ -1455,11 +1460,21 @@ "postApplyNothingPending": "Nada pendiente — todo actualizado.", "postApplyPartial": "{pending} paquete(s) siguen pendientes tras la ejecución.", "postApplyPartialSubline": "{applied} aplicados. Algunas actualizaciones no finalizaron — revisa la salida del terminal.", - "checkNow": "Comprobar ahora", + "checkNow": "Comprobar actualizaciones", "checkReadOnlyHelp": "Lee el sistema operativo actual y los paquetes pendientes a partir de los índices existentes. No instala nada ni actualiza los índices de paquetes.", "checkCooldown": "Ya hay una comprobación en curso o se ha solicitado hace poco. Inténtalo de nuevo en {seconds} segundos.", "checkStale": "Comprobación caducada", - "checkStaleHelp": "Este resultado tiene más de 48 horas o no tiene una fecha de comprobación válida. Lanza una nueva comprobación antes de fiarte de él." + "checkStaleHelp": "Este resultado tiene más de 48 horas o no tiene una fecha de comprobación válida. Lanza una nueva comprobación antes de fiarte de él.", + "checkSystemPackages": "Comprobar paquetes del sistema", + "checkInProgress": "Comprobación en curso", + "refreshCheckHelp": "Renueva los índices de paquetes y comprueba las actualizaciones pendientes mientras el contenedor está en marcha. No instala nada.", + "notCheckedYet": "Las actualizaciones aún no se han comprobado.", + "checkStillRunning": "La comprobación sigue en curso. Su resultado se guardará al terminar.", + "checkBeforeUpdate": "Comprobar actualizaciones primero", + "startBeforeCheck": "Inicie el contenedor y después compruebe las actualizaciones. Que no aparezca el indicador no significa que no haya actualizaciones.", + "runCustomCommand": "Ejecutar comando personalizado", + "installedVersionUnverified": "La versión instalada no está verificada. No se pudo confirmar si hay una actualización disponible.", + "versionStatusUnknown": "No se ha verificado si hay una actualización disponible. La información de versión falta o está incompleta." }, "bulkUpdate": { "title": "Actualización en bloque", @@ -1468,7 +1483,7 @@ "notConfigured": "No configurada.", "configure": "Configurar", "edit": "Editar", - "apply": "Aplicar actualizaciones", + "apply": "Iniciar actualización masiva", "osTarget": "SO", "osRequired": "Obligatorio en una actualización en bloque.", "includesDependencies": "Incluye también las dependencias declaradas: {names}", @@ -1484,7 +1499,10 @@ "saveFailed": "No se pudo guardar la configuración de actualización en bloque.", "deleteFailed": "No se pudo eliminar la configuración de actualización en bloque.", "planFailed": "No se pudo preparar la actualización en bloque. Edite la configuración y revise los métodos no disponibles.", - "loadFailed": "no se pudo cargar la configuración de actualización masiva." + "loadFailed": "no se pudo cargar la configuración de actualización masiva.", + "osDockerScope": "Las actualizaciones del sistema operativo también incluyen Docker cuando está instalado como paquete del sistema.", + "unverifiedTargetsHelp": "No se ha verificado si hay actualización para algunos de los elementos seleccionados. Al iniciar se ejecutarán los métodos de actualización elegidos aunque no haya una versión nueva confirmada.", + "dockerImageMethod": "Imagen Docker" }, "appEditor": { "dockerDetectedWithWorkloads": "Docker detectado con {count} aplicaciones en contenedores", @@ -2183,6 +2201,17 @@ "localPveApi": "La dirección no puede apuntar a la API PVE local (localhost:8006/8007).", "metadataIp": "La IP de metadatos del enlace local no se puede utilizar como punto final de Gotify." }, + "history": { + "title": "Historial de notificaciones", + "description": "Las últimas 20 notificaciones enviadas, incluida cada ejecución del resumen diario, con el resultado de cada una.", + "empty": "Todavía no se ha enviado ninguna notificación.", + "loadFailed": "No se pudo cargar el historial de notificaciones.", + "sent": "Enviada", + "notSent": "No enviada", + "severityInfo": "Info", + "severityWarning": "Advertencia", + "severityCritical": "Crítico" + }, "customPromptInfo": { "title": "Modo de aviso personalizado", "description": "Cree su propio mensaje de IA para las notificaciones de ProxMenux Monitor", @@ -3410,7 +3439,7 @@ "webhookHttps": "Las notificaciones de Proxmox llegan al Monitor con HTTPS activado — se entregan en una dirección solo local y ya no fallan por un error de certificado.", "persistentLogs": "Una ráfaga de errores en los logs que ya terminó no se presenta como persistente: el patrón tiene que seguir apareciendo durante 15 minutos, y su aviso se cierra solo (reportado por @Joshua1264).", "mountsLanAddress": "Los puntos de montaje en LVM-thin y otros almacenamientos de bloques muestran su uso, y las aplicaciones multicontenedor se abren en su dirección de la LAN.", - "ociManager": "OCI manager Apps (beta) — una nueva entrada del menú de ProxMenux instala aplicaciones desde sus imágenes de contenedor oficiales como LXC nativos de Proxmox, sin motor Docker. El catálogo reúne más de 360 aplicaciones.", + "ociManager": "OCI manager Apps (Technology Preview) — una nueva entrada del menú de ProxMenux instala aplicaciones desde sus imágenes de contenedor oficiales como LXC nativos de Proxmox, sin motor Docker. El catálogo reúne más de 360 aplicaciones.", "ociInMonitor": "Aplicaciones OCI en el Monitor — la pestaña App las reconoce, la pestaña Actualizaciones las actualiza y modifica igual que el menú, y una pestaña Logs sigue en directo la salida de consola de la aplicación.", "ociAcceleration": "Aceleración por hardware para aplicaciones OCI — el instalador ofrece solo los perfiles que el host puede ejecutar: GPU NVIDIA, Intel y AMD y, para Frigate, una NPU de Intel o una Coral. NVIDIA Container Toolkit se instala con el driver.", "ociRecovery": "Una aplicación OCI viaja con su backup — restaurada en este host o en otro, vuelve a quedar registrada y gestionada, y en un clúster arranca en el nodo al que se mueva.", @@ -4992,7 +5021,9 @@ "lxcTerminal": { "title": "Terminal: {name} (ID: {id})", "errors": { - "invalidVmid": "VMID no válido: se niega a ejecutar pct enter" + "invalidVmid": "VMID no válido: se niega a ejecutar pct enter", + "noShell": "La imagen de este contenedor no incluye shell, así que no puede abrir una terminal. Su salida está en la pestaña Logs.", + "enterFailed": "No se pudo entrar en el contenedor." }, "commands": { "listFiles": "Listar todos los archivos con detalles", @@ -5207,6 +5238,7 @@ "processing": "Tratamiento...", "controlSequences": "Secuencias de control", "cancelInterrupt": "Cancelar/Interrumpir", + "endOfInput": "Fin de la entrada", "exitNano": "Salir (nano)", "searchHistory": "Historial de búsqueda", "clipboard": "Portapapeles", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index f7120796..80582c89 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1081,11 +1081,15 @@ "logs": "Logs" }, "actions": { - "start": "Commencer", - "shutdown": "Fermer", + "start": "Démarrer", + "shutdown": "Éteindre", "reboot": "Redémarrer", "forceStop": "Forcer l'arrêt", - "working": "Fonctionnement..." + "working": "En cours...", + "startPending": "Démarrage...", + "shutdownPending": "Extinction...", + "rebootPending": "Redémarrage...", + "stopPending": "Arrêt..." }, "confirm": { "forceStopTitle": "Forcer l'arrêt", @@ -1134,7 +1138,8 @@ "saveCustomCommandFailed": "Impossible d'enregistrer la commande de mise à jour personnalisée : {message}", "removeCustomCommandConfirm": "Supprimer la commande de mise à jour personnalisée pour \"{name}\" ?", "removeCustomCommandFailed": "Impossible de supprimer la commande de mise à jour personnalisée : {message}", - "hideNoticeFailed": "Impossible de masquer cet avis : {message}" + "hideNoticeFailed": "Impossible de masquer cet avis : {message}", + "controlNotConfirmed": "La fin de l'opération n'a pas été confirmée. Vérifiez l'état de l'invité avant de réessayer." }, "backupModal": { "title": "Sauvegarde {type} {vmid} ({name})", @@ -1459,7 +1464,17 @@ "checkReadOnlyHelp": "Lit le système d’exploitation actuel et les paquets en attente à partir des index existants. Aucune installation ni actualisation des index de paquets.", "checkCooldown": "Une vérification est déjà en cours ou a été demandée récemment. Réessayez dans {seconds} secondes.", "checkStale": "Vérification obsolète", - "checkStaleHelp": "Ce résultat date de plus de 48 heures ou n’a pas de date de vérification valide. Lancez une nouvelle vérification avant de vous y fier." + "checkStaleHelp": "Ce résultat date de plus de 48 heures ou n’a pas de date de vérification valide. Lancez une nouvelle vérification avant de vous y fier.", + "checkSystemPackages": "Vérifier les paquets système", + "checkInProgress": "Vérification en cours", + "refreshCheckHelp": "Actualise les index de paquets et vérifie les mises à jour en attente pendant que le conteneur est en cours d'exécution. Rien n'est installé.", + "notCheckedYet": "Les mises à jour n'ont pas encore été vérifiées.", + "checkStillRunning": "La vérification est toujours en cours. Son résultat sera enregistré à la fin.", + "checkBeforeUpdate": "Vérifier d'abord les mises à jour", + "startBeforeCheck": "Démarrez le conteneur, puis vérifiez les mises à jour. L'absence de badge ne signifie pas qu'il n'y a pas de mises à jour.", + "runCustomCommand": "Exécuter une commande personnalisée", + "installedVersionUnverified": "La version installée n'est pas vérifiée. La disponibilité d'une mise à jour n'a pas pu être confirmée.", + "versionStatusUnknown": "La disponibilité d'une mise à jour n'est pas vérifiée. Les informations de version sont indisponibles ou incomplètes." }, "bulkUpdate": { "title": "Mise à jour groupée", @@ -1484,7 +1499,10 @@ "saveFailed": "Impossible d’enregistrer la configuration de mise à jour groupée.", "deleteFailed": "Impossible de supprimer la configuration de mise à jour groupée.", "planFailed": "Impossible de préparer la mise à jour groupée. Vérifiez les méthodes indisponibles.", - "loadFailed": "Impossible de charger la configuration de la mise à jour groupée." + "loadFailed": "Impossible de charger la configuration de la mise à jour groupée.", + "osDockerScope": "Les mises à jour du système incluent aussi Docker lorsqu'il est installé comme paquet système.", + "unverifiedTargetsHelp": "La disponibilité des mises à jour n'est pas vérifiée pour certains éléments sélectionnés. Le lancement exécutera les méthodes de mise à jour choisies même sans nouvelle version confirmée.", + "dockerImageMethod": "Image Docker" }, "appEditor": { "methodOciImage": "Image OCI (installée par ProxMenux)", @@ -2199,6 +2217,17 @@ "gettingStartedDescription": "Un exemple d’invite est prêt à vous aider à démarrer. Vous pouvez l'ajuster, l'exporter pour le partager avec d'autres ou importer une invite de la communauté.", "loadExample": "Charger un exemple", "startFromScratch": "Repartir de zéro" + }, + "history": { + "title": "Historique des notifications", + "description": "Les 20 dernières notifications envoyées, y compris chaque exécution du résumé quotidien, avec le résultat de chacune.", + "empty": "Aucune notification n'a encore été envoyée.", + "loadFailed": "L'historique des notifications n'a pas pu être chargé.", + "sent": "Envoyé", + "notSent": "Non envoyé", + "severityInfo": "Info", + "severityWarning": "Avertissement", + "severityCritical": "Critique" } }, "optimizations": { @@ -3410,7 +3439,7 @@ "webhookHttps": "Les notifications de Proxmox atteignent le Monitor avec HTTPS activé — elles sont livrées sur une adresse uniquement locale et n'échouent plus sur une erreur de certificat.", "persistentLogs": "Une rafale d'erreurs de logs terminée n'est plus signalée comme persistante : un motif doit continuer d'apparaître pendant 15 minutes, et son avertissement se ferme de lui-même (signalé par @Joshua1264).", "mountsLanAddress": "Les points de montage sur LVM-thin et autres stockages en mode bloc affichent leur utilisation, et les applications multi-conteneurs s'ouvrent sur leur adresse LAN.", - "ociManager": "OCI manager Apps (bêta) — une nouvelle entrée du menu ProxMenux installe les applications à partir de leurs images de conteneur officielles en tant que LXC Proxmox natifs, sans moteur Docker. Le catalogue réunit plus de 360 applications.", + "ociManager": "OCI manager Apps (Technology Preview) — une nouvelle entrée du menu ProxMenux installe les applications à partir de leurs images de conteneur officielles en tant que LXC Proxmox natifs, sans moteur Docker. Le catalogue réunit plus de 360 applications.", "ociInMonitor": "Applications OCI dans le Monitor — l'onglet App les reconnaît, l'onglet Mises à jour les met à jour et les modifie comme le fait le menu, et un onglet Logs suit en direct la sortie console de l'application.", "ociAcceleration": "Accélération matérielle pour les applications OCI — l'installateur ne propose que les profils que l'hôte peut exécuter : GPU NVIDIA, Intel et AMD et, pour Frigate, un NPU Intel ou une Coral. Le NVIDIA Container Toolkit s'installe avec le pilote.", "ociRecovery": "Une application OCI voyage avec sa sauvegarde — restaurée sur cet hôte ou sur un autre, elle est de nouveau enregistrée et gérée, et dans un cluster elle démarre sur le nœud vers lequel elle est déplacée.", @@ -4992,7 +5021,9 @@ "lxcTerminal": { "title": "Terminal : {name} (ID : {id})", "errors": { - "invalidVmid": "VMID invalide - refus d'exécuter PCT Enter" + "invalidVmid": "VMID invalide - refus d'exécuter PCT Enter", + "noShell": "L'image de ce conteneur n'a pas de shell, elle ne peut donc pas ouvrir de terminal. Sa sortie se trouve dans l'onglet Logs.", + "enterFailed": "Impossible d'entrer dans le conteneur." }, "commands": { "listFiles": "Lister tous les fichiers avec des détails", @@ -5225,7 +5256,8 @@ "yes": "Oui", "yourInput": "Votre contribution :", "submit": "Soumettre", - "ok": "OK" + "ok": "OK", + "endOfInput": "Fin de la saisie" }, "apps": { "searchPlaceholder": "Search app or CT…", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index 44f2c243..27a5a76b 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1085,7 +1085,11 @@ "shutdown": "Spegni", "reboot": "Riavvia", "forceStop": "Forza arresto", - "working": "Operazione in corso..." + "working": "Operazione in corso...", + "startPending": "Avvio...", + "shutdownPending": "Spegnimento...", + "rebootPending": "Riavvio...", + "stopPending": "Arresto..." }, "confirm": { "forceStopTitle": "Forza arresto", @@ -1134,7 +1138,8 @@ "saveCustomCommandFailed": "Impossibile salvare il comando di aggiornamento personalizzato: {message}", "removeCustomCommandConfirm": "Rimuovere il comando di aggiornamento personalizzato per \"{name}\"?", "removeCustomCommandFailed": "Impossibile rimuovere il comando di aggiornamento personalizzato: {message}", - "hideNoticeFailed": "Impossibile nascondere questo avviso: {message}" + "hideNoticeFailed": "Impossibile nascondere questo avviso: {message}", + "controlNotConfirmed": "Il completamento dell'operazione non è stato confermato. Controlla lo stato del guest prima di riprovare." }, "backupModal": { "title": "Backup {type} {vmid} ({name})", @@ -1459,7 +1464,17 @@ "checkReadOnlyHelp": "Legge il sistema operativo attuale e i pacchetti in sospeso dagli indici esistenti. Nessuna installazione né aggiornamento degli indici dei pacchetti.", "checkCooldown": "Un controllo è già in corso o è stato richiesto di recente. Riprovare tra {seconds} secondi.", "checkStale": "Controllo obsoleto", - "checkStaleHelp": "Questo risultato ha più di 48 ore o non ha una data di controllo valida. Eseguire un nuovo controllo prima di farvi affidamento." + "checkStaleHelp": "Questo risultato ha più di 48 ore o non ha una data di controllo valida. Eseguire un nuovo controllo prima di farvi affidamento.", + "checkSystemPackages": "Controlla pacchetti di sistema", + "checkInProgress": "Controllo in corso", + "refreshCheckHelp": "Aggiorna gli indici dei pacchetti e controlla gli aggiornamenti in sospeso mentre il container è in esecuzione. Non viene installato nulla.", + "notCheckedYet": "Gli aggiornamenti non sono ancora stati controllati.", + "checkStillRunning": "Il controllo è ancora in corso. Il risultato verrà salvato al termine.", + "checkBeforeUpdate": "Controlla prima gli aggiornamenti", + "startBeforeCheck": "Avvia il container, poi controlla gli aggiornamenti. L'assenza del badge non significa che non ci siano aggiornamenti.", + "runCustomCommand": "Esegui comando personalizzato", + "installedVersionUnverified": "La versione installata non è verificata. Non è stato possibile confermare la disponibilità di un aggiornamento.", + "versionStatusUnknown": "La disponibilità di un aggiornamento non è verificata. Le informazioni sulla versione non sono disponibili o sono incomplete." }, "bulkUpdate": { "title": "Aggiornamento in blocco", @@ -1484,7 +1499,10 @@ "saveFailed": "Impossibile salvare la configurazione di aggiornamento in blocco.", "deleteFailed": "Impossibile rimuovere la configurazione di aggiornamento in blocco.", "planFailed": "Impossibile preparare l’aggiornamento in blocco. Modifica la configurazione e controlla i metodi non disponibili.", - "loadFailed": "Impossibile caricare la configurazione dell’aggiornamento in blocco." + "loadFailed": "Impossibile caricare la configurazione dell’aggiornamento in blocco.", + "osDockerScope": "Gli aggiornamenti del sistema operativo includono anche Docker quando è installato come pacchetto di sistema.", + "unverifiedTargetsHelp": "La disponibilità di aggiornamenti per alcuni elementi selezionati non è verificata. L'avvio eseguirà i metodi di aggiornamento scelti anche senza una nuova versione confermata.", + "dockerImageMethod": "Immagine Docker" }, "appEditor": { "methodOciImage": "Immagine OCI (installata da ProxMenux)", @@ -2199,6 +2217,17 @@ "gettingStartedDescription": "Un prompt di esempio è pronto per aiutarti a iniziare. Puoi modificarlo, esportarlo per condividerlo con altri o importare un prompt dalla community.", "loadExample": "Carica esempio", "startFromScratch": "Inizia da zero" + }, + "history": { + "title": "Cronologia delle notifiche", + "description": "Le ultime 20 notifiche inviate, inclusa ogni esecuzione del digest giornaliero, con il risultato di ciascuna.", + "empty": "Nessuna notifica è stata ancora inviata.", + "loadFailed": "Non è stato possibile caricare la cronologia delle notifiche.", + "sent": "Inviata", + "notSent": "Non inviata", + "severityInfo": "Info", + "severityWarning": "Avviso", + "severityCritical": "Critico" } }, "optimizations": { @@ -3410,7 +3439,7 @@ "webhookHttps": "Le notifiche Proxmox raggiungono il Monitor con HTTPS attivo: consegnate su un indirizzo solo locale, non falliscono più per errori di certificato.", "persistentLogs": "Una raffica di errori di log conclusa non è più segnalata come persistente: un pattern deve continuare a comparire per 15 minuti e l’avviso si chiude da solo (segnalato da @Joshua1264).", "mountsLanAddress": "I mount su LVM-thin e altri storage a blocchi mostrano l’utilizzo; le app multi-container si aprono al proprio indirizzo LAN.", - "ociManager": "OCI manager Apps (beta): una nuova voce nel menu di ProxMenux installa le applicazioni dalle loro immagini container ufficiali come LXC nativi di Proxmox, senza motore Docker. Il catalogo contiene oltre 360 applicazioni.", + "ociManager": "OCI manager Apps (Technology Preview): una nuova voce nel menu di ProxMenux installa le applicazioni dalle loro immagini container ufficiali come LXC nativi di Proxmox, senza motore Docker. Il catalogo contiene oltre 360 applicazioni.", "ociInMonitor": "Applicazioni OCI nel Monitor: la scheda App le riconosce, la scheda Aggiornamenti le aggiorna e le modifica come fa il menu, e una scheda Log segue in tempo reale l'output della console dell'applicazione.", "ociAcceleration": "Accelerazione hardware per le applicazioni OCI: l'installer offre solo i profili che l'host può eseguire: GPU NVIDIA, Intel e AMD e, per Frigate, una NPU Intel o una Coral. NVIDIA Container Toolkit viene installato con il driver.", "ociRecovery": "Un'applicazione OCI viaggia con il suo backup: ripristinata su questo host o su un altro, viene di nuovo registrata e gestita, e in un cluster si avvia sul nodo su cui viene spostata.", @@ -4992,7 +5021,9 @@ "lxcTerminal": { "title": "Terminale: {name} (ID: {id})", "errors": { - "invalidVmid": "VMID non valido: esecuzione di pct enter rifiutata" + "invalidVmid": "VMID non valido: esecuzione di pct enter rifiutata", + "noShell": "L'immagine di questo container non ha una shell, quindi non può aprire un terminale. Il suo output si trova nella scheda Log.", + "enterFailed": "Impossibile entrare nel container." }, "commands": { "listFiles": "Elenca tutti i file con dettagli", @@ -5225,7 +5256,8 @@ "yes": "Sì", "yourInput": "Input:", "submit": "Invia", - "ok": "OK" + "ok": "OK", + "endOfInput": "Fine dell'input" }, "apps": { "searchPlaceholder": "Cerca app o CT…", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index 7b783e25..2c688625 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1081,11 +1081,15 @@ "logs": "Logs" }, "actions": { - "start": "Começar", + "start": "Iniciar", "shutdown": "Desligar", - "reboot": "Reinício", + "reboot": "Reiniciar", "forceStop": "Forçar parada", - "working": "Trabalhando..." + "working": "Processando...", + "startPending": "Iniciando...", + "shutdownPending": "Desligando...", + "rebootPending": "Reiniciando...", + "stopPending": "Parando..." }, "confirm": { "forceStopTitle": "Forçar parada", @@ -1134,7 +1138,8 @@ "saveCustomCommandFailed": "Não foi possível salvar o comando de atualização personalizado: {message}", "removeCustomCommandConfirm": "Remover o comando de atualização personalizado para \"{name}\"?", "removeCustomCommandFailed": "não foi possível remover o comando de atualização personalizado: {message}", - "hideNoticeFailed": "Não foi possível ocultar este aviso: {message}" + "hideNoticeFailed": "Não foi possível ocultar este aviso: {message}", + "controlNotConfirmed": "A conclusão da operação não foi confirmada. Verifique o estado do convidado antes de tentar novamente." }, "backupModal": { "title": "Backup {type} {vmid} ({name})", @@ -1459,7 +1464,17 @@ "checkReadOnlyHelp": "Lê o sistema operativo atual e os pacotes pendentes a partir dos índices existentes. Não instala nada nem atualiza os índices de pacotes.", "checkCooldown": "Já há uma verificação em execução ou pedida recentemente. Tente novamente dentro de {seconds} segundos.", "checkStale": "Verificação desatualizada", - "checkStaleHelp": "Este resultado tem mais de 48 horas ou não tem uma data de verificação válida. Execute uma nova verificação antes de confiar nele." + "checkStaleHelp": "Este resultado tem mais de 48 horas ou não tem uma data de verificação válida. Execute uma nova verificação antes de confiar nele.", + "checkSystemPackages": "Verificar pacotes do sistema", + "checkInProgress": "Verificação em andamento", + "refreshCheckHelp": "Atualiza os índices de pacotes e verifica as atualizações pendentes enquanto o contêiner está em execução. Nada é instalado.", + "notCheckedYet": "As atualizações ainda não foram verificadas.", + "checkStillRunning": "A verificação ainda está em execução. O resultado será salvo quando terminar.", + "checkBeforeUpdate": "Verificar atualizações primeiro", + "startBeforeCheck": "Inicie o contêiner e depois verifique as atualizações. A ausência do selo não significa que não haja atualizações.", + "runCustomCommand": "Executar comando personalizado", + "installedVersionUnverified": "A versão instalada não foi verificada. Não foi possível confirmar se há uma atualização disponível.", + "versionStatusUnknown": "A disponibilidade de atualização não foi verificada. As informações de versão estão indisponíveis ou incompletas." }, "bulkUpdate": { "title": "Atualização em bloco", @@ -1484,7 +1499,10 @@ "saveFailed": "Não foi possível guardar a configuração de atualização em bloco.", "deleteFailed": "Não foi possível remover a configuração de atualização em bloco.", "planFailed": "Não foi possível preparar a atualização em bloco. Reveja os métodos indisponíveis.", - "loadFailed": "não foi possível carregar a configuração de atualização em massa." + "loadFailed": "não foi possível carregar a configuração de atualização em massa.", + "osDockerScope": "As atualizações do sistema operacional também incluem o Docker quando ele está instalado como pacote do sistema.", + "unverifiedTargetsHelp": "A disponibilidade de atualização de alguns itens selecionados não foi verificada. Ao iniciar, os métodos de atualização escolhidos serão executados mesmo sem uma nova versão confirmada.", + "dockerImageMethod": "Imagem Docker" }, "appEditor": { "methodOciImage": "Imagem OCI (instalada pelo ProxMenux)", @@ -2199,6 +2217,17 @@ "gettingStartedDescription": "Um exemplo de prompt está pronto para ajudá-lo a começar. Você pode ajustá-lo, exportá-lo para compartilhar com outras pessoas ou importar um prompt da comunidade.", "loadExample": "Exemplo de carregamento", "startFromScratch": "Comece do zero" + }, + "history": { + "title": "Histórico de notificações", + "description": "As últimas 20 notificações enviadas, incluindo cada execução do resumo diário, com o resultado de cada uma.", + "empty": "Nenhuma notificação foi enviada ainda.", + "loadFailed": "O histórico de notificações não pôde ser carregado.", + "sent": "Enviado", + "notSent": "Não enviado", + "severityInfo": "Info", + "severityWarning": "Aviso", + "severityCritical": "Crítico" } }, "optimizations": { @@ -3410,7 +3439,7 @@ "webhookHttps": "As notificações do Proxmox chegam ao Monitor com HTTPS ativo — são entregues num endereço apenas local e já não falham por um erro de certificado.", "persistentLogs": "Uma rajada de erros nos logs que terminou já não é apresentada como persistente: um padrão tem de continuar a aparecer durante 15 minutos, e o seu aviso fecha-se sozinho (reportado por @Joshua1264).", "mountsLanAddress": "Os pontos de montagem em LVM-thin e outros armazenamentos em bloco mostram a sua utilização, e as aplicações com vários contentores abrem no seu endereço LAN.", - "ociManager": "OCI manager Apps (beta) — uma nova entrada no menu do ProxMenux instala aplicações a partir das suas imagens de contentor oficiais como LXC nativos do Proxmox, sem motor Docker. O catálogo reúne mais de 360 aplicações.", + "ociManager": "OCI manager Apps (Technology Preview) — uma nova entrada no menu do ProxMenux instala aplicações a partir das suas imagens de contentor oficiais como LXC nativos do Proxmox, sem motor Docker. O catálogo reúne mais de 360 aplicações.", "ociInMonitor": "Aplicações OCI no Monitor — o separador App reconhece-as, o separador Atualizações atualiza-as e modifica-as como o menu, e um separador Logs segue em direto a saída da consola da aplicação.", "ociAcceleration": "Aceleração por hardware para aplicações OCI — o instalador oferece apenas os perfis que o host pode executar: GPU NVIDIA, Intel e AMD e, para o Frigate, uma NPU Intel ou uma Coral. O NVIDIA Container Toolkit é instalado com o driver.", "ociRecovery": "Uma aplicação OCI viaja com o seu backup — restaurada neste host ou noutro, volta a ficar registada e gerida, e num cluster arranca no nó para onde for movida.", @@ -4992,7 +5021,9 @@ "lxcTerminal": { "title": "Terminal: {name} (ID: {id})", "errors": { - "invalidVmid": "VMID inválido – recusando-se a executar pct enter" + "invalidVmid": "VMID inválido – recusando-se a executar pct enter", + "noShell": "A imagem deste contêiner não possui shell, portanto não pode abrir um terminal. A sua saída está na aba Logs.", + "enterFailed": "Não foi possível entrar no contêiner." }, "commands": { "listFiles": "Liste todos os arquivos com detalhes", @@ -5225,7 +5256,8 @@ "yes": "Sim", "yourInput": "Sua entrada:", "submit": "Enviar", - "ok": "OK" + "ok": "OK", + "endOfInput": "Fim da entrada" }, "apps": { "searchPlaceholder": "Search app or CT…", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index b21c1c45..dc5b7b80 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1106,7 +1106,11 @@ "shutdown": "Vypnúť", "reboot": "Reštartovať", "forceStop": "Vynútiť vypnutie", - "working": "Pracujem..." + "working": "Pracujem...", + "startPending": "Spúšťa sa...", + "shutdownPending": "Vypína sa...", + "rebootPending": "Reštartuje sa...", + "stopPending": "Vypína sa..." }, "confirm": { "forceStopTitle": "Vynútiť vypnutie", @@ -1150,6 +1154,7 @@ "unknown": "Neznáma chyba", "backupStartFailed": "Zálohu sa nepodarilo spustiť: {message}", "controlFailed": "Akcia {action} pre VM {vmid} zlyhala: {message}", + "controlNotConfirmed": "Dokončenie operácie sa zatiaľ nepodarilo potvrdiť. Pred ďalším pokusom skontrolujte stav VM alebo LXC.", "saveNotesFailed": "Poznámky sa nepodarilo uložiť. Skúste to znova.", "appNotFound": "Táto aplikácia už nie je dostupná. Obnovte stránku a skúste to znova.", "saveCustomCommandFailed": "Vlastný príkaz na aktualizáciu sa nepodarilo uložiť: {message}", @@ -1340,7 +1345,14 @@ "updatedWithDockerImage": "Aktualizuje sa spolu so svojím obrazom Docker.", "dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.", "osPackagesTitle": "Balíky systému", - "checkNow": "Skontrolovať teraz", + "checkNow": "Skontrolovať aktualizácie", + "checkSystemPackages": "Skontrolovať balíky systému", + "checkInProgress": "Prebieha kontrola", + "refreshCheckHelp": "Obnoví zoznamy balíkov a skontroluje čakajúce aktualizácie, kým je kontajner spustený. Nič nenainštaluje.", + "notCheckedYet": "Aktualizácie zatiaľ nie sú skontrolované.", + "checkStillRunning": "Kontrola ešte prebieha. Po dokončení sa uloží jej výsledok.", + "checkBeforeUpdate": "Najprv skontrolovať aktualizácie", + "startBeforeCheck": "Spustite kontajner a potom skontrolujte aktualizácie. Chýbajúci odznak neznamená, že nie sú žiadne aktualizácie.", "checkReadOnlyHelp": "Zistí aktuálny systém a čakajúce balíky podľa existujúcich zoznamov. Nič nenainštaluje ani neobnoví zoznamy balíkov.", "checkCooldown": "Kontrola už prebieha alebo sa práve vykonala. Skúste znova o {seconds} sekúnd.", "checkStale": "Zastaraná kontrola", @@ -1388,6 +1400,9 @@ "applyUpdate": "Aktualizovať", "upToDate": "Aktuálne", "runUpdater": "Spustiť aktualizáciu", + "runCustomCommand": "Spustiť vlastný príkaz", + "installedVersionUnverified": "Nainštalovaná verzia nie je overená. Dostupnosť aktualizácie sa nepodarilo potvrdiť.", + "versionStatusUnknown": "Dostupnosť aktualizácie nie je overená. Údaje o verzii chýbajú alebo sú neúplné.", "terminalTitle": "Aktualizácia — CT {vmid}", "terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...", "terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...", @@ -1489,9 +1504,12 @@ "notConfigured": "Nenakonfigurované.", "configure": "Nastaviť", "edit": "Upraviť", - "apply": "Použiť aktualizácie", + "apply": "Spustiť hromadnú aktualizáciu", "osTarget": "Operačný systém", "osRequired": "Povinné pre hromadnú aktualizáciu.", + "osDockerScope": "Systémová aktualizácia zahŕňa aj Docker nainštalovaný ako systémový balík.", + "unverifiedTargetsHelp": "Dostupnosť aktualizácií pre niektoré vybrané položky nie je overená. Spustenie vykoná vybrané aktualizačné metódy aj bez potvrdenej novej verzie.", + "dockerImageMethod": "Docker obraz", "includesDependencies": "Zahŕňa aj deklarované závislosti: {names}", "noMethod": "Nie je dostupná žiadna spustiteľná metóda aktualizácie.", "staleTarget": "Tento výber už nie je dostupný. Pred uložením ho odstráňte.", @@ -2198,6 +2216,17 @@ "gettingStartedDescription": "Na začiatok máte pripravený vzorový prompt. Môžete ho upraviť, exportovať a zdieľať alebo importovať prompt od komunity.", "loadExample": "Načítať príklad", "startFromScratch": "Začať od začiatku" + }, + "history": { + "title": "História upozornení", + "description": "Posledných 20 odoslaných upozornení vrátane každého spustenia denného súhrnu s výsledkom každého z nich.", + "empty": "Zatiaľ neboli odoslané žiadne upozornenia.", + "loadFailed": "Históriu upozornení sa nepodarilo načítať.", + "sent": "Odoslané", + "notSent": "Neodoslané", + "severityInfo": "Info", + "severityWarning": "Upozornenie", + "severityCritical": "Kritické" } }, "optimizations": { @@ -3409,7 +3438,7 @@ "webhookHttps": "Upozornenia Proxmoxu sa dostanú do Monitora aj so zapnutým HTTPS — doručujú sa na výhradne lokálnu adresu a už nezlyhávajú na chybe certifikátu.", "persistentLogs": "Skončená vlna chýb v logoch sa už nehlási ako trvalá: vzor sa musí opakovať 15 minút a jeho upozornenie sa zatvorí samo (nahlásil @Joshua1264).", "mountsLanAddress": "Body pripojenia na LVM-thin a inom blokovom úložisku zobrazujú svoje využitie a aplikácie s viacerými kontajnermi sa otvárajú na svojej LAN adrese.", - "ociManager": "OCI manager Apps (beta) — nová položka v ponuke ProxMenux inštaluje aplikácie z ich oficiálnych kontajnerových image ako natívne Proxmox LXC, bez enginu Docker. Katalóg obsahuje viac ako 360 aplikácií.", + "ociManager": "OCI manager Apps (Technology Preview) — nová položka v ponuke ProxMenux inštaluje aplikácie z ich oficiálnych kontajnerových image ako natívne Proxmox LXC, bez enginu Docker. Katalóg obsahuje viac ako 360 aplikácií.", "ociInMonitor": "OCI aplikácie v Monitore — karta Aplikácie ich rozpozná, karta Aktualizácie ich aktualizuje a upravuje rovnako ako ponuka a karta Logy naživo sleduje výstup konzoly aplikácie.", "ociAcceleration": "Hardvérová akcelerácia pre OCI aplikácie — inštalátor ponúka iba profily, ktoré hostiteľ dokáže spustiť: GPU NVIDIA, Intel a AMD a pre Frigate Intel NPU alebo Coral. NVIDIA Container Toolkit sa inštaluje spolu s ovládačom.", "ociRecovery": "OCI aplikácia cestuje so svojou zálohou — po obnovení na tomto alebo inom hostiteľovi je znova zaregistrovaná a spravovaná a v klastri sa spustí na uzle, na ktorý sa presunie.", @@ -4991,7 +5020,9 @@ "lxcTerminal": { "title": "Terminál: {name} (ID: {id})", "errors": { - "invalidVmid": "Neplatné VMID — pct enter sa nespustí" + "invalidVmid": "Neplatné VMID — pct enter sa nespustí", + "noShell": "Image tohto kontajnera nemá shell, takže nemôže otvoriť terminál. Jeho výstup je na karte Logy.", + "enterFailed": "Do kontajnera sa nepodarilo vstúpiť." }, "commands": { "listFiles": "Zobraziť podrobnosti o súboroch", @@ -5224,7 +5255,8 @@ "yes": "Áno", "yourInput": "Tvoj vstup:", "submit": "Odoslať", - "ok": "OK" + "ok": "OK", + "endOfInput": "Koniec zadávania" }, "apps": { "searchPlaceholder": "Hľadať aplikáciu, VM alebo LXC…", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index 0f9e86c0..a40378c9 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1081,11 +1081,15 @@ "logs": "Loggar" }, "actions": { - "start": "Start", - "shutdown": "Stängning", + "start": "Starta", + "shutdown": "Stäng av", "reboot": "Starta om", "forceStop": "Tvinga stopp", - "working": "Arbetssätt..." + "working": "Arbetar...", + "startPending": "Startar...", + "shutdownPending": "Stänger av...", + "rebootPending": "Startar om...", + "stopPending": "Stoppar..." }, "confirm": { "forceStopTitle": "Tvinga stopp", @@ -1134,7 +1138,8 @@ "saveCustomCommandFailed": "Kunde inte spara det anpassade uppdateringskommandot: {message}", "removeCustomCommandConfirm": "Ta bort det anpassade uppdateringskommandot för \"{name}\"?", "removeCustomCommandFailed": "Kunde inte ta bort det anpassade uppdateringskommandot: {message}", - "hideNoticeFailed": "Kunde inte dölja detta meddelande: {message}" + "hideNoticeFailed": "Kunde inte dölja detta meddelande: {message}", + "controlNotConfirmed": "Det har inte bekräftats att åtgärden är slutförd. Kontrollera gästens status innan du försöker igen." }, "backupModal": { "title": "Säkerhetskopiera {type} {vmid} ({name})", @@ -1459,7 +1464,17 @@ "checkReadOnlyHelp": "Läser aktuellt operativsystem och väntande paket från befintliga index. Ingen installation och ingen uppdatering av paketindex.", "checkCooldown": "En kontroll körs redan eller har nyligen begärts. Försök igen om {seconds} sekunder.", "checkStale": "Föråldrad kontroll", - "checkStaleHelp": "Detta resultat är äldre än 48 timmar eller saknar ett giltigt kontrolldatum. Kör en ny kontroll innan du litar på det." + "checkStaleHelp": "Detta resultat är äldre än 48 timmar eller saknar ett giltigt kontrolldatum. Kör en ny kontroll innan du litar på det.", + "checkSystemPackages": "Kontrollera systempaket", + "checkInProgress": "Kontroll pågår", + "refreshCheckHelp": "Uppdaterar paketindex och kontrollerar väntande uppdateringar medan behållaren körs. Inget installeras.", + "notCheckedYet": "Uppdateringar har inte kontrollerats ännu.", + "checkStillRunning": "Kontrollen pågår fortfarande. Resultatet sparas när den är klar.", + "checkBeforeUpdate": "Kontrollera uppdateringar först", + "startBeforeCheck": "Starta behållaren och kontrollera sedan uppdateringar. Att ett märke saknas betyder inte att det inte finns uppdateringar.", + "runCustomCommand": "Kör anpassat kommando", + "installedVersionUnverified": "Den installerade versionen är inte verifierad. Det gick inte att bekräfta om en uppdatering finns.", + "versionStatusUnknown": "Det är inte verifierat om en uppdatering finns. Versionsinformationen saknas eller är ofullständig." }, "bulkUpdate": { "title": "Gruppuppdatering", @@ -1484,7 +1499,10 @@ "saveFailed": "Det gick inte att spara gruppuppdateringskonfigurationen.", "deleteFailed": "Det gick inte att ta bort gruppuppdateringskonfigurationen.", "planFailed": "Det gick inte att förbereda gruppuppdateringen. Kontrollera otillgängliga metoder.", - "loadFailed": "Det gick inte att läsa in massuppdateringskonfigurationen." + "loadFailed": "Det gick inte att läsa in massuppdateringskonfigurationen.", + "osDockerScope": "OS-uppdateringar omfattar även Docker när det är installerat som systempaket.", + "unverifiedTargetsHelp": "För vissa valda objekt är det inte verifierat om en uppdatering finns. Vid start körs de valda uppdateringsmetoderna även utan en bekräftad ny version.", + "dockerImageMethod": "Docker-avbildning" }, "appEditor": { "methodOciImage": "OCI-avbild (installerad av ProxMenux)", @@ -2199,6 +2217,17 @@ "gettingStartedDescription": "Ett exempel är redo för att hjälpa dig att komma igång. Du kan justera den, exportera den för att dela med andra eller importera en uppmaning från communityn.", "loadExample": "Ladda exempel", "startFromScratch": "Börja från början" + }, + "history": { + "title": "Aviseringshistorik", + "description": "De senaste 20 aviseringarna som skickats, inklusive varje körning av det dagliga sammandraget, med resultatet av var och en.", + "empty": "Inga meddelanden har skickats än.", + "loadFailed": "Aviseringshistoriken kunde inte laddas.", + "sent": "Skickat", + "notSent": "Ej skickat", + "severityInfo": "Info", + "severityWarning": "Varning", + "severityCritical": "Kritisk" } }, "optimizations": { @@ -3410,7 +3439,7 @@ "webhookHttps": "Proxmox-aviseringar når Monitor med HTTPS aktiverat — de levereras till en adress som bara är lokal och misslyckas inte längre på grund av ett certifikatfel.", "persistentLogs": "En avslutad skur av loggfel rapporteras inte längre som bestående: ett mönster måste fortsätta att dyka upp i 15 minuter, och dess varning stängs av sig själv (rapporterat av @Joshua1264).", "mountsLanAddress": "Monteringspunkter på LVM-thin och annan blocklagring visar sin användning, och program med flera containrar öppnas på sin LAN-adress.", - "ociManager": "OCI manager Apps (beta) — en ny post i ProxMenux-menyn installerar applikationer från deras officiella containeravbilder som inbyggda Proxmox LXC, utan Docker-motor. Katalogen innehåller över 360 applikationer.", + "ociManager": "OCI manager Apps (Technology Preview) — en ny post i ProxMenux-menyn installerar applikationer från deras officiella containeravbilder som inbyggda Proxmox LXC, utan Docker-motor. Katalogen innehåller över 360 applikationer.", "ociInMonitor": "OCI-applikationer i Monitor — fliken App känner igen dem, fliken Uppdateringar uppdaterar och ändrar dem som menyn gör, och fliken Logs följer applikationens konsolutdata live.", "ociAcceleration": "Hårdvaruacceleration för OCI-applikationer — installationsprogrammet erbjuder bara de profiler som värden kan köra: NVIDIA-, Intel- och AMD-GPU:er och, för Frigate, en Intel NPU eller en Coral. NVIDIA Container Toolkit installeras med drivrutinen.", "ociRecovery": "En OCI-applikation reser med sin säkerhetskopia — återställd på den här värden eller på en annan registreras och hanteras den igen, och i ett kluster startar den på den nod den flyttas till.", @@ -4992,7 +5021,9 @@ "lxcTerminal": { "title": "Terminal: {name} (ID: {id})", "errors": { - "invalidVmid": "Ogiltig VMID — vägrar att köra pct enter" + "invalidVmid": "Ogiltig VMID — vägrar att köra pct enter", + "noShell": "Den här behållarens avbildning har inget skal, så den kan inte öppna en terminal. Dess utdata finns på fliken Loggar.", + "enterFailed": "Det gick inte att gå in i behållaren." }, "commands": { "listFiles": "Lista alla filer med detaljer", @@ -5225,7 +5256,8 @@ "yes": "Ja", "yourInput": "Din inmatning:", "submit": "Skicka", - "ok": "OK" + "ok": "OK", + "endOfInput": "Slut på inmatning" }, "apps": { "searchPlaceholder": "Search app or CT…", diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index cfb1ccb2..b0df1302 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -147,6 +147,7 @@ cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo " cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found" cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found" cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found" cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found" diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 78f79f8c..13da0971 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -6729,6 +6729,11 @@ def _get_lxc_update_status_map() -> dict: 'managed_oci_app': it.get('_managed_oci_app'), 'os_family': it.get('_os_family'), } + if os.path.exists(f'/run/lock/proxmenux-lxc-check-invalid-{int(vmid)}'): + # Read-only projection: a failed/pending explicit check must not + # re-expose a former positive count after a persistence failure. + out[str(vmid)].update(available=False, count=0, packages=[], + error='Latest explicit package check is running or failed; check again') return out @@ -6843,10 +6848,15 @@ def get_proxmox_vms(): continue vm_type = 'lxc' if resource.get('type') == 'lxc' else 'qemu' + # The PVE summary can lag behind a completed LXC task. + # Use the same direct local cgroup check as the detail + # route; never resurrect a stopped CT from that summary. + vm_status = (_fast_guest_status(resource.get('vmid'), vm_type) + if vm_type == 'lxc' else resource.get('status', 'unknown')) vm_data = { 'vmid': resource.get('vmid'), 'name': resource.get('name', f"VM-{resource.get('vmid')}"), - 'status': resource.get('status', 'unknown'), + 'status': vm_status, 'type': vm_type, 'cpu': resource.get('cpu', 0), 'mem': resource.get('mem', 0), @@ -6901,7 +6911,7 @@ def get_proxmox_vms(): # detail-aware clients. The cache is still warmed # at startup and invalidated by LXC lifecycle events. vm_data['ips'] = [] - if resource.get('status') == 'running': + if vm_data['status'] == 'running': lxc_ip_info = _get_lxc_ip_info_cached(resource.get('vmid')) lxc_ips = _normalise_lxc_ips(lxc_ip_info) vm_data['ips'] = lxc_ips @@ -13676,19 +13686,57 @@ def api_lxc_updates_check(vmid): 'retry_after': retry}), 429, {'Retry-After': str(retry)} _lxc_update_check_attempts[vmid] = now _lxc_update_check_inflight.add(vmid) + operation_lock = None try: + import lxc_check_job + operation_lock = lxc_check_job.acquire(vmid) item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False) if item is None: return jsonify({'error': 'No supported running LXC detected'}), 409 _vm_cache_invalidate(vmid, _lxc_os_info_cache) return jsonify({'vmid': vmid, 'update_check': _get_lxc_update_status_map().get(str(vmid))}) + except BlockingIOError: + return jsonify({'error': 'Another container operation is running'}), 409 except Exception as exc: return jsonify({'error': str(exc)}), 500 finally: + if operation_lock is not None: + operation_lock.close() with _lxc_update_check_lock: _lxc_update_check_inflight.discard(vmid) +@app.route('/api/vms//updates/refresh-check', methods=['POST']) +@require_auth +def api_lxc_updates_refresh_check(vmid): + """Explicit index refresh/check for a running CT; never installs.""" + import lxc_check_job + try: + result, code = lxc_check_job.start(vmid) + if code == 429: + return jsonify({'error': 'Check already running or recently requested', + 'retry_after': 60}), 429, {'Retry-After': '60'} + return jsonify(result), code + except BlockingIOError: + return jsonify({'error': 'Another container operation is running'}), 409 + except (OSError, RuntimeError, ValueError) as exc: + return jsonify({'error': str(exc)}), 409 + + +@app.route('/api/vms//updates/refresh-check/', methods=['GET']) +@require_auth +def api_lxc_updates_refresh_check_status(vmid, job_id): + import lxc_check_job + result = lxc_check_job.get(vmid, job_id) + if result is None: + return jsonify({'error': 'Check result not found; check again'}), 404 + if result['state'] != 'running': + _vm_cache_invalidate(vmid, _lxc_os_info_cache) + _pvesh_cache['cluster_resources_vm_time'] = 0 + result['update_check'] = _get_lxc_update_status_map().get(str(vmid)) + return jsonify(result) + + # ─── LXC Update Detection toggle ──────────────────────────────────────────── # Dedicated toggle so the operator can opt out of the per-CT `pct exec apt # list --upgradable` scan entirely. The Notifications section keeps its own @@ -14265,6 +14313,7 @@ def api_vm_apps_dismiss(vmid): # ─── LXC update finalization ──────────────────────────────────────────────── _LXC_APPLY_UPDATES_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh" +_LXC_INSTALL_CHECKED_OS_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh" _LXC_UPDATE_RUN_ID_RE = re.compile(r'^[A-Za-z0-9._:-]{1,128}$') _LXC_UPDATE_TARGET_RE = re.compile(r'^[A-Za-z0-9._:-]{1,180}$') from lxc_update_finalization import lock as _lxc_update_finalization_lock, runs as _lxc_update_finalizations @@ -14718,7 +14767,9 @@ def _finalize_lxc_update( def _terminal_lxc_update_completed(*, script_path, params, exit_code, duration_seconds): - if os.path.realpath(script_path) != os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT): + if os.path.realpath(script_path) not in { + os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT), + os.path.realpath(_LXC_INSTALL_CHECKED_OS_SCRIPT)}: return run_id = _normalise_lxc_update_run_id(params.get('RUN_ID'), create=False) if not run_id: @@ -15831,6 +15882,8 @@ def api_vm_config_set(vmid): @require_auth def api_vm_control(vmid): """Control VM/LXC (start, stop, shutdown, reboot)""" + operation_lock = None + task_identity_confirmed = False try: data = request.get_json() action = data.get('action') # start, stop, shutdown, reboot @@ -15853,6 +15906,13 @@ def api_vm_control(vmid): vm_type = 'lxc' if vm_info.get('type') == 'lxc' else 'qemu' node = vm_info.get('node', 'pve') + if vm_type == 'lxc': + import lxc_check_job + try: + operation_lock = lxc_check_job.acquire(vmid) + except BlockingIOError: + return jsonify({'error': 'Another container operation is running'}), 409 + lxc_check_job.begin_control(operation_lock, node) # Execute action control_result = subprocess.run( @@ -15860,6 +15920,37 @@ def api_vm_control(vmid): capture_output=True, text=True, timeout=30) if control_result.returncode == 0: + import re + # pvesh can print progress lines before the task identity. + # The final non-empty line is the only task associated with + # this request; accepting a different log line would make the + # frontend poll an unrelated guest operation. + lines = [line.strip().strip('"') for line in + (control_result.stdout or '').splitlines() if line.strip()] + task = lines[-1] if lines else '' + task_types = [f"{'vz' if vm_type == 'lxc' else 'qm'}{action}"] + if action == 'start': + task_types.append('hastart') + elif action in ('stop', 'shutdown'): + # Proxmox 9 routes HA shutdown through hastop for both + # LXC and QEMU guests. + task_types.append('hastop') + task_pattern = (r'UPID:[\w.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:' + r'[0-9A-Fa-f]+:(?:' + '|'.join(task_types) + + rf'):{vmid}:[\w.@!-]+:') + if not re.fullmatch(task_pattern, task): + # A successful process exit without a verifiable task + # cannot be polled. Do not leave a task-less control + # marker that would block this CT forever. + if operation_lock is not None: + lxc_check_job.cancel_control(operation_lock) + return jsonify({ + 'success': False, + 'vmid': vmid, + 'action': action, + 'error': 'Control task was not confirmed by Proxmox', + }), 502 + task_identity_confirmed = True # Invalidate VM resources cache so the next /api/vms call # returns fresh status instead of the pre-action snapshot. _pvesh_cache['cluster_resources_vm_time'] = 0 @@ -15867,13 +15958,21 @@ def api_vm_control(vmid): # (lxc_ip_info, os_info) — a start/stop flips those, so # drop the modal caches for this vmid. _vm_cache_invalidate(vmid) + # The frontend waits for this exact task rather than assuming + # that accepting the request means the action completed. + if operation_lock is not None: + lxc_check_job.retain_control(vmid, task, operation_lock) + operation_lock = None return jsonify({ 'success': True, 'vmid': vmid, 'action': action, + 'task': task, 'message': f'Successfully executed {action} on {vm_info.get("name")}' }) else: + if operation_lock is not None: + lxc_check_job.cancel_control(operation_lock) # `pvesh` failed → fire the matching vm_fail / ct_fail # notification so the user gets paged on their channels # too, not just an in-dashboard alert. Previously this @@ -15925,8 +16024,70 @@ def api_vm_control(vmid): else: return jsonify({'error': 'Failed to get VM details'}), 500 except Exception as e: + # A transport error happens before we have an identity that can be + # recovered later. Leaving the pre-submission marker would otherwise + # make the CT permanently busy after one timed-out pvesh invocation. + if operation_lock is not None and not task_identity_confirmed: + try: + lxc_check_job.cancel_control(operation_lock) + except Exception: + pass return jsonify({'error': str(e)}), 500 + finally: + if operation_lock is not None: + operation_lock.close() + +@app.route('/api/vms//control-task/', methods=['GET']) +@require_auth +def api_vm_control_task(vmid, task): + """Read only the power-control task bound to this guest and node.""" + import re + import json + if not re.fullmatch(r'UPID:[\w.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:' + r'(?:(?:vz|qm)(?:start|stop|shutdown|reboot)|ha(?:start|stop)):' + r'[0-9]+:[\w.@!-]+:', task): + return jsonify({'error': 'Invalid control task'}), 400 + parts = task.split(':') + resource = next((item for item in get_cached_pvesh_cluster_resources_vm() or [] + if item.get('vmid') == vmid), None) + if resource is None: + return jsonify({'error': 'VM/LXC not found'}), 404 + vm_type = 'lxc' if resource.get('type') == 'lxc' else 'qemu' + node = resource.get('node', 'pve') + prefix = 'vz' if vm_type == 'lxc' else 'qm' + allowed_task_types = {f'{prefix}{action}' for action in + ('start', 'stop', 'shutdown', 'reboot')} + allowed_task_types.update(('hastart', 'hastop')) + if (parts[1] != node or parts[6] != str(vmid) + or parts[5] not in allowed_task_types): + return jsonify({'error': 'Control task does not belong to this guest'}), 400 + try: + result = subprocess.run(['pvesh', 'get', f'/nodes/{node}/tasks/{task}/status', + '--output-format', 'json'], + capture_output=True, text=True, timeout=5) + if result.returncode != 0: + return jsonify({'error': 'Control task status unavailable'}), 502 + status = json.loads(result.stdout) + if not isinstance(status, dict) or status.get('status') not in ('running', 'stopped'): + return jsonify({'error': 'Invalid control task status'}), 502 + payload = {'status': status['status'], 'exitstatus': status.get('exitstatus')} + if status['status'] == 'stopped' and status.get('exitstatus') == 'OK': + current = subprocess.run(['pvesh', 'get', f'/nodes/{node}/{vm_type}/{vmid}/status/current', + '--output-format', 'json'], + capture_output=True, text=True, timeout=5) + if current.returncode != 0: + return jsonify({'error': 'Guest status unavailable'}), 502 + guest = json.loads(current.stdout) + if not isinstance(guest, dict) or guest.get('status') not in ('running', 'stopped'): + return jsonify({'error': 'Invalid guest status'}), 502 + payload['guest_status'] = guest['status'] + _pvesh_cache['cluster_resources_vm_time'] = 0 + _vm_cache_invalidate(vmid) + return jsonify(payload) + except (ValueError, subprocess.TimeoutExpired): + return jsonify({'error': 'Control task status unavailable'}), 502 + @app.route('/api/vms//description', methods=['PUT']) @app.route('/api/vms//config', methods=['PUT']) # legacy alias @require_auth diff --git a/AppImage/scripts/lxc_check_job.py b/AppImage/scripts/lxc_check_job.py new file mode 100644 index 00000000..0ca2d733 --- /dev/null +++ b/AppImage/scripts/lxc_check_job.py @@ -0,0 +1,301 @@ +"""Explicit check-only jobs; the shared updater lock protects each CT. + +Importing or reading a job never probes a guest. Only start() may launch work +for an already-running CT; package indexes are never refreshed by starting one. +""" +import datetime +import fcntl +import json +import os +import re +from pathlib import Path +import subprocess +import threading +import time +import uuid + +import managed_installs +import lxc_update_observation + +LOCK_DIR = Path(os.environ.get('PROXMENUX_LOCK_DIR', '/run/lock')) +CONF_DIR = Path('/etc/pve/lxc') +OCI_REGISTRY = Path('/usr/local/share/proxmenux/oci/installed.json') +_lock = threading.RLock() +_jobs = {} +_attempts = {} +COOLDOWN = 60 +JOB_TTL = 3600 +CONTROL_TASK_WAIT_SECONDS = 180 + + +def acquire(vmid): + path = LOCK_DIR / f'proxmenux-lxc-update-{int(vmid)}.lock' + handle = os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'r+') + try: + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + check_control_task(vmid, handle) + return handle + except BaseException: + handle.close() + raise + + +def _invalid_marker(vmid): + return LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}' + + +def _acquire_invalid_marker_guard(vmid): + path = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}.guard' + handle = os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'r+') + try: + fcntl.flock(handle, fcntl.LOCK_EX) + return handle + except BaseException: + handle.close() + raise + + +def _mark_check_invalid(vmid): + handle = _acquire_invalid_marker_guard(vmid) + try: + _invalid_marker(vmid).touch(mode=0o600) + finally: + handle.close() + + +def clear_invalid_marker(vmid, *, check_started_ns=None): + """Clear an obsolete failed-check marker without hiding a newer run.""" + try: + handle = _acquire_invalid_marker_guard(vmid) + except OSError: + return False + try: + marker = _invalid_marker(vmid) + try: + if (check_started_ns is not None + and marker.stat().st_mtime_ns > check_started_ns): + return False + marker.unlink() + return True + except FileNotFoundError: + # A concurrent successful check may already have published the + # valid state; there is no stale marker left to clear. + return True + except OSError: + return False + finally: + handle.close() + + +def _write_control(handle, record): + handle.seek(0) + if record is not None: + handle.write(json.dumps(record)) + handle.flush() + # Do not erase the old reservation before the replacement was written. + # A partial/failed write stays non-empty and therefore fails closed. + handle.truncate(handle.tell()) + + +def begin_control(handle, node): + """Persist uncertainty before submission, including across Monitor restart.""" + if not isinstance(node, str) or not re.fullmatch(r'[\w.-]+', node): + raise ValueError('Invalid control node') + _write_control(handle, {'node': node, 'task': None}) + + +def cancel_control(handle): + """Clear a request for which no recoverable task identity was recorded.""" + _write_control(handle, None) + + +def check_control_task(vmid, handle=None): + """Called under the shared flock, also by the shell update runner. + + No record means no probe. A restart releases OS file descriptors, but the + record still forbids a new operation until PVE confirms task termination. + Unknown submission/status cannot safely be treated as completion. + """ + if handle is None: + with open(LOCK_DIR / f'proxmenux-lxc-update-{int(vmid)}.lock', 'r+') as stream: + return check_control_task(vmid, stream) + handle.seek(0) + raw = handle.read(1025) + if not raw: + return + try: + record = json.loads(raw) + node, task = record['node'], record['task'] + if not isinstance(node, str) or not re.fullmatch(r'[\w.-]+', node): + raise ValueError('Invalid node') + if (not isinstance(task, str) + or not re.fullmatch(r'UPID:[\w.-]+:[0-9A-Fa-f]+:[0-9A-Fa-f]+:' + r'[0-9A-Fa-f]+:(?:vz(?:start|stop|shutdown|reboot)' + r'|ha(?:start|stop)):' + r'[0-9]+:[\w.@!-]+:', task) + or task.split(':')[1] != node or task.split(':')[6] != str(vmid)): + raise ValueError('Unconfirmed control task') + result = subprocess.run(['pvesh', 'get', f'/nodes/{node}/tasks/{task}/status', + '--output-format', 'json'], + capture_output=True, text=True, timeout=5) + status = json.loads(result.stdout) if result.returncode == 0 else None + if (not isinstance(status, dict) or status.get('status') != 'stopped' + or not isinstance(status.get('exitstatus'), str) or not status['exitstatus']): + raise ValueError('Control task completion not confirmed') + except (ValueError, KeyError, TypeError, OSError, subprocess.TimeoutExpired) as exc: + raise BlockingIOError('Container control completion is not confirmed') from exc + _write_control(handle, None) + + +def _wait_control(vmid, reservation): + deadline = time.monotonic() + CONTROL_TASK_WAIT_SECONDS + try: + while time.monotonic() < deadline: + try: + check_control_task(vmid, reservation) + return + except BlockingIOError: + # Keep the known task record for recovery after this bounded + # waiter exits; a later caller asks Proxmox to verify it. + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(2, remaining)) + finally: + reservation.close() + + +def retain_control(vmid, task, reservation): + reservation.seek(0) + record = json.loads(reservation.read(1025)) + record['task'] = task + _write_control(reservation, record) + # Reservation ownership transfers only after Thread.start succeeds. + threading.Thread(target=_wait_control, args=(vmid, reservation), daemon=True).start() + + +def guard(vmid): + if not managed_installs._lxc_updates_detection_enabled(): + raise RuntimeError('LXC update detection is disabled') + # Fail closed if current configuration cannot be read. Never infer OCI + # eligibility from an old OS hint or from a failed config read. + config = (CONF_DIR / f'{int(vmid)}.conf').read_text() + # The normal collector tolerates broken OCI metadata. An action must + # instead fail closed: an unreadable registry is not an empty registry. + if OCI_REGISTRY.exists(): + registry = json.loads(OCI_REGISTRY.read_text()) + instances = registry.get('instances') + if not isinstance(instances, dict) or any(not isinstance(item, dict) for item in instances.values()): + raise RuntimeError('OCI registry could not be verified') + if any(str(item.get('vmid')) == str(vmid) for item in instances.values()): + raise RuntimeError('OCI containers require their own updater') + if any(line.lstrip().startswith(managed_installs._OCI_LXC_MARKERS) + for line in config.splitlines()) or str(vmid) in managed_installs._get_oci_managed_vmids(): + raise RuntimeError('OCI containers require their own updater') + # Retain the independent registered-OCI boundary if its collector is + # temporarily unavailable. These hints can only forbid an action. + if any(str(item.get('_vmid')) == str(vmid) and item.get('_managed_oci_app') + for item in managed_installs.get_active_items()): + raise RuntimeError('OCI containers require their own updater') + if any(line.startswith('lock:') for line in config.splitlines()): + raise RuntimeError('Container is locked by Proxmox') + + +def command(vmid, *args, timeout=30): + result = subprocess.run([managed_installs._PCT_BIN, *args[:1], str(vmid), *args[1:]], + capture_output=True, text=True, timeout=timeout) + if result.returncode: + raise RuntimeError(f'Container {args[0]} failed') + return result.stdout.strip() + + +def status(vmid): + value = command(vmid, 'status') + if value not in ('status: running', 'status: stopped'): + raise RuntimeError('Container state could not be determined') + return value.split(': ', 1)[1] + + +def checked_os(vmid): + """Installation precondition; called under the updater lock before start.""" + guard(vmid) + if (LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}').exists(): + raise RuntimeError('Latest check failed or is still running') + item = next((item for item in managed_installs.get_active_items() + if item.get('id') == f'lxc:{int(vmid)}'), {}) + observation = item.get('update_check') or {} + checked = datetime.datetime.fromisoformat(observation.get('last_check') or '') + if checked.tzinfo is None: + checked = checked.replace(tzinfo=datetime.timezone.utc) + age = (datetime.datetime.now(datetime.timezone.utc) - checked).total_seconds() + if (not 0 <= age < 172800 or observation.get('error') + or observation.get('available') is not True + or type(observation.get('_count')) is not int or observation['_count'] <= 0): + raise RuntimeError('Check OS updates successfully before installation') + + +def run(vmid, job_id, reservation): + error = None + check_started = False + try: + _mark_check_invalid(vmid) + guard(vmid) + if status(vmid) != 'running': + raise RuntimeError('Container is stopped; start it first') + check_started = True + observation = lxc_update_observation.capture(vmid, job_id, refresh_indexes=True) + if not managed_installs.store_lxc_update_observation(vmid, observation): + raise RuntimeError(observation['result'].get('error') or 'Package check failed') + lxc_update_observation.discard(vmid, job_id) + except Exception as exc: + error = str(exc) + # A failed package operation must block installation. A state change + # before the worker runs did not check or alter packages, so keep the + # last verified observation but leave the invalid marker in place. + if check_started: + try: + managed_installs.store_lxc_update_observation(vmid, None) + except Exception: + error += '; package result could not be persisted' + finally: + if not error: + if not clear_invalid_marker(vmid): + error = 'Successful check could not be published for installation' + reservation.close() + with _lock: + _jobs[job_id].update(state='failed' if error else 'complete', error=error) + + +def start(vmid): + now = time.monotonic() + with _lock: + for key in list(_jobs): + if _jobs[key]['state'] != 'running' and now - _jobs[key]['created'] >= JOB_TTL: + del _jobs[key] + last = _attempts.get(vmid) + if (any(job['vmid'] == vmid and job['state'] == 'running' for job in _jobs.values()) + or last is not None and now - last < COOLDOWN): + return None, 429 + reservation = acquire(vmid) + try: + guard(vmid) + if status(vmid) != 'running': + raise RuntimeError('Container is stopped; start it first') + job_id = uuid.uuid4().hex + _jobs[job_id] = dict(vmid=vmid, state='running', error=None, created=now) + _attempts[vmid] = now + threading.Thread(target=run, args=(vmid, job_id, reservation), daemon=False).start() + except BaseException: + if 'job_id' in locals(): + _jobs.pop(job_id, None) + _attempts.pop(vmid, None) + reservation.close() + raise + return dict(job_id=job_id, vmid=vmid, state='running'), 202 + + +def get(vmid, job_id): + with _lock: + job = _jobs.get(job_id) + if job is None or job['vmid'] != vmid: + return None + return {key: value for key, value in job.items() if key != 'created'} diff --git a/AppImage/scripts/managed_installs.py b/AppImage/scripts/managed_installs.py index e85f3fb7..b05ab307 100644 --- a/AppImage/scripts/managed_installs.py +++ b/AppImage/scripts/managed_installs.py @@ -1796,6 +1796,22 @@ def _store_update_result(item: dict, result: dict) -> None: item["update_check"][extra_key] = result[extra_key] +def _clear_lxc_check_invalid_marker(vmid: int, check_started_ns: int) -> bool: + """Publish a persisted newer LXC check to the installation gate. + + The marker is created by the explicit refresh worker. Its own lock also + protects this cleanup against a later refresh starting while a scheduled + or lifecycle check is still completing. + """ + try: + import lxc_check_job + return lxc_check_job.clear_invalid_marker( + vmid, check_started_ns=check_started_ns) + except Exception as exc: + print(f'[ProxMenux] could not clear stale LXC check marker for {vmid}: {exc}') + return False + + def refresh_oci_app(app_id: str) -> Optional[dict]: """Read one OCI-managed application again and store what it reports. @@ -1834,6 +1850,7 @@ def check_for_updates(force: bool = False) -> list[dict]: _nvidia_cache["fetched_at"] = 0 updates_available: list[dict] = [] + successful_lxc_checks: list[tuple[int, int]] = [] with _lock: reg = _read_registry() items = reg.get("items", []) @@ -1854,6 +1871,7 @@ def check_for_updates(force: bool = False) -> list[dict]: checker = _CHECKERS.get(it.get("type")) if not checker: continue + check_started_ns = time.time_ns() if it.get('type') == 'lxc' else None try: result = checker(it) except Exception as e: @@ -1863,13 +1881,20 @@ def check_for_updates(force: bool = False) -> list[dict]: "last_check": _now_iso(), "error": str(e)} _store_update_result(it, result) + if check_started_ns is not None and result.get('error') is None: + try: + successful_lxc_checks.append((int(it.get('_vmid')), check_started_ns)) + except (TypeError, ValueError): + pass if it["update_check"]["available"]: updates_available.append(it) reg["items"] = items reg["last_check_run"] = _now_iso() - _write_registry(reg) + if _write_registry(reg): + for vmid, check_started_ns in successful_lxc_checks: + _clear_lxc_check_invalid_marker(vmid, check_started_ns) return updates_available @@ -1945,6 +1970,7 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di else: _merge_detected_entry(target, entry, now) + check_started_ns = time.time_ns() try: result = (_check_lxc_updates(target) if refresh_package_cache else _check_lxc_updates(target, refresh_package_cache=False)) @@ -1961,4 +1987,6 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di reg["last_targeted_refresh"] = now if not _write_registry(reg): raise OSError("could not persist LXC update check") + if result.get('error') is None: + _clear_lxc_check_invalid_marker(target_vmid, check_started_ns) return dict(target) diff --git a/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py b/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py index e41dd989..7f3500d4 100644 --- a/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py +++ b/AppImage/scripts/tests/test_lxc_vm_ip_inventory.py @@ -36,6 +36,7 @@ class LxcVmIpInventoryTests(unittest.TestCase): "_get_lxc_app_watch_map": lambda: {}, "_get_lxc_docker_inventory_map": lambda: {}, "get_proxmox_node_name": lambda: "pve", + "_fast_guest_status": lambda _vmid, _vm_type: "running", "_get_cached_vm_list_search_notes": lambda _resources, _node: {}, "get_cached_vm_disk": lambda _vmid: None, "_guest_lifecycle_lock": threading.RLock(), diff --git a/lang/de.json b/lang/de.json index c46e03ef..ca6161de 100644 --- a/lang/de.json +++ b/lang/de.json @@ -3620,7 +3620,7 @@ "Memory optimizations removed": "Speicheroptimierungen entfernt", "Memory restored.": "Speicher wiederhergestellt.", "Memory settings optimized successfully": "Speichereinstellungen erfolgreich optimiert", - "Memory:": "Erinnerung:", + "Memory:": "Arbeitsspeicher:", "Memos is a lightweight, self-hosted memo hub. Open Source and Free forever.": "Memos ist ein leichter, selbst gehosteter Memo-Hub. Open Source und Free für immer.", "Messaging & Notifications": "Nachrichten und Benachrichtigungen", "Messaging & Queues": "Messaging & Queues", @@ -3817,6 +3817,7 @@ "NICs:": "Netzwerkkarten:", "NOT FOUND": "NICHT GEFUNDEN", "NOTE: The host directory and its contents will remain unchanged.": "HINWEIS: Das Hostverzeichnis und sein Inhalt bleiben unverändert.", + "NUT server user for clients such as PeaNUT": "NUT server-Benutzer für Clients wie PeaNUT", "NVENC patch detected — list narrowed to versions supported by keylase/nvidia-patch.": "NVENC-Patch erkannt – Liste eingegrenzt auf Versionen, die von keylase/nvidia-patch unterstützt werden.", "NVIDIA (CUDA)": "NVIDIEN (CUDA)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; offizielles GPU-Bild)", @@ -3932,6 +3933,7 @@ "Network Restart Failed": "Netzwerkneustart fehlgeschlagen", "Network Restarted": "Netzwerk neu gestartet", "Network Tools": "Netzwerk-Tools", + "Network UPS Tools server for a UPS connected to the host by USB.": "Netzwerk-UPS-Tools-Server für eine USV, die über USB mit dem Host verbunden ist.", "Network access:": "Netzwerkzugriff:", "Network bridge": "Netzbrücke", "Network configuration backed up": "Netzwerkkonfiguration gesichert", @@ -4070,6 +4072,7 @@ "No Shares Found": "Keine Aktien gefunden", "No Shares to Test": "Keine Freigaben zum Testen", "No Storage Found": "Kein Speicher gefunden", + "No UPS was found on a USB port of this host. Connect it and start the installation again.": "An einem USB-Port dieses Hosts wurde keine USV gefunden. Schließen Sie sie an und starten Sie die Installation erneut.", "No USB device was found on this host.": "Auf diesem Host wurde kein USB-Gerät gefunden.", "No USB drives detected. Enter the mountpoint path manually:": "Keine USB-Laufwerke erkannt. Geben Sie den Mountpoint-Pfad manuell ein:", "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Noch keine USB-Laufwerke von ProxMenux gemountet. Montieren Sie zuerst eines, um es als Ziel zu verwenden.", @@ -4314,8 +4317,8 @@ "OCI containers require Proxmox VE 9.1 or later.": "OCI-Container erfordern Proxmox VE 9.1 oder höher.", "OCI management": "OCI-Verwaltung", "OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.": "Das OCI-Management konnte nicht abgeschlossen werden. Überprüfen Sie den Backend-Status; es wurde keine zusätzliche Bereinigung autorisiert.", - "OCI manager Apps (beta)": "OCI manager Apps (beta)", - "OCI manager Apps is a beta: if something does not work as expected, please report it on GitHub with the application name.": "OCI Manager Apps ist eine Beta: Wenn etwas nicht wie erwartet funktioniert, melden Sie es bitte auf GitHub mit dem Anwendungsnamen.", + "OCI manager Apps (Technology Preview)": "OCI manager Apps (Technology Preview)", + "OCI manager Apps is a Technology Preview: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps ist eine Technology Preview: Wenn etwas nicht wie erwartet funktioniert, melden Sie es bitte auf GitHub unter Angabe des Anwendungsnamens.", "OCI metadata integrity mismatch": "OCI Unstimmigkeiten bei Metadaten", "OCI metadata too large": "OCI-Metadaten zu groß", "OCI stack management": "OCI-Stackmanagement", @@ -4510,6 +4513,7 @@ "Pass /dev/video11 to the LXC": "Pass /dev/video11 zum LXC", "Pass /dev/video12 to the LXC": "Pass /dev/video12 zum LXC", "Pass a host device to the LXC": "Übergeben Sie ein Host-Gerät an den LXC", + "Pass the UPS to the LXC": "Übergeben Sie die USV an LXC", "Passing NVIDIA does not enable acceleration inside the application. The host needs NVIDIA Container Toolkit and a compatible image. Continue?": "Durch die Übergabe von NVIDIA wird keine Beschleunigung innerhalb der Anwendung aktiviert. Der Host benötigt NVIDIA Container Toolkit und ein kompatibles Image. Weitermachen?", "Passphrase used to unlock the imported keyfile (leave blank if the keyfile is unencrypted / kdf=none):": "Passphrase, die zum Entsperren der importierten Schlüsseldatei verwendet wird (leer lassen, wenn die Schlüsseldatei unverschlüsselt ist / kdf=none):", "Passphrases do not match.": "Passphrasen stimmen nicht überein.", @@ -4535,6 +4539,7 @@ "Password of the LibreDB Studio administrator": "Passwort des LibreDB Studio-Administrators", "Password of the MariaDB user": "Passwort des MariaDB-Benutzers", "Password of the MineOS web interface user": "Kennwort des MineOS Web Interface User", + "Password of the NUT server user": "Passwort des NUT server-Benutzers", "Password of the NetBox admin account": "Passwort des NetBox Admin Accounts", "Password of the OpenList admin user": "Passwort des OpenList-Admin-Benutzers", "Password of the PhotoPrism admin user (at least 8 characters)": "Passwort des PhotoPrism-Admin-Benutzers (mindestens 8 Zeichen)", @@ -6860,6 +6865,7 @@ "USB Accelerators:": "USB-Beschleuniger:", "USB bus directory": "USB-Busverzeichnis", "USB bus numbers can change after reconnecting or rebooting.": "USB-Busnummern können sich nach erneutem Anschließen oder Neustart ändern.", + "USB device of the UPS": "USB-Gerät der USV", "USB device to attach": "USB-Gerät zum Anschließen", "USB disk target": "USB-Festplattenziel", "USB drives mounted now:": "Jetzt gemountete USB-Laufwerke:", diff --git a/lang/es.json b/lang/es.json index df9289a3..f5137377 100644 --- a/lang/es.json +++ b/lang/es.json @@ -3813,6 +3813,7 @@ "NICs:": "NIC:", "NOT FOUND": "NO ENCONTRADO", "NOTE: The host directory and its contents will remain unchanged.": "NOTA: El directorio del host y su contenido permanecerán sin cambios.", + "NUT server user for clients such as PeaNUT": "usuario NUT server para clientes como PeaNUT", "NVENC patch detected — list narrowed to versions supported by keylase/nvidia-patch.": "Parche NVENC detectado: lista reducida a versiones compatibles con keylase/nvidia-patch.", "NVIDIA (CUDA)": "NVIDIA (CUDA)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; imagen oficial para GPU)", @@ -3928,6 +3929,7 @@ "Network Restart Failed": "Error al reiniciar la red", "Network Restarted": "Red reiniciada", "Network Tools": "Herramientas de red", + "Network UPS Tools server for a UPS connected to the host by USB.": "Servidor de red UPS Tools para un UPS conectado al host mediante USB.", "Network access:": "Acceso a la red:", "Network bridge": "Puente de red", "Network configuration backed up": "Backup de la configuración de red creado", @@ -4066,6 +4068,7 @@ "No Shares Found": "No se encontraron recursos compartidos", "No Shares to Test": "Sin recursos compartidos que probar", "No Storage Found": "No se encontró almacenamiento", + "No UPS was found on a USB port of this host. Connect it and start the installation again.": "No se encontró ningún SAI en los puertos USB de este host. Conéctelo y vuelva a iniciar la instalación.", "No USB drives detected. Enter the mountpoint path manually:": "No se detectaron unidades USB.Ingrese la ruta del punto de montaje manualmente:", "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Aún no hay unidades USB montadas por ProxMenux. Monte una primero para usarla como destino.", "No UUP folder found.": "No se encontró ninguna carpeta UUP.", @@ -4306,8 +4309,8 @@ "OCI containers require Proxmox VE 9.1 or later.": "Los contenedores OCI requieren Proxmox VE 9.1 o posterior.", "OCI management": "Gestión OCI", "OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.": "La gestión OCI no pudo completarse. Comprueba el estado del backend; no se ha autorizado ninguna limpieza adicional.", - "OCI manager Apps (beta)": "OCI manager Apps (beta)", - "OCI manager Apps is a beta: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps es una beta: si algo no funciona como esperabas, repórtalo en GitHub con el nombre de la aplicación.", + "OCI manager Apps (Technology Preview)": "OCI manager Apps (Technology Preview)", + "OCI manager Apps is a Technology Preview: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps es una Technology Preview: si algo no funciona como esperabas, repórtalo en GitHub con el nombre de la aplicación.", "OCI metadata integrity mismatch": "Desigualdad de la integridad de los metadatos de la OCI", "OCI metadata too large": "OCI metadatos demasiado grandes", "OCI stack management": "Gestión de pilas OCI", @@ -4502,6 +4505,7 @@ "Pass /dev/video11 to the LXC": "Pasar /dev/video11 al LXC", "Pass /dev/video12 to the LXC": "Pasar /dev/video12 al LXC", "Pass a host device to the LXC": "Pasar un dispositivo del host al LXC", + "Pass the UPS to the LXC": "Pase el UPS al LXC", "Passing NVIDIA does not enable acceleration inside the application. The host needs NVIDIA Container Toolkit and a compatible image. Continue?": "Pasar NVIDIA no habilita la aceleración dentro de la aplicación. El host necesita NVIDIA Container Toolkit y una imagen compatible.¿Continuar?", "Passphrase used to unlock the imported keyfile (leave blank if the keyfile is unencrypted / kdf=none):": "Frase de contraseña utilizada para desbloquear el archivo de claves importado (déjelo en blanco si el archivo de claves no está cifrado/kdf=none):", "Passphrases do not match.": "Las frases de contraseña no coinciden.", @@ -4527,6 +4531,7 @@ "Password of the LibreDB Studio administrator": "Contraseña del administrador de LibreDB Studio", "Password of the MariaDB user": "Contraseña del usuario de MariaDB", "Password of the MineOS web interface user": "Contraseña del usuario de la interfaz web de MineOS", + "Password of the NUT server user": "Contraseña del usuario NUT server", "Password of the NetBox admin account": "Contraseña de la cuenta de administrador de NetBox", "Password of the OpenList admin user": "Contraseña del usuario administrador de OpenList", "Password of the PhotoPrism admin user (at least 8 characters)": "Contraseña del usuario administrador de PhotoPrism (al menos 8 caracteres)", @@ -6835,6 +6840,7 @@ "USB Accelerators:": "Aceleradores USB:", "USB bus directory": "Directorio del bus USB", "USB bus numbers can change after reconnecting or rebooting.": "Los números de bus USB pueden cambiar al volver a conectar el dispositivo o al reiniciar.", + "USB device of the UPS": "dispositivo USB del UPS", "USB device to attach": "Dispositivo USB que añadir", "USB disk target": "Destino de disco USB", "USB drives mounted now:": "Unidades USB montadas ahora:", diff --git a/lang/fr.json b/lang/fr.json index 9dd2f8d1..2fccc25c 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -3817,6 +3817,7 @@ "NICs:": "Cartes réseau :", "NOT FOUND": "NON TROUVÉ", "NOTE: The host directory and its contents will remain unchanged.": "REMARQUE : Le répertoire hôte et son contenu resteront inchangés.", + "NUT server user for clients such as PeaNUT": "utilisateur NUT server pour des clients tels que PeaNUT", "NVENC patch detected — list narrowed to versions supported by keylase/nvidia-patch.": "Correctif NVENC détecté — liste restreinte aux versions prises en charge par keylase/nvidia-patch.", "NVIDIA (CUDA)": "NVIDIA (CUDA)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; image officielle du GPU)", @@ -3932,6 +3933,7 @@ "Network Restart Failed": "Échec du redémarrage du réseau", "Network Restarted": "Réseau redémarré", "Network Tools": "Outils réseau", + "Network UPS Tools server for a UPS connected to the host by USB.": "Serveur Network UPS Tools pour un UPS connecté à l'hôte par USB.", "Network access:": "Accès au réseau :", "Network bridge": "Pont réseau", "Network configuration backed up": "Configuration réseau sauvegardée", @@ -4070,6 +4072,7 @@ "No Shares Found": "Aucun partage trouvé", "No Shares to Test": "aucun partage à tester", "No Storage Found": "Aucun stockage trouvé", + "No UPS was found on a USB port of this host. Connect it and start the installation again.": "Aucun onduleur n'a été trouvé sur un port USB de cet hôte. Branchez-le et relancez l'installation.", "No USB device was found on this host.": "Aucun périphérique USB n'a été trouvé sur cet hôte.", "No USB drives detected. Enter the mountpoint path manually:": "Aucune clé USB détectée. Saisissez manuellement le chemin du point de montage :", "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Aucune clé USB montée par ProxMenux pour le moment. Montez-en un d’abord pour l’utiliser comme cible.", @@ -4314,8 +4317,8 @@ "OCI containers require Proxmox VE 9.1 or later.": "Les conteneurs OCI nécessitent Proxmox VE 9.1 ou version ultérieure.", "OCI management": "Gestion OCI", "OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.": "La gestion de OCI n'a pas pu être terminée. Vérifiez l'état du moteur; aucun nettoyage supplémentaire n'a été autorisé.", - "OCI manager Apps (beta)": "OCI manager Apps (beta)", - "OCI manager Apps is a beta: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps est une bêta : si quelque chose ne fonctionne pas comme prévu, veuillez le signaler sur GitHub avec le nom de l'application.", + "OCI manager Apps (Technology Preview)": "OCI manager Apps (Technology Preview)", + "OCI manager Apps is a Technology Preview: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps est une Technology Preview : si quelque chose ne fonctionne pas comme prévu, veuillez le signaler sur GitHub avec le nom de l'application.", "OCI metadata integrity mismatch": "Inadéquation de l'intégrité des métadonnées OCI", "OCI metadata too large": "Métadonnées OCI trop grandes", "OCI stack management": "Gestion de la pile OCI", @@ -4510,6 +4513,7 @@ "Pass /dev/video11 to the LXC": "Passez /dev/video11 au LXC", "Pass /dev/video12 to the LXC": "Passer /dev/video12 au LXC", "Pass a host device to the LXC": "Passer un appareil hôte au LXC", + "Pass the UPS to the LXC": "Passer l'UPS au LXC", "Passing NVIDIA does not enable acceleration inside the application. The host needs NVIDIA Container Toolkit and a compatible image. Continue?": "Passer NVIDIA n’active pas l’accélération dans l’application. L'hôte a besoin du NVIDIA Container Toolkit et d'une image compatible. Continuer?", "Passphrase used to unlock the imported keyfile (leave blank if the keyfile is unencrypted / kdf=none):": "Phrase secrète utilisée pour déverrouiller le fichier de clés importé (laisser vide si le fichier de clés n'est pas chiffré / kdf=aucun) :", "Passphrases do not match.": "Les phrases secrètes ne correspondent pas.", @@ -4535,6 +4539,7 @@ "Password of the LibreDB Studio administrator": "Mot de passe de l'administrateur LibreDB Studio", "Password of the MariaDB user": "Mot de passe de l'utilisateur MariaDB", "Password of the MineOS web interface user": "Mot de passe de l'utilisateur de l'interface web MineOS", + "Password of the NUT server user": "Mot de passe de l'utilisateur NUT server", "Password of the NetBox admin account": "Mot de passe du compte admin NetBox", "Password of the OpenList admin user": "Mot de passe de l'utilisateur administrateur OpenList", "Password of the PhotoPrism admin user (at least 8 characters)": "Mot de passe de l'utilisateur administrateur PhotoPrism (au moins 8 caractères)", @@ -6859,6 +6864,7 @@ "USB Accelerators:": "Accélérateurs USB :", "USB bus directory": "Répertoire de bus USB", "USB bus numbers can change after reconnecting or rebooting.": "Les numéros de bus USB peuvent changer après la reconnexion ou le redémarrage.", + "USB device of the UPS": "Périphérique USB de l'onduleur", "USB device to attach": "Périphérique USB à connecter", "USB disk target": "cible du disque USB", "USB drives mounted now:": "Clés USB montées maintenant :", diff --git a/lang/it.json b/lang/it.json index 8d9acded..e1d3a6e8 100644 --- a/lang/it.json +++ b/lang/it.json @@ -3817,6 +3817,7 @@ "NICs:": "NIC:", "NOT FOUND": "NON TROVATO", "NOTE: The host directory and its contents will remain unchanged.": "NOTA: la directory host e il suo contenuto rimarranno invariati.", + "NUT server user for clients such as PeaNUT": "utente NUT server per client come PeaNUT", "NVENC patch detected — list narrowed to versions supported by keylase/nvidia-patch.": "Rilevata patch NVENC: elenco ristretto alle versioni supportate da keylase/nvidia-patch.", "NVIDIA (CUDA)": "NVIDIA (CUDA)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; immagine GPU ufficiale)", @@ -3932,6 +3933,7 @@ "Network Restart Failed": "Riavvio rete non riuscito", "Network Restarted": "Rete riavviata", "Network Tools": "Strumenti rete", + "Network UPS Tools server for a UPS connected to the host by USB.": "Server Network UPS Tools per un UPS collegato all'host tramite USB.", "Network access:": "Accesso rete:", "Network bridge": "Bridge di rete", "Network configuration backed up": "Backup della configurazione di rete creato", @@ -4070,6 +4072,7 @@ "No Shares Found": "Nessuna condivisione trovata", "No Shares to Test": "Nessuna condivisione da testare", "No Storage Found": "Nessuno storage trovato", + "No UPS was found on a USB port of this host. Connect it and start the installation again.": "Nessun UPS trovato su una porta USB di questo host. Collegatelo e riavviate l'installazione.", "No USB device was found on this host.": "nessun dispositivo USB trovato su questo host.", "No USB drives detected. Enter the mountpoint path manually:": "Nessuna unità USB rilevata. Inserisci manualmente il percorso del punto di montaggio:", "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Ancora nessuna unità USB montata da ProxMenux. Montane una prima per usarla come destinazione.", @@ -4314,8 +4317,8 @@ "OCI containers require Proxmox VE 9.1 or later.": "I container OCI richiedono Proxmox VE 9.1 o versione successiva.", "OCI management": "Gestione OCI", "OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.": "Impossibile completare la gestione OCI. Controlla lo stato del backend; non è stata autorizzata alcuna ulteriore pulizia.", - "OCI manager Apps (beta)": "OCI manager Apps (beta)", - "OCI manager Apps is a beta: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps è in versione beta: se qualcosa non funziona come previsto, segnalarlo su GitHub indicando il nome dell'applicazione.", + "OCI manager Apps (Technology Preview)": "OCI manager Apps (Technology Preview)", + "OCI manager Apps is a Technology Preview: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps è una Technology Preview: se qualcosa non funziona come previsto, segnalalo su GitHub con il nome dell'applicazione.", "OCI metadata integrity mismatch": "Verifica integrità metadati OCI non superata", "OCI metadata too large": "Metadati OCI troppo grandi", "OCI stack management": "Gestione stack OCI", @@ -4510,6 +4513,7 @@ "Pass /dev/video11 to the LXC": "Passa /dev/video11 al container LXC", "Pass /dev/video12 to the LXC": "Collega /dev/video12 al container LXC", "Pass a host device to the LXC": "Collega un dispositivo dell’host al container LXC", + "Pass the UPS to the LXC": "passa l'UPS al LXC", "Passing NVIDIA does not enable acceleration inside the application. The host needs NVIDIA Container Toolkit and a compatible image. Continue?": "il passaggio di NVIDIA non abilita l'accelerazione all'interno dell'applicazione. L'host necessita di NVIDIA Container Toolkit e di un'immagine compatibile. Continuare?", "Passphrase used to unlock the imported keyfile (leave blank if the keyfile is unencrypted / kdf=none):": "Passphrase per sbloccare il file di chiavi importato (lascia vuoto se non è crittografato / kdf=none):", "Passphrases do not match.": "Le passphrase non corrispondono.", @@ -4535,6 +4539,7 @@ "Password of the LibreDB Studio administrator": "Password amministratore LibreDB Studio", "Password of the MariaDB user": "Password dell'utente MariaDB", "Password of the MineOS web interface user": "Password utente interfaccia web MineOS", + "Password of the NUT server user": "Password dell'utente NUT server", "Password of the NetBox admin account": "Password account amministratore NetBox", "Password of the OpenList admin user": "Password amministratore OpenList", "Password of the PhotoPrism admin user (at least 8 characters)": "Password amministratore PhotoPrism (almeno 8 caratteri)", @@ -6860,6 +6865,7 @@ "USB Accelerators:": "Acceleratori USB:", "USB bus directory": "Directory bus USB", "USB bus numbers can change after reconnecting or rebooting.": "I numeri del bus USB possono cambiare dopo una riconnessione o un riavvio.", + "USB device of the UPS": "Dispositivo USB dell'UPS", "USB device to attach": "Dispositivo USB da collegare", "USB disk target": "Destinazione disco USB", "USB drives mounted now:": "Unità USB montate ora:", diff --git a/lang/pt.json b/lang/pt.json index e10033a1..8b136015 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -3817,6 +3817,7 @@ "NICs:": "Placas de rede:", "NOT FOUND": "NÃO ENCONTRADO", "NOTE: The host directory and its contents will remain unchanged.": "NOTA: O diretório host e seu conteúdo permanecerão inalterados.", + "NUT server user for clients such as PeaNUT": "usuário NUT server para clientes como PeaNUT", "NVENC patch detected — list narrowed to versions supported by keylase/nvidia-patch.": "Patch NVENC detectado — lista reduzida às versões suportadas por keylase/nvidia-patch.", "NVIDIA (CUDA)": "NVIDIA (CUDA)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; imagem oficial da GPU)", @@ -3932,6 +3933,7 @@ "Network Restart Failed": "Falha ao reiniciar a rede", "Network Restarted": "Rede reiniciada", "Network Tools": "Ferramentas de rede", + "Network UPS Tools server for a UPS connected to the host by USB.": "Servidor Network UPS Tools para um UPS conectado ao host por USB.", "Network access:": "Acesso à rede:", "Network bridge": "Ponte de rede", "Network configuration backed up": "Configuração de rede com backup", @@ -4070,6 +4072,7 @@ "No Shares Found": "Nenhum compartilhamento encontrado", "No Shares to Test": "Sem compartilhamentos para testar", "No Storage Found": "Nenhum armazenamento encontrado", + "No UPS was found on a USB port of this host. Connect it and start the installation again.": "Nenhum UPS foi encontrado em uma porta USB deste host. Conecte-o e reinicie a instalação.", "No USB device was found on this host.": "Nenhum dispositivo USB foi encontrado neste host.", "No USB drives detected. Enter the mountpoint path manually:": "Nenhuma unidade USB detectada. Insira o caminho do ponto de montagem manualmente:", "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Ainda não há unidades USB montadas pelo ProxMenux. Monte um primeiro para usá-lo como alvo.", @@ -4314,8 +4317,8 @@ "OCI containers require Proxmox VE 9.1 or later.": "Os contêineres OCI requerem Proxmox VE 9.1 ou posterior.", "OCI management": "Gestão de OCI", "OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.": "A gestão do OCI não pôde ser concluída. Verifique o estado da infra- estrutura; nenhuma limpeza adicional foi autorizada.", - "OCI manager Apps (beta)": "OCI manager Apps (beta)", - "OCI manager Apps is a beta: if something does not work as expected, please report it on GitHub with the application name.": "Gerenciador de OCI Apps é um beta: se algo não funcionar como esperado, por favor, relate no GitHub com o nome da aplicação.", + "OCI manager Apps (Technology Preview)": "OCI manager Apps (Technology Preview)", + "OCI manager Apps is a Technology Preview: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps é uma Technology Preview: se algo não funcionar conforme o esperado, informe no GitHub com o nome do aplicativo.", "OCI metadata integrity mismatch": "Incompatibilidade da integridade dos metadados OCI", "OCI metadata too large": "Metadados OCI demasiado grandes", "OCI stack management": "Gestão de pilhas OCI", @@ -4510,6 +4513,7 @@ "Pass /dev/video11 to the LXC": "Passe /dev/video11 para o LXC", "Pass /dev/video12 to the LXC": "Passe /dev/video12 para o LXC", "Pass a host device to the LXC": "Passe um dispositivo da máquina para o LXC", + "Pass the UPS to the LXC": "Passe o UPS para o LXC", "Passing NVIDIA does not enable acceleration inside the application. The host needs NVIDIA Container Toolkit and a compatible image. Continue?": "Passar NVIDIA não habilita a aceleração dentro do aplicativo. O host precisa do NVIDIA Container Toolkit e de uma imagem compatível. Continuar?", "Passphrase used to unlock the imported keyfile (leave blank if the keyfile is unencrypted / kdf=none):": "Senha usada para desbloquear o arquivo-chave importado (deixe em branco se o arquivo-chave não estiver criptografado / kdf=none):", "Passphrases do not match.": "As senhas não correspondem.", @@ -4535,6 +4539,7 @@ "Password of the LibreDB Studio administrator": "Senha do administrador do LibreDB Studio", "Password of the MariaDB user": "Senha do usuário MariaDB", "Password of the MineOS web interface user": "Senha do utilizador da interface Web MineOS", + "Password of the NUT server user": "Senha do usuário NUT server", "Password of the NetBox admin account": "Senha da conta de administração NetBox", "Password of the OpenList admin user": "Senha do usuário administrador do OpenList", "Password of the PhotoPrism admin user (at least 8 characters)": "Senha do usuário administrador do PhotoPrism (pelo menos 8 caracteres)", @@ -6860,6 +6865,7 @@ "USB Accelerators:": "Aceleradores USB:", "USB bus directory": "Directório do barramento USB", "USB bus numbers can change after reconnecting or rebooting.": "Os números do barramento USB podem mudar após reconectar ou reiniciar.", + "USB device of the UPS": "Dispositivo USB do UPS", "USB device to attach": "dispositivo USB para conectar", "USB disk target": "destino de disco USB", "USB drives mounted now:": "unidades USB montadas agora:", diff --git a/lang/sk.json b/lang/sk.json index 82c2ae05..4b8dd643 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -3817,6 +3817,7 @@ "NICs:": "Sieťové karty:", "NOT FOUND": "NENÁJDENÉ", "NOTE: The host directory and its contents will remain unchanged.": "POZNÁMKA: priečinok na hostovi ani jeho obsah sa nezmenia.", + "NUT server user for clients such as PeaNUT": "Používateľ NUT server pre klientov ako PeaNUT", "NVENC patch detected — list narrowed to versions supported by keylase/nvidia-patch.": "Zistil sa NVENC patch - zoznam je zúžený na verzie podporované cez keylase/nvidia-patch.", "NVIDIA (CUDA)": "NVIDIA (CUDA)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; oficiálny obraz GPU)", @@ -3932,6 +3933,7 @@ "Network Restart Failed": "Reštart siete zlyhal", "Network Restarted": "Sieť bola reštartovaná", "Network Tools": "Sieťové nástroje", + "Network UPS Tools server for a UPS connected to the host by USB.": "Sieťový server UPS Tools pre UPS pripojený k hostiteľovi cez USB.", "Network access:": "Sieťový prístup:", "Network bridge": "Sieťový most", "Network configuration backed up": "Nastavenie siete bolo zálohované", @@ -4070,6 +4072,7 @@ "No Shares Found": "Nenašli sa žiadne zdieľania", "No Shares to Test": "Žiadne zdieľania na testovanie", "No Storage Found": "Nenašlo sa žiadne úložisko", + "No UPS was found on a USB port of this host. Connect it and start the installation again.": "Na USB porte tohto hostiteľa sa nenašiel žiadny UPS. Pripojte ho a spustite inštaláciu znova.", "No USB device was found on this host.": "Na tomto hostiteľovi sa nenašlo žiadne zariadenie USB.", "No USB drives detected. Enter the mountpoint path manually:": "Nenašli sa žiadne USB disky. Zadajte cestu mount pointu ručne:", "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "ProxMenux zatiaľ nepripojil žiadny USB disk. Najprv jeden pripojte, aby sa dal použiť ako cieľ.", @@ -4314,7 +4317,9 @@ "OCI containers require Proxmox VE 9.1 or later.": "OCI kontajnery vyžadujú Proxmox VE 9.1 alebo novší.", "OCI management": "Správa OCI aplikácií", "OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.": "Správu OCI aplikácií nebolo možné dokončiť. Skontrolujte stav backendu; žiadne ďalšie vyčistenie nebolo povolené.", + "OCI manager Apps (Technology Preview)": "Správca OCI aplikácií (Technology Preview)", "OCI manager Apps (beta)": "Správca OCI aplikácií (beta)", + "OCI manager Apps is a Technology Preview: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps je Technology Preview: ak niečo nefunguje podľa očakávania, nahláste to na GitHub s názvom aplikácie.", "OCI manager Apps is a beta: if something does not work as expected, please report it on GitHub with the application name.": "Správca OCI aplikácií je beta: ak niečo nefunguje podľa očakávania, nahláste to na GitHub spolu s názvom aplikácie.", "OCI metadata integrity mismatch": "Nepomer integrity metaúdajov OCI", "OCI metadata too large": "Metaúdaje OCI príliš veľké", @@ -4510,6 +4515,7 @@ "Pass /dev/video11 to the LXC": "Prešiel /dev/video11 na LXC", "Pass /dev/video12 to the LXC": "Prechod /dev/video12 na LXC", "Pass a host device to the LXC": "Posuňte hostiteľské zariadenie na LXC", + "Pass the UPS to the LXC": "Pošlite UPS do LXC", "Passing NVIDIA does not enable acceleration inside the application. The host needs NVIDIA Container Toolkit and a compatible image. Continue?": "Prejdenie NVIDIA neumožní zrýchlenie v aplikácii. Hostiteľ potrebuje NVIDIA Container Toolkit a kompatibilný obrázok. Pokračovať?", "Passphrase used to unlock the imported keyfile (leave blank if the keyfile is unencrypted / kdf=none):": "Fráza na odomknutie importovaného keyfile (nechajte prázdne, ak keyfile nie je šifrovaný / kdf=none):", "Passphrases do not match.": "Frázy sa nezhodujú.", @@ -4535,6 +4541,7 @@ "Password of the LibreDB Studio administrator": "Heslo správcu LibreDB Studio", "Password of the MariaDB user": "Heslo používateľa MariaDB", "Password of the MineOS web interface user": "Heslo používateľa webového rozhrania MineOS", + "Password of the NUT server user": "Heslo používateľa NUT server", "Password of the NetBox admin account": "Heslo admin účtu NetBox", "Password of the OpenList admin user": "Heslo užívateľa OpenList admin", "Password of the PhotoPrism admin user (at least 8 characters)": "Heslo PhotoPrism admin užívateľa (aspoň 8 znakov)", @@ -6859,6 +6866,7 @@ "USB Accelerators:": "USB akcelerátory:", "USB bus directory": "Adresár USB zbernice", "USB bus numbers can change after reconnecting or rebooting.": "Čísla zbernice USB sa môžu zmeniť po opätovnom pripojení alebo reštarte.", + "USB device of the UPS": "USB zariadenie UPS", "USB device to attach": "Zariadenie USB na pripojenie", "USB disk target": "Cieľ na USB disku", "USB drives mounted now:": "Aktuálne pripojené USB disky:", diff --git a/lang/sv.json b/lang/sv.json index 6d2ac0f6..9395122a 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -3817,6 +3817,7 @@ "NICs:": "NIC:er:", "NOT FOUND": "HITTADE INTE", "NOTE: The host directory and its contents will remain unchanged.": "OBS: Värdkatalogen och dess innehåll förblir oförändrade.", + "NUT server user for clients such as PeaNUT": "NUT server användare för klienter som PeaNUT", "NVENC patch detected — list narrowed to versions supported by keylase/nvidia-patch.": "NVENC-patch upptäckt — listan minskad till versioner som stöds av keylase/nvidia-patch.", "NVIDIA (CUDA)": "NVIDIA (CUDA)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA, officiell GPU-bild)", @@ -3932,6 +3933,7 @@ "Network Restart Failed": "Omstart av nätverket misslyckades", "Network Restarted": "Nätverket har startat om", "Network Tools": "Nätverksverktyg", + "Network UPS Tools server for a UPS connected to the host by USB.": "Network UPS Tools-server för en UPS ansluten till värden via USB.", "Network access:": "Nätverksåtkomst:", "Network bridge": "Nätverksbro", "Network configuration backed up": "Nätverkskonfiguration säkerhetskopierad", @@ -4070,6 +4072,7 @@ "No Shares Found": "Inga aktier hittades", "No Shares to Test": "Inga aktier att testa", "No Storage Found": "Ingen lagring hittades", + "No UPS was found on a USB port of this host. Connect it and start the installation again.": "Ingen UPS hittades på en USB-port på denna värd. Anslut den och starta installationen igen.", "No USB device was found on this host.": "Ingen USB-enhet hittades på denna värd.", "No USB drives detected. Enter the mountpoint path manually:": "Inga USB-enheter upptäcktes. Ange monteringspunktens sökväg manuellt:", "No USB drives mounted by ProxMenux yet. Mount one first to use it as a target.": "Inga USB-enheter monterade av ProxMenux ännu. Montera en först för att använda den som mål.", @@ -4314,8 +4317,8 @@ "OCI containers require Proxmox VE 9.1 or later.": "OCI-behållare kräver Proxmox VE 9.1 eller senare.", "OCI management": "OCI management", "OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.": "OCI-hanteringen kunde inte slutföras. Kontrollera backend status; ingen ytterligare rengöring har godkänts.", - "OCI manager Apps (beta)": "OCI manager Apps (beta)", - "OCI manager Apps is a beta: if something does not work as expected, please report it on GitHub with the application name.": "OCI Manager Apps är en beta: om något inte fungerar som förväntat, vänligen rapportera det på GitHub med applikationsnamnet.", + "OCI manager Apps (Technology Preview)": "OCI manager Apps (Technology Preview)", + "OCI manager Apps is a Technology Preview: if something does not work as expected, please report it on GitHub with the application name.": "OCI manager Apps är en Technology Preview: om något inte fungerar som förväntat, rapportera det på GitHub med applikationsnamnet.", "OCI metadata integrity mismatch": "OCI metadata integritet mismatch", "OCI metadata too large": "OCI metadata för stor", "OCI stack management": "OCI stackhantering", @@ -4510,6 +4513,7 @@ "Pass /dev/video11 to the LXC": "Pass /dev/video11 till LXC", "Pass /dev/video12 to the LXC": "Pass /dev/video12 till LXC", "Pass a host device to the LXC": "Passera en värdenhet till LXC", + "Pass the UPS to the LXC": "Skicka UPS:en till LXC", "Passing NVIDIA does not enable acceleration inside the application. The host needs NVIDIA Container Toolkit and a compatible image. Continue?": "Att passera NVIDIA möjliggör inte acceleration inuti applikationen. Värden behöver NVIDIA Container Toolkit och en kompatibel bild. Fortsätta?", "Passphrase used to unlock the imported keyfile (leave blank if the keyfile is unencrypted / kdf=none):": "Lösenfras som används för att låsa upp den importerade nyckelfilen (lämna tomt om nyckelfilen är okrypterad / kdf=ingen):", "Passphrases do not match.": "Lösenfraser matchar inte.", @@ -4535,6 +4539,7 @@ "Password of the LibreDB Studio administrator": "Lösenord för LibreDB Studio-administratören", "Password of the MariaDB user": "Lösenordet för MariaDB-användaren", "Password of the MineOS web interface user": "Lösenord för MineOS webbgränssnitt användare", + "Password of the NUT server user": "Lösenordet för NUT server-användaren", "Password of the NetBox admin account": "Lösenord för NetBox admin konto", "Password of the OpenList admin user": "Lösenord för OpenList admin användaren", "Password of the PhotoPrism admin user (at least 8 characters)": "Lösenord för PhotoPrism-administratören (minst 8 tecken)", @@ -6860,6 +6865,7 @@ "USB Accelerators:": "USB-acceleratorer:", "USB bus directory": "USB buss katalog", "USB bus numbers can change after reconnecting or rebooting.": "USB-bussnummer kan ändras efter återanslutning eller omstart.", + "USB device of the UPS": "UPS:ens USB-enhet", "USB device to attach": "USB-enhet att ansluta", "USB disk target": "USB-diskmål", "USB drives mounted now:": "USB-enheter monterade nu:", diff --git a/oci/VALIDATION.md b/oci/VALIDATION.md index ab136e60..47dac724 100644 --- a/oci/VALIDATION.md +++ b/oci/VALIDATION.md @@ -2,7 +2,7 @@ -43 of 334 applications in the OCI catalog have been tested for real: 41 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists. +43 of 335 applications in the OCI catalog have been tested for real: 41 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists. Each test describes the scenario that was run and names the image it ran on. It does not cover every possible configuration of the application, and a newer image has not been tested until someone reports it. diff --git a/oci/catalog/apps/nut-server.json b/oci/catalog/apps/nut-server.json new file mode 100644 index 00000000..bfff7353 --- /dev/null +++ b/oci/catalog/apps/nut-server.json @@ -0,0 +1,469 @@ +{ + "schema_version": "0.5.0", + "kind": "proxmenux.oci-template", + "id": "image-nut-server", + "status": "generated-unvalidated", + "catalog_ui": { + "title": { + "en_US": "NUT Server" + }, + "tagline": { + "en_US": "Network UPS Tools server for a UPS connected to the host by USB." + }, + "description": { + "en_US": "Network UPS Tools server (upsd) with the driver of a UPS connected to a USB port of the host. It reads the battery, load and power state of the UPS and serves them on port 3493 to NUT clients: PeaNUT, the Proxmox host and any other machine that has to shut down when the power fails.\n\n**Before installing:**\n- Connect the UPS to a USB port of the Proxmox host. The installation passes it to the container.\n- Clients connect with the user upsmon and the password shown at the end of the installation.\n- The Proxmox host is not shut down by this server on its own: it needs its own NUT client (upsmon) pointing to it.\n\n**Learn More:**\n- [Network UPS Tools](https://networkupstools.org/)\n- [Image documentation](https://github.com/instantlinux/docker-tools/tree/main/images/nut-upsd)" + }, + "category": "monitoring", + "category_label": "Monitoring & Analytics", + "author": "instantlinux", + "developer": "Network UPS Tools", + "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/network-ups-tools.webp", + "thumbnail": null, + "screenshots": [], + "architectures": [ + "amd64", + "arm64" + ], + "launch": { + "scheme": "http", + "port": null, + "path": "/" + }, + "website": "https://networkupstools.org/", + "documentation": "https://github.com/instantlinux/docker-tools/tree/main/images/nut-upsd", + "repository": "https://github.com/instantlinux/docker-tools", + "tips": [], + "mini_changelog": [], + "display_version": null, + "updated_at": null + }, + "source": { + "provider": "instantlinux", + "repository": "https://github.com/instantlinux/docker-tools", + "revision": "3d3e17dc5dafb3f544ce0bdaa5ea4cf3814d5b1cb5b6323af2c40d1b224c5fd9", + "image_repository_url": "https://hub.docker.com/r/instantlinux/nut-upsd", + "readme_pushed_at": "", + "compose_sha256": "3d3e17dc5dafb3f544ce0bdaa5ea4cf3814d5b1cb5b6323af2c40d1b224c5fd9", + "generated_at": "2026-10-15T17:50:24+00:00" + }, + "container_contract": { + "service_name": "nut-upsd", + "container_name": "nut-upsd", + "image": { + "reference": "instantlinux/nut-upsd:latest", + "registry": "docker.io", + "repository": "instantlinux/nut-upsd", + "tag": "latest", + "digest": null, + "pull_policy": "resolve-written-reference-to-architecture-digest-at-install" + }, + "environment": [ + { + "name": "NAME", + "example": "ups", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "DESCRIPTION", + "example": "UPS", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "DRIVER", + "example": "usbhid-ups", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "API_USER", + "example": "upsmon", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "API_PASSWORD", + "example": "", + "required": true, + "sensitive": true, + "source": "docker-compose", + "prompt": "Password of the NUT server user" + } + ], + "volumes": [], + "ports": [ + { + "container_port": 3493, + "published_example": 3493, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + } + ], + "related_services": [], + "restart": "unless-stopped", + "stop_grace_period": null, + "original_compose": "services:\n nut-upsd:\n image: instantlinux/nut-upsd:latest\n container_name: nut-upsd\n restart: unless-stopped\n environment:\n NAME: ups\n DESCRIPTION: UPS\n DRIVER: usbhid-ups\n API_USER: upsmon\n API_PASSWORD: ${GENERATED_API_PASSWORD}\n ports:\n - 3493:3493\n devices:\n - /dev/bus/usb/001/001:/dev/bus/usb/001/001\n" + }, + "compose_stack": { + "project_name": "nut-upsd", + "deployment_model": "one-native-oci-lxc-per-compose-service", + "user_experience": "single-application-install", + "main_service": "nut-upsd", + "service_count": 1, + "services": [ + { + "name": "nut-upsd", + "image": "instantlinux/nut-upsd:latest", + "is_main": true, + "role": "frontend", + "vmid_offset": 0, + "depends_on": [], + "frontend_network": true, + "private_network": false, + "compose": { + "image": "instantlinux/nut-upsd:latest", + "container_name": "nut-upsd", + "restart": "unless-stopped", + "environment": { + "NAME": "ups", + "DESCRIPTION": "UPS", + "DRIVER": "usbhid-ups", + "API_USER": "upsmon", + "API_PASSWORD": "${GENERATED_API_PASSWORD}" + }, + "ports": [ + "3493:3493" + ], + "devices": [ + "/dev/bus/usb/001/001:/dev/bus/usb/001/001" + ] + } + } + ], + "top_level": {}, + "networking": { + "frontend": "selected-proxmox-bridge", + "private_required": false, + "private_creation": "automatic-create-if-missing", + "private_address_allocation": "automatic-static-address-per-service", + "service_discovery": "private-addresses-with-compose-service-host-aliases", + "dependency_external_access": "disabled-unless-service-publishes-ports", + "prompt_user_for_private_network": false + }, + "storage": [], + "orchestration": { + "reserve_vmids_atomically": 1, + "start_order": [ + "nut-upsd" + ], + "stop_order": [ + "nut-upsd" + ], + "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback", + "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes" + }, + "installer_inputs": { + "prompted": [ + "stack_name", + "base_vmid", + "rootfs_storage", + "persistent_data_destinations", + "frontend_bridge", + "frontend_ipv4_mode" + ], + "automatic": [ + "dependent_vmids", + "private_bridge", + "private_subnet", + "private_service_addresses", + "compose_service_aliases", + "generated_secrets", + "dependency_start_and_stop_order" + ], + "generated_secrets": [ + { + "id": "api-password", + "strategy": "generate-cryptographically-random-at-install", + "bindings": [ + { + "service": "nut-upsd", + "environment_variable": "API_PASSWORD" + } + ] + } + ] + } + }, + "first_run": { + "endpoints": [], + "credentials": [ + { + "label": "NUT server user for clients such as PeaNUT", + "type": "configured-or-installer-generated", + "username": "upsmon", + "password": null, + "password_environment": "API_PASSWORD", + "change_required": false, + "source": "image-documentation", + "retrieval": null + } + ] + }, + "proxmox": { + "runtime": "native-oci-lxc", + "technology_status": "proxmox-technology-preview", + "defaults": { + "unprivileged": true, + "ostype": "auto-from-image", + "cores": 1, + "memory_mb": 256, + "swap_mb": 256, + "rootfs_size_gb": 4, + "rootfs_storage": "local-lvm", + "volume_storage": "local-lvm", + "template_storage": "local", + "bridge": "vmbr0", + "ipv4": "dhcp", + "firewall": true, + "host_managed_network": true, + "onboot": true, + "features": [ + "nesting=1" + ], + "shutdown_timeout_seconds": 30 + }, + "image_metadata_policy": { + "entrypoint": "import-from-oci-image", + "cmd": "import-from-oci-image", + "environment": "import-image-env-then-apply-compose-overrides", + "user": "import-from-oci-image", + "working_dir": "import-from-oci-image", + "stop_signal": "import-from-oci-image" + }, + "installer_profile": { + "device_requests": [ + { + "id": "ups-usb", + "kind": "character-device", + "purpose": "ups", + "enable_prompt": "Pass the UPS to the LXC", + "enabled_default": true, + "required_for_runtime": true, + "required_by_compose": true, + "path_prompt": "USB device of the UPS", + "host_path_default": "/dev/bus/usb/001/001", + "container_path_strategy": "same-as-host", + "mode": "0666", + "deny_write": false, + "gid_strategy": "host-device-gid", + "source_mapping": "/dev/bus/usb/001/001:/dev/bus/usb/001/001" + } + ], + "generated_sensitive_environment": { + "API_PASSWORD": { + "strategy": "token-hex", + "bytes": 12, + "prompt": true + } + } + }, + "adaptations": [ + { + "id": "imported-compose-source", + "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", + "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.", + "reason": "Catalog import must not imply runtime compatibility.", + "behavioral_impact": "No automatic installation before review.", + "validation": "pending-per-application" + }, + { + "id": "rolling-latest-image", + "upstream_behavior": "A discovered Compose may pin a release tag or digest.", + "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.", + "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.", + "behavioral_impact": "The installed release can be newer than the discovered Compose revision.", + "validation": "pending-per-application" + }, + { + "id": "dedicated-lxc-network", + "upstream_behavior": "Docker publishes selected container ports on the Docker host.", + "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.", + "reason": "A native LXC has its own address and does not require Docker port NAT.", + "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.", + "validation": "pending-per-application" + }, + { + "id": "compose-shm-size", + "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.", + "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.", + "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-command", + "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.", + "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.", + "reason": "Proxmox stores the effective OCI process as one entrypoint string.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-privileged-mode", + "upstream_behavior": "Compose selects whether the container runs in privileged mode.", + "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.", + "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.", + "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.", + "validation": "native-equivalent" + }, + { + "id": "compose-process-runtime", + "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.", + "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.", + "reason": "The OCI process must start with the same identity, command and working directory without Docker.", + "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-healthcheck", + "upstream_behavior": "Docker periodically executes the declared container healthcheck.", + "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.", + "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.", + "behavioral_impact": "The check runs during installation rather than continuously after installation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-cpu-priority", + "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.", + "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.", + "reason": "Both settings express relative CPU priority on different scales.", + "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-resource-limits", + "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.", + "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.", + "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.", + "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-identity", + "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.", + "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.", + "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.", + "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-mode", + "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.", + "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.", + "reason": "The LXC is the application host and already has its own address and port namespace.", + "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-capabilities-and-sysctls", + "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.", + "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.", + "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.", + "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.", + "validation": "not-requested-by-compose" + }, + { + "id": "docker-engine-metadata", + "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.", + "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.", + "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.", + "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-device-passthrough", + "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.", + "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.", + "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.", + "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.", + "validation": "pending-per-application" + }, + { + "id": "compose-host-ipc", + "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.", + "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.", + "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.", + "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.", + "validation": "not-requested-by-compose" + } + ] + }, + "compatibility": { + "automatic_install_candidate": true, + "validated": false, + "supported_compose_keys": [ + "cap_add", + "command", + "container_name", + "cpu_shares", + "deploy", + "devices", + "entrypoint", + "environment", + "extra_hosts", + "group_add", + "healthcheck", + "hostname", + "image", + "init", + "ipc", + "labels", + "logging", + "mac_address", + "mem_limit", + "network_mode", + "networks", + "ports", + "privileged", + "restart", + "runtime", + "security_opt", + "shm_size", + "stdin_open", + "stop_grace_period", + "sysctls", + "tty", + "ulimits", + "user", + "volumes", + "working_dir" + ], + "untranslated_blockers": [], + "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available." + }, + "validation": { + "schema": "passed-at-generation", + "clean_install": "pending", + "service_health": "pending", + "restart_persistence": "pending", + "backup_restore": "pending", + "update_preserves_data": "pending" + }, + "lifecycle": { + "update_strategy": "resolve-latest-image-then-apply-reviewed-native-lxc-update", + "registry_state": { + "resolved_architecture": null, + "resolved_digest": null, + "image_version_label": null, + "image_created": null + }, + "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", + "automatic_unattended_updates": false + } +} diff --git a/oci/catalog/curated/nut-server.json b/oci/catalog/curated/nut-server.json new file mode 100644 index 00000000..bfff7353 --- /dev/null +++ b/oci/catalog/curated/nut-server.json @@ -0,0 +1,469 @@ +{ + "schema_version": "0.5.0", + "kind": "proxmenux.oci-template", + "id": "image-nut-server", + "status": "generated-unvalidated", + "catalog_ui": { + "title": { + "en_US": "NUT Server" + }, + "tagline": { + "en_US": "Network UPS Tools server for a UPS connected to the host by USB." + }, + "description": { + "en_US": "Network UPS Tools server (upsd) with the driver of a UPS connected to a USB port of the host. It reads the battery, load and power state of the UPS and serves them on port 3493 to NUT clients: PeaNUT, the Proxmox host and any other machine that has to shut down when the power fails.\n\n**Before installing:**\n- Connect the UPS to a USB port of the Proxmox host. The installation passes it to the container.\n- Clients connect with the user upsmon and the password shown at the end of the installation.\n- The Proxmox host is not shut down by this server on its own: it needs its own NUT client (upsmon) pointing to it.\n\n**Learn More:**\n- [Network UPS Tools](https://networkupstools.org/)\n- [Image documentation](https://github.com/instantlinux/docker-tools/tree/main/images/nut-upsd)" + }, + "category": "monitoring", + "category_label": "Monitoring & Analytics", + "author": "instantlinux", + "developer": "Network UPS Tools", + "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/network-ups-tools.webp", + "thumbnail": null, + "screenshots": [], + "architectures": [ + "amd64", + "arm64" + ], + "launch": { + "scheme": "http", + "port": null, + "path": "/" + }, + "website": "https://networkupstools.org/", + "documentation": "https://github.com/instantlinux/docker-tools/tree/main/images/nut-upsd", + "repository": "https://github.com/instantlinux/docker-tools", + "tips": [], + "mini_changelog": [], + "display_version": null, + "updated_at": null + }, + "source": { + "provider": "instantlinux", + "repository": "https://github.com/instantlinux/docker-tools", + "revision": "3d3e17dc5dafb3f544ce0bdaa5ea4cf3814d5b1cb5b6323af2c40d1b224c5fd9", + "image_repository_url": "https://hub.docker.com/r/instantlinux/nut-upsd", + "readme_pushed_at": "", + "compose_sha256": "3d3e17dc5dafb3f544ce0bdaa5ea4cf3814d5b1cb5b6323af2c40d1b224c5fd9", + "generated_at": "2026-10-15T17:50:24+00:00" + }, + "container_contract": { + "service_name": "nut-upsd", + "container_name": "nut-upsd", + "image": { + "reference": "instantlinux/nut-upsd:latest", + "registry": "docker.io", + "repository": "instantlinux/nut-upsd", + "tag": "latest", + "digest": null, + "pull_policy": "resolve-written-reference-to-architecture-digest-at-install" + }, + "environment": [ + { + "name": "NAME", + "example": "ups", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "DESCRIPTION", + "example": "UPS", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "DRIVER", + "example": "usbhid-ups", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "API_USER", + "example": "upsmon", + "required": true, + "sensitive": false, + "source": "docker-compose" + }, + { + "name": "API_PASSWORD", + "example": "", + "required": true, + "sensitive": true, + "source": "docker-compose", + "prompt": "Password of the NUT server user" + } + ], + "volumes": [], + "ports": [ + { + "container_port": 3493, + "published_example": 3493, + "protocol": "tcp", + "required": true, + "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat" + } + ], + "related_services": [], + "restart": "unless-stopped", + "stop_grace_period": null, + "original_compose": "services:\n nut-upsd:\n image: instantlinux/nut-upsd:latest\n container_name: nut-upsd\n restart: unless-stopped\n environment:\n NAME: ups\n DESCRIPTION: UPS\n DRIVER: usbhid-ups\n API_USER: upsmon\n API_PASSWORD: ${GENERATED_API_PASSWORD}\n ports:\n - 3493:3493\n devices:\n - /dev/bus/usb/001/001:/dev/bus/usb/001/001\n" + }, + "compose_stack": { + "project_name": "nut-upsd", + "deployment_model": "one-native-oci-lxc-per-compose-service", + "user_experience": "single-application-install", + "main_service": "nut-upsd", + "service_count": 1, + "services": [ + { + "name": "nut-upsd", + "image": "instantlinux/nut-upsd:latest", + "is_main": true, + "role": "frontend", + "vmid_offset": 0, + "depends_on": [], + "frontend_network": true, + "private_network": false, + "compose": { + "image": "instantlinux/nut-upsd:latest", + "container_name": "nut-upsd", + "restart": "unless-stopped", + "environment": { + "NAME": "ups", + "DESCRIPTION": "UPS", + "DRIVER": "usbhid-ups", + "API_USER": "upsmon", + "API_PASSWORD": "${GENERATED_API_PASSWORD}" + }, + "ports": [ + "3493:3493" + ], + "devices": [ + "/dev/bus/usb/001/001:/dev/bus/usb/001/001" + ] + } + } + ], + "top_level": {}, + "networking": { + "frontend": "selected-proxmox-bridge", + "private_required": false, + "private_creation": "automatic-create-if-missing", + "private_address_allocation": "automatic-static-address-per-service", + "service_discovery": "private-addresses-with-compose-service-host-aliases", + "dependency_external_access": "disabled-unless-service-publishes-ports", + "prompt_user_for_private_network": false + }, + "storage": [], + "orchestration": { + "reserve_vmids_atomically": 1, + "start_order": [ + "nut-upsd" + ], + "stop_order": [ + "nut-upsd" + ], + "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback", + "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes" + }, + "installer_inputs": { + "prompted": [ + "stack_name", + "base_vmid", + "rootfs_storage", + "persistent_data_destinations", + "frontend_bridge", + "frontend_ipv4_mode" + ], + "automatic": [ + "dependent_vmids", + "private_bridge", + "private_subnet", + "private_service_addresses", + "compose_service_aliases", + "generated_secrets", + "dependency_start_and_stop_order" + ], + "generated_secrets": [ + { + "id": "api-password", + "strategy": "generate-cryptographically-random-at-install", + "bindings": [ + { + "service": "nut-upsd", + "environment_variable": "API_PASSWORD" + } + ] + } + ] + } + }, + "first_run": { + "endpoints": [], + "credentials": [ + { + "label": "NUT server user for clients such as PeaNUT", + "type": "configured-or-installer-generated", + "username": "upsmon", + "password": null, + "password_environment": "API_PASSWORD", + "change_required": false, + "source": "image-documentation", + "retrieval": null + } + ] + }, + "proxmox": { + "runtime": "native-oci-lxc", + "technology_status": "proxmox-technology-preview", + "defaults": { + "unprivileged": true, + "ostype": "auto-from-image", + "cores": 1, + "memory_mb": 256, + "swap_mb": 256, + "rootfs_size_gb": 4, + "rootfs_storage": "local-lvm", + "volume_storage": "local-lvm", + "template_storage": "local", + "bridge": "vmbr0", + "ipv4": "dhcp", + "firewall": true, + "host_managed_network": true, + "onboot": true, + "features": [ + "nesting=1" + ], + "shutdown_timeout_seconds": 30 + }, + "image_metadata_policy": { + "entrypoint": "import-from-oci-image", + "cmd": "import-from-oci-image", + "environment": "import-image-env-then-apply-compose-overrides", + "user": "import-from-oci-image", + "working_dir": "import-from-oci-image", + "stop_signal": "import-from-oci-image" + }, + "installer_profile": { + "device_requests": [ + { + "id": "ups-usb", + "kind": "character-device", + "purpose": "ups", + "enable_prompt": "Pass the UPS to the LXC", + "enabled_default": true, + "required_for_runtime": true, + "required_by_compose": true, + "path_prompt": "USB device of the UPS", + "host_path_default": "/dev/bus/usb/001/001", + "container_path_strategy": "same-as-host", + "mode": "0666", + "deny_write": false, + "gid_strategy": "host-device-gid", + "source_mapping": "/dev/bus/usb/001/001:/dev/bus/usb/001/001" + } + ], + "generated_sensitive_environment": { + "API_PASSWORD": { + "strategy": "token-hex", + "bytes": 12, + "prompt": true + } + } + }, + "adaptations": [ + { + "id": "imported-compose-source", + "upstream_behavior": "The source definition deploys the complete Docker Compose application model.", + "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.", + "reason": "Catalog import must not imply runtime compatibility.", + "behavioral_impact": "No automatic installation before review.", + "validation": "pending-per-application" + }, + { + "id": "rolling-latest-image", + "upstream_behavior": "A discovered Compose may pin a release tag or digest.", + "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.", + "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.", + "behavioral_impact": "The installed release can be newer than the discovered Compose revision.", + "validation": "pending-per-application" + }, + { + "id": "dedicated-lxc-network", + "upstream_behavior": "Docker publishes selected container ports on the Docker host.", + "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.", + "reason": "A native LXC has its own address and does not require Docker port NAT.", + "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.", + "validation": "pending-per-application" + }, + { + "id": "compose-shm-size", + "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.", + "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.", + "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-command", + "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.", + "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.", + "reason": "Proxmox stores the effective OCI process as one entrypoint string.", + "behavioral_impact": "None expected.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-privileged-mode", + "upstream_behavior": "Compose selects whether the container runs in privileged mode.", + "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.", + "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.", + "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.", + "validation": "native-equivalent" + }, + { + "id": "compose-process-runtime", + "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.", + "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.", + "reason": "The OCI process must start with the same identity, command and working directory without Docker.", + "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-healthcheck", + "upstream_behavior": "Docker periodically executes the declared container healthcheck.", + "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.", + "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.", + "behavioral_impact": "The check runs during installation rather than continuously after installation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-cpu-priority", + "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.", + "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.", + "reason": "Both settings express relative CPU priority on different scales.", + "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-resource-limits", + "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.", + "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.", + "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.", + "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-identity", + "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.", + "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.", + "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.", + "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-network-mode", + "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.", + "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.", + "reason": "The LXC is the application host and already has its own address and port namespace.", + "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-capabilities-and-sysctls", + "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.", + "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.", + "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.", + "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.", + "validation": "not-requested-by-compose" + }, + { + "id": "docker-engine-metadata", + "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.", + "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.", + "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.", + "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.", + "validation": "not-requested-by-compose" + }, + { + "id": "compose-device-passthrough", + "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.", + "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.", + "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.", + "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.", + "validation": "pending-per-application" + }, + { + "id": "compose-host-ipc", + "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.", + "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.", + "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.", + "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.", + "validation": "not-requested-by-compose" + } + ] + }, + "compatibility": { + "automatic_install_candidate": true, + "validated": false, + "supported_compose_keys": [ + "cap_add", + "command", + "container_name", + "cpu_shares", + "deploy", + "devices", + "entrypoint", + "environment", + "extra_hosts", + "group_add", + "healthcheck", + "hostname", + "image", + "init", + "ipc", + "labels", + "logging", + "mac_address", + "mem_limit", + "network_mode", + "networks", + "ports", + "privileged", + "restart", + "runtime", + "security_opt", + "shm_size", + "stdin_open", + "stop_grace_period", + "sysctls", + "tty", + "ulimits", + "user", + "volumes", + "working_dir" + ], + "untranslated_blockers": [], + "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available." + }, + "validation": { + "schema": "passed-at-generation", + "clean_install": "pending", + "service_health": "pending", + "restart_persistence": "pending", + "backup_restore": "pending", + "update_preserves_data": "pending" + }, + "lifecycle": { + "update_strategy": "resolve-latest-image-then-apply-reviewed-native-lxc-update", + "registry_state": { + "resolved_architecture": null, + "resolved_digest": null, + "image_version_label": null, + "image_created": null + }, + "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest", + "automatic_unattended_updates": false + } +} diff --git a/oci/catalog/generation-report-curated.json b/oci/catalog/generation-report-curated.json index c797e035..b40d683c 100644 --- a/oci/catalog/generation-report-curated.json +++ b/oci/catalog/generation-report-curated.json @@ -12,6 +12,7 @@ "makemkv", "mkvtoolnix", "nextcloud-stack", + "nut-server", "open-webui", "open-webui-cuda", "open-webui-ollama", @@ -22,7 +23,7 @@ "stremio", "tandoor" ], - "generated_count": 21, + "generated_count": 22, "failed": [], "failed_count": 0 } diff --git a/oci/catalog/index.json b/oci/catalog/index.json index 6a73ff8b..5d0cc45a 100644 --- a/oci/catalog/index.json +++ b/oci/catalog/index.json @@ -7154,6 +7154,38 @@ "hidden": false, "template_id": "image-node-red" }, + { + "id": "nut-server", + "provider": "instantlinux", + "template_family": "curated-profile", + "title": "NUT Server", + "repository": "https://github.com/instantlinux/docker-tools", + "description": "Network UPS Tools server for a UPS connected to the host by USB.", + "website": "https://networkupstools.org/", + "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/network-ups-tools.webp", + "architectures": [ + "amd64", + "arm64" + ], + "updated_at": null, + "main_image": "instantlinux/nut-upsd:latest", + "category": "monitoring", + "category_label": "Monitoring & Analytics", + "replaces_discovered_ids": [], + "curated_path": "catalog/curated/nut-server.json", + "template": "apps/nut-server.json", + "template_status": "generated-unvalidated", + "automatic_install_candidate": true, + "untranslated_blockers": [], + "requires_privileged_lxc": false, + "optional_privileged_lxc": false, + "requires_host_pid_namespace": false, + "requires_relaxed_confinement": false, + "optional_relaxed_confinement": false, + "requires_security_confirmation": false, + "hidden": false, + "template_id": "image-nut-server" + }, { "id": "nzbfast", "source_app_id": "nzbfast", diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py index 2bcf75b8..4e9025d9 100644 --- a/oci/src/proxmenux_oci/cli.py +++ b/oci/src/proxmenux_oci/cli.py @@ -29,7 +29,7 @@ from .installer import ( run_remote_install, run_remote_rclone_mount, ) -from .ui import APP_TITLE, DETAIL_WINDOW_WIDTH, UserCancelled, interactive_ui, menu_size +from .ui import APP_TITLE, DETAIL_WINDOW_WIDTH, ReadableTags, UserCancelled, interactive_ui, menu_size PROJECT_ROOT = Path(__file__).resolve().parents[2] @@ -173,7 +173,7 @@ def _app_detail_text(catalog: Catalog, item: dict[str, Any], template: dict[str, lines.append(f"{label + ':':<17} {value}") row(translate("Source"), publisher(item)) - # Only a real test is shown; OCI manager Apps is labelled beta as a whole. + # Only a real test is shown; OCI manager Apps is a Technology Preview as a whole. if is_tested(item): row(translate("Status"), translate("Verified")) row(translate("Architectures"), _display_architectures(ui)) @@ -389,7 +389,7 @@ def _print_installation_summary(result: dict[str, Any], images_removed: str | No if result.get("log"): console.msg_note(f"{translate('Installation log:')} {result['log']}") print() - console.msg_note(translate("OCI manager Apps is a beta: if something does not work as expected, " + console.msg_note(translate("OCI manager Apps is a Technology Preview: if something does not work as expected, " "please report it on GitHub with the application name.")) @@ -565,8 +565,8 @@ def interactive(catalog: Catalog) -> int: for number, key in category_by_index.items()] selection = "search" while True: - selection = ui.choose(translate("Select a category or search for applications:"), options, selection, - size=MENU_SIZE) + selection = ui.choose(translate("Select a category or search for applications:"), ReadableTags(options), + selection, size=MENU_SIZE) if selection is None: return 0 try: @@ -612,7 +612,7 @@ def _template_summary_text(template: dict[str, Any]) -> str: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="oci_manager_apps.sh", - description="ProxMenux OCI manager Apps (beta)") + description="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") diff --git a/oci/src/proxmenux_oci/extra_devices.py b/oci/src/proxmenux_oci/extra_devices.py index e8689e7a..bb005543 100644 --- a/oci/src/proxmenux_oci/extra_devices.py +++ b/oci/src/proxmenux_oci/extra_devices.py @@ -5,7 +5,7 @@ from pathlib import Path from . import host from .i18n import translate -from .ui import UserCancelled +from .ui import ReadableTags, UserCancelled GPU_NODE = re.compile(r'/dev/dri/(?:renderD|card)[0-9]+|/dev/kfd') @@ -22,7 +22,7 @@ def choose_usb_device(ui, title, default=None, attached=()): ui.message(translate('No selectable USB device was found. Type the host node by hand.')) return ui.ask(manual_prompt, default or '/dev/ttyACM0') options.append(('manual', translate('Another node, typed by hand'))) - selected = ui.choose(title, options, default if default in dict(options) else options[0][0]) + selected = ui.choose(title, ReadableTags(options), default if default in dict(options) else options[0][0]) if selected is None: raise UserCancelled(translate('Device configuration cancelled')) return ui.ask(manual_prompt, default or '/dev/ttyACM0') if selected == 'manual' else selected diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index e510ec97..a10f92c5 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -18,7 +18,7 @@ from typing import Any from . import host from . import network as access from .i18n import source_text, translate -from .ui import DialogUI, TerminalUI, UserCancelled +from .ui import DialogUI, ReadableTags, TerminalUI, UserCancelled from .custom_mounts import ask_custom_mounts from .extra_devices import device_permissions @@ -82,6 +82,19 @@ def _image_documentation(template: dict[str, Any]) -> str | None: if str(ui.get(key) or "").startswith(("http://", "https://"))), None) +def ups_device_request(item: dict[str, Any], advanced: bool) -> dict[str, Any]: + """The UPS proposed is the one the host sees on USB: its bus node changes + with the port and the order of connection. Without one, only the advanced + installation goes on, to type the node by hand.""" + found = [row["path"] for row in host.usb_devices() if row["kind"] == "UPS"] + if found: + return {**item, "host_path_default": found[0]} + if not advanced: + raise UserCancelled(translate("No UPS was found on a USB port of this host. " + "Connect it and start the installation again.")) + return item + + def external_services_notice(template: dict[str, Any]) -> str | None: """What an application needs already running on the network, as a database of its own. Said before its installation asks how to reach it, so it can be @@ -147,7 +160,7 @@ def ask_storage(ui, text: str, content: str, default: str, mode: str = ADVANCED_ options = [(row["storage"], f"{row.get('type', '')} {host.gib(row.get('avail'))} GB {translate('free')}") for row in rows] names = [name for name, _ in options] - selected = ui.choose(text, options, default if default in names else names[0]) + selected = ui.choose(text, ReadableTags(options), default if default in names else names[0]) if selected is None: raise UserCancelled(text) return selected @@ -289,7 +302,7 @@ def ask_bridge(ui, text: str, default: str, mode: str = ADVANCED_MODE) -> str: return ui.ask(text, default) options = [(row["iface"], row.get("cidr") or (row.get("comments") or "").strip() or "-") for row in rows] names = [name for name, _ in options] - selected = ui.choose(text, options, default if default in names else names[0]) + selected = ui.choose(text, ReadableTags(options), default if default in names else names[0]) if selected is None: raise UserCancelled(text) return selected @@ -1974,9 +1987,11 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod if vendor_id in item.get("drm_vendor_ids", []) for node in host.gpus()[vendor]] if nodes and item["host_path_default"] not in nodes: item = {**item, "host_path_default": nodes[0]} + if item.get("purpose") == "ups": + item = ups_device_request(item, advanced) if not advanced: host_path = item["host_path_default"] - elif item.get("purpose") in ("serial", "user-selected-device"): + elif item.get("purpose") in ("serial", "user-selected-device", "ups"): from .extra_devices import choose_usb_device host_path = choose_usb_device(ui, translate(item["path_prompt"]), item["host_path_default"], {device.get("host_path") for device in devices}) diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index 41e9928b..cbf8e041 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -254,27 +254,33 @@ def _interactive_management(project, ui): recover_automatically(project) offer_recovery(project, ui) carry_records(project, mount_stopped=False, shown=True) - rows = saved_inventory(project) - if not rows: - ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management')) - return - # Same layout as the catalog lists; only an unusual state is shown. - tag_width = max(len(str(r['vmid'])) for r in rows) - header = f" {'CT':<{tag_width + 2}}{translate('Application')[:32]:<32} {translate('Image')}" - options = [] - for r in rows: - line = f"{(r['title'] or r['hostname'])[:32]:<32} " - if r['status'] == 'installed': - line += r['image'][:30] - else: - line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn" - options.append((str(r['vmid']), f"{line:<72}")) - selection = ui.choose(header, options, size=menu_size(), colors=True, - title=translate('Manage installed OCI applications')) - if selection is None: - return - row = next(r for r in rows if str(r['vmid']) == selection) - manage_instance(project, ui, row) + # After each operation the list is shown again. Every operation leaves + # the copies of the records up to date, so they are not all refreshed again. + first = True + while True: + rows = saved_inventory(project) + if not rows: + if first: + ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management')) + return + first = False + # Same layout as the catalog lists; only an unusual state is shown. + tag_width = max(len(str(r['vmid'])) for r in rows) + header = f" {'CT':<{tag_width + 2}}{translate('Application')[:32]:<32} {translate('Image')}" + options = [] + for r in rows: + line = f"{(r['title'] or r['hostname'])[:32]:<32} " + if r['status'] == 'installed': + line += r['image'][:30] + else: + line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn" + options.append((str(r['vmid']), f"{line:<72}")) + selection = ui.choose(header, options, size=menu_size(), colors=True, + title=translate('Manage installed OCI applications')) + if selection is None: + return + row = next(r for r in rows if str(r['vmid']) == selection) + manage_instance(project, ui, row) def set_watchdog(project, vmids, enabled): @@ -291,6 +297,8 @@ def set_watchdog(project, vmids, enabled): done.update(oci_watchdog.set_watchdog(instances.ROOT, vmid, enabled)) except (OSError, ValueError, KeyError): return False + # The copy a restore on another host reads carries the watchdog as well. + carry_records(project, mount_stopped=False, vmids=sorted(done)) return True diff --git a/oci/src/proxmenux_oci/ui.py b/oci/src/proxmenux_oci/ui.py index cbd2d4e9..8238d3c2 100644 --- a/oci/src/proxmenux_oci/ui.py +++ b/oci/src/proxmenux_oci/ui.py @@ -107,7 +107,7 @@ class BacktrackUI: raise UserCancelled(translate('Wizard cancelled')) -APP_TITLE = translate("OCI manager Apps (beta)") +APP_TITLE = translate("OCI manager Apps (Technology Preview)") @dataclass @@ -190,6 +190,11 @@ class TerminalUI: # Width of the windows of the installer: questions, choices and notices. The # final summary and the page of an application, which show tables, are wider. MENU_WIDTH = 72 + + +class ReadableTags(list): + """Menu options whose tags the user reads, as a storage, a bridge or a + device node: they are shown as they are, not numbered.""" SUMMARY_WIDTH = 78 DETAIL_WINDOW_WIDTH = 90 @@ -279,6 +284,19 @@ class DialogUI: def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None, title: str | None = None, show_tags: bool = True, size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None: + # A word is an internal value: the menu numbers the options, as the + # rest of ProxMenux does, and returns the value of the chosen one. + values: dict[str, str] = {} + if not isinstance(options, ReadableTags) and any(tag and not tag.isdigit() for tag, _ in options): + numbered = [] + for tag, label in options: + if tag: + values[str(len(values) + 1)] = tag + if tag == default: + default = str(len(values)) + tag = str(len(values)) + numbered.append((tag, label)) + options = numbered if size: widget = ["--colors"] if colors else [] widget += ["--default-item", default] if default else [] @@ -286,7 +304,8 @@ class DialogUI: for tag, label in options: widget += [tag, label] result = self._run(widget, title) - return result.stderr.strip() if result.returncode == 0 else None + selected = result.stderr.strip() if result.returncode == 0 else None + return values.get(selected, selected) if values and selected else selected terminal = shutil.get_terminal_size((100, 30)) tag_width = max((len(tag) for tag, _ in options), default=0) + 2 if show_tags else 0 width = min(max(MENU_WIDTH, max((len(label) for _, label in options), default=0) + tag_width + 14, @@ -302,7 +321,8 @@ class DialogUI: for tag, label in options: widget += [tag, label] result = self._run(widget, title) - return result.stderr.strip() if result.returncode == 0 else None + selected = result.stderr.strip() if result.returncode == 0 else None + return values.get(selected, selected) if values and selected else selected def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None, title: str | None = None) -> str | None: diff --git a/oci/tests/test_dialog_menu_tags.py b/oci/tests/test_dialog_menu_tags.py new file mode 100644 index 00000000..af1d9a41 --- /dev/null +++ b/oci/tests/test_dialog_menu_tags.py @@ -0,0 +1,60 @@ +"""A menu shows numbers, not internal values: the code still receives the +value of the option chosen. A tag the user reads, such as a storage, stays.""" + +from pathlib import Path +import sys +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from proxmenux_oci.ui import DialogUI, ReadableTags + +SOURCES = [("paste", "Paste its Compose file in the terminal"), + ("file", "Read its Compose file from a file of this host"), + ("oci-compose-paste", "Paste an oci-compose in the terminal")] + + +class DialogMenuTagsTests(unittest.TestCase): + def run_menu(self, options, answer, **kwargs): + seen = {} + + def run(widget, title): + seen["widget"] = widget + return SimpleNamespace(returncode=0, stderr=answer) + + ui = DialogUI() + with patch.object(ui, "_run", side_effect=run): + selected = ui.choose("How is the image described?", options, **kwargs) + menu = seen["widget"][seen["widget"].index("--menu") + 5:] + return selected, menu[0::2], seen["widget"] + + def test_word_tags_are_shown_as_numbers_and_the_value_comes_back(self): + selected, tags, _ = self.run_menu(SOURCES, "3", default="file", size=(20, 80, 10)) + self.assertEqual(tags, ["1", "2", "3"]) + self.assertEqual(selected, "oci-compose-paste") + + def test_the_default_follows_its_number(self): + _, _, widget = self.run_menu(SOURCES, "2", default="file") + self.assertEqual(widget[widget.index("--default-item") + 1], "2") + + def test_a_tag_the_user_reads_is_kept(self): + storages = [("local-lvm", "lvmthin 669 GB free"), ("local2-lvm", "lvmthin 269 GB free")] + selected, tags, _ = self.run_menu(ReadableTags(storages), "local2-lvm") + self.assertEqual((tags, selected), (["local-lvm", "local2-lvm"], "local2-lvm")) + + def test_number_tags_and_separators_are_left_as_they_are(self): + options = [("101", "Jellyfin"), ("", ""), ("205", "Immich")] + selected, tags, _ = self.run_menu(options, "205") + self.assertEqual((tags, selected), (["101", "", "205"], "205")) + + def test_cancelling_returns_nothing(self): + ui = DialogUI() + with patch.object(ui, "_run", return_value=SimpleNamespace(returncode=1, stderr="")): + self.assertIsNone(ui.choose("How is the image described?", SOURCES)) + + +if __name__ == "__main__": + unittest.main() diff --git a/oci/tests/test_usb_devices.py b/oci/tests/test_usb_devices.py index 4b466bcb..55e22843 100644 --- a/oci/tests/test_usb_devices.py +++ b/oci/tests/test_usb_devices.py @@ -90,5 +90,37 @@ class UsbDevicesTests(unittest.TestCase): [("/dev/ttyACM0", "/dev/ttyACM0")]) +class UpsDeviceRequestTests(unittest.TestCase): + """The UPS a recipe asks for is the one the host sees on USB.""" + + REQUEST = {"id": "ups-usb", "purpose": "ups", "host_path_default": "/dev/bus/usb/001/001"} + + def test_the_host_ups_is_proposed(self): + from proxmenux_oci.installer import ups_device_request + rows = [{"path": "/dev/ttyACM0", "name": "ConBee II", "kind": "Serial"}, + {"path": "/dev/bus/usb/003/002", "name": "Ellipse ECO", "kind": "UPS"}] + with patch.object(host, "usb_devices", return_value=rows): + self.assertEqual(ups_device_request(self.REQUEST, False)["host_path_default"], "/dev/bus/usb/003/002") + + def test_without_a_ups_the_installation_stops(self): + from proxmenux_oci.installer import ups_device_request + from proxmenux_oci.ui import UserCancelled + with patch.object(host, "usb_devices", return_value=[]): + with self.assertRaises(UserCancelled): + ups_device_request(self.REQUEST, False) + # The advanced installation lets the node be typed by hand. + self.assertEqual(ups_device_request(self.REQUEST, True), self.REQUEST) + + def test_the_nut_server_recipe_asks_for_the_ups(self): + import json + recipe = json.loads((ROOT / "catalog/curated/nut-server.json").read_text(encoding="utf-8")) + self.assertEqual(recipe, json.loads((ROOT / "catalog/apps/nut-server.json").read_text(encoding="utf-8"))) + request, = recipe["proxmox"]["installer_profile"]["device_requests"] + self.assertEqual((request["purpose"], request["mode"], request["required_for_runtime"]), ("ups", "0666", True)) + self.assertIn("API_PASSWORD", recipe["proxmox"]["installer_profile"]["generated_sensitive_environment"]) + self.assertIsNone(recipe["catalog_ui"]["launch"]["port"]) + self.assertTrue(recipe["proxmox"]["defaults"]["onboot"]) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/global/pci_passthrough_helpers.sh b/scripts/global/pci_passthrough_helpers.sh index 17b11601..5937d457 100644 --- a/scripts/global/pci_passthrough_helpers.sh +++ b/scripts/global/pci_passthrough_helpers.sh @@ -456,6 +456,34 @@ PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE="${PROXMENUX_NVIDIA_SERVICE_LEGACY_STATE:- # remove them when migrating a host that ran the older init-top hook. PROXMENUX_VFIO_BIND_LEGACY_HOOK="${PROXMENUX_VFIO_BIND_LEGACY_HOOK:-${PROXMENUX_ETC_ROOT}/initramfs-tools/scripts/init-top/proxmenux-vfio-bind}" +# The udev rule the state file asks for. +_proxmenux_vfio_bind_render_udev_rule() { + { + echo "# ProxMenux: per-BDF VFIO driver override" + echo "# Auto-generated from $PROXMENUX_VFIO_BIND_STATE" + echo "# DO NOT EDIT MANUALLY — regenerated by add_gpu_vm.sh / switch_gpu_mode*.sh" + while IFS= read -r bdf; do + [[ -z "$bdf" ]] && continue + [[ "$bdf" == \#* ]] && continue + # KERNEL match expects the "0000:XX:YY.Z" form + local full="$bdf" + [[ "$full" != 0000:* ]] && full="0000:${full}" + echo "SUBSYSTEM==\"pci\", KERNEL==\"${full}\", ATTR{driver_override}=\"vfio-pci\"" + done < "$PROXMENUX_VFIO_BIND_STATE" + } +} + +# True when the udev rule on disk is the one the state file asks for, so a +# rule removed or edited by hand is written again on the next run. +_proxmenux_vfio_bind_udev_rule_current() { + if [[ ! -s "$PROXMENUX_VFIO_BIND_STATE" ]]; then + [[ ! -e "$PROXMENUX_VFIO_BIND_UDEV_RULE" ]] + return + fi + [[ -f "$PROXMENUX_VFIO_BIND_UDEV_RULE" ]] \ + && cmp -s <(_proxmenux_vfio_bind_render_udev_rule) "$PROXMENUX_VFIO_BIND_UDEV_RULE" +} + _proxmenux_vfio_bind_write_udev_rule() { # Always nuke the obsolete init-top hook from earlier attempts (if it # still exists) so a stale copy in initramfs can't run alongside the @@ -470,19 +498,7 @@ _proxmenux_vfio_bind_write_udev_rule() { fi mkdir -p "$(dirname "$PROXMENUX_VFIO_BIND_UDEV_RULE")" - { - echo "# ProxMenux: per-BDF VFIO driver override" - echo "# Auto-generated from $PROXMENUX_VFIO_BIND_STATE" - echo "# DO NOT EDIT MANUALLY — regenerated by add_gpu_vm.sh / switch_gpu_mode*.sh" - while IFS= read -r bdf; do - [[ -z "$bdf" ]] && continue - [[ "$bdf" == \#* ]] && continue - # KERNEL match expects the "0000:XX:YY.Z" form - local full="$bdf" - [[ "$full" != 0000:* ]] && full="0000:${full}" - echo "SUBSYSTEM==\"pci\", KERNEL==\"${full}\", ATTR{driver_override}=\"vfio-pci\"" - done < "$PROXMENUX_VFIO_BIND_STATE" - } > "$PROXMENUX_VFIO_BIND_UDEV_RULE" + _proxmenux_vfio_bind_render_udev_rule > "$PROXMENUX_VFIO_BIND_UDEV_RULE" udevadm control --reload-rules >/dev/null 2>&1 || true } @@ -549,7 +565,7 @@ _proxmenux_vfio_bind_add_bdfs() { changed=true fi done - if $changed; then + if $changed || ! _proxmenux_vfio_bind_udev_rule_current; then _proxmenux_vfio_bind_write_udev_rule _proxmenux_nvidia_vfio_policy_sync || true _proxmenux_mark_host_config_changed diff --git a/scripts/global/repository_policy.py b/scripts/global/repository_policy.py index fa0d01b2..7e0dca70 100644 --- a/scripts/global/repository_policy.py +++ b/scripts/global/repository_policy.py @@ -96,20 +96,29 @@ def evaluate(suite, apply=False): pve = [] ceph = [] debian = False + wrong_pve = [] + wrong_ceph = [] for path, index, row in entries: if not row['Enabled'] or 'deb' not in row['Types']: continue components = set(row['Components']) if components & PVE_CHANNELS: if suite not in row['Suites']: - raise ValueError('PVE repository suite does not match the installed version; no repository changed.') + wrong_pve.append(f"{' '.join(sorted(components & PVE_CHANNELS))} in {path} (suite {' '.join(row['Suites'])})") pve.append((path, index, row)) if 'main' in components and suite in row['Suites'] and not components & PVE_CHANNELS: debian = True if 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']): if suite not in row['Suites']: - raise ValueError('Enterprise Ceph repository suite does not match this PVE version; correct it in the Proxmox repository UI before switching.') + wrong_ceph.append(f"{path} (suite {' '.join(row['Suites'])})") 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 Proxmox VE on {suite}, but these Proxmox VE repositories are for another suite: " + f"{'; '.join(wrong_pve)}. Disable or remove them in Node > Updates > Repositories and run the update again; no repository changed.") + 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.") if any(set(row['Components']) & {'pve-no-subscription', 'pve-test', 'pvetest'} for _, _, row in pve): return 'preserve' diff --git a/scripts/gpu_tpu/add_gpu_vm.sh b/scripts/gpu_tpu/add_gpu_vm.sh index a1b44238..bbbb194a 100644 --- a/scripts/gpu_tpu/add_gpu_vm.sh +++ b/scripts/gpu_tpu/add_gpu_vm.sh @@ -339,6 +339,11 @@ evaluate_host_reboot_requirement() { needs_change=true fi done + # The state alone is not enough: its udev rule may have been removed. + if declare -F _proxmenux_vfio_bind_udev_rule_current >/dev/null 2>&1 \ + && ! _proxmenux_vfio_bind_udev_rule_current; then + needs_change=true + fi _file_has_exact_line "softdep nvidia pre: vfio-pci" "$vfio_conf" || needs_change=true _file_has_exact_line "softdep nvidia_drm pre: vfio-pci" "$vfio_conf" || needs_change=true _file_has_exact_line "softdep nvidia_modeset pre: vfio-pci" "$vfio_conf" || needs_change=true diff --git a/scripts/gpu_tpu/tests/test_nvidia_per_bdf_state.sh b/scripts/gpu_tpu/tests/test_nvidia_per_bdf_state.sh index 38e2e5dd..fdb7c331 100644 --- a/scripts/gpu_tpu/tests/test_nvidia_per_bdf_state.sh +++ b/scripts/gpu_tpu/tests/test_nvidia_per_bdf_state.sh @@ -67,6 +67,28 @@ grep -q 'ids=8086:7ad0' "$PROXMENUX_VFIO_CONF" ! grep -qi '10de:228b' "$PROXMENUX_VFIO_CONF" _proxmenux_all_nvidia_in_vfio [[ -f "$PROXMENUX_NVIDIA_VFIO_BLACKLIST" ]] +_proxmenux_vfio_bind_udev_rule_current + +# A rule removed by hand while the state still lists the GPU is written again +# when the GPU is added once more, and the host is marked for a reboot. +rm -f "$PROXMENUX_VFIO_BIND_UDEV_RULE" +! _proxmenux_vfio_bind_udev_rule_current +HOST_CONFIG_CHANGED=false +_proxmenux_vfio_bind_add_bdfs 0000:01:00.0 0000:01:00.1 +grep -qF 'KERNEL=="0000:01:00.0"' "$PROXMENUX_VFIO_BIND_UDEV_RULE" +grep -qF 'KERNEL=="0000:02:00.1"' "$PROXMENUX_VFIO_BIND_UDEV_RULE" +_proxmenux_vfio_bind_udev_rule_current +[[ "$HOST_CONFIG_CHANGED" == true ]] + +# A rule that already matches the state is left alone. +HOST_CONFIG_CHANGED=false +_proxmenux_vfio_bind_add_bdfs 0000:01:00.0 +[[ "$HOST_CONFIG_CHANGED" == false ]] + +# A rule edited by hand is replaced by the one the state asks for. +echo '# edited' >> "$PROXMENUX_VFIO_BIND_UDEV_RULE" +_proxmenux_vfio_bind_add_bdfs 0000:01:00.0 +! grep -qF '# edited' "$PROXMENUX_VFIO_BIND_UDEV_RULE" # Returning only one GPU to the host must keep the other exact BDF in VFIO, # remove the global NVIDIA blacklist, retain NVIDIA softdeps, and restore any diff --git a/scripts/lxc/apply_updates.sh b/scripts/lxc/apply_updates.sh index bf2728f6..5737e66b 100755 --- a/scripts/lxc/apply_updates.sh +++ b/scripts/lxc/apply_updates.sh @@ -63,6 +63,13 @@ ALLOW_HELPER_WITH_CUSTOM="${ALLOW_HELPER_WITH_CUSTOM:-0}" DOCKER_STANDALONE_TARGETS="${DOCKER_STANDALONE_TARGETS:-}" UPDATE_DOCKER_ENGINE="${UPDATE_DOCKER_ENGINE:-0}" +# Reject an abandoned local-only mode rather than silently installing if an +# older launcher still exports it. The check now has its own non-install job. +if [[ "${CHECK_APPLY_OS:-0}" != "0" ]]; then + echo "ERROR: combined check-and-update was removed; run the explicit package check first." >&2 + exit 4 +fi + if [[ ! "$VMID" =~ ^[1-9][0-9]*$ ]]; then echo "ERROR: VMID must be a positive integer." >&2 exit 1 @@ -117,12 +124,26 @@ extract_helper_slug_from_wrapper() { # One update per CT at a time, regardless of whether it came from the # UI or the scheduler. The descriptor remains open for this process. LOCK_DIR="${PROXMENUX_LOCK_DIR:-/run/lock}" -exec 9>"${LOCK_DIR}/proxmenux-lxc-update-${VMID}.lock" +exec 9>>"${LOCK_DIR}/proxmenux-lxc-update-${VMID}.lock" if ! flock -n 9; then echo "ERROR: another ProxMenux update is already running for CT $VMID." >&2 exit 7 fi +if [[ -s "${LOCK_DIR}/proxmenux-lxc-update-${VMID}.lock" ]]; then + if ! python3 -c 'import sys; sys.path.insert(0, "/usr/local/share/proxmenux/monitor-app/usr/bin"); import lxc_check_job; lxc_check_job.check_control_task(int(sys.argv[1]))' "$VMID"; then + echo "ERROR: container control completion is not confirmed; nothing was changed." >&2 + exit 7 + fi +fi + +if [[ "${REQUIRE_CHECKED_OS:-0}" == "1" ]]; then + if ! python3 -c 'import sys; sys.path.insert(0, "/usr/local/share/proxmenux/monitor-app/usr/bin"); import lxc_check_job; lxc_check_job.checked_os(int(sys.argv[1]))' "$VMID"; then + echo "ERROR: a fresh successful positive OS check is required; nothing was installed." >&2 + exit 4 + fi +fi + STARTED_AT=$(date -Iseconds) NODE=$(hostname) echo "=== ProxMenux LXC update — CT $VMID on $NODE ===" diff --git a/scripts/lxc/install_checked_os_updates.sh b/scripts/lxc/install_checked_os_updates.sh new file mode 100644 index 00000000..fbb5c8f2 --- /dev/null +++ b/scripts/lxc/install_checked_os_updates.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Install OS packages only after a valid positive check. The shared runner +# verifies that precondition under its CT lock before any lifecycle effect. +export REQUIRE_CHECKED_OS=1 TARGET=os RUN_HELPER=0 UPDATE_COMMAND="" +export ALLOW_HELPER_WITH_CUSTOM=0 DOCKER_STANDALONE_TARGETS="" UPDATE_DOCKER_ENGINE=0 +exec /bin/bash "$(dirname -- "$(readlink -f -- "$0")")/apply_updates.sh" diff --git a/scripts/menus/main_menu.sh b/scripts/menus/main_menu.sh index e2e0c7dc..fb3d806d 100644 --- a/scripts/menus/main_menu.sh +++ b/scripts/menus/main_menu.sh @@ -73,7 +73,7 @@ show_menu() { 4 "$(translate "Disk Manager")" \ 5 "$(translate "Storage & Share Manager")" \ 6 "$(translate "Proxmox VE Helper Scripts")" \ - o "$(translate "OCI manager Apps (beta)")" \ + o "$(translate "OCI manager Apps (Technology Preview)")" \ 7 "$(translate "Network Management")" \ 8 "$(translate "Security")" \ 9 "$(translate "Utilities and Tools")" \ diff --git a/scripts/menus/search_index.json b/scripts/menus/search_index.json index 749caef2..e74677bc 100644 --- a/scripts/menus/search_index.json +++ b/scripts/menus/search_index.json @@ -36,8 +36,8 @@ "keywords": "" }, { - "title": "OCI manager Apps (beta)", - "menu": "OCI manager Apps (beta)", + "title": "OCI manager Apps (Technology Preview)", + "menu": "OCI manager Apps (Technology Preview)", "script": "oci/oci_manager_apps.sh", "keywords": "docker container image contenedor imagen apps" }, diff --git a/scripts/oci/oci_manager_apps.sh b/scripts/oci/oci_manager_apps.sh index 1460d8b6..2e0f813f 100755 --- a/scripts/oci/oci_manager_apps.sh +++ b/scripts/oci/oci_manager_apps.sh @@ -1,6 +1,6 @@ #!/bin/bash # ========================================================== -# ProxMenux - OCI manager Apps (beta) +# ProxMenux - OCI manager Apps (Technology Preview) # ========================================================== # Author : MacRimi # Copyright : (c) 2024 MacRimi @@ -37,7 +37,7 @@ ensure_oci_dependencies() { [[ ${#missing[@]} -eq 0 ]] && return 0 show_proxmenux_logo - msg_title "$(translate "OCI manager Apps (beta)")" + msg_title "$(translate "OCI manager Apps (Technology Preview)")" msg_info "$(translate "Installing required packages...")" apt-get update >/dev/null 2>&1 local status @@ -56,6 +56,31 @@ ensure_oci_dependencies() { stop_spinner } +# Proxmox VE runs OCI images as LXC containers from 9.1 on. +pve_supports_oci() { + local version major minor + version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1) + major=${version%%.*} + minor=${version#*.} + [[ $major =~ ^[0-9]+$ && $minor =~ ^[0-9]+$ ]] || return 1 + (( major > 9 || (major == 9 && minor >= 1) )) +} + +if ! pve_supports_oci; then + show_proxmenux_logo + msg_title "$(translate "OCI manager Apps (Technology Preview)")" + msg_error "$(translate "OCI containers require Proxmox VE 9.1 or later.")" + PVE_VERSION=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9.]+' | head -1) + [[ -n $PVE_VERSION ]] && msg_info2 "$(translate "Current version:") $PVE_VERSION" + if [[ -t 0 ]]; then + echo + msg_success "$(translate "Press Enter to return to the menu...")" + read -r + [[ $# -eq 0 ]] && exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh" + fi + exit 1 +fi + if [[ ! -f "$OCI_ENGINE_DIR/src/proxmenux_oci/__main__.py" ]]; then msg_error "$(translate "The OCI engine is not installed. Update ProxMenux and try again.")" exit 1 diff --git a/scripts/vm/select_linux_iso.sh b/scripts/vm/select_linux_iso.sh index 97bee392..8b0116b1 100644 --- a/scripts/vm/select_linux_iso.sh +++ b/scripts/vm/select_linux_iso.sh @@ -113,10 +113,10 @@ function select_linux_iso_official() { "Ubuntu 24.04 Server|CLI|ProxMenux|https://releases.ubuntu.com/24.04/ubuntu-24.04.4-live-server-amd64.iso" "Ubuntu 22.04 Server|CLI|ProxMenux|https://releases.ubuntu.com/22.04/ubuntu-22.04.5-live-server-amd64.iso" "Ubuntu 20.04 Server|CLI|ProxMenux|https://releases.ubuntu.com/20.04/ubuntu-20.04.6-live-server-amd64.iso" - "Debian 13|Desktop|ProxMenux|https://cdimage.debian.org/debian-cd/current/amd64/iso-dvd/debian-13.4.0-amd64-DVD-1.iso" + "Debian 13|Desktop|ProxMenux|https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-dvd/debian-13.6.0-amd64-DVD-1.iso" "Debian 12|Desktop|ProxMenux|https://cdimage.debian.org/cdimage/archive/12.13.0/amd64/iso-dvd/debian-12.13.0-amd64-DVD-1.iso" "Debian 11|Desktop|ProxMenux|https://cdimage.debian.org/cdimage/archive/11.11.0/amd64/iso-dvd/debian-11.11.0-amd64-DVD-1.iso" - "Debian 13 Netinst|CLI|ProxMenux|https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-13.4.0-amd64-netinst.iso" + "Debian 13 Netinst|CLI|ProxMenux|https://cdimage.debian.org/cdimage/archive/13.6.0/amd64/iso-cd/debian-13.6.0-amd64-netinst.iso" "Debian 12 Netinst|CLI|ProxMenux|https://cdimage.debian.org/cdimage/archive/12.13.0/amd64/iso-cd/debian-12.13.0-amd64-netinst.iso" "Debian 11 Netinst|CLI|ProxMenux|https://cdimage.debian.org/cdimage/archive/11.11.0/amd64/iso-cd/debian-11.11.0-amd64-netinst.iso" "Fedora Workstation 42|Desktop|ProxMenux|https://download.fedoraproject.org/pub/fedora/linux/releases/42/Workstation/x86_64/iso/Fedora-Workstation-Live-42-1.1.x86_64.iso" @@ -170,18 +170,15 @@ function select_linux_iso_official() { function select_linux_cloudinit() { + # The maintained Debian and Ubuntu helpers select their own release. local CLOUDINIT_OPTIONS=( - "1" "Arch Linux (Cloud-Init automated) │ Helper-Scripts" - "2" "Debian 13 (Cloud-Init automated) │ Helper-Scripts" - "3" "Debian 12 (Cloud-Init automated) │ Helper-Scripts" - "4" "Ubuntu 22.04 (Cloud-Init automated) │ Helper-Scripts" - "5" "Ubuntu 24.04 (Cloud-Init automated) │ Helper-Scripts" - "6" "Ubuntu 24.10 (Cloud-Init automated) │ Helper-Scripts" - "7" "Ubuntu 25.04 (Cloud-Init automated) │ Helper-Scripts" - "8" "$(translate "Return to Main Menu")" + "1" "Arch Linux (Cloud-Init automated) │ Helper-Scripts" + "2" "Debian (Cloud-Init automated) │ Helper-Scripts" + "3" "Ubuntu (Cloud-Init automated) │ Helper-Scripts" + "4" "$(translate "Return to Main Menu")" ) - local script_selection + local script_selection script_url script_content script_selection=$(dialog --backtitle "ProxMenux" --title "$(translate "Cloud-Init Automated Installers")" \ --menu "\n$(translate "Select a pre-configured Linux VM script to execute:")" 20 78 10 \ "${CLOUDINIT_OPTIONS[@]}" 3>&1 1>&2 2>&3) @@ -190,34 +187,29 @@ function select_linux_cloudinit() { case "$script_selection" in 1) - bash <(curl -s "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/archlinux-vm.sh") + script_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/archlinux-vm.sh" ;; 2) - bash <(curl -s "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/debian-13-vm.sh") + script_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/debian-vm.sh" ;; 3) - bash <(curl -s "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/debian-vm.sh") + script_url="https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu-vm.sh" ;; 4) - bash <(curl -s "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu2204-vm.sh") - ;; - 5) - bash <(curl -s "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu2404-vm.sh") - ;; - 6) - bash <(curl -s "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu2410-vm.sh") - ;; - 7) - bash <(curl -s "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/ubuntu2504-vm.sh") - echo -e - echo -e "after installation, checkout:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/272" - echo -e - ;; - 8) return ;; + *) + return 1 + ;; esac + # Do not execute a failed or partial download through process substitution. + if ! script_content=$(curl -fsSL --connect-timeout 5 --max-time 60 "$script_url") || [[ -z "${script_content//[[:space:]]/}" ]]; then + dialog --title "Helper Scripts" --msgbox "$(translate "Error: Failed to download the script.")" 8 70 + return 1 + fi + bash -c "$script_content" || return $? + msg_success "$(translate "Press Enter to return to menu...")" read -r diff --git a/scripts/vm/select_nas_iso.sh b/scripts/vm/select_nas_iso.sh index d45b8b76..53d0828f 100644 --- a/scripts/vm/select_nas_iso.sh +++ b/scripts/vm/select_nas_iso.sh @@ -100,7 +100,7 @@ function resolve_truenas_core_iso() { } function resolve_omv_iso() { - local default_ver="8.1.1" + local default_ver="8.3.1" local detected_ver="" if _has_curl; then @@ -157,13 +157,14 @@ function resolve_xigmanas_iso() { } function resolve_rockstor_iso() { - local default_file="Rockstor-Leap15.6-generic.x86_64-5.0.15-0.install.iso" + local default_file="Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso" local detected_file="" local base_url="https://rockstor.com/downloads/installer/leap/15.6/x86_64" if _has_curl; then detected_file=$( - curl -fsSL "${base_url}/" 2>/dev/null \ + # The installer directory forbids listings; use the official downloads page. + curl -fsSL "https://rockstor.com/dls/index.html" 2>/dev/null \ | grep -Eo 'Rockstor-Leap15\.6-generic\.x86_64-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+\.install\.iso' \ | _latest_version_from_lines ) diff --git a/scripts/vm/vm_creator.sh b/scripts/vm/vm_creator.sh index 19bbc47f..f81e84c6 100644 --- a/scripts/vm/vm_creator.sh +++ b/scripts/vm/vm_creator.sh @@ -259,6 +259,25 @@ function run_gpu_passthrough_wizard() { +# Download beside the destination, publishing only a complete non-empty file. +function download_vm_iso() { + local iso_url="$1" iso_path="$2" + local staging_dir download_status=1 + local -a wget_options=(--no-verbose --show-progress) + [[ "$iso_url" == *"sourceforge.net"* ]] && wget_options+=(--content-disposition) + + staging_dir=$(mktemp -d "${iso_path}.part.XXXXXX") || return 1 + if wget "${wget_options[@]}" -O "$staging_dir/media.iso" "$iso_url" \ + && [[ -s "$staging_dir/media.iso" ]] \ + && { [[ ! -f "$iso_path" ]] || chmod --reference="$iso_path" "$staging_dir/media.iso"; } \ + && mv -fT -- "$staging_dir/media.iso" "$iso_path"; then + download_status=0 + fi + rm -f -- "$staging_dir/media.iso" + rmdir -- "$staging_dir" || return 1 + return "$download_status" +} + # ========================================================== # Create VM # ========================================================== @@ -269,18 +288,9 @@ function create_vm() { local ISO_DIR="/var/lib/vz/template/iso" - if [[ -n "$ISO_PATH" && -n "$ISO_URL" && ! -f "$ISO_PATH" ]]; then + if [[ -n "$ISO_PATH" && -n "$ISO_URL" ]] && [[ ! -f "$ISO_PATH" || ! -s "$ISO_PATH" ]]; then - if [[ "$ISO_URL" == *"sourceforge.net"* ]]; then - - wget --content-disposition --show-progress -O "$ISO_PATH" "$ISO_URL" - else - - wget --no-verbose --show-progress -O "$ISO_PATH" "$ISO_URL" - fi - - - if [[ -f "$ISO_PATH" ]]; then + if download_vm_iso "$ISO_URL" "$ISO_PATH"; then ISO_VOLID="${ISO_VOLID:-local:iso/$(basename "$ISO_PATH")}" msg_ok "$(translate "ISO image downloaded")" else @@ -662,21 +672,13 @@ fi case "$VIRTIO_OPTION" in 1) - if [[ -f "$VIRTIO_DIR/virtio-win.iso" ]]; then - if whiptail --title "ProxMenux" --yesno "$(translate "A VirtIO ISO already exists. Do you want to overwrite it?")" 10 60; then - wget -q --show-progress -O "$VIRTIO_DIR/virtio-win.iso" "$VIRTIO_DOWNLOAD_URL" - if [[ -f "$VIRTIO_DIR/virtio-win.iso" ]]; then - msg_ok "$(translate "VirtIO driver ISO downloaded successfully.")" - else - msg_error "$(translate "Failed to download VirtIO driver ISO.")" - fi - fi - else - wget -q --show-progress -O "$VIRTIO_DIR/virtio-win.iso" "$VIRTIO_DOWNLOAD_URL" - if [[ -f "$VIRTIO_DIR/virtio-win.iso" ]]; then + if [[ ! -f "$VIRTIO_DIR/virtio-win.iso" || ! -s "$VIRTIO_DIR/virtio-win.iso" ]] \ + || whiptail --title "ProxMenux" --yesno "$(translate "A VirtIO ISO already exists. Do you want to overwrite it?")" 10 60; then + if download_vm_iso "$VIRTIO_DOWNLOAD_URL" "$VIRTIO_DIR/virtio-win.iso"; then msg_ok "$(translate "VirtIO driver ISO downloaded successfully.")" else msg_error "$(translate "Failed to download VirtIO driver ISO.")" + continue fi fi diff --git a/tests/lxc_updates/test_apply_updates.sh b/tests/lxc_updates/test_apply_updates.sh index 728fb108..8b2ed500 100644 --- a/tests/lxc_updates/test_apply_updates.sh +++ b/tests/lxc_updates/test_apply_updates.sh @@ -67,6 +67,10 @@ EOF cat >"$MOCK_BIN/python3" <<'EOF' #!/bin/bash cat >/dev/null +if [[ "${1:-}" == '-c' ]]; then + [[ "${MOCK_CHECK_FAIL:-0}" != '1' ]] + exit $? +fi if [[ "${2:-}" == 'protect-update-command' ]]; then printf '%s' "$UPDATE_COMMAND" exit 0 @@ -82,6 +86,7 @@ EOF cat >"$MOCK_BIN/vzdump" <<'EOF' #!/bin/bash echo "vzdump $*" >>"$MOCK_LOG" +[[ "${MOCK_BACKUP_FAIL:-0}" != '1' ]] EOF chmod +x "$MOCK_BIN"/* @@ -161,4 +166,18 @@ run_case locked RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_FLOCK_FAIL=1 [[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC" [[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater" +run_case checked_missing TARGET=os REQUIRE_CHECKED_OS=1 MOCK_CHECK_FAIL=1 +[[ $CASE_RC -eq 4 ]] || fail "unchecked installation was not rejected" +[[ $(count 'pct ') -eq 0 ]] || fail "unchecked installation probed or started a CT" +[[ $(count OS_EXEC) -eq 0 ]] || fail "unchecked installation upgraded packages" + +run_case checked_backup_fail TARGET=os REQUIRE_CHECKED_OS=1 MOCK_INITIAL_STATE=stopped BACKUP=1 BACKUP_STORAGE=local MOCK_BACKUP_FAIL=1 +[[ $CASE_RC -eq 3 ]] || fail "backup failure was not reported" +[[ $(count OS_EXEC) -eq 0 ]] || fail "backup failure upgraded packages" +[[ $(count 'pct shutdown 101 --timeout 60') -eq 1 ]] || fail "backup failure did not restore stopped state" + +run_case checked_positive TARGET=os REQUIRE_CHECKED_OS=1 +[[ $CASE_RC -eq 0 ]] || fail "positive checked installation failed" +[[ $(count OS_EXEC) -gt 0 ]] || fail "positive checked installation did not use existing OS path" + echo "apply_updates.sh: all deterministic tests passed" diff --git a/tests/lxc_updates/test_check_only_job.py b/tests/lxc_updates/test_check_only_job.py new file mode 100644 index 00000000..2953ac57 --- /dev/null +++ b/tests/lxc_updates/test_check_only_job.py @@ -0,0 +1,348 @@ +"""Real job, Flask routes and shared flock; guest transport is isolated.""" +import ast +import datetime +import os +import subprocess +import sys +import tempfile +import threading +import time +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest.mock import Mock, patch + +from flask import Flask, jsonify +from flask import request + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'AppImage/scripts')) +import lxc_check_job as job + + +class CheckOnlyTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.folder = Path(self.temp.name) + self.conf = self.folder / '100.conf' + self.conf.write_text('hostname: fixture\n') + self.patches = [patch.object(job, 'LOCK_DIR', self.folder), + patch.object(job, 'CONF_DIR', self.folder), + patch.object(job, 'OCI_REGISTRY', self.folder / 'installed.json'), + patch.object(job.managed_installs, '_lxc_updates_detection_enabled', return_value=True), + patch.object(job.managed_installs, '_get_oci_managed_vmids', return_value={}), + patch.object(job.managed_installs, 'get_active_items', return_value=[])] + for p in self.patches: + p.start() + self.addCleanup(p.stop) + job._jobs.clear() + job._attempts.clear() + self.state = 'stopped' + self.calls = [] + self.result = {'result': {'available': True, '_count': 2}, 'entry': {}} + + def transport(self, vmid, action, *args, **kwargs): + self.calls.append(action) + if action == 'status': + return 'status: ' + self.state + if action == 'start': + self.state = 'running' + if action in ('stop', 'shutdown'): + self.state = 'stopped' + return '' + + def run_check(self, capture_error=None, persistence_error=None, transport=None): + reservation = job.acquire(100) + job._jobs['run'] = dict(vmid=100, state='running', created=time.monotonic()) + with patch.object(job, 'command', side_effect=transport or self.transport), \ + patch.object(job.lxc_update_observation, 'capture', return_value=self.result, + side_effect=capture_error) as capture, \ + patch.object(job.lxc_update_observation, 'discard'), \ + patch.object(job.managed_installs, 'store_lxc_update_observation', + return_value=True, side_effect=persistence_error) as store: + job.run(100, 'run', reservation) + return job.get(100, 'run'), capture, store + + def test_stopped_check_is_rejected_before_a_job_or_guest_mutation(self): + with patch.object(job, 'command', side_effect=self.transport): + with self.assertRaisesRegex(RuntimeError, 'start it first'): + job.start(100) + self.assertEqual(self.state, 'stopped') + self.assertEqual(self.calls, ['status']) + self.assertEqual(job._jobs, {}) + self.assertEqual(job._attempts, {}) + self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists()) + reservation = job.acquire(100) + reservation.close() + + def test_running_check_has_no_lifecycle_effect(self): + self.state = 'running' + result, _, _ = self.run_check() + self.assertEqual(result['state'], 'complete') + self.assertEqual(self.calls, ['status']) + self.assertEqual(self.state, 'running') + self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists()) + + def test_successful_running_check_clears_an_older_failure_marker(self): + self.state = 'running' + (self.folder / 'proxmenux-lxc-check-invalid-100').touch() + result, _, _ = self.run_check() + self.assertEqual(result['state'], 'complete') + self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists()) + + def test_marker_cleanup_failure_reports_failure_and_releases_the_operation_lock(self): + self.state = 'running' + with patch.object(job, 'clear_invalid_marker', return_value=False): + result, _, _ = self.run_check() + self.assertEqual(result['state'], 'failed') + self.assertIn('could not be published', result['error']) + reservation = job.acquire(100) + reservation.close() + + def test_marker_created_after_a_check_started_is_not_cleared(self): + marker = self.folder / 'proxmenux-lxc-check-invalid-100' + marker.touch() + os.utime(marker, ns=(2_000, 2_000)) + + self.assertFalse(job.clear_invalid_marker(100, check_started_ns=1_000)) + self.assertTrue(marker.exists()) + + def test_already_cleared_marker_is_a_successful_publish(self): + self.assertTrue(job.clear_invalid_marker(100)) + + def test_index_read_timeout_and_persistence_errors_keep_running_guest_running(self): + self.state = 'running' + for error in [RuntimeError('index failed'), OSError('read failed'), + subprocess.TimeoutExpired('package listing', 30)]: + with self.subTest(error=type(error).__name__): + result, _, _ = self.run_check(capture_error=error) + self.assertEqual(result['state'], 'failed') + self.assertEqual(self.state, 'running') + result, _, _ = self.run_check(persistence_error=OSError('disk full')) + self.assertEqual(result['state'], 'failed') + self.assertEqual(self.state, 'running') + self.assertIn('could not be persisted', result['error']) + + def test_unexpected_stopped_worker_never_starts_or_probes_the_guest(self): + result, capture, store = self.run_check() + self.assertEqual(result['state'], 'failed') + self.assertEqual(self.state, 'stopped') + self.assertEqual(self.calls, ['status']) + capture.assert_not_called() + store.assert_not_called() + self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists()) + + def test_unknown_status_does_not_start(self): + self.state = 'unknown' + result, capture, _ = self.run_check() + self.assertEqual(result['state'], 'failed') + self.assertEqual(self.calls, ['status']) + capture.assert_not_called() + + def test_guard_oci_config_lock_and_disabled_forbid_all_guest_calls(self): + for config in ['lxc.environment.runtime: OCI=1\n', 'lock: backup\n']: + self.conf.write_text(config) + result, capture, _ = self.run_check() + self.assertEqual(result['state'], 'failed') + self.assertEqual(self.calls, []) + capture.assert_not_called() + self.conf.unlink() + result, capture, _ = self.run_check() + self.assertEqual(result['state'], 'failed') + self.assertEqual(self.calls, []) + capture.assert_not_called() + self.conf.write_text('hostname: fixture\n') + with patch.object(job.managed_installs, '_lxc_updates_detection_enabled', return_value=False): + result, capture, _ = self.run_check() + self.assertEqual(self.calls, []) + capture.assert_not_called() + + def test_registered_oci_is_excluded_before_probe(self): + with patch.object(job.managed_installs, '_get_oci_managed_vmids', return_value={'100': 'fixture'}): + result, capture, _ = self.run_check() + self.assertEqual(result['state'], 'failed') + self.assertEqual(self.calls, []) + capture.assert_not_called() + + def test_unreadable_or_invalid_oci_registry_never_means_non_oci(self): + for content in ['broken', '{"instances": []}', '{"instances": {"app": {"vmid": 100}}}']: + job.OCI_REGISTRY.write_text(content) + result, capture, _ = self.run_check() + self.assertEqual(result['state'], 'failed') + self.assertEqual(self.calls, []) + capture.assert_not_called() + + def test_thread_start_failure_releases_lock_and_job(self): + with patch.object(job, 'status', return_value='running'), \ + patch.object(job.threading, 'Thread') as thread: + thread.return_value.start.side_effect = RuntimeError('cannot create worker') + with self.assertRaises(RuntimeError): + job.start(100) + self.assertEqual(job._jobs, {}) + self.assertEqual(job._attempts, {}) + reservation = job.acquire(100) + reservation.close() + + def test_corrupt_registry_is_preserved_and_installation_stays_blocked(self): + registry = self.folder / 'registry.json' + registry.write_text('malformed-but-must-not-be-overwritten') + with patch.object(job.managed_installs, '_REGISTRY_PATH', str(registry)): + result, _, _ = self.run_check(persistence_error=job.managed_installs.store_lxc_update_observation) + self.assertEqual(result['state'], 'failed') + self.assertEqual(self.state, 'stopped') + self.assertEqual(registry.read_text(), 'malformed-but-must-not-be-overwritten') + self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists()) + + def test_shared_lock_excludes_scheduler_and_manual_runner(self): + reservation = job.acquire(100) + self.addCleanup(reservation.close) + with self.assertRaises(BlockingIOError): + job.start(100) + process = subprocess.run(['/usr/bin/flock', '-n', + str(self.folder / 'proxmenux-lxc-update-100.lock'), '/bin/true']) + self.assertEqual(process.returncode, 1) + self.assertEqual(job._jobs, {}) + reservation.close() + other = job.acquire(100) + other.close() + + def test_cooldown_boundaries_and_inflight(self): + for elapsed, expected in [(59.999, 429), (60, 202), (60.001, 202)]: + job._jobs.clear() + job._attempts[100] = 100 + reservation = Mock() + with patch.object(job.time, 'monotonic', return_value=100 + elapsed), \ + patch.object(job.threading, 'Thread'), patch.object(job, 'acquire', return_value=reservation), \ + patch.object(job, 'status', return_value='running'): + result, code = job.start(100) + self.assertEqual(code, expected) + job._jobs['active'] = dict(vmid=100, state='running', created=100) + job._attempts[100] = 0 + with patch.object(job.time, 'monotonic', return_value=10000): + self.assertEqual(job.start(100)[1], 429) + + def test_poll_is_read_only_and_scoped_to_guest(self): + job._jobs['run'] = dict(vmid=100, state='running', error=None, created=1) + with patch.object(job, 'command') as command, patch.object(job, 'guard') as guard: + self.assertEqual(job.get(100, 'run')['state'], 'running') + self.assertIsNone(job.get(101, 'run')) + self.assertIsNone(job.get(100, 'missing')) + command.assert_not_called() + guard.assert_not_called() + + def test_install_gate_positive_zero_unknown_error_and_freshness(self): + now = datetime.datetime.now(datetime.timezone.utc) + for count, available, error, age, allowed in [ + (2, True, None, 60, True), (0, False, None, 60, False), + (None, True, None, 60, False), (2, True, 'failed', 60, False), + (2, True, None, 172799, True), (2, True, None, 172800, False), + (2, True, None, -60, False)]: + with self.subTest(count=count, age=age, error=error): + item = {'id': 'lxc:100', 'update_check': {'_count': count, + 'available': available, 'error': error, + 'last_check': (now - datetime.timedelta(seconds=age)).isoformat()}} + with patch.object(job.managed_installs, 'get_active_items', return_value=[item]): + if allowed: + job.checked_os(100) + else: + with self.assertRaises((RuntimeError, ValueError)): + job.checked_os(100) + + def test_pending_failure_marker_forbids_install_before_probe(self): + (self.folder / 'proxmenux-lxc-check-invalid-100').touch() + with self.assertRaises(RuntimeError): + job.checked_os(100) + + def test_real_capture_refreshes_lists_and_persists_without_installation(self): + for count, index_rc in [(0, 0), (2, 0), (2, 1)]: + with self.subTest(count=count, index_rc=index_rc): + self.state = 'running' + reservation = job.acquire(100) + job._jobs['real'] = dict(vmid=100, state='running', created=time.monotonic()) + entry = {'id': 'lxc:100', 'type': 'lxc', '_vmid': '100', '_os_family': 'debian'} + output = 'Listing...\n' + '\n'.join( + f'pkg{i}/stable 2.0 amd64 [upgradable from: 1.0]' for i in range(count)) + commands = [] + def transport(command, **kwargs): + commands.append(command) + return subprocess.CompletedProcess(command, index_rc if 'update' in command else 0, + output if 'list' in ' '.join(command) else '', '') + with patch.object(job, 'command', side_effect=self.transport), \ + patch.object(job.managed_installs, '_DB_DIR', str(self.folder)), \ + patch.object(job.managed_installs, '_REGISTRY_PATH', str(self.folder / 'registry.json')), \ + patch.object(job.lxc_update_observation, 'OBSERVATION_DIR', self.folder / 'observations'), \ + patch.object(job.managed_installs, '_detect_lxc_containers', return_value=[entry]), \ + patch.object(job.managed_installs, '_probe_lxc_os', return_value='debian'), \ + patch.object(job.managed_installs.subprocess, 'run', side_effect=transport): + job.run(100, 'real', reservation) + stored = job.managed_installs.get_registry()['items'][0]['update_check'] + self.assertEqual(self.state, 'running') + self.assertEqual(job.get(100, 'real')['state'], 'failed' if index_rc else 'complete') + self.assertTrue(any('update' in command for command in commands)) + self.assertFalse(any(any(word in command for word in ('upgrade', 'install', 'full-upgrade')) + for command in commands)) + if not index_rc: + self.assertEqual(stored['_count'], count) + else: + self.assertTrue(stored['error']) + self.assertFalse(any('apt list' in ' '.join(command) for command in commands)) + + def test_control_route_uses_same_lock_and_preserves_qemu(self): + app = Flask(__name__) + def control_result(argv, **_): + path = argv[2] + vm_type, vmid, action = path.split('/')[3], path.split('/')[4], path.rsplit('/', 1)[1] + task_type = ('vz' if vm_type == 'lxc' else 'qm') + action + return subprocess.CompletedProcess( + [], 0, f'"UPID:fixture:000A:000B:000C:{task_type}:{vmid}:root@pam:"\n', '') + command = Mock(side_effect=control_result) + scope = {'app': app, 'jsonify': jsonify, 'request': request, + 'require_auth': lambda fn: fn, + 'get_cached_pvesh_cluster_resources_vm': lambda: [ + {'vmid': 100, 'type': 'lxc', 'node': 'fixture'}, + {'vmid': 200, 'type': 'qemu', 'node': 'fixture'}], + 'subprocess': SimpleNamespace(run=command), '_pvesh_cache': {}, + '_vm_cache_invalidate': Mock()} + node = next(n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body + if isinstance(n, ast.FunctionDef) and n.name == 'api_vm_control') + exec(compile(ast.Module(body=[node], type_ignores=[]), 'route', 'exec'), scope) + client = app.test_client() + reservation = job.acquire(100) + try: + self.assertEqual(client.post('/api/vms/100/control', json={'action': 'stop'}).status_code, 409) + command.assert_not_called() + self.assertEqual(client.post('/api/vms/200/control', json={'action': 'reboot'}).status_code, 200) + finally: + reservation.close() + # The production waiter owns a long-lived reservation. Isolate its + # launch here so it cannot probe through another test's transport. + with patch.object(job.threading, 'Thread') as worker: + response = client.post('/api/vms/100/control', json={'action': 'start'}) + worker.return_value.start.assert_called_once_with() + retained = worker.call_args.kwargs['args'][1] + self.addCleanup(retained.close) + job.cancel_control(retained) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json['success']) + self.assertEqual(response.json['task'], 'UPID:fixture:000A:000B:000C:vzstart:100:root@pam:') + self.assertIn('/nodes/fixture/lxc/100/status/start', command.call_args.args[0]) + + def test_actual_routes_submit_poll_and_control_lock(self): + app = Flask(__name__) + scope = {'app': app, 'jsonify': jsonify, 'require_auth': lambda fn: fn, + '_vm_cache_invalidate': Mock(), '_lxc_os_info_cache': {}, + '_pvesh_cache': {}, '_get_lxc_update_status_map': lambda: {'100': {'count': 2}}} + names = {'api_lxc_updates_refresh_check', 'api_lxc_updates_refresh_check_status'} + nodes = [n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body + if isinstance(n, ast.FunctionDef) and n.name in names] + exec(compile(ast.Module(body=nodes, type_ignores=[]), 'routes', 'exec'), scope) + client = app.test_client() + with patch.object(job, 'start', return_value=({'job_id': 'run'}, 202)): + self.assertEqual(client.post('/api/vms/100/updates/refresh-check').status_code, 202) + job._jobs['run'] = dict(vmid=100, state='complete', error=None, created=1) + with patch.object(job, 'command') as command: + response = client.get('/api/vms/100/updates/refresh-check/run') + self.assertEqual(response.json['update_check']['count'], 2) + self.assertEqual(client.get('/api/vms/101/updates/refresh-check/run').status_code, 404) + command.assert_not_called() diff --git a/tests/lxc_updates/test_control_task_reservation.py b/tests/lxc_updates/test_control_task_reservation.py new file mode 100644 index 00000000..d09c5755 --- /dev/null +++ b/tests/lxc_updates/test_control_task_reservation.py @@ -0,0 +1,218 @@ +"""Actual Flask submission, real flock, isolated Proxmox reads and shell caller.""" +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +import test_vm_control_task as control_fixture + +TASK, ROOT = control_fixture.TASK, control_fixture.ROOT + +sys.path.insert(0, str(ROOT / 'AppImage/scripts')) +import lxc_check_job as job + + +class ControlReservationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.folder = Path(self.temp.name) + self.lock_path = self.folder / 'proxmenux-lxc-update-100.lock' + self.lock_patch = patch.object(job, 'LOCK_DIR', self.folder) + self.lock_patch.start() + self.addCleanup(self.lock_patch.stop) + job._jobs.clear() + job._attempts.clear() + self.route = control_fixture.ControlTaskTests() + self.route.setUp() + self.thread = Mock() + self.factory = patch.object(job.threading, 'Thread', return_value=self.thread) + self.thread_factory = self.factory.start() + self.addCleanup(self.factory.stop) + self.reservations = [] + self.addCleanup(lambda: [handle.close() for handle in self.reservations if not handle.closed]) + + def accepted(self, action='reboot'): + task = TASK.replace('vzreboot', 'vz' + action) + self.route.run.return_value = self.route.result(task) + response = self.route.client.post('/api/vms/100/control', json={'action': action}) + self.assertEqual(response.status_code, 200, response.json) + handle = self.thread_factory.call_args.kwargs['args'][1] + self.reservations.append(handle) + self.assertEqual(response.json['task'], task) + return handle + + @staticmethod + def result(payload, code=0): + return subprocess.CompletedProcess([], code, json.dumps(payload), '') + + def test_every_power_action_keeps_reservation_after_post_and_blocks_second_caller(self): + for action in ('start', 'shutdown', 'reboot', 'stop'): + with self.subTest(action=action): + handle = self.accepted(action) + self.route.run.reset_mock() + response = self.route.client.post('/api/vms/100/control', json={'action': 'start'}) + self.assertEqual(response.status_code, 409) + self.route.run.assert_not_called() + with self.assertRaises(BlockingIOError): + job.start(100) + self.assertFalse(handle.closed) + job.cancel_control(handle) + handle.close() + + def test_waiter_survives_read_failure_and_running_until_confirmed_terminal_status(self): + handle = self.accepted() + probes = [subprocess.TimeoutExpired('pvesh', 5), + self.result({'status': 'running'}), self.result({'status': 'stopped'}), + self.result({'status': 'stopped', 'exitstatus': 'OK'})] + def still_reserved(_): + with self.assertRaises(BlockingIOError): + job.acquire(100) + self.assertFalse(handle.closed) + with patch.object(job.subprocess, 'run', side_effect=probes) as reads, \ + patch.object(job.time, 'sleep', side_effect=still_reserved) as sleeps: + job._wait_control(100, handle) + self.assertEqual(sleeps.call_count, 3) + self.assertTrue(handle.closed) + self.assertEqual(self.lock_path.read_text(), '') + self.assertTrue(all(call.args[0][:2] == ['pvesh', 'get'] for call in reads.call_args_list)) + job.acquire(100).close() + + def test_restart_releases_fd_but_marker_blocks_until_pve_verifies_completion(self): + handle = self.accepted() + handle.close() # simulate Monitor shutdown, not guest task termination + with patch.object(job.subprocess, 'run', return_value=self.result({'status': 'running'})): + with self.assertRaises(BlockingIOError): + job.acquire(100) + with patch.object(job.subprocess, 'run', return_value=self.result( + {'status': 'stopped', 'exitstatus': 'start failed'})): + job.acquire(100).close() + self.assertEqual(self.lock_path.read_text(), '') + + def test_unknown_or_malformed_records_make_no_pve_probe_and_keep_blocked(self): + for content in ['not-json', '{}', json.dumps({'node': 'fixture', 'task': None}), + json.dumps({'node': 'other', 'task': TASK}), + json.dumps({'node': 'fixture', 'task': TASK.replace(':100:', ':200:')}), + 'x' * 1025]: + self.lock_path.write_text(content) + with patch.object(job.subprocess, 'run') as probe: + with self.assertRaises(BlockingIOError): + job.acquire(100) + probe.assert_not_called() + self.assertEqual(self.lock_path.read_text(), content) + + def test_plain_lock_acquire_has_no_new_probe(self): + with patch.object(job.subprocess, 'run') as probe: + job.acquire(100).close() + probe.assert_not_called() + + def test_definite_failure_or_unidentified_submission_never_leaves_a_permanent_reservation(self): + self.route.run.return_value = self.result({}, code=1) + self.assertEqual(self.route.client.post('/api/vms/100/control', json={'action': 'start'}).status_code, 500) + self.assertEqual(self.lock_path.read_text(), '') + for response in [subprocess.TimeoutExpired('pvesh', 30), self.result('')]: + self.route.run.side_effect = response if isinstance(response, Exception) else None + self.route.run.return_value = response + response = self.route.client.post('/api/vms/100/control', json={'action': 'start'}) + self.assertIn(response.status_code, (500, 502), response.json) + self.assertEqual(self.lock_path.read_text(), '') + job.acquire(100).close() + + def test_ha_task_recovers_like_a_native_lxc_task(self): + task = TASK.replace('vzreboot', 'hastop') + self.lock_path.write_text(json.dumps({'node': 'fixture', 'task': task})) + with patch.object(job.subprocess, 'run', return_value=self.result( + {'status': 'stopped', 'exitstatus': 'OK'})) as probe: + job.acquire(100).close() + self.assertEqual(probe.call_count, 1) + self.assertEqual(self.lock_path.read_text(), '') + + def test_wait_deadline_releases_fd_but_keeps_known_task_for_recovery(self): + handle = self.accepted() + with patch.object(job, 'CONTROL_TASK_WAIT_SECONDS', 0): + job._wait_control(100, handle) + self.assertTrue(handle.closed) + record = json.loads(self.lock_path.read_text()) + self.assertEqual(record['task'], TASK) + with patch.object(job.subprocess, 'run', return_value=self.result( + {'status': 'stopped', 'exitstatus': 'OK'})): + job.acquire(100).close() + self.assertEqual(self.lock_path.read_text(), '') + + def test_thread_launch_failure_retains_marker_for_recovery(self): + self.thread.start.side_effect = RuntimeError('thread failed') + self.route.run.return_value = self.route.result(TASK) + self.assertEqual(self.route.client.post('/api/vms/100/control', json={'action': 'reboot'}).status_code, 500) + self.assertEqual(json.loads(self.lock_path.read_text())['task'], TASK) + with patch.object(job.subprocess, 'run', return_value=self.result({'status': 'running'})): + with self.assertRaises(BlockingIOError): + job.acquire(100) + + def test_failed_task_record_write_keeps_uncertainty_before_any_next_operation(self): + original = job._write_control + calls = 0 + def fail_second(handle, record): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError('persistence failed') + original(handle, record) + self.route.run.return_value = self.route.result(TASK) + with patch.object(job, '_write_control', side_effect=fail_second): + response = self.route.client.post('/api/vms/100/control', json={'action': 'reboot'}) + self.assertEqual(response.status_code, 500) + self.assertIsNone(json.loads(self.lock_path.read_text())['task']) + with patch.object(job.subprocess, 'run') as probe: + with self.assertRaises(BlockingIOError): + job.acquire(100) + probe.assert_not_called() + + def test_shell_blocked_by_live_reservation_runs_no_probe_or_guest_action(self): + self.accepted() + import os + env = {**os.environ, 'PROXMENUX_LOCK_DIR': str(self.folder), + 'VMID': '100', 'TARGET': 'os', 'BACKUP': '1'} + result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')], + env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 7, result.stdout + result.stderr) + self.assertIn('another ProxMenux update', result.stderr) + self.assertEqual(result.stdout, '') + + def test_retired_combined_mode_fails_before_lock_or_any_guest_operation(self): + import os + env = {**os.environ, 'PROXMENUX_LOCK_DIR': str(self.folder), + 'VMID': '100', 'TARGET': 'os', 'CHECK_APPLY_OS': '1'} + result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')], + env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 4, result.stdout + result.stderr) + self.assertFalse(self.lock_path.exists()) + self.assertEqual(result.stdout, '') + + def test_shell_caller_preserves_pending_record_and_performs_no_guest_operation(self): + # Covers process-restart recovery: no Python thread holds this flock. + self.lock_path.write_text(json.dumps({'node': 'fixture', 'task': TASK})) + binary = self.folder / 'bin' + binary.mkdir() + call_log = self.folder / 'calls' + for name, body in [('pvesh', 'echo \'{"status":"running"}\''), + ('pct', f'echo pct >> "{call_log}"; exit 99')]: + script = binary / name + script.write_text('#!/bin/sh\n' + body + '\n') + script.chmod(0o755) + import os + before = self.lock_path.read_text() + env = {**os.environ, 'PATH': str(binary) + ':' + os.environ['PATH'], + 'PYTHONPATH': str(ROOT / 'AppImage/scripts'), + 'PROXMENUX_LOCK_DIR': str(self.folder), 'VMID': '100', 'TARGET': 'os', 'BACKUP': '1'} + result = subprocess.run(['/bin/bash', str(ROOT / 'scripts/lxc/apply_updates.sh')], + env=env, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 7, result.stdout + result.stderr) + self.assertFalse(call_log.exists()) + self.assertEqual(self.lock_path.read_text(), before) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/lxc_updates/test_docker_delegated_ui.cjs b/tests/lxc_updates/test_docker_delegated_ui.cjs index 1d259e87..69cff64c 100644 --- a/tests/lxc_updates/test_docker_delegated_ui.cjs +++ b/tests/lxc_updates/test_docker_delegated_ui.cjs @@ -67,3 +67,90 @@ for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) { for (const key of ['dockerWorkloadsHeading', 'runsInsideDocker', 'upstreamDelegatedTitle', 'upstreamDelegatedHelp']) assert.equal(typeof messages.vmLxc.appEditor[key], 'string') } console.log('PASS Docker delegation: live cache, lifecycle invalidation, edit protection, no extra HTTP, unique image counts, eight locales') + +// Exercise the actual bulk-choice construction, keeping target identities +// intact while distinguishing a custom updater from a same-named image. +const choicesSource = source.slice(source.indexOf('const bulkAppChoices ='), source.indexOf('const bulkChoiceIds =')) +assert(choicesSource.startsWith('const bulkAppChoices =')) +for (const locale of ['en', 'sk']) { + const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`))) + const t = (key, values = {}) => { + const value = key.split('.').reduce((obj, part) => obj?.[part], messages) + assert.equal(typeof value, 'string', key) + return value.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? '') + } + const registeredApps = [ + {id: 'custom', name: 'ghstats', update_command: 'existing updater'}, + {id: 'helper', name: 'Helper app', update_method: 'helper', helper_slug: 'helper-app'}, + {id: 'unique', name: 'Only custom', update_command: 'another updater'}, + {id: 'engine', name: 'Docker', helper_slug: 'docker'}, + ].map(Object.freeze) + const units = [ + {id: 'docker-unit:ghstats', kind: 'standalone', display_name: 'ghstats', dependent_services: []}, + {id: 'docker-unit:helper', kind: 'compose', display_name: 'Helper app', dependent_services: ['database']}, + {id: 'docker-unit:unique', kind: 'standalone', display_name: 'Only image', dependent_services: []}, + ].map(Object.freeze) + const before = JSON.stringify({registeredApps, units}) + const result = compile(choicesSource + '\nexports.choices = bulkChoices;', { + exports: {}, t, registeredApps, helperExists: true, uc: {helper_slug: 'helper-app'}, + dockerWorkloadsRegistered: true, dockerRegistered: true, + selectedVM: {docker_inventory: {update_units: units}}, followedImageReferences: new Set(), + }).choices + assert.deepEqual(Array.from(result, x => x.id), ['os', 'app:custom', 'app:helper', 'app:unique', + 'docker-engine', 'docker-unit:ghstats', 'docker-unit:helper', 'docker-unit:unique']) + assert.equal(result.find(x => x.id === 'app:custom').label, `ghstats — ${t('vmLxc.updates.customCommandTitle')}`) + assert.equal(result.find(x => x.id === 'docker-unit:ghstats').label, `ghstats — ${t('vmLxc.bulkUpdate.dockerImageMethod')}`) + assert.equal(result.find(x => x.id === 'app:helper').label, `Helper app — ${t('vmLxc.updates.helperMethod')}`) + assert.equal(result.find(x => x.id === 'docker-unit:helper').label, 'Helper app — Docker Compose') + assert.equal(result.find(x => x.id === 'app:unique').label, 'Only custom') + assert.equal(result.find(x => x.id === 'docker-unit:unique').label, 'Only image') + assert.equal(result.find(x => x.id === 'docker-unit:helper').detail, + t('vmLxc.bulkUpdate.includesDependencies', {names: 'database'})) + assert.equal(JSON.stringify({registeredApps, units}), before, 'display must never edit registrations or units') + assert.equal(typeof messages.vmLxc.bulkUpdate.osDockerScope, 'string') +} +console.log('PASS bulk display: duplicate method names, unchanged unique names/target IDs/dependencies and frozen input, EN/SK') + +const actionStyle = compile(fs.readFileSync(path.join(root, 'lib/update-action-style.ts'), 'utf8'), {exports: {}}) +let bulkButton +function findBulkButton(node) { + if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'Button') { + const props = node.openingElement.attributes.properties + if (props.some(p => ts.isJsxAttribute(p) && p.name.text === 'onClick' + && p.initializer?.getText(tree).includes('applyBulkUpdate('))) bulkButton = props + } + ts.forEachChild(node, findBulkButton) +} +findBulkButton(tree) +assert(bulkButton, 'actual bulk execution button exists') +function bulkProp(name, context) { + const prop = bulkButton.find(p => p.name?.text === name) + assert(prop && ts.isJsxExpression(prop.initializer)) + return compile('exports.value = (' + prop.initializer.expression.getText(tree) + ');', { + exports: {}, getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass, ...context, + }).value +} +const enabledBulk = {bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false, + osUpdateStatusKnown: true, bulkAllTargetsUpToDate: false, bulkHasPendingUpdates: true, + bulkTargets: ['os', 'app:one'], dockerInventoryRefreshing: false, dockerInventoryAvailable: true} +assert.equal(bulkProp('disabled', enabledBulk), false) +assert.match(bulkProp('className', enabledBulk), /text-purple-300/) +const unknownBulk = {...enabledBulk, osUpdateStatusKnown: false, bulkHasPendingUpdates: false} +assert.equal(bulkProp('disabled', unknownBulk), true, 'consistent action color must not enable an unknown OS') +assert.equal(bulkProp('className', unknownBulk), bulkProp('className', enabledBulk)) +const currentBulk = {...enabledBulk, bulkAllTargetsUpToDate: true, bulkHasPendingUpdates: false} +assert.equal(bulkProp('disabled', currentBulk), true) +assert.match(bulkProp('className', currentBulk), /text-green-400/) +assert.equal(bulkProp('disabled', {...enabledBulk, bulkApplying: true}), true) +assert.equal(bulkProp('disabled', {...enabledBulk, bulkTargets: ['os', 'docker-unit:one'], + dockerInventoryAvailable: false}), true) +const helperLabelSource = source.slice(source.indexOf('const cls = getUpdateActionButtonClass(hasUpd'), + source.indexOf('const cls = getUpdateActionButtonClass(hasUpd') + 550) +const helperLabel = helperLabelSource.slice(0, helperLabelSource.indexOf('return (')) +const unknownHelper = compile(helperLabel + '\nexports.value = {cls, label};', { + exports: {}, hasUpd: false, upToD: false, t: k => k, + getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass, +}).value +assert.match(unknownHelper.cls, /text-purple-300/) +assert.equal(unknownHelper.label, 'vmLxc.updates.runUpdater', 'unknown version must not be presented as a detected upgrade') +console.log('PASS actual update actions: shared author colors, unknown remains unknown, bulk guards unchanged') diff --git a/tests/lxc_updates/test_download_update_guard.py b/tests/lxc_updates/test_download_update_guard.py index 2b1411fb..4b9047b0 100644 --- a/tests/lxc_updates/test_download_update_guard.py +++ b/tests/lxc_updates/test_download_update_guard.py @@ -138,6 +138,7 @@ esac notification = Mock() ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification, _LXC_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'), + _LXC_INSTALL_CHECKED_OS_SCRIPT=str(ROOT / 'scripts/lxc/install_checked_os_updates.sh'), _normalise_lxc_update_run_id=lambda value, **_: value, _normalise_lxc_update_targets=lambda values, _: values, _normalise_lxc_update_labels=lambda values: values, diff --git a/tests/lxc_updates/test_observed_state.py b/tests/lxc_updates/test_observed_state.py index 9df042fc..fe83bf52 100644 --- a/tests/lxc_updates/test_observed_state.py +++ b/tests/lxc_updates/test_observed_state.py @@ -22,6 +22,7 @@ from flask import Flask, jsonify ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "AppImage/scripts")) import managed_installs as managed +import lxc_check_job SERVER = ROOT / "AppImage/scripts/flask_server.py" FUNCTIONS = { @@ -50,6 +51,9 @@ class ObservedStateRoutesTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) + lock_patch = patch.object(lxc_check_job, 'LOCK_DIR', Path(self.temp.name)) + lock_patch.start() + self.addCleanup(lock_patch.stop) self.conf = Path(self.temp.name) / "100.conf" self.conf.write_text("onboot: 0\nnet0: name=eth0,ip=dhcp\ntags: old\n") self.clock = Mock(time=Mock(return_value=1000), monotonic=Mock(return_value=1000)) @@ -141,6 +145,26 @@ class ObservedStateRoutesTests(unittest.TestCase): self.assertEqual(response.status_code, 200, response.json) return response.json[0] + def test_completed_shutdown_cannot_revert_to_stale_cluster_running(self): + self.running = False + self.scope['get_cached_pvesh_cluster_resources_vm'] = lambda: [ + dict(type='lxc', node='fixture-pve', vmid=100, name='dev-trixie', status='running')] + for _ in range(3): + row = self.row() + self.assertEqual(row['status'], 'stopped', 'cached summary cannot override live local state') + self.assertEqual(row['ips'], []) + self.ip_probe.assert_not_called() + self.assertEqual(self.commands, [], 'status polling never enters the guest') + + def test_completed_start_cannot_revert_to_stale_cluster_stopped(self): + self.running = True + self.scope['get_cached_pvesh_cluster_resources_vm'] = lambda: [ + dict(type='lxc', node='fixture-pve', vmid=100, name='dev-trixie', status='stopped')] + row = self.row() + self.assertEqual(row['status'], 'running') + self.assertEqual(row['ip'], self.ip) + self.assertEqual(self.commands, []) + def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self): first = self.row() self.assertEqual(first["ip"], "10.100.100.222") @@ -359,6 +383,35 @@ class ObservedStateRoutesTests(unittest.TestCase): self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian") self.assertEqual(self.row()["update_check"]["count"], 0) + def test_daily_successful_check_clears_an_older_failed_check_marker(self): + marker = Path(self.temp.name) / 'proxmenux-lxc-check-invalid-100' + marker.touch() + + with patch.object(managed, '_refresh_lxc_pkg_cache_if_stale', return_value={}): + managed.check_for_updates() + + self.assertFalse(marker.exists()) + self.assertIsNone(managed.get_registry()['items'][0]['update_check']['error']) + + def test_daily_check_keeps_a_marker_created_after_it_started(self): + marker = Path(self.temp.name) / 'proxmenux-lxc-check-invalid-100' + marker.touch() + os.utime(marker, ns=(2_000, 2_000)) + + with patch.object(managed.time, 'time_ns', return_value=1_000): + managed.check_for_updates() + + self.assertTrue(marker.exists()) + + def test_daily_check_does_not_clear_marker_when_result_cannot_be_persisted(self): + marker = Path(self.temp.name) / 'proxmenux-lxc-check-invalid-100' + marker.touch() + + with patch.object(managed, '_write_registry', return_value=False): + managed.check_for_updates() + + self.assertTrue(marker.exists()) + def test_existing_global_refresh_route_keeps_its_collector_contract(self): with patch.object(managed, "_DETECTORS", [managed._detect_lxc_containers]), \ patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh: @@ -368,10 +421,13 @@ class ObservedStateRoutesTests(unittest.TestCase): self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian") def test_existing_lifecycle_refresh_keeps_index_refresh_default(self): + marker = Path(self.temp.name) / 'proxmenux-lxc-check-invalid-100' + marker.touch() with patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh: item = managed.refresh_lxc(100) refresh.assert_called_once_with("100", "debian") self.assertEqual(item["update_check"]["_count"], 0) + self.assertFalse(marker.exists()) def test_other_lxc_has_independent_ip_cache_and_manual_rate_limit(self): conf200 = Path(self.temp.name) / "200.conf" diff --git a/tests/lxc_updates/test_observed_state_ui.cjs b/tests/lxc_updates/test_observed_state_ui.cjs index 020d866d..46226904 100644 --- a/tests/lxc_updates/test_observed_state_ui.cjs +++ b/tests/lxc_updates/test_observed_state_ui.cjs @@ -19,6 +19,7 @@ const requests = [] const helpers = compile(fs.readFileSync(path.join(root, "lib/lxc-update-observation.ts"), "utf8"), { exports: {}, require: () => ({ fetchApi: async (...args) => { requests.push(args); return {} } }), }) +const actionStyle = compile(fs.readFileSync(path.join(root, "lib/update-action-style.ts"), "utf8"), { exports: {} }) assert.equal(requests.length, 0, "loading helpers never schedules a package check") const now = Date.parse("2026-10-13T12:00:00Z") const recent = { last_check: "2026-10-13T11:59:00Z" } @@ -41,25 +42,51 @@ for (const lang of ['en', 'sk']) { const source = fs.readFileSync(path.join(root, "components/virtual-machines.tsx"), "utf8") const tree = ts.createSourceFile("vm.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) assert.equal(tree.parseDiagnostics.length, 0) -const wanted = new Set(["checkOsUpdates", "getAggregateUpdateCheck", "renderLxcUpdateBadge"]) +const wanted = new Set(["checkOsUpdates", "getAggregateUpdateCheck", "renderLxcUpdateBadge", "openApplyTerminal", "handleVMControl"]) const definitions = [] +const preferenceDefinitions = [] const status = [] let detailEffect +let resultEffect +let resultTimeout +let osApplyButton +let bulkApplyButton +let osSection function walk(node) { + if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "div" + && node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "className" + && p.initializer?.getText(tree) === '"pb-4"') + && node.getText(tree).includes('vmLxc.updates.osPackagesTitle')) osSection = node.getText(tree) + if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "Button" + && node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "onClick" + && p.initializer?.getText(tree).includes('openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })'))) + osApplyButton = node.getText(tree) + if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "Button" + && node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "onClick" + && p.initializer?.getText(tree).includes('applyBulkUpdate(selectedVM.vmid)'))) + bulkApplyButton = node.getText(tree) if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect" && node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") { detailEffect = node.arguments[0].getText(tree) } + if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect" + && node.arguments[1]?.getText(tree) === "[selectedVM, updatesRefreshing, updatesBaselineCount]") + resultEffect = node.arguments[0].getText(tree) + if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect" + && node.arguments[1]?.getText(tree) === "[updatesRefreshing]") + resultTimeout = node.arguments[0].getText(tree) if (ts.isVariableDeclaration(node)) { const name = node.name.getText(tree) if (wanted.has(name)) definitions.push(`const ${node.getText(tree)};`) + if (["applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name)) + preferenceDefinitions.push(`const ${node.getText(tree)};`) if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name)) status.push(`const ${node.getText(tree)};`) } ts.forEachChild(node, walk) } walk(tree) -assert.equal(definitions.length, 3) +assert.equal(definitions.length, 5) assert.equal(status.length, 3) assert.ok(detailEffect) let checking = null @@ -73,27 +100,61 @@ const context = { Badge: "Badge", Clock: "Clock", ArrowUpCircle: "Arrow", ChevronRight: "Chevron", t: (key, args) => key + (args ? JSON.stringify(args) : ""), isLxcUpdateStale: o => helpers.isLxcUpdateStale(o, now), - checkLxcUpdates: async id => { + getLxcOsUpdateState: o => helpers.getLxcOsUpdateState(o, now), + getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass, + refreshCheckLxcUpdates: async id => { if (errorToThrow) throw errorToThrow - return helpers.checkLxcUpdates(id) + requests.push([`/api/vms/${id}/updates/refresh-check`, { method: "POST" }]) + return { ...recent, available: true, count: 2 } }, fetchApi: async endpoint => { requests.push([endpoint]); return { config: { onboot: 1 }, os_info: { id: "debian" } } }, mutate: async () => { polls++ }, selectedVMRef: { current: { vmid: 100 } }, + osCheckRequestRef: { current: null }, selectedVM: { vmid: 100 }, setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 }, clearInterval: id => { context.clearedTimer = id }, - vmModalCacheRef: { current: { details: new Map() } }, + vmModalCacheRef: { current: { details: new Map(), backups: new Map(), mountPoints: new Map(), schedule: new Map(), firewall: new Map() } }, setVMDetails: value => { details = value }, setOsCheckingVmid: value => { checking = value }, setOsCheckError: value => { displayedError = value }, + updatesRefreshing: true, + updatesBaselineCount: 20, + updatesBaselineCountRef: { current: 20 }, + setUpdatesResult: value => { context.updateResult = value }, + setUpdatesRefreshing: value => { context.refreshing = value }, + setUpdatesBaselineCount: value => { context.baseline = value }, + setTimeout: callback => { context.resultTimer = callback; return 2 }, + clearTimeout: () => {}, + Button: "Button", RefreshCw: "Refresh", confirm: () => context.confirmed, + window: { crypto: { randomUUID: () => "ui-test-run" } }, + applyBackupStorage: "", selectedBackupStorage: "", backupStorages: [], + osCheckingVmid: null, pendingBtnCls: "pending", upToDateBtnCls: "current", neutralBtnCls: "unknown", + bulkApplying: false, bulkTargets: ["os", "app:test"], bulkHasPendingUpdates: true, + bulkAllTargetsUpToDate: false, dockerInventoryRefreshing: false, dockerInventoryAvailable: true, + controlLoading: false, Package: "Package", Shield: "Shield", CheckCircle2: "Current", AlertTriangle: "Unknown", + osCheckError: null, uc: undefined, osUpdateStale: true, + setControlLoading: value => { context.controlLoading = value }, + invalidateLxcApps: () => {}, setSelectedVM: value => { context.selectionChanged = value }, + alert: value => { context.alerted = value }, } +for (const name of ["Vmid", "Target", "RunId", "TargetIds", "TargetLabels", "UpdateCommand", "AppName", + "RunHelper", "AllowHelperWithCustom", "DockerStandaloneTargets", "DockerEngine", "DockerRefresh", + "BackupStorage", "StartedAt", "Open", "CheckAndUpdate", "CheckedOs"]) + context[`setApply${name}`] = value => { context[`apply${name}`] = value } const ui = compile(definitions.join("\n") + ` exports.check = checkOsUpdates; exports.aggregate = getAggregateUpdateCheck; exports.badge = renderLxcUpdateBadge; exports.osStatus = (uc) => { ${status.join("\n")} return { osUpdateStatusKnown, hasOsUpdates }; }; exports.revalidateDetails = ${detailEffect}; +exports.settleResult = ${resultEffect}; +exports.resultTimeout = ${resultTimeout}; +exports.openApply = openApplyTerminal; +exports.osApplyButton = () => (${osApplyButton}); +exports.bulkApplyButton = () => (${bulkApplyButton}); +exports.osSection = () => (${osSection}); +exports.control = handleVMControl; `, context) const legacy = { last_check: "2026-10-12T00:00:00Z", available: true, count: 51, stale: true } assert.equal(ui.osStatus(legacy).osUpdateStatusKnown, false, "old count must not claim live OS status") @@ -106,22 +167,285 @@ assert.equal(ui.badge(legacy).props.children[1], "vmLxc.updates.checkStale") assert.equal(ui.badge({ ...recent, available: true, count: 2 }).props.children[1], 2) assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null) assert.equal(requests.length, 0) +context.updatesRefreshing = false +for (const [observation, expected] of [ + [undefined, "unknown"], [legacy, "unknown"], [{ ...recent, error: "failed" }, "unknown"], + [{ ...recent, available: false }, "unknown"], + [{ ...recent, available: true, count: 0 }, "unknown"], + [{ ...recent, available: false, count: 0 }, "current"], + [{ ...recent, available: true, count: 3 }, "pending"], +]) { + assert.equal(helpers.getLxcOsUpdateState(observation, now), expected) + context.selectedVMRef.current = { vmid: 100, status: "stopped", update_check: observation } + context.selectedVM = context.selectedVMRef.current + Object.assign(context, ui.osStatus(observation)) + context.applyOpen = false + context.confirmed = true + assert.equal(ui.osApplyButton().props.disabled, expected !== "pending") + assert.equal(ui.bulkApplyButton().props.disabled, expected === "unknown") + ui.openApply(100, "os") + assert.equal(context.applyOpen, expected === "pending", "handler must enforce the same boundary as the button") +} +context.confirmed = false +context.applyOpen = false +ui.openApply(100, "os") +assert.equal(context.applyOpen, true, "an explicit installation click must not ask for a second stopped-guest confirmation") +const oldConfirm = context.confirm +let confirmationCalls = 0 +context.confirm = () => { confirmationCalls++; return false } +for (const [target, options] of [ + ['os', { checkedOs: true }], + ['both', { updateCommand: 'registered-app-update', targetIds: ['os', 'app:test'] }], + ['app', { updateCommand: 'registered-app-update' }], + ['app', { dockerEngine: true }], + ['app', { dockerStandaloneTargets: ['registered-container'] }], +]) { + context.applyOpen = false + ui.openApply(100, target, options) + assert.equal(context.applyOpen, true, `explicit ${target} update opens the terminal directly`) +} +assert.equal(confirmationCalls, 0, 'ordinary update entry points never open a browser confirmation') +assert(!source.includes('checkAndUpdate'), 'the abandoned combined check/install action has no UI path') +assert(!fs.existsSync(path.join(root, '../scripts/lxc/check_apply_updates.sh'))) +context.confirm = oldConfirm +context.confirmed = true +context.applyOpen = false +context.osCheckingVmid = 100 +ui.openApply(100, "os") +assert.equal(context.applyOpen, false, "checking cannot race an update launch") +context.osCheckingVmid = null +context.bulkAllTargetsUpToDate = true +assert.equal(ui.bulkApplyButton().props.disabled, true) +context.bulkAllTargetsUpToDate = false +context.confirmed = true +context.selectedVMRef.current = { vmid: 100, status: "stopped", update_check: { ...recent, available: false, count: 0 } } +ui.openApply(100, "both", { updateCommand: "app-updater", targetIds: ["os", "app:test"] }) +assert.equal(context.applyOpen, true, "combined app update remains supported when OS packages are current") +assert.equal(context.applyUpdateCommand, "app-updater") +context.selectedVMRef.current = { vmid: 100, status: "running", update_check: { ...recent, error: "failed" } } +context.applyOpen = false +ui.openApply(100, "both") +assert.equal(context.applyOpen, false, "a combined plan cannot bypass unknown OS state") +ui.openApply(100, "app", { updateCommand: "app-updater" }) +assert.equal(context.applyOpen, true, "app-only updates do not depend on OS package observation") +function collectButtons(node) { + if (!node || typeof node !== "object") return [] + if (Array.isArray(node)) return node.flatMap(collectButtons) + return [...(node.type === "Button" ? [node] : []), ...collectButtons(node.props?.children)] +} +function collectText(node) { + if (typeof node === "string") return node + if (!node || typeof node !== "object") return "" + if (Array.isArray(node)) return node.map(collectText).join(" ") + return collectText(node.props?.children) +} +for (const observation of [undefined, legacy, { ...recent, error: "check in progress" }, + { ...recent, available: false, count: 0 }, { ...recent, available: true, count: 3 }]) { + context.selectedVM = { vmid: 100, status: "running" } + context.uc = observation + context.osUpdateStale = helpers.isLxcUpdateStale(observation, now) + Object.assign(context, ui.osStatus(observation)) + context.osCheckingVmid = 100 + const rendered = ui.osSection() + assert.match(collectText(rendered), /vmLxc\.updates\.checkInProgress/, + "an active check takes precedence over old, missing and invalidated observations") + assert.doesNotMatch(collectText(rendered), /osStatusUnavailable|osUpToDate|noPending/) + assert.equal(collectButtons(rendered).length, 1, "hide the previous result during a check") + context.osCheckingVmid = 211 + assert.doesNotMatch(collectText(ui.osSection()), /checkInProgress/, + "another container's check cannot replace this container's result") +} +context.osCheckingVmid = null +context.uc = { ...recent, error: "check failed" } +Object.assign(context, ui.osStatus(context.uc)) +assert.match(collectText(ui.osSection()), /osStatusUnavailable/, "completed failures remain visible") +for (const status of ["running", "stopped"]) { + for (const observation of [undefined, legacy, { ...recent, error: 'read failed' }, + { ...recent, available: true, count: 0 }, + { ...recent, available: false, count: 0 }, { ...recent, available: true, count: 3 }]) { + context.selectedVM = { vmid: 100, status, update_check: observation } + context.selectedVMRef.current = context.selectedVM + context.uc = observation + context.osUpdateStale = helpers.isLxcUpdateStale(observation, now) + Object.assign(context, ui.osStatus(observation)) + const buttons = collectButtons(ui.osSection()) + assert.equal(buttons.length, context.osUpdateStatusKnown ? 2 : 1, + 'retain the original bottom-right OS status when a verified zero is available') + assert.equal(buttons[0].props.children[1], "vmLxc.updates.checkSystemPackages") + if (context.osUpdateStatusKnown && !context.hasOsUpdates) { + const currentStatus = buttons[1] + assert.equal(currentStatus.props.asChild, true, 'current is a status, not an installation action') + assert.equal(currentStatus.props.onClick, undefined) + assert.equal(currentStatus.props.className, context.upToDateBtnCls) + assert.equal(currentStatus.props.children.type, 'span') + assert.equal(currentStatus.props.children.props.role, 'status') + assert.equal(currentStatus.props.children.props.children, 'vmLxc.updates.osUpToDate') + } + assert.equal((osSection.match(/vmLxc\.updates\.refreshCheckHelp/g) || []).length, 1, + 'keep the explanation in the check tooltip without duplicating it as an inline paragraph') + if (status === "stopped") { + context.applyOpen = false + context.confirmed = false + ui.openApply(100, "os", { checkedOs: true }) + assert.equal(context.applyOpen, context.hasOsUpdates, "explicit OS installation has no second popup") + if (context.hasOsUpdates) assert.equal(context.applyCheckedOs, true) + } + } +} +context.selectedVMRef.current = { vmid: 100, status: "running" } +context.updatesRefreshing = true +context.selectedVM = { update_check: { count: 0, error: "verification failed" } } +ui.settleResult() +assert.equal(context.updateResult, null, "unknown must not produce an all-updated banner") +context.selectedVMRef.current = context.selectedVM +ui.resultTimeout() +context.resultTimer() +assert.equal(context.updateResult, null, "timeout must not turn an unknown observation into success") +context.selectedVM = { update_check: { count: 0, error: null } } +ui.settleResult() +assert.equal(context.updateResult.pendingAfter, 0) +assert.equal(context.updateResult.appliedCount, 20) +context.selectedVM = { vmid: 100 } +context.selectedVMRef.current = { vmid: 100 } ;(async () => { + // Execute the actual loaders with delayed GETs to test guest isolation. + const preferenceState = {} + const pendingReads = [] + const preferenceContext = { + exports: {}, CRON_PRESETS: [{ value: "daily-3am", cron: "0 3 * * *" }], + selectedVMRef: { current: { vmid: 213 } }, + scheduleLoadRequestRef: { current: 0 }, bulkLoadRequestRef: { current: 0 }, + vmModalCacheRef: { current: { schedule: new Map() } }, + t: key => key, + fetchApi: (url, options) => new Promise((resolve, reject) => { + assert.equal(options, undefined, "loading preferences never writes or starts an update") + pendingReads.push({ url, resolve, reject }) + }), + } + for (const name of ["ScheduleEnabled", "ScheduleConfigured", "ScheduleCron", "SchedulePreset", + "ScheduleTarget", "ScheduleTargets", "ScheduleReleaseDelayDays", "ApplyBackup", "ApplyBackupStorage", + "ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunLog", + "ScheduleLastRunRebootRequired", "ScheduleLastRunRebootPackages", "ExternalCron", "ScheduleError", + "ScheduleLoaded", "BulkError", "BulkLoaded", "BulkEditMode", "BulkConfigured", "BulkTargets", "BulkPersistedTargets"]) + preferenceContext[`set${name}`] = value => { preferenceState[name] = value } + assert.equal(preferenceDefinitions.length, 3) + const preferences = compile(preferenceDefinitions.join("\n") + ` + exports.apply = applySchedulePayload; exports.schedule = loadSchedule; exports.bulk = loadBulkUpdate; + `, preferenceContext) + preferences.apply({ backup: true, backup_storage: "local", restart: true }) + preferences.apply({}) + assert.equal(preferenceState.ApplyBackup, false, "a guest without saved options cannot inherit backup") + assert.equal(preferenceState.ApplyBackupStorage, "", "missing storage clears another guest's choice") + assert.equal(preferenceState.ApplyRestart, false, "missing restart cannot inherit a reboot") + preferences.apply({ backup: false, backup_storage: "", restart: false }) + assert.equal(preferenceState.ApplyBackupStorage, "") + preferenceContext.vmModalCacheRef.current.schedule.set(213, { backup: true, backup_storage: "local", restart: true }) + const previousSchedule = preferences.schedule(213) + assert.equal(preferenceState.ApplyBackup, true, "use only the selected guest's own cache") + preferenceContext.selectedVMRef.current = { vmid: 210 } + const currentSchedule = preferences.schedule(210) + assert.equal(preferenceState.ApplyBackup, false, "clear inherited options before the GET completes") + assert.equal(preferenceState.ApplyRestart, false) + pendingReads[1].resolve({}) + await currentSchedule + pendingReads[0].resolve({ backup: true, backup_storage: "wrong", restart: true }) + await previousSchedule + assert.equal(preferenceState.ApplyBackup, false) + assert.equal(preferenceState.ScheduleLoaded, 210, "old completion cannot mark another guest loaded") + const older = preferences.schedule(210) + const newer = preferences.schedule(210) + pendingReads[3].resolve({ backup: true, backup_storage: "new", restart: false }) + await newer + pendingReads[2].resolve({ backup: false, backup_storage: "old", restart: true }) + await older + assert.equal(preferenceState.ApplyBackupStorage, "new", "only the latest request may publish preferences") + assert.equal(preferenceState.ApplyRestart, false) + const closed = preferences.schedule(210) + const closedState = JSON.stringify(preferenceState) + preferenceContext.selectedVMRef.current = null + pendingReads[4].reject(new Error("old guest failure")) + await closed + assert.equal(JSON.stringify(preferenceState), closedState, "closing the modal forbids late error and loaded writes") + preferenceContext.selectedVMRef.current = { vmid: 213 } + const oldBulk = preferences.bulk(213) + preferenceContext.selectedVMRef.current = { vmid: 210 } + const newBulk = preferences.bulk(210) + pendingReads[6].resolve({ targets: ["os", "app:ghstats"] }) + await newBulk + pendingReads[5].resolve({ targets: ["os", "docker-engine"] }) + await oldBulk + assert.equal(JSON.stringify(preferenceState.BulkTargets), '["os","app:ghstats"]') + assert.equal(preferenceState.BulkLoaded, 210) + const oldBulkSameGuest = preferences.bulk(210) + const newBulkSameGuest = preferences.bulk(210) + pendingReads[8].resolve({ targets: ["os", "docker-engine"] }) + await newBulkSameGuest + pendingReads[7].reject(new Error("superseded bulk failure")) + await oldBulkSameGuest + assert.equal(preferenceState.BulkError, null) + assert.equal(JSON.stringify(preferenceState.BulkTargets), '["os","docker-engine"]') + const failedSchedule = preferences.schedule(210) + pendingReads[9].resolve(null) + await failedSchedule + assert.equal(preferenceState.ScheduleError, "vmLxc.scheduled.loadFailed", "invalid responses are failed reads") + const failedBulk = preferences.bulk(210) + pendingReads[10].resolve([]) + await failedBulk + assert.equal(preferenceState.BulkError, "vmLxc.bulkUpdate.loadFailed") + const closedBulk = preferences.bulk(210) + const closedBulkState = JSON.stringify(preferenceState) + preferenceContext.selectedVMRef.current = null + pendingReads[11].resolve({ targets: ["os", "app:wrong-guest"] }) + await closedBulk + assert.equal(JSON.stringify(preferenceState), closedBulkState) + const readsBeforeClosedLoad = pendingReads.length + await preferences.schedule(210) + await preferences.bulk(210) + assert.equal(pendingReads.length, readsBeforeClosedLoad, "an unselected guest cannot start a preferences GET") + assert(pendingReads.every(read => /\/(schedule|bulk-update)$/.test(read.url)), "no guest or updater routes") + // Exercise the real separate helper: POST submits, GET waits, no apply. + const io = [] + let jobState = 'running' + const jobHelpers = compile(fs.readFileSync(path.join(root, 'lib/lxc-update-observation.ts'), 'utf8'), { + exports: {}, Date, setTimeout: callback => { jobState = 'complete'; callback() }, + require: () => ({ fetchApi: async (url, opts) => { + io.push([url, opts]) + if (opts?.method === 'POST') return { job_id: 'check-only' } + return { state: jobState, update_check: { count: 0, available: false }, error: 'index failed' } + } }), + }) + assert.equal((await jobHelpers.refreshCheckLxcUpdates(100)).count, 0) + assert.equal(io[0][0], '/api/vms/100/updates/refresh-check') + assert.equal(io.filter(entry => entry[1]?.method === 'POST').length, 1) + assert(io.slice(1).every(entry => entry[0].endsWith('/refresh-check/check-only'))) + jobState = 'failed' + await assert.rejects(jobHelpers.refreshCheckLxcUpdates(100), /osStatusUnavailable/) + io.length = 0 + await jobHelpers.checkLxcUpdates(100) + assert.equal(io[0][0], '/api/vms/100/updates/check', 'legacy helper retains read-only endpoint') + context.applyOpen = false + context.updatesRefreshing = false await ui.check(100) - assert.equal(requests[0][0], "/api/vms/100/updates/check") + assert.equal(requests[0][0], "/api/vms/100/updates/refresh-check") assert.equal(requests[0][1].method, "POST") assert.equal(requests[1][0], "/api/vms/100") - assert.equal(polls, 1) + assert.equal(polls, 2) assert.equal(details.os_info.id, "debian") assert.equal(checking, null) + errorToThrow = new Error('raw transport error must not appear in translated UI') + await ui.check(100) + assert.equal(displayedError, 'vmLxc.updates.osStatusUnavailable') + errorToThrow = new Error('checkStillRunning') + await ui.check(100) + assert.equal(displayedError, 'vmLxc.updates.checkStillRunning') const before = requests.length errorToThrow = { status: 429, body: { retry_after: 60 } } await ui.check(100) assert.match(displayedError, /checkCooldown.*60/) assert.equal(checking, null) assert.equal(requests.length, before) - assert.equal(polls, 1) + assert.equal(polls, 2) // Switching guests while a request runs must not replace another modal. errorToThrow = null context.selectedVMRef.current = { vmid: 200 } @@ -143,9 +467,45 @@ assert.equal(requests.length, 0) assert.equal(context.clearedTimer, 1) for (const locale of ["en", "sk"]) { const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`))).vmLxc.updates - for (const key of ["checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp"]) + for (const key of ["checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp", + "checkBeforeUpdate", "startBeforeCheck", "notCheckedYet", + "checkStillRunning"]) assert.ok(messages[key]?.trim()) assert.match(messages.checkCooldown, /\{seconds\}/) } - console.log("PASS observed-state UI: freshness, badges, safe button, cooldown, modal isolation, EN/SK") + const oldFetch = context.fetchApi + const oldMutate = context.mutate + context.controlRequestRef = { current: false } + context.setControlAction = () => {} + context.waitForVmControlTask = async () => ({ guest_status: 'running' }) + context.setSelectedVM = update => { + const previous = context.selectedVM + const next = typeof update === 'function' ? update(previous) : update + assert.equal(next?.vmid, previous?.vmid, 'control preserves the selected guest') + context.selectedVM = next + } + context.fetchApi = async () => ({ config: { onboot: 1 } }) + context.mutate = async () => {} + for (const action of ["start", "shutdown", "reboot", "stop"]) { + context.selectedVMRef.current = { vmid: 100 } + context.selectionChanged = undefined + await ui.control(100, action) + assert.equal(context.selectionChanged, undefined, "control must not close or reset the selected modal") + assert.equal(context.controlLoading, false) + } + for (const selection of [null, { vmid: 200 }]) { + context.selectedVMRef.current = { vmid: 100 } + context.mutate = async () => { context.selectedVMRef.current = selection } + details = null + await ui.control(100, "start") + assert.equal(details, null, "closing or switching guests during control must not reopen/overwrite a modal") + } + context.selectedVMRef.current = { vmid: 100 } + context.mutate = async () => { throw new Error("poll failed") } + context.alerted = null + await ui.control(100, "shutdown") + assert.equal(context.alerted, null, "failed revalidation is not failed control") + context.fetchApi = oldFetch + context.mutate = oldMutate + console.log("PASS observed-state UI: freshness, safe actions, per-guest preferences/bulk races, EN/SK") })().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/tests/lxc_updates/test_update_status_ui.cjs b/tests/lxc_updates/test_update_status_ui.cjs new file mode 100644 index 00000000..127bf321 --- /dev/null +++ b/tests/lxc_updates/test_update_status_ui.cjs @@ -0,0 +1,132 @@ +// Execute the real Updates JSX and handlers with independent state fixtures. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const root = path.resolve(__dirname, '../../AppImage') +const ts = require(path.join(root, 'node_modules/typescript')) +const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8') +const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +let appRender, bulkRender, checkButton, checkHandler +const declarations = [] +function walk(node) { + if (ts.isCallExpression(node) && node.expression.getText(tree) === 'appSections.map') appRender = node.arguments[0].getText(tree) + if (ts.isConditionalExpression(node) && node.condition.getText(tree) === 'bulkConfigured' + && node.whenTrue.getText(tree).includes('applyBulkUpdate(')) bulkRender = node.whenTrue.getText(tree) + if (ts.isVariableDeclaration(node)) { + if (node.name.getText(tree) === 'checkOsUpdates') checkHandler = node.getText(tree) + if (['selectedBulkUpdateStates', 'bulkHasPendingUpdates', 'bulkAllTargetsUpToDate', 'bulkHasUnknownTargets'].includes(node.name.getText(tree))) + declarations.push('const ' + node.getText(tree) + ';') + } + if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'Button' + && node.openingElement.attributes.getText(tree).includes('checkOsUpdates(selectedVM.vmid)')) checkButton = node.getText(tree) + ts.forEachChild(node, walk) +} +walk(tree) +assert(appRender && bulkRender && checkButton && checkHandler) +function compile(code, context) { + const result = ts.transpileModule(code, {compilerOptions: {target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX}, reportDiagnostics: true}) + assert.equal(result.diagnostics.length, 0) + vm.runInNewContext(result.outputText, context) + return context.exports +} +function flatten(node) { + if (Array.isArray(node)) return node.flatMap(flatten) + return node && typeof node === 'object' ? [node, ...flatten(node.props?.children)] : [] +} +function text(node) { + if (Array.isArray(node)) return node.map(text).join(' ') + return node && typeof node === 'object' ? text(node.props?.children) : typeof node === 'string' ? node : '' +} +for (const locale of ['en', 'sk']) { + const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`))) + const t = key => key.split('.').reduce((o, part) => o?.[part], messages) ?? key + const calls = [] + const context = {exports: {}, t, customCmdEditingApp: null, uc: null, helperExists: false, + helperUsesWebUpdater: false, selectedVM: {vmid: 210, status: 'stopped'}, openCustomCmdEditor() {}, + openApplyTerminal: (...args) => calls.push(args), getUpdateActionButtonClass: () => 'original-style', + bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false, controlLoading: false, + applyOpen: false, osUpdateStatusKnown: true, hasOsUpdates: false, + dockerEngineHasUpdate: false, dockerEngineUpToDate: true, dockerInventoryRefreshing: false, + dockerInventoryAvailable: true, dockerUpdateUnits: [], selectedBulkLabels: ['OS', 'Custom app'], + bulkTargets: ['os', 'app:custom'], applyBulkUpdate: () => calls.push('bulk'), checkOsUpdates() {}, + require: () => ({jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})})} + for (const name of ['Button', 'Package', 'Settings2', 'ArrowUpCircle', 'CheckCircle2', 'RefreshCw', 'Pin', 'Loader2']) context[name] = name + const ui = compile('exports.app = (' + appRender + '); exports.check = () => (' + checkButton + ');', context) + const original = {id: 'custom', name: 'Custom app', installed_via: 'manual', installed_version: null, + latest_version: '2.0', update_available: null, update_command: 'approved-command', checked_at: '2026-10-14T10:43:19Z'} + const before = JSON.stringify(original) + const rendered = ui.app(original) + assert.equal(ui.check().props.disabled, true, 'a stopped CT must not start an index refresh from the check button') + assert.match(text(rendered), locale === 'sk' ? /Nainštalovaná verzia nie je overená/ : /Installed version is unverified/) + assert.doesNotMatch(text(rendered), /Čaká sa|pending|upstreamAvailable/) + const action = flatten(rendered).find(n => n.type === 'Button') + assert.equal(text(action).trim(), locale === 'sk' ? 'Spustiť vlastný príkaz' : 'Run custom command') + assert.equal(calls.length, 0, 'render/check timestamp must not execute an updater') + action.props.onClick() + assert.equal(calls.length, 1) + assert.equal(calls[0][0], 210) + assert.equal(calls[0][1], 'app') + assert.equal(calls[0][2].updateCommand, 'approved-command') + assert.deepEqual(Array.from(calls[0][2].targetIds), ['app:custom']) + assert.equal(JSON.stringify(original), before, 'UI must not confirm or edit the stored version') + calls.length = 0 + const knownVersion = ui.app({...original, installed_version: '1.0'}) + assert(text(knownVersion).includes('1.0'), 'keep the known installed version when comparison is unavailable') + assert.doesNotMatch(text(knownVersion), /Čaká sa|Version tracking pending/) + const noDetector = ui.app({...original, installed_via: undefined}) + assert(text(noDetector).includes(t('vmLxc.updates.versionTrackingNotConfigured'))) + assert.equal(text(flatten(noDetector).find(n => n.type === 'Button')).trim(), t('vmLxc.updates.runCustomCommand')) + assert.equal(text(ui.check()).trim(), locale === 'sk' ? 'Skontrolovať balíky systému' : 'Check system packages') + for (const installed_via of ['binary', 'file', 'dpkg', 'apk']) { + const unavailable = ui.app({...original, installed_via, error: 'probe failed'}) + assert.match(text(unavailable), locale === 'sk' ? /Dostupnosť aktualizácie nie je overená/ : /Update availability is unverified/) + assert.doesNotMatch(text(unavailable), /Čaká sa|next scheduled|upToDateAtLabel/) + } + const current = {...original, installed_version: '2.0', update_available: false} + const pending = {...original, installed_version: '1.0', update_available: true} + assert(text(ui.app(current)).includes(t('vmLxc.updates.upToDateAtLabel'))) + assert(text(ui.app(pending)).includes(t('vmLxc.updates.installedLabel'))) + assert.equal(text(flatten(ui.app(pending)).find(n => n.type === 'Button')).trim(), t('vmLxc.updates.applyUpdate')) + assert(!flatten(ui.app({...original, update_command: ''})).some(n => n.type === 'Button'), 'no command means no execution') + function bulk(app, osKnown = true, extra = {}) { + return compile(declarations.join('\n') + '\nexports.rendered=(' + bulkRender + ');', + {...context, exports: {}, registeredApps: [app], osUpdateStatusKnown: osKnown, ...extra}).rendered + } + const unknownBulk = bulk(original) + assert.match(text(unknownBulk), locale === 'sk' ? /nie je overená/ : /is unverified/) + assert.equal(flatten(unknownBulk).find(n => n.type === 'Button').props.disabled, false, 'preserve explicit manual execution') + assert.equal(flatten(bulk(current)).find(n => n.type === 'Button').props.disabled, true) + assert.equal(flatten(bulk(original, false)).find(n => n.type === 'Button').props.disabled, true, 'unknown OS blocks bulk') + assert.equal(flatten(bulk(original, true, {bulkApplying: true})).find(n => n.type === 'Button').props.disabled, true) + const mixed = bulk(pending, true, {registeredApps: [pending, {...original, id: 'unknown'}], + bulkTargets: ['os', 'app:custom', 'app:unknown']}) + assert.match(text(mixed), locale === 'sk' ? /nie je overená/ : /is unverified/) + assert.equal(flatten(mixed).find(n => n.type === 'Button').props.disabled, false) + const imageUnavailable = bulk(original, true, {bulkTargets: ['os', 'docker-unit:one'], dockerInventoryAvailable: false}) + assert.equal(flatten(imageUnavailable).find(n => n.type === 'Button').props.disabled, true, 'unavailable image cannot be run') + assert.doesNotMatch(text(bulk(pending)), locale === 'sk' ? /nie je overená/ : /is unverified/) + assert.equal(calls.length, 0, 'all status fixtures are side-effect free') +} +assert.match(source, /t\("vmLxc\.updates\.startBeforeCheck"\)/, + 'the Updates tab must explain why checking a stopped CT is disabled') +;(async () => { + const context = {exports: {}, osCheckRequestRef: {current: null}, applyOpen: false, controlLoading: false, + selectedVMRef: {current: {vmid: 210}}, vmModalCacheRef: {current: {details: new Map()}}, + setOsCheckingVmid() {}, setOsCheckError() {}, setSelectedVM() {}, setVMDetails() {}, mutate: async () => {}, + t: key => key, requests: []} + context.refreshCheckLxcUpdates = async vmid => {context.requests.push(['check', vmid]); return {count: 0}} + context.fetchApi = async (url, options) => {context.requests.push([url, options]); return {}} + const handler = compile('const ' + checkHandler + '; exports.check = checkOsUpdates;', context) + await handler.check(210) + assert.deepEqual(context.requests, [['check', 210], ['/api/vms/210', undefined]]) + context.requests.length = 0 + for (const guard of ['applyOpen', 'controlLoading']) { + context[guard] = true + await handler.check(210) + context[guard] = false + } + assert.equal(context.requests.length, 0, 'blocked checks make no API/probe requests') + console.log('PASS common update status: EN/SK unknown/current/pending/error/custom/no-command/bulk/OS check guards; actual JSX and handlers') +})().catch(error => {console.error(error); process.exitCode = 1}) diff --git a/tests/lxc_updates/test_vm_control_task.py b/tests/lxc_updates/test_vm_control_task.py new file mode 100644 index 00000000..d8e2b84c --- /dev/null +++ b/tests/lxc_updates/test_vm_control_task.py @@ -0,0 +1,183 @@ +"""Actual Flask routes: additive task metadata and confined read-only status.""" +import ast +import json +import subprocess +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch +from flask import Flask, jsonify, request + +ROOT = Path(__file__).resolve().parents[2] +TASK = 'UPID:fixture:000A:000B:000C:vzreboot:100:root@pam:' + +class ControlTaskTests(unittest.TestCase): + def setUp(self): + self.app = Flask(__name__) + self.run = Mock() + self.cache = {} + self.invalidate = Mock() + scope = dict(app=self.app, jsonify=jsonify, request=request, require_auth=lambda fn: fn, + get_cached_pvesh_cluster_resources_vm=lambda: [ + dict(vmid=100, type='lxc', node='fixture'), + dict(vmid=200, type='qemu', node='fixture')], + subprocess=SimpleNamespace(run=self.run, TimeoutExpired=subprocess.TimeoutExpired), + _pvesh_cache=self.cache, _vm_cache_invalidate=self.invalidate) + nodes = [n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body + if isinstance(n, ast.FunctionDef) and n.name in ('api_vm_control_task', 'api_vm_control')] + exec(compile(ast.Module(body=nodes, type_ignores=[]), 'routes', 'exec'), scope) + self.client = self.app.test_client() + + def get(self, task=TASK, vmid=100): + return self.client.get(f'/api/vms/{vmid}/control-task/{task}') + + def result(self, payload, code=0): + return subprocess.CompletedProcess([], code, json.dumps(payload), '') + + def test_actual_control_post_preserves_each_action_and_adds_its_task(self): + lock = Mock() + retained = Mock() + module = SimpleNamespace(acquire=lambda _: lock, begin_control=Mock(), + retain_control=retained, cancel_control=Mock()) + with patch.dict('sys.modules', {'lxc_check_job': module}): + for family, vmid in [('vz', 100), ('qm', 200)]: + for action in ['start', 'shutdown', 'reboot', 'stop']: + self.run.reset_mock() + task = TASK.replace('vzreboot:100', f'{family}{action}:{vmid}') + self.run.return_value = self.result(task) + response = self.client.post(f'/api/vms/{vmid}/control', json={'action': action}) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json['success']) + self.assertEqual(response.json['action'], action) + self.assertEqual(response.json['vmid'], vmid) + self.assertEqual(response.json['task'], task) + self.assertEqual(self.run.call_count, 1, 'POST accepts once without polling or extra action') + self.assertEqual(self.run.call_args.args[0], + ['pvesh', 'create', f'/nodes/fixture/{"lxc" if family == "vz" else "qemu"}/{vmid}/status/{action}']) + self.assertEqual(retained.call_count, 4) + lock.close.assert_not_called() + + def test_control_post_reads_final_upid_after_pvesh_log_output(self): + lock = Mock() + retained = Mock() + module = SimpleNamespace(acquire=lambda _: lock, begin_control=Mock(), + retain_control=retained, cancel_control=Mock()) + task = TASK.replace('vzreboot', 'vzstart') + self.run.return_value = subprocess.CompletedProcess( + [], 0, f'waiting for server lock\n"{task}"\n', '') + with patch.dict('sys.modules', {'lxc_check_job': module}): + response = self.client.post('/api/vms/100/control', json={'action': 'start'}) + self.assertEqual(response.status_code, 200, response.json) + self.assertEqual(response.json['task'], task) + retained.assert_called_once_with(100, task, lock) + + def test_control_post_accepts_ha_shutdown_task_for_lxc_and_qemu(self): + lock = Mock() + retained = Mock() + module = SimpleNamespace(acquire=lambda _: lock, begin_control=Mock(), + retain_control=retained, cancel_control=Mock()) + with patch.dict('sys.modules', {'lxc_check_job': module}): + for vmid in (100, 200): + with self.subTest(vmid=vmid): + self.run.reset_mock() + task = TASK.replace('vzreboot:100', f'hastop:{vmid}') + self.run.return_value = self.result(task) + response = self.client.post( + f'/api/vms/{vmid}/control', json={'action': 'shutdown'}) + self.assertEqual(response.status_code, 200, response.json) + self.assertTrue(response.json['success']) + self.assertEqual(response.json['task'], task) + self.assertEqual(self.run.call_count, 1) + self.assertEqual(self.run.call_args.args[0], [ + 'pvesh', 'create', + f'/nodes/fixture/{"lxc" if vmid == 100 else "qemu"}/{vmid}/status/shutdown', + ]) + retained.assert_called_once_with(100, TASK.replace('vzreboot:100', 'hastop:100'), lock) + + def test_control_post_rejects_success_without_a_confirmable_task(self): + lock = Mock() + module = SimpleNamespace(acquire=lambda _: lock, begin_control=Mock(), + retain_control=Mock(), cancel_control=Mock()) + self.run.return_value = subprocess.CompletedProcess([], 0, 'waiting for server lock\n', '') + with patch.dict('sys.modules', {'lxc_check_job': module}): + response = self.client.post('/api/vms/100/control', json={'action': 'start'}) + self.assertEqual(response.status_code, 502, response.json) + self.assertFalse(response.json['success']) + self.assertIn('task', response.json['error'].lower()) + module.retain_control.assert_not_called() + module.cancel_control.assert_called_once_with(lock) + lock.close.assert_called_once() + self.run.reset_mock() + self.assertEqual(self.client.post('/api/vms/100/control', json={'action': 'destroy'}).status_code, 400) + self.run.assert_not_called() + + def test_running_task_never_probes_guest_or_mutates(self): + self.run.return_value = self.result(dict(status='running')) + response = self.get() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json, dict(status='running', exitstatus=None)) + self.assertEqual(self.run.call_count, 1) + self.assertEqual(self.run.call_args.args[0][:2], ['pvesh', 'get']) + self.invalidate.assert_not_called() + + def test_complete_task_checks_actual_guest_for_all_power_actions_and_qemu(self): + for family, vmid in [('vz', 100), ('qm', 200)]: + for action in ['start', 'shutdown', 'reboot', 'stop']: + with self.subTest(family=family, action=action): + self.run.reset_mock() + expected = 'stopped' if action in ('shutdown', 'stop') else 'running' + self.run.side_effect = [self.result(dict(status='stopped', exitstatus='OK')), + self.result(dict(status=expected))] + task = TASK.replace('vzreboot:100', f'{family}{action}:{vmid}') + response = self.get(task, vmid) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json['guest_status'], expected) + self.assertEqual(self.run.call_count, 2) + self.assertTrue(all(call.args[0][:2] == ['pvesh', 'get'] for call in self.run.call_args_list)) + + def test_ha_start_and_stop_tasks_belong_to_the_guest(self): + for action, expected in [('hastart', 'running'), ('hastop', 'stopped')]: + with self.subTest(action=action): + self.run.reset_mock() + self.run.side_effect = [self.result(dict(status='stopped', exitstatus='OK')), + self.result(dict(status=expected))] + task = TASK.replace('vzreboot', action) + response = self.get(task) + self.assertEqual(response.status_code, 200, response.json) + self.assertEqual(response.json['guest_status'], expected) + self.assertEqual(self.run.call_count, 2) + + def test_wrong_guest_node_type_and_path_make_zero_probes(self): + for task in [TASK.replace(':100:', ':200:'), TASK.replace('fixture', 'other'), + TASK.replace('vzreboot', 'qmreboot'), TASK.replace('vzreboot', 'vzdump'), + TASK + '/extra', '../../etc/passwd', 'not-a-task']: + with self.subTest(task=task): + self.run.reset_mock() + self.assertIn(self.get(task).status_code, [400, 404]) + self.run.assert_not_called() + self.assertEqual(self.get(vmid=999).status_code, 404) + self.run.assert_not_called() + + def test_task_failure_never_probes_guest_or_claims_success(self): + self.run.return_value = self.result(dict(status='stopped', exitstatus='start failed')) + self.assertEqual(self.get().json['exitstatus'], 'start failed') + self.assertEqual(self.run.call_count, 1) + self.invalidate.assert_not_called() + + def test_unknown_and_invalid_reads_are_errors_not_success(self): + for value in [[], {}, dict(status='unknown'), dict(status='running')]: + self.run.return_value = self.result(value, code=1 if value == dict(status='running') else 0) + self.assertEqual(self.get().status_code, 502) + self.run.return_value = subprocess.CompletedProcess([], 0, '', '') + self.assertEqual(self.get().status_code, 502) + self.run.side_effect = subprocess.TimeoutExpired('pvesh', 5) + self.assertEqual(self.get().status_code, 502) + + def test_invalid_final_guest_never_clears_cache(self): + for value in [{}, [], dict(status='unknown')]: + self.run.side_effect = [self.result(dict(status='stopped', exitstatus='OK')), self.result(value)] + self.assertEqual(self.get().status_code, 502) + self.invalidate.assert_not_called() + +if __name__ == '__main__': + unittest.main() diff --git a/tests/lxc_updates/test_vm_control_ui.cjs b/tests/lxc_updates/test_vm_control_ui.cjs new file mode 100644 index 00000000..2149d6a1 --- /dev/null +++ b/tests/lxc_updates/test_vm_control_ui.cjs @@ -0,0 +1,135 @@ +// Execute the actual handler and footer: acceptance is not task completion. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const root = path.resolve(__dirname, '../../AppImage') +const ts = require(path.join(root, 'node_modules/typescript')) +const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8') +const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) +let handler, footer +function walk(node) { + if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'handleVMControl') handler = node.getText(tree) + if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'div' + && node.openingElement.attributes.getText(tree).includes('border-t border-border bg-background px-6 py-4 mt-auto')) + footer = node.getText(tree) + ts.forEachChild(node, walk) +} +walk(tree) +function compile(code, context) { + vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX, + }}).outputText, context) + return context.exports +} +function deferred() { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } } +function buttons(node) { + if (!node || typeof node !== 'object') return [] + if (Array.isArray(node)) return node.flatMap(buttons) + return [...(node.type === 'Button' ? [node] : []), ...buttons(node.props?.children)] +} +;(async () => { + for (const action of ['start', 'shutdown', 'reboot', 'stop']) { + const completion = deferred(), accepted = deferred() + const context = { + exports: {}, console: { error() {} }, selectedVM: { vmid: 100, status: action === 'start' ? 'stopped' : 'running', type: 'lxc' }, + selectedVMRef: { current: { vmid: 100 } }, controlRequestRef: { current: false }, + controlLoading: false, controlAction: null, posts: 0, alerts: [], + setControlLoading(value) { context.controlLoading = value }, + setControlAction(value) { context.controlAction = value }, + setSelectedVM(value) { context.selectedVM = typeof value === 'function' ? value(context.selectedVM) : value }, + rows: [{ vmid: 100, status: 'running' }, { vmid: 200, status: 'stopped' }], publications: 0, + setVMDetails() {}, mutate: async (update, options) => { + if (!update) return + assert.equal(options.revalidate, false, 'publish the confirmation before another GET') + context.rows = update(context.rows) + assert.equal(context.rows[1].status, 'stopped', 'only the operated guest changes') + context.publications++ + }, invalidateLxcApps() {}, + vmModalCacheRef: { current: Object.fromEntries(['details','backups','mountPoints','schedule','firewall'].map(n => [n, new Map()])) }, + fetchApi: async (url, options) => { + if (options?.method === 'POST') { context.posts++; return accepted.promise } + return { config: {} } + }, + waitForVmControlTask: () => completion.promise, + t: key => key, alert: text => context.alerts.push(text), + require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }) }), + } + for (const name of ['Button','Terminal','Play','Power','RotateCcw','StopCircle','Loader2']) context[name] = name + const ui = compile(`const ${handler}; exports.control = handleVMControl; exports.footer = () => (${footer});`, context) + const run = ui.control(100, action) + assert.equal(context.controlLoading, true, 'busy starts before the POST') + accepted.resolve({ success: true, task: 'task-id' }) + await new Promise(resolve => setImmediate(resolve)) + assert.equal(context.controlLoading, true, 'accepted control must stay busy until the Proxmox task completes') + assert.equal(context.publications, 0, 'acceptance never publishes an assumed final status') + assert(buttons(ui.footer()).filter(b => b.props.title).every(b => b.props.disabled), 'all power buttons stay disabled') + await ui.control(100, action) + assert.equal(context.posts, 1, 'a double click never submits a second operation') + completion.resolve({ guest_status: action === 'shutdown' || action === 'stop' ? 'stopped' : 'running' }) + await run + assert.equal(context.controlLoading, false) + assert.equal(context.controlRequestRef.current, false) + assert.equal(context.selectedVM.status, action === 'shutdown' || action === 'stop' ? 'stopped' : 'running') + assert.equal(context.alerts.length, 0) + assert.equal(context.controlAction, null) + assert.equal(context.publications, 1) + assert.equal(context.rows[0].status, action === 'shutdown' || action === 'stop' ? 'stopped' : 'running') + const failed = deferred() + context.fetchApi = async () => ({ task: 'task-id' }) + context.waitForVmControlTask = () => failed.promise + const failureRun = ui.control(100, action) + failed.reject(new Error('task failed')) + await failureRun + assert.equal(context.controlLoading, false) + assert.equal(context.alerts.length, 1, 'a task error cannot look like completion') + const switched = deferred() + context.waitForVmControlTask = () => switched.promise + const switchRun = ui.control(100, action) + context.selectedVMRef.current = { vmid: 200 } + context.selectedVM = { vmid: 200, status: 'stopped' } + switched.resolve({ guest_status: 'running' }) + await switchRun + assert.equal(context.selectedVM.vmid, 200) + assert.equal(context.selectedVM.status, 'stopped', 'completion cannot overwrite another guest') + assert.equal(buttons(ui.footer()).filter(b => b.props.title).some(b => /Pending/.test(b.props.title)), false) + } + const polls = [], sequence = [] + let clock = 0, readError = null + const helper = compile(fs.readFileSync(path.join(root, 'lib/vm-control.ts'), 'utf8'), { + exports: {}, Date: { now: () => clock }, AbortSignal: { timeout: () => ({}) }, + setTimeout: callback => { clock += 1000; callback() }, + require: () => ({ fetchApi: async (url, options) => { + polls.push([url, options]); if (readError) throw readError + return sequence.shift() || { status: 'running' } + }}), + }) + // A reboot with "running" status is still pending until its task reports OK. + for (const action of ['start','shutdown','reboot','stop']) { + clock = 0; polls.length = 0 + const expected = ['shutdown','stop'].includes(action) ? 'stopped' : 'running' + sequence.push({ status: 'running', guest_status: expected }, + { status: 'stopped', exitstatus: 'OK', guest_status: expected === 'running' ? 'stopped' : 'running' }, + { status: 'stopped', exitstatus: 'OK', guest_status: expected }) + assert.equal((await helper.waitForVmControlTask(100, action, 'UPID:test')).guest_status, expected) + assert.equal(polls.length, 3, 'wait for both the task and actual target state') + assert(polls.every(([url, options]) => url === '/api/vms/100/control-task/UPID%3Atest' && !options.method), + 'polling only reads the accepted task; no second POST or package check') + } + for (const response of [null, {}, { status:'stopped' }, { status:'stopped', exitstatus:'failed' }]) { + sequence.push(response) + // null must reach the helper, rather than the fake's default running state. + if (response === null) sequence[sequence.length - 1] = { status: null } + await assert.rejects(helper.waitForVmControlTask(100, 'reboot', 'task'), /controlNotConfirmed|failed/) + } + clock = 0; polls.length = 0 + await assert.rejects(helper.waitForVmControlTask(100, 'reboot', 'task'), /controlNotConfirmed/) + assert.equal(clock, 180000, 'bounded wait includes the exact deadline') + polls.length = 0 + await assert.rejects(helper.waitForVmControlTask(100, 'start', ''), /controlNotConfirmed/) + assert.equal(polls.length, 0, 'a missing task never starts speculative probes') + clock = 0; readError = new Error('unavailable') + await assert.rejects(helper.waitForVmControlTask(100, 'start', 'task'), /controlNotConfirmed/) + assert.equal(polls.length, 3, 'repeated read failures do not report success') + console.log('PASS VM control UI: accepted/pending/completed, all actions, duplicate guard and task failure') +})().catch(error => { console.error(error); process.exitCode = 1 }) diff --git a/tests/test_repository_preservation.py b/tests/test_repository_preservation.py index 839b79fe..053da743 100644 --- a/tests/test_repository_preservation.py +++ b/tests/test_repository_preservation.py @@ -468,6 +468,25 @@ class RepositoryPolicyTest(unittest.TestCase): self.run_policy(apply=True) self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls)) + def test_wrong_suite_names_each_stale_source_and_where_to_fix_it(self): + self.repositories = fixture( + ('/etc/apt/sources.list.d/pve.list', [repo('pve-no-subscription')]), + ('/etc/apt/sources.list.d/pve-install-repo.list', + [repo('pve-no-subscription', uri='https://user:secret@mirror.example/debian/pve', suite='bookworm')]), + ('/etc/apt/sources.list.d/debian.sources', [repo('main', uri='https://deb.example/debian')]), + ) + with self.assertRaises(ValueError) as error: + self.run_policy(apply=True) + message = str(error.exception) + self.assertIn('/etc/apt/sources.list.d/pve-install-repo.list', message) + self.assertIn('pve-no-subscription', message) + self.assertIn('suite bookworm', message) + self.assertIn('runs Proxmox VE on trixie', message) + self.assertIn('Node > Updates > Repositories', message) + self.assertNotIn('/etc/apt/sources.list.d/pve.list', message) + self.assertNotIn('secret', message) + self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls)) + def test_ceph_enterprise_mirror_is_disabled_without_choosing_ceph_channel(self): self.repositories['files'].append({'path': '/etc/apt/sources.list.d/storage.list', 'repositories': [repo('enterprise', uri='https://mirror.example/ceph-squid')]}) diff --git a/web/messages/en/docs/monitor/notifications.json b/web/messages/en/docs/monitor/notifications.json index 656991a1..b6758553 100644 --- a/web/messages/en/docs/monitor/notifications.json +++ b/web/messages/en/docs/monitor/notifications.json @@ -441,7 +441,7 @@ "history": { "heading": "History", "body1": "Every dispatch attempt the dispatcher actually performs is recorded in the notification_history SQLite table. Each row stores the timestamp (sent_at), channel, event type, severity, title, rendered message body, a success flag and — when the send failed — the error returned by the provider in error_message. Burst-aggregated events appear as a single row with the burst_* event type. Events suppressed by the cooldown stage are not logged: they never become a dispatch attempt.", - "body2": "The History tab inside Settings → Notifications shows the last 20 entries and has a single Clear button that wipes the table.", + "body2": "Settings → Notifications has a Notification history section, closed by default, with the last 20 entries: the result of each send, its channel and date, the error when it failed and, for each daily digest run, whether it had something to send. The page does not offer to erase it: Audit & Report reads the same history to check the delivery of each channel.", "body3": "The same data is exposed at GET /api/notifications/history with optional limit, offset, severity and channel query parameters, and can be cleared with DELETE /api/notifications/history." }, "api": { diff --git a/web/messages/en/docs/oci-manager/index.json b/web/messages/en/docs/oci-manager/index.json index e9e4663b..574d8970 100644 --- a/web/messages/en/docs/oci-manager/index.json +++ b/web/messages/en/docs/oci-manager/index.json @@ -33,7 +33,7 @@ { "id": "where", "title": "Where it is", - "intro": "OCI manager Apps opens from option OCI manager Apps (beta) of the ProxMenux main menu, on the Proxmox node and as root. Its first screen offers:", + "intro": "OCI manager Apps opens from option OCI manager Apps (Technology Preview) of the ProxMenux main menu, on the Proxmox node and as root. Its first screen offers:", "blocks": [ { "list": { @@ -54,13 +54,21 @@ } }, { - "p": "Selecting an application shows its description, the image it runs and two installation modes: Install with default settings, which asks almost nothing, and Install with advanced settings, which offers the real storage, bridge and resource selectors of the node." + "p": "Selecting an application shows its description, the image it runs and two installation modes: Install with default settings, which asks only what every installation decides (storage, where the data goes, address and start), and Install with advanced settings, which opens every decision in order: VMID and resources, the storage of each path, the bridge and the devices." }, { "p": "An application that has been tested for real shows Verified in that view and is marked with ✓ in the lists, whether it was tested in the ProxMenux lab or by the community." } ] }, + { + "id": "beta", + "title": "Why it is a Technology Preview", + "blocks": [ + { "p": "OCI manager Apps carries the Technology Preview label that Proxmox VE gives to application containers created from OCI images, introduced in Proxmox VE 9.1. It refers to what OCI manager Apps runs on, not to the ProxMenux engine: the behaviour of those containers can still change between Proxmox VE releases. The label stays until Proxmox VE marks that support as stable." }, + { "p": "The engine of OCI manager Apps (installation, update, modification, recreation, backups, restore, migration and watchdog) is tested in the ProxMenux lab. Each application is tested on its own: the ones tested for real show Verified and carry a ✓ in the lists." } + ] + }, { "id": "translation", "title": "What Docker expresses and what OCI manager Apps turns it into", diff --git a/web/messages/es/docs/monitor/notifications.json b/web/messages/es/docs/monitor/notifications.json index a0d409ca..85089fa0 100644 --- a/web/messages/es/docs/monitor/notifications.json +++ b/web/messages/es/docs/monitor/notifications.json @@ -441,7 +441,7 @@ "history": { "heading": "Historial", "body1": "Cada intento de despacho que el dispatcher realmente ejecuta se registra en la tabla SQLite notification_history. Cada fila guarda el timestamp (sent_at), canal, tipo de evento, severidad, título, cuerpo de mensaje renderizado, un flag success y — cuando el envío falló — el error devuelto por el proveedor en error_message. Los eventos agregados en ráfaga aparecen como una única fila con el tipo de evento burst_*. Los eventos suprimidos por la etapa de cooldown no se loguean: nunca se convierten en un intento de despacho.", - "body2": "La pestaña History dentro de Settings → Notifications muestra las últimas 20 entradas y tiene un único botón Clear que borra la tabla.", + "body2": "Settings → Notifications tiene una sección Historial de notificaciones, cerrada por defecto, con las últimas 20 entradas: el resultado de cada envío, su canal y su fecha, el error cuando falló y, en cada ejecución del resumen diario, si tenía algo que enviar. La página no ofrece borrarlo: Audit & Report lee ese mismo historial para comprobar la entrega de cada canal.", "body3": "Los mismos datos se exponen en GET /api/notifications/history con parámetros opcionales limit, offset, severity y channel, y se pueden borrar con DELETE /api/notifications/history." }, "api": { diff --git a/web/messages/es/docs/oci-manager/index.json b/web/messages/es/docs/oci-manager/index.json index 55a308ae..aefa2ca0 100644 --- a/web/messages/es/docs/oci-manager/index.json +++ b/web/messages/es/docs/oci-manager/index.json @@ -33,7 +33,7 @@ { "id": "where", "title": "Dónde se encuentra", - "intro": "OCI manager Apps se abre desde la opción OCI manager Apps (beta) del menú principal de ProxMenux, en el nodo Proxmox y como root. Su primera pantalla ofrece:", + "intro": "OCI manager Apps se abre desde la opción OCI manager Apps (Technology Preview) del menú principal de ProxMenux, en el nodo Proxmox y como root. Su primera pantalla ofrece:", "blocks": [ { "list": { @@ -54,13 +54,21 @@ } }, { - "p": "Al seleccionar una aplicación se muestra su descripción, la imagen que ejecuta y dos modos de instalación: Instalar con la configuración predeterminada, que apenas hace preguntas, e Instalar con la configuración avanzada, que ofrece los selectores reales de almacenamiento, bridge y recursos del nodo." + "p": "Al seleccionar una aplicación se muestra su descripción, la imagen que ejecuta y dos modos de instalación: Instalar con la configuración predeterminada, que pregunta solo lo que decide toda instalación (almacenamiento, dónde van los datos, dirección e inicio), e Instalar con la configuración avanzada, que abre cada decisión en orden: VMID y recursos, el almacenamiento de cada ruta, el bridge y los dispositivos." }, { "p": "Una aplicación probada en real muestra Verificado en esa vista y aparece marcada con ✓ en las listas, tanto si se probó en el laboratorio de ProxMenux como si la probó la comunidad." } ] }, + { + "id": "beta", + "title": "Por qué es una Technology Preview", + "blocks": [ + { "p": "OCI manager Apps lleva la etiqueta Technology Preview que Proxmox VE da a los contenedores de aplicación creados a partir de imágenes OCI, introducidos en Proxmox VE 9.1. Se refiere a aquello sobre lo que funciona OCI manager Apps, no al motor de ProxMenux: el comportamiento de esos contenedores todavía puede cambiar entre versiones de Proxmox VE. La etiqueta se mantiene hasta que Proxmox VE dé ese soporte por estable." }, + { "p": "El motor de OCI manager Apps (instalación, actualización, modificación, recreación, backups, restauración, migración y vigilancia) está probado en el laboratorio de ProxMenux. Cada aplicación se prueba por separado: las que se han probado de verdad muestran Verified y llevan una ✓ en las listas." } + ] + }, { "id": "translation", "title": "Lo que Docker expresa y en qué lo convierte OCI manager Apps",