Merge branch 'develop' into feature/zram-memory-profile

This commit is contained in:
MacRimi
2026-10-16 17:27:04 +02:00
79 changed files with 5421 additions and 764 deletions
@@ -126,7 +126,7 @@
"Dateieigentümer ändern", "Dateieigentümer ändern",
"Tar.gz-Archiv extrahieren", "Tar.gz-Archiv extrahieren",
"Tar.gz-Archiv erstellen", "Tar.gz-Archiv erstellen",
"Festplattennutzung anzeigen", "Laufwerksnutzung anzeigen",
"Verzeichnisgrößen anzeigen", "Verzeichnisgrößen anzeigen",
"Speichernutzung anzeigen", "Speichernutzung anzeigen",
"Laufende Prozesse anzeigen", "Laufende Prozesse anzeigen",
@@ -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()
@@ -212,10 +212,28 @@ class CommandDescriptionsTests(unittest.TestCase):
# New LXC observation labels intentionally ship only EN/SK. # New LXC observation labels intentionally ship only EN/SK.
# Model the bot's future output in temporary catalogs, just # Model the bot's future output in temporary catalogs, just
# like the other pending keys above; never rewrite locales. # 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", for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown",
"checkStale", "checkStaleHelp"): "checkStale", "checkStaleHelp", "checkBeforeUpdate",
"startBeforeCheck", "notCheckedYet",
"checkStillRunning", "refreshCheckHelp",
"checkInProgress", "checkSystemPackages", "runCustomCommand",
"installedVersionUnverified", "versionStatusUnknown"):
messages["vmLxc"]["updates"].setdefault( messages["vmLxc"]["updates"].setdefault(
key, english["vmLxc"]["updates"][key]) 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)) path.write_text(json.dumps(messages, ensure_ascii=False))
before = {p: p.read_bytes() for p in root.glob("*/common.json")} before = {p: p.read_bytes() for p in root.glob("*/common.json")}
argv = [str(SCRIPT), "--source", str(root / "en/common.json"), argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
@@ -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()
@@ -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()
@@ -7,7 +7,7 @@ from pathlib import Path
import subprocess import subprocess
from types import SimpleNamespace from types import SimpleNamespace
from unittest import TestCase from unittest import TestCase
from unittest.mock import Mock from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[3] ROOT = Path(__file__).resolve().parents[3]
MENU = ROOT / 'oci/src/proxmenux_oci/management.py' 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.message.assert_called_once_with(EMPTY, 'OCI management')
ui.choose.assert_not_called() 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): def test_selector_filters_missing_guest_without_erasing_saved_contract(self):
class Entry: class Entry:
name = '101' name = '101'
@@ -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()
@@ -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()
@@ -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, "<html>No release links</html>"),
):
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 = (
'<a href="https://rockstor.com/downloads/installer/leap/15.6/x86_64/'
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso">'
'Rockstor-Leap15.6-generic.x86_64-5.1.0-0.install.iso</a> <br/>'
)
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, "<html>No installer links</html>"),
):
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 = (
'<a href="/projects/openmediavault/files/iso/8.3.1/" '
'title="Click to enter 8.3.1">8.3.1</a>\n'
'<a href="/projects/openmediavault/files/iso/7.4.17/" '
'title="Click to enter 7.4.17">7.4.17</a>'
)
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 = ('<a href="/projects/openmediavault/files/iso/8.10.0/">new</a>'
'<a href="/projects/openmediavault/files/iso/8.9.0/">old</a>')
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()
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
90a46ab219d193ee635165ae1761f1c3806f0f0be2fc4434907ea6e8d2c86414 ProxMenux-1.2.6.2-beta.AppImage 79ba79ea553e9ba473b33c8614f4dae5f684c960390f42272dc282c902627020 ProxMenux-1.2.6.2-beta.AppImage
@@ -382,6 +382,19 @@ export function LxcTerminalModal({
return 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 { } else {
@@ -688,6 +701,10 @@ export function LxcTerminalModal({
<span className="font-mono text-xs mr-2">Ctrl+C</span> <span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x04")}>
<span className="font-mono text-xs mr-2">Ctrl+D</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendKey("\x18")}> <DropdownMenuItem onSelect={() => sendKey("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span> <span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
+79 -17
View File
@@ -18,7 +18,7 @@ import {
Trash2, ChevronRight, TestTube2, Mail, Webhook, Trash2, ChevronRight, TestTube2, Mail, Webhook,
Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload, Copy, Server, Shield, ExternalLink, RefreshCw, Download, Upload,
Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb, Cloud, Brain, Globe, MessageSquareText, Sparkles, Pencil, Save, RotateCcw, Lightbulb,
Moon, Newspaper Moon, Newspaper, History, ChevronDown
} from "lucide-react" } from "lucide-react"
interface ChannelConfig { interface ChannelConfig {
@@ -109,7 +109,8 @@ interface HistoryEntry {
title: string title: string
severity: string severity: string
sent_at: string sent_at: string
success: boolean success: boolean | number
message?: string | null
error_message: string | null error_message: string | null
} }
@@ -331,7 +332,8 @@ export function NotificationSettings() {
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG) const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
const [status, setStatus] = useState<ServiceStatus | null>(null) const [status, setStatus] = useState<ServiceStatus | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>([]) const [history, setHistory] = useState<HistoryEntry[] | null>(null)
const [historyError, setHistoryError] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
@@ -452,12 +454,12 @@ export function NotificationSettings() {
const loadHistory = useCallback(async () => { const loadHistory = useCallback(async () => {
try { try {
const data = await fetchApi<{ success: boolean; history: HistoryEntry[]; total: number }>("/api/notifications/history?limit=20") const data = await fetchApi<{ history?: HistoryEntry[]; error?: string }>("/api/notifications/history?limit=20")
if (data.success) { if (data.error || !Array.isArray(data.history)) throw new Error("history unavailable")
setHistory(data.history || []) setHistory(data.history)
} setHistoryError(false)
} catch { } 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 // Maps each eye-button local key to the body shape the backend
// expects for /api/notifications/reveal-secret. Centralised here so // expects for /api/notifications/reveal-secret. Centralised here so
// the JSX call site stays a one-liner — `toggleSecret(key)`. // the JSX call site stays a one-liner — `toggleSecret(key)`.
@@ -2761,6 +2754,75 @@ export function NotificationSettings() {
)} )}
</div> </div>
{/* ── Notification history ── */}
<div className="space-y-3">
<button
type="button"
className="w-full flex items-start gap-3 py-1 text-left"
onClick={() => setShowHistory(open => !open)}
aria-expanded={showHistory}
>
<History className="h-5 w-5 text-blue-400 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{t("settings.notifications.history.title")}</span>
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.description")}</p>
</div>
<ChevronDown className={`h-4 w-4 mt-1 shrink-0 text-muted-foreground transition-transform ${showHistory ? "rotate-180" : ""}`} />
</button>
{showHistory && (
historyError ? (
<p className="text-xs sm:text-sm text-red-400">{t("settings.notifications.history.loadFailed")}</p>
) : history === null ? (
<div className="flex justify-center py-3"><Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /></div>
) : history.length === 0 ? (
<p className="text-xs sm:text-sm text-muted-foreground">{t("settings.notifications.history.empty")}</p>
) : (
<div className="space-y-1.5 max-h-80 overflow-y-auto pr-1">
{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 (
<div key={entry.id} className="flex items-start gap-2.5 p-2.5 rounded-md bg-muted/30 border border-border/50">
{sent ? (
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.sent")} />
) : (
<XCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" aria-label={t("settings.notifications.history.notSent")} />
)}
<div className="flex-1 min-w-0">
<span className="text-xs sm:text-sm font-medium block truncate">{entry.title || entry.event_type}</span>
<span className="text-[11px] sm:text-xs text-muted-foreground">
{entry.channel ? entry.channel.charAt(0).toUpperCase() + entry.channel.slice(1) : ""} · {new Date(entry.sent_at).toLocaleString()}
</span>
{detail && (
<p className={`text-[11px] sm:text-xs mt-0.5 break-words ${sent ? "text-muted-foreground" : "text-red-400"}`}>{detail}</p>
)}
</div>
{(severity === "critical" || severity === "warning" || severity === "info") && (
<Badge
variant="outline"
className={`text-[10px] shrink-0 ${
severity === "critical"
? "border-red-500/30 text-red-400"
: severity === "warning"
? "border-amber-500/30 text-amber-400"
: "border-blue-500/30 text-blue-400"
}`}
>
{severity === "critical"
? t("settings.notifications.history.severityCritical")
: severity === "warning"
? t("settings.notifications.history.severityWarning")
: t("settings.notifications.history.severityInfo")}
</Badge>
)}
</div>
)
})}
</div>
)
)}
</div>
</> </>
)} )}
+1 -1
View File
@@ -307,7 +307,7 @@ const CURRENT_VERSION_FEATURES = [
{ {
icon: <Package className="h-5 w-5" />, icon: <Package className="h-5 w-5" />,
key: "releaseNotes.currentFeatures.ociManager", 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: <ScrollText className="h-5 w-5" />, icon: <ScrollText className="h-5 w-5" />,
@@ -918,6 +918,10 @@ const initMessage = {
<span className="font-mono text-xs mr-2">Ctrl+C</span> <span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x04")}>
<span className="font-mono text-xs mr-2">Ctrl+D</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendCommand("\x18")}> <DropdownMenuItem onSelect={() => sendCommand("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span> <span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
+4
View File
@@ -1038,6 +1038,10 @@ const handleClose = () => {
<span className="font-mono text-xs mr-2">Ctrl+C</span> <span className="font-mono text-xs mr-2">Ctrl+C</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.cancelInterrupt")}</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendSequence("\x04")}>
<span className="font-mono text-xs mr-2">Ctrl+D</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.endOfInput")}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => sendSequence("\x18")}> <DropdownMenuItem onSelect={() => sendSequence("\x18")}>
<span className="font-mono text-xs mr-2">Ctrl+X</span> <span className="font-mono text-xs mr-2">Ctrl+X</span>
<span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span> <span className="text-muted-foreground text-xs">{t("scriptTerminal.exitNano")}</span>
+194 -72
View File
@@ -4,6 +4,7 @@ import type React from "react"
import { useState, useMemo, useEffect, useRef } from "react" import { useState, useMemo, useEffect, useRef } from "react"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache" 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 { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
@@ -27,8 +28,9 @@ import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
import { formatStorage } from "../lib/utils" import { formatStorage } from "../lib/utils"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { waitForVmControlTask } from "../lib/vm-control"
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search" 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 DOMPurify from "dompurify"
import { marked } from "marked" import { marked } from "marked"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider" import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
@@ -871,6 +873,8 @@ export function VirtualMachines() {
const lifecycleCacheRevisionsRef = useRef(new Map<number, number>()) const lifecycleCacheRevisionsRef = useRef(new Map<number, number>())
const [dockerInventoryRefreshingVmid, setDockerInventoryRefreshingVmid] = useState<number | null>(null) const [dockerInventoryRefreshingVmid, setDockerInventoryRefreshingVmid] = useState<number | null>(null)
const [controlLoading, setControlLoading] = useState(false) 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 // Destructive control confirmation. `Force Stop` and `Reboot` skip the OS
// shutdown sequence and can corrupt running guests; gate them behind a // shutdown sequence and can corrupt running guests; gate them behind a
// typed-VMID match prompt to prevent misclicks. See audit Tier 2 #17. // 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). // (or after a 15 s safety timeout).
const [updatesRefreshing, setUpdatesRefreshing] = useState(false) const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null) const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null)
const osCheckRequestRef = useRef<number | null>(null)
const [osCheckError, setOsCheckError] = useState<string | null>(null) const [osCheckError, setOsCheckError] = useState<string | null>(null)
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null) const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null) const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
@@ -1231,6 +1236,12 @@ export function VirtualMachines() {
useEffect(() => { useEffect(() => {
if (!updatesRefreshing) return if (!updatesRefreshing) return
if (!selectedVM) 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 const currentCount = selectedVM.update_check?.count ?? 0
if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) { if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) {
const applied = Math.max(0, updatesBaselineCount - currentCount) const applied = Math.max(0, updatesBaselineCount - currentCount)
@@ -1250,7 +1261,8 @@ export function VirtualMachines() {
const safety = setTimeout(() => { const safety = setTimeout(() => {
const currentCount = selectedVMRef.current?.update_check?.count ?? 0 const currentCount = selectedVMRef.current?.update_check?.count ?? 0
const baseline = updatesBaselineCountRef.current const baseline = updatesBaselineCountRef.current
setUpdatesResult({ const observation = selectedVMRef.current?.update_check
setUpdatesResult(!observation || observation.error ? null : {
pendingAfter: currentCount, pendingAfter: currentCount,
appliedCount: Math.max(0, (baseline ?? 0) - currentCount), appliedCount: Math.max(0, (baseline ?? 0) - currentCount),
}) })
@@ -1788,12 +1800,16 @@ export function VirtualMachines() {
} }
const handleVMControl = async (vmid: number, action: string) => { const handleVMControl = async (vmid: number, action: string) => {
if (controlRequestRef.current) return
controlRequestRef.current = true
setControlAction({ vmid, action })
setControlLoading(true) setControlLoading(true)
try { try {
await fetchApi(`/api/vms/${vmid}/control`, { const accepted = await fetchApi<{ task: string }>(`/api/vms/${vmid}/control`, {
method: "POST", method: "POST",
body: JSON.stringify({ action }), body: JSON.stringify({ action }),
}) })
const completed = await waitForVmControlTask(vmid, action, accepted.task)
// Any control action can change what the guest reports: a stop // Any control action can change what the guest reports: a stop
// hides runtime-only fields, a start may bring a new config that // hides runtime-only fields, a start may bring a new config that
@@ -1809,16 +1825,37 @@ export function VirtualMachines() {
cache.firewall.delete(vmid) cache.firewall.delete(vmid)
invalidateLxcApps(vmid) invalidateLxcApps(vmid)
mutate() // Publish the confirmed result as an SWR mutation before revalidating.
setSelectedVM(null) // SWR then discards list requests started before this completion.
setVMDetails(null) 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<VMDetails>(`/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) { } catch (error) {
console.error(`Failed to ${action} VM ${vmid}:`, error) console.error(`Failed to ${action} VM ${vmid}:`, error)
// Same UX issue as handleCreateBackup: a silent console.error left the // Same UX issue as handleCreateBackup: a silent console.error left the
// user looking at a "Stop"/"Start" button that just never reacted. // 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 })) alert(t("vmLxc.errors.controlFailed", { action, vmid, message: msg }))
} finally { } finally {
controlRequestRef.current = false
setControlAction(null)
setControlLoading(false) setControlLoading(false)
} }
} }
@@ -1983,6 +2020,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const [applyRestart, setApplyRestart] = useState(false) const [applyRestart, setApplyRestart] = useState(false)
const [applyStartedAt, setApplyStartedAt] = useState<number>(0) const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
const [applyRunId, setApplyRunId] = useState<string>("") const [applyRunId, setApplyRunId] = useState<string>("")
const [applyCheckedOs, setApplyCheckedOs] = useState(false)
const [applyTargetIds, setApplyTargetIds] = useState<string[]>([]) const [applyTargetIds, setApplyTargetIds] = useState<string[]>([])
const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([]) const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([])
// Extra state carried alongside applyTarget when the App branch is // 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 // detected via the community-scripts pattern. All loaded once when
// the user opens the Updates tab of a specific LXC. // the user opens the Updates tab of a specific LXC.
const [scheduleLoaded, setScheduleLoaded] = useState<number | null>(null) const [scheduleLoaded, setScheduleLoaded] = useState<number | null>(null)
const scheduleLoadRequestRef = useRef(0)
const [scheduleEnabled, setScheduleEnabled] = useState(false) const [scheduleEnabled, setScheduleEnabled] = useState(false)
const [scheduleCron, setScheduleCron] = useState("0 3 * * *") const [scheduleCron, setScheduleCron] = useState("0 3 * * *")
const [schedulePreset, setSchedulePreset] = useState<string>("daily-3am") const [schedulePreset, setSchedulePreset] = useState<string>("daily-3am")
@@ -2073,6 +2112,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// with scheduled updates: changing a one-click manual selection must never // with scheduled updates: changing a one-click manual selection must never
// rewrite the cron automation configured in Options. // rewrite the cron automation configured in Options.
const [bulkLoaded, setBulkLoaded] = useState<number | null>(null) const [bulkLoaded, setBulkLoaded] = useState<number | null>(null)
const bulkLoadRequestRef = useRef(0)
const [bulkConfigured, setBulkConfigured] = useState(false) const [bulkConfigured, setBulkConfigured] = useState(false)
const [bulkTargets, setBulkTargets] = useState<string[]>(["os"]) const [bulkTargets, setBulkTargets] = useState<string[]>(["os"])
const [bulkPersistedTargets, setBulkPersistedTargets] = useState<string[]>(["os"]) const [bulkPersistedTargets, setBulkPersistedTargets] = useState<string[]>(["os"])
@@ -2107,9 +2147,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? s.targets.map((value: any) => String(value)) ? s.targets.map((value: any) => String(value))
: ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])])) : ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])]))
setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0) setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0)
if (s.backup !== undefined) setApplyBackup(!!s.backup) setApplyBackup(!!s.backup)
if (s.backup_storage) setApplyBackupStorage(s.backup_storage) setApplyBackupStorage(typeof s.backup_storage === "string" ? s.backup_storage : "")
if (s.restart !== undefined) setApplyRestart(!!s.restart) setApplyRestart(!!s.restart)
setScheduleLastRunAt(s.last_run_at || null) setScheduleLastRunAt(s.last_run_at || null)
setScheduleLastRunStatus(s.last_run_status || null) setScheduleLastRunStatus(s.last_run_status || null)
setScheduleLastRunReason(s.last_run_reason || null) setScheduleLastRunReason(s.last_run_reason || null)
@@ -2122,30 +2162,46 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
} }
const loadSchedule = async (vmid: number) => { 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) setScheduleError(null)
// Seed from ref cache so tab-switching to Updates isn't a "Loading…" // Seed from ref cache so tab-switching to Updates isn't a "Loading…"
// moment when the payload was already fetched this session. // moment when the payload was already fetched this session.
const cachedSched = vmModalCacheRef.current.schedule.get(vmid) 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 { try {
const s: any = await fetchApi(`/api/vms/${vmid}/schedule`) const s: any = await fetchApi(`/api/vms/${vmid}/schedule`)
if (s && typeof s === "object") { if (!isCurrent()) return
vmModalCacheRef.current.schedule.set(vmid, s) if (!s || typeof s !== "object" || Array.isArray(s))
applySchedulePayload(s) throw new Error(t("vmLxc.scheduled.loadFailed"))
} vmModalCacheRef.current.schedule.set(vmid, s)
applySchedulePayload(s)
} catch (e: any) { } catch (e: any) {
setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed")) if (isCurrent()) setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
} finally { } finally {
setScheduleLoaded(vmid) if (isCurrent()) setScheduleLoaded(vmid)
} }
} }
const loadBulkUpdate = async (vmid: number) => { 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) setBulkError(null)
setBulkLoaded(null) setBulkLoaded(null)
setBulkEditMode(false) setBulkEditMode(false)
setBulkConfigured(false)
setBulkTargets(["os"])
setBulkPersistedTargets(["os"])
try { try {
const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`) 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) const targets = Array.isArray(config?.targets)
? Array.from(new Set(config.targets.map((value: any) => String(value)))) as string[] ? 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) setBulkTargets(next)
setBulkPersistedTargets(next) setBulkPersistedTargets(next)
} catch (e: any) { } catch (e: any) {
if (!isCurrent()) return
setBulkConfigured(false) setBulkConfigured(false)
setBulkTargets(["os"]) setBulkTargets(["os"])
setBulkPersistedTargets(["os"]) setBulkPersistedTargets(["os"])
setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed")) setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed"))
} finally { } finally {
setBulkLoaded(vmid) if (isCurrent()) setBulkLoaded(vmid)
} }
} }
@@ -2560,8 +2617,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const openApplyTerminal = ( const openApplyTerminal = (
vmid: number, vmid: number,
target: "os" | "app" | "both", 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" const fallbackTargets = target === "os"
? ["os"] ? ["os"]
: target === "both" : target === "both"
@@ -2584,6 +2654,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setApplyVmid(vmid) setApplyVmid(vmid)
setApplyTarget(target) setApplyTarget(target)
setApplyRunId(runId) setApplyRunId(runId)
setApplyCheckedOs(opts?.checkedOs === true)
setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets) setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets)
setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels) setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels)
setApplyUpdateCommand(opts?.updateCommand || "") 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 // false "N package pending" every time a registered app has an
// upstream bump. // upstream bump.
const checkOsUpdates = async (vmid: number) => { const checkOsUpdates = async (vmid: number) => {
if (osCheckRequestRef.current !== null || applyOpen || controlLoading) return
osCheckRequestRef.current = vmid
setOsCheckingVmid(vmid) setOsCheckingVmid(vmid)
setOsCheckError(null) setOsCheckError(null)
try { try {
await checkLxcUpdates(vmid) const observation = await refreshCheckLxcUpdates<LxcUpdateCheck>(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<VMDetails>(`/api/vms/${vmid}`) const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
vmModalCacheRef.current.details.set(vmid, details) vmModalCacheRef.current.details.set(vmid, details)
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details) if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
@@ -2692,9 +2769,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
if (selectedVMRef.current?.vmid === vmid) { if (selectedVMRef.current?.vmid === vmid) {
setOsCheckError(error.status === 429 setOsCheckError(error.status === 429
? t("vmLxc.updates.checkCooldown", { seconds: error.body?.retry_after ?? 60 }) ? 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 { } finally {
osCheckRequestRef.current = null
setOsCheckingVmid(null) setOsCheckingVmid(null)
} }
} }
@@ -5707,7 +5787,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
!selectedVM.update_check?.is_oci_lxc && (() => { !selectedVM.update_check?.is_oci_lxc && (() => {
const uc = selectedVM.update_check const uc = selectedVM.update_check
const osUpdateStale = isLxcUpdateStale(uc) const osUpdateStale = isLxcUpdateStale(uc)
const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale const osUpdateStatusKnown = getLxcOsUpdateState(uc) !== "unknown"
const hasOsUpdates = osUpdateStatusKnown && !!uc.available const hasOsUpdates = osUpdateStatusKnown && !!uc.available
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker") const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
const dockerRegistered = !!dockerAppWatch const dockerRegistered = !!dockerAppWatch
@@ -5863,6 +5943,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
return { return {
id: `app:${app.id}`, id: `app:${app.id}`,
label: app.name || t("vmLxc.updates.applicationDefaultName"), label: app.name || t("vmLxc.updates.applicationDefaultName"),
methodLabel: app.update_command?.trim()
? t("vmLxc.updates.customCommandTitle")
: t("vmLxc.updates.helperMethod"),
detail: "", detail: "",
logoUrl: webLinkLogo || app.logo_url?.trim() || "", logoUrl: webLinkLogo || app.logo_url?.trim() || "",
} }
@@ -5877,6 +5960,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? [{ ? [{
id: "docker-engine", id: "docker-engine",
label: "Docker Engine", label: "Docker Engine",
methodLabel: t("vmLxc.scheduled.dockerEngineTarget"),
detail: "", detail: "",
logoUrl: "", logoUrl: "",
}] }]
@@ -5884,6 +5968,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
...dockerUpdateUnits.map((unit) => ({ ...dockerUpdateUnits.map((unit) => ({
id: unit.id, id: unit.id,
label: unit.display_name || unit.primary_reference || unit.primary_service || 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 detail: (unit.dependent_services || []).length
? t("vmLxc.bulkUpdate.includesDependencies", { ? t("vmLxc.bulkUpdate.includesDependencies", {
names: (unit.dependent_services || []).join(", "), names: (unit.dependent_services || []).join(", "),
@@ -5891,7 +5978,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
: "", : "",
logoUrl: unit.logo_url || "", 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 = [{ const bulkChoices = [{
id: "os", id: "os",
label: t("vmLxc.bulkUpdate.osTarget"), label: t("vmLxc.bulkUpdate.osTarget"),
@@ -5935,7 +6029,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// has been checked and is current, otherwise unknown. // has been checked and is current, otherwise unknown.
const selectedBulkUpdateStates = bulkTargets.map((target) => { const selectedBulkUpdateStates = bulkTargets.map((target) => {
if (target === "os") { if (target === "os") {
if (!uc || uc.error) return null if (!osUpdateStatusKnown) return null
return hasOsUpdates return hasOsUpdates
} }
if (target === "docker-engine") { if (target === "docker-engine") {
@@ -5960,6 +6054,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
return null return null
}) })
const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true) const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true)
const bulkHasUnknownTargets = selectedBulkUpdateStates.some((state) => state === null)
const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0 const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0
&& selectedBulkUpdateStates.every((state) => state === false) && selectedBulkUpdateStates.every((state) => state === false)
const toggleBulkChoice = (id: string, checked: boolean) => { 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])) ? Array.from(new Set(["os", ...current.filter((value) => value !== "os"), id]))
: current.filter((value) => value !== 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 pendingBtnCls = getUpdateActionButtonClass(true, false)
const upToDateBtnCls = "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300" const upToDateBtnCls = getUpdateActionButtonClass(false, true)
const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
return ( return (
<> <>
<Card className="border border-border bg-card/50"> <Card className="border border-border bg-card/50">
@@ -5982,14 +6076,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3> <h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
<Button <Button
size="sm" variant="outline" className="ml-auto" size="sm" variant="outline" className="ml-auto"
disabled={selectedVM.status !== "running" || osCheckingVmid !== null} disabled={selectedVM.status !== "running" || osCheckingVmid !== null || updatesRefreshing || controlLoading || applyOpen}
title={t("vmLxc.updates.checkReadOnlyHelp")} title={t("vmLxc.updates.refreshCheckHelp")}
onClick={() => checkOsUpdates(selectedVM.vmid)} onClick={() => checkOsUpdates(selectedVM.vmid)}
> >
<RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} />
{t("vmLxc.updates.checkNow")} {t("vmLxc.updates.checkSystemPackages")}
</Button> </Button>
</div> </div>
{selectedVM.status !== "running" && (
<p className="text-sm text-muted-foreground mb-3">
{t("vmLxc.updates.startBeforeCheck")}
</p>
)}
<div className="text-xs text-muted-foreground mb-3 leading-relaxed"> <div className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t("vmLxc.updates.lastCheckedPrefix")}{" "} {t("vmLxc.updates.lastCheckedPrefix")}{" "}
{uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"} {uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"}
@@ -5998,7 +6097,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)} )}
</div> </div>
{osCheckError && <p role="alert" className="text-sm text-amber-400 mb-2">{osCheckError}</p>} {osCheckError && <p role="alert" className="text-sm text-amber-400 mb-2">{osCheckError}</p>}
{osUpdateStale && !uc?.error ? ( {osCheckingVmid === selectedVM.vmid ? (
<div role="status" className="text-sm text-muted-foreground flex items-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin flex-shrink-0" />
{t("vmLxc.updates.checkInProgress")}
</div>
) : !uc?.last_check && !uc?.error ? (
<p className="text-sm text-muted-foreground">{t("vmLxc.updates.notCheckedYet")}</p>
) : osUpdateStale && !uc?.error ? (
<p className="text-sm text-amber-400">{t("vmLxc.updates.checkStaleHelp")}</p> <p className="text-sm text-amber-400">{t("vmLxc.updates.checkStaleHelp")}</p>
) : !osUpdateStatusKnown ? ( ) : !osUpdateStatusKnown ? (
<div <div
@@ -6054,23 +6160,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.updates.noOsUpdatesPending")} {t("vmLxc.updates.noOsUpdatesPending")}
</div> </div>
)} )}
<div className="mt-3 flex justify-end"> {osUpdateStatusKnown && osCheckingVmid !== selectedVM.vmid && <div className="mt-3 flex justify-end">
{hasOsUpdates ? (
<Button <Button
size="sm" size="sm"
onClick={() => openApplyTerminal(selectedVM.vmid, "os")} disabled={!hasOsUpdates || osCheckingVmid !== null || updatesRefreshing || controlLoading || !!osCheckError}
className={hasOsUpdates onClick={() => openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })}
? pendingBtnCls className={getUpdateActionButtonClass(hasOsUpdates, osUpdateStatusKnown)}
: osUpdateStatusKnown
? upToDateBtnCls
: neutralBtnCls}
> >
{hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />} {hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{!hasOsUpdates && !osUpdateStatusKnown && <RefreshCw className="h-4 w-4 mr-1.5" />} {!hasOsUpdates && !osUpdateStatusKnown && <RefreshCw className="h-4 w-4 mr-1.5" />}
{hasOsUpdates || !osUpdateStatusKnown {hasOsUpdates
? t("vmLxc.updates.applyOsUpdate") ? t("vmLxc.updates.applyOsUpdate")
: t("vmLxc.updates.osUpToDate")} : osUpdateStatusKnown
? t("vmLxc.updates.osUpToDate")
: t("vmLxc.updates.checkBeforeUpdate")}
</Button> </Button>
</div> ) : (
<Button asChild size="sm" className={upToDateBtnCls}>
<span role="status">{t("vmLxc.updates.osUpToDate")}</span>
</Button>
)}
</div>}
</div> </div>
{/* Docker has three independent lifecycles: {/* Docker has three independent lifecycles:
@@ -6200,11 +6311,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
dockerRefresh: true, dockerRefresh: true,
}, },
)} )}
className={`${dockerEngineHasUpdate className={`${getUpdateActionButtonClass(dockerEngineHasUpdate, dockerEngineUpToDate)} flex-shrink-0`}
? pendingBtnCls
: dockerEngineUpToDate
? upToDateBtnCls
: neutralBtnCls} flex-shrink-0`}
> >
{dockerEngineHasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />} {dockerEngineHasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{!dockerEngineHasUpdate && !dockerEngineUpToDate && <RefreshCw className="h-4 w-4 mr-1.5" />} {!dockerEngineHasUpdate && !dockerEngineUpToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
@@ -6480,7 +6587,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div> </div>
) : helperTracksVersion ? ( ) : helperTracksVersion ? (
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.updates.versionTrackingPending")} {t("vmLxc.updates.versionStatusUnknown")}
</p> </p>
) : helperExists ? ( ) : helperExists ? (
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
@@ -6503,7 +6610,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// still force the helper's own // still force the helper's own
// update script. // update script.
const noState = !hasUpd && !upToD 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") const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
return ( return (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
@@ -6639,7 +6746,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div> </div>
) : null ) : null
) : ( ) : (
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div> <div>{t("vmLxc.updates.versionStatusUnknown")}</div>
)} )}
</div> </div>
) : tracksVersion ? ( ) : tracksVersion ? (
@@ -6651,7 +6758,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}> <a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}>
{t("vmLxc.updates.dockerImagesSubheading")} {t("vmLxc.updates.dockerImagesSubheading")}
</a></> </a></>
: t("vmLxc.updates.versionTrackingPendingShort")} : aw.installed_via === "manual"
? t("vmLxc.updates.installedVersionUnverified")
: t("vmLxc.updates.versionStatusUnknown")}
</div> </div>
) : hasCmd ? ( ) : hasCmd ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@@ -6673,7 +6782,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
targetLabels: [aw.name || t("vmLxc.updates.applicationDefaultName")], targetLabels: [aw.name || t("vmLxc.updates.applicationDefaultName")],
}, },
)} )}
className={hasUpdate ? pendingBtnCls : upToDate ? upToDateBtnCls : neutralBtnCls} className={getUpdateActionButtonClass(hasUpdate, upToDate)}
> >
{hasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />} {hasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{!hasUpdate && !upToDate && <RefreshCw className="h-4 w-4 mr-1.5" />} {!hasUpdate && !upToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
@@ -6681,7 +6790,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? t("vmLxc.updates.applyUpdate") ? t("vmLxc.updates.applyUpdate")
: upToDate : upToDate
? t("vmLxc.updates.upToDate") ? t("vmLxc.updates.upToDate")
: t("vmLxc.updates.runUpdater")} : aw.update_method === "helper"
? t("vmLxc.updates.runUpdater")
: t("vmLxc.updates.runCustomCommand")}
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -6719,6 +6830,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<p className="text-xs text-muted-foreground mt-2 leading-relaxed"> <p className="text-xs text-muted-foreground mt-2 leading-relaxed">
{t("vmLxc.bulkUpdate.description")} {t("vmLxc.bulkUpdate.description")}
</p> </p>
{dockerRegistered && (
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{t("vmLxc.bulkUpdate.osDockerScope")}
</p>
)}
</div> </div>
{!bulkEditMode && ( {!bulkEditMode && (
<button <button
@@ -6868,19 +6984,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="text-sm text-muted-foreground break-words"> <div className="text-sm text-muted-foreground break-words">
{selectedBulkLabels.join(" · ")} {selectedBulkLabels.join(" · ")}
</div> </div>
{bulkHasUnknownTargets && (
<p className="text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.bulkUpdate.unverifiedTargetsHelp")}
</p>
)}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
size="sm" size="sm"
onClick={() => applyBulkUpdate(selectedVM.vmid)} onClick={() => applyBulkUpdate(selectedVM.vmid)}
disabled={bulkApplying || ( disabled={bulkApplying || osCheckingVmid !== null || updatesRefreshing || !osUpdateStatusKnown || bulkAllTargetsUpToDate || (
bulkTargets.some((target) => target.startsWith("docker-unit:")) bulkTargets.some((target) => target.startsWith("docker-unit:"))
&& (dockerInventoryRefreshing || !dockerInventoryAvailable) && (dockerInventoryRefreshing || !dockerInventoryAvailable)
)} )}
className={bulkHasPendingUpdates className={getUpdateActionButtonClass(bulkHasPendingUpdates, bulkAllTargetsUpToDate)}
? pendingBtnCls
: bulkAllTargetsUpToDate
? upToDateBtnCls
: neutralBtnCls}
> >
{bulkApplying {bulkApplying
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
@@ -7534,8 +7651,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
tab we added. */} tab we added. */}
{(() => { {(() => {
const hasTerminal = selectedVM?.type === "lxc" && selectedVM?.status === "running" const hasTerminal = selectedVM?.type === "lxc" && selectedVM?.status === "running"
const pendingAction = controlAction?.vmid === selectedVM?.vmid ? controlAction?.action : null
const controlLabel = (action: string, key: string) => pendingAction === action
? t(`vmLxc.actions.${action}Pending`) : t(key)
return ( return (
<div className={`grid gap-2 sm:gap-3 ${hasTerminal ? "grid-cols-4 lg:grid-cols-5" : "grid-cols-4"}`}> <div aria-busy={controlLoading} className={`grid gap-2 sm:gap-3 ${hasTerminal ? "grid-cols-4 lg:grid-cols-5" : "grid-cols-4"}`}>
{hasTerminal && ( {hasTerminal && (
<Button <Button
className="w-full bg-zinc-600/20 border border-zinc-600/50 text-zinc-300 hover:bg-zinc-600/30 col-span-4 lg:col-span-1" className="w-full bg-zinc-600/20 border border-zinc-600/50 text-zinc-300 hover:bg-zinc-600/30 col-span-4 lg:col-span-1"
@@ -7549,19 +7669,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
className="w-full bg-green-600/20 border border-green-600/50 text-green-400 hover:bg-green-600/30" className="w-full bg-green-600/20 border border-green-600/50 text-green-400 hover:bg-green-600/30"
disabled={selectedVM?.status === "running" || controlLoading} disabled={selectedVM?.status === "running" || controlLoading}
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "start")} onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "start")}
title={t("vmLxc.actions.start")} title={controlLabel("start", "vmLxc.actions.start")}
> >
<Play className="h-4 w-4 sm:mr-2" /> {pendingAction === "start" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <Play className="h-4 w-4 sm:mr-2" />}
<span className="hidden sm:inline">{t("vmLxc.actions.start")}</span> <span className="hidden sm:inline">{controlLabel("start", "vmLxc.actions.start")}</span>
</Button> </Button>
<Button <Button
className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30" className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
disabled={selectedVM?.status !== "running" || controlLoading} disabled={selectedVM?.status !== "running" || controlLoading}
onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "shutdown")} onClick={() => selectedVM && handleVMControl(selectedVM.vmid, "shutdown")}
title={t("vmLxc.actions.shutdown")} title={controlLabel("shutdown", "vmLxc.actions.shutdown")}
> >
<Power className="h-4 w-4 sm:mr-2" /> {pendingAction === "shutdown" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <Power className="h-4 w-4 sm:mr-2" />}
<span className="hidden sm:inline">{t("vmLxc.actions.shutdown")}</span> <span className="hidden sm:inline">{controlLabel("shutdown", "vmLxc.actions.shutdown")}</span>
</Button> </Button>
<Button <Button
className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30" className="w-full bg-blue-600/20 border border-blue-600/50 text-blue-400 hover:bg-blue-600/30"
@@ -7571,10 +7691,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
vmid: selectedVM.vmid, vmid: selectedVM.vmid,
vmName: selectedVM.name, vmName: selectedVM.name,
})} })}
title={t("vmLxc.actions.reboot")} title={controlLabel("reboot", "vmLxc.actions.reboot")}
> >
<RotateCcw className="h-4 w-4 sm:mr-2" /> {pendingAction === "reboot" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <RotateCcw className="h-4 w-4 sm:mr-2" />}
<span className="hidden sm:inline">{t("vmLxc.actions.reboot")}</span> <span className="hidden sm:inline">{controlLabel("reboot", "vmLxc.actions.reboot")}</span>
</Button> </Button>
<Button <Button
className="w-full bg-red-600/20 border border-red-600/50 text-red-400 hover:bg-red-600/30" className="w-full bg-red-600/20 border border-red-600/50 text-red-400 hover:bg-red-600/30"
@@ -7584,10 +7704,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
vmid: selectedVM.vmid, vmid: selectedVM.vmid,
vmName: selectedVM.name, vmName: selectedVM.name,
})} })}
title={t("vmLxc.actions.forceStop")} title={controlLabel("stop", "vmLxc.actions.forceStop")}
> >
<StopCircle className="h-4 w-4 sm:mr-2" /> {pendingAction === "stop" ? <Loader2 className="h-4 w-4 sm:mr-2 animate-spin" /> : <StopCircle className="h-4 w-4 sm:mr-2" />}
<span className="hidden sm:inline">{t("vmLxc.actions.forceStop")}</span> <span className="hidden sm:inline">{controlLabel("stop", "vmLxc.actions.forceStop")}</span>
</Button> </Button>
</div> </div>
) )
@@ -7916,7 +8036,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setApplyVmid(null) setApplyVmid(null)
}} }}
onComplete={handleApplyComplete} 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" scriptName="lxc_apply_updates"
title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })} title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })}
completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")} completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")}
+26
View File
@@ -4,6 +4,17 @@ interface UpdateObservation {
last_check?: string | null last_check?: string | null
stale?: boolean stale?: boolean
stale_after_seconds?: number 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 { export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now()): boolean {
@@ -19,3 +30,18 @@ export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now
export function checkLxcUpdates<T>(vmid: number): Promise<T> { export function checkLxcUpdates<T>(vmid: number): Promise<T> {
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" }) return fetchApi<T>(`/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<T>(vmid: number): Promise<T> {
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")
}
+7
View File
@@ -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"
}
+32
View File
@@ -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')
}
File diff suppressed because it is too large Load Diff
+37 -5
View File
@@ -1106,7 +1106,11 @@
"shutdown": "Shutdown", "shutdown": "Shutdown",
"reboot": "Reboot", "reboot": "Reboot",
"forceStop": "Force Stop", "forceStop": "Force Stop",
"working": "Working..." "working": "Working...",
"startPending": "Starting...",
"shutdownPending": "Shutting down...",
"rebootPending": "Restarting...",
"stopPending": "Stopping..."
}, },
"confirm": { "confirm": {
"forceStopTitle": "Force Stop", "forceStopTitle": "Force Stop",
@@ -1150,6 +1154,7 @@
"unknown": "Unknown error", "unknown": "Unknown error",
"backupStartFailed": "Failed to start backup: {message}", "backupStartFailed": "Failed to start backup: {message}",
"controlFailed": "Failed to {action} VM {vmid}: {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.", "saveNotesFailed": "Error saving notes. Please try again.",
"appNotFound": "This application is no longer available. Refresh the page and try again.", "appNotFound": "This application is no longer available. Refresh the page and try again.",
"saveCustomCommandFailed": "Could not save the custom update command: {message}", "saveCustomCommandFailed": "Could not save the custom update command: {message}",
@@ -1338,7 +1343,14 @@
}, },
"updates": { "updates": {
"osPackagesTitle": "OS packages", "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.", "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.", "checkCooldown": "Check already running or recently requested. Try again in {seconds} seconds.",
"checkStale": "Check outdated", "checkStale": "Check outdated",
@@ -1386,6 +1398,9 @@
"applyUpdate": "Apply update", "applyUpdate": "Apply update",
"upToDate": "Up to date", "upToDate": "Up to date",
"runUpdater": "Run updater", "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}", "terminalTitle": "Apply updates — CT {vmid}",
"terminalDescriptionOs": "Applying OS package updates inside the container...", "terminalDescriptionOs": "Applying OS package updates inside the container...",
"terminalDescriptionApp": "Running the application updater inside the container...", "terminalDescriptionApp": "Running the application updater inside the container...",
@@ -1489,9 +1504,12 @@
"notConfigured": "Not configured.", "notConfigured": "Not configured.",
"configure": "Configure", "configure": "Configure",
"edit": "Edit", "edit": "Edit",
"apply": "Apply updates", "apply": "Start bulk update",
"osTarget": "OS", "osTarget": "OS",
"osRequired": "Required for a bulk update.", "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}", "includesDependencies": "Also includes declared dependencies: {names}",
"noMethod": "No executable update method is available.", "noMethod": "No executable update method is available.",
"staleTarget": "This selection is no longer available. Remove it before saving.", "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).", "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." "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": { "customPromptInfo": {
"title": "Custom prompt mode", "title": "Custom prompt mode",
"description": "Create your own AI prompt for ProxMenux Monitor notifications", "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.", "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).", "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.", "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.", "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.", "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.", "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": { "lxcTerminal": {
"title": "Terminal: {name} (ID: {id})", "title": "Terminal: {name} (ID: {id})",
"errors": { "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": { "commands": {
"listFiles": "List all files with details", "listFiles": "List all files with details",
@@ -5206,6 +5237,7 @@
"processing": "Processing...", "processing": "Processing...",
"controlSequences": "Control Sequences", "controlSequences": "Control Sequences",
"cancelInterrupt": "Cancel/Interrupt", "cancelInterrupt": "Cancel/Interrupt",
"endOfInput": "End of input",
"exitNano": "Exit (nano)", "exitNano": "Exit (nano)",
"searchHistory": "Search history", "searchHistory": "Search history",
"clipboard": "Clipboard", "clipboard": "Clipboard",
+40 -8
View File
@@ -1085,7 +1085,11 @@
"shutdown": "Apagar", "shutdown": "Apagar",
"reboot": "Reiniciar", "reboot": "Reiniciar",
"forceStop": "Parar", "forceStop": "Parar",
"working": "Procesando..." "working": "Procesando...",
"startPending": "Iniciando...",
"shutdownPending": "Apagando...",
"rebootPending": "Reiniciando...",
"stopPending": "Parando..."
}, },
"confirm": { "confirm": {
"forceStopTitle": "Parar", "forceStopTitle": "Parar",
@@ -1134,7 +1138,8 @@
"saveCustomCommandFailed": "No se pudo guardar el comando de actualización personalizado: {message}", "saveCustomCommandFailed": "No se pudo guardar el comando de actualización personalizado: {message}",
"removeCustomCommandConfirm": "¿Eliminar el comando de actualización personalizado para \"{name}\"?", "removeCustomCommandConfirm": "¿Eliminar el comando de actualización personalizado para \"{name}\"?",
"removeCustomCommandFailed": "No se pudo eliminar el comando de actualización personalizado: {message}", "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": { "backupModal": {
"title": "Backup de {type} {vmid} ({name})", "title": "Backup de {type} {vmid} ({name})",
@@ -1455,11 +1460,21 @@
"postApplyNothingPending": "Nada pendiente — todo actualizado.", "postApplyNothingPending": "Nada pendiente — todo actualizado.",
"postApplyPartial": "{pending} paquete(s) siguen pendientes tras la ejecución.", "postApplyPartial": "{pending} paquete(s) siguen pendientes tras la ejecución.",
"postApplyPartialSubline": "{applied} aplicados. Algunas actualizaciones no finalizaron — revisa la salida del terminal.", "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.", "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.", "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", "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": { "bulkUpdate": {
"title": "Actualización en bloque", "title": "Actualización en bloque",
@@ -1468,7 +1483,7 @@
"notConfigured": "No configurada.", "notConfigured": "No configurada.",
"configure": "Configurar", "configure": "Configurar",
"edit": "Editar", "edit": "Editar",
"apply": "Aplicar actualizaciones", "apply": "Iniciar actualización masiva",
"osTarget": "SO", "osTarget": "SO",
"osRequired": "Obligatorio en una actualización en bloque.", "osRequired": "Obligatorio en una actualización en bloque.",
"includesDependencies": "Incluye también las dependencias declaradas: {names}", "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.", "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.", "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.", "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": { "appEditor": {
"dockerDetectedWithWorkloads": "Docker detectado con {count} aplicaciones en contenedores", "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).", "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." "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": { "customPromptInfo": {
"title": "Modo de aviso personalizado", "title": "Modo de aviso personalizado",
"description": "Cree su propio mensaje de IA para las notificaciones de ProxMenux Monitor", "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.", "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).", "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.", "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.", "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.", "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.", "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": { "lxcTerminal": {
"title": "Terminal: {name} (ID: {id})", "title": "Terminal: {name} (ID: {id})",
"errors": { "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": { "commands": {
"listFiles": "Listar todos los archivos con detalles", "listFiles": "Listar todos los archivos con detalles",
@@ -5207,6 +5238,7 @@
"processing": "Tratamiento...", "processing": "Tratamiento...",
"controlSequences": "Secuencias de control", "controlSequences": "Secuencias de control",
"cancelInterrupt": "Cancelar/Interrumpir", "cancelInterrupt": "Cancelar/Interrumpir",
"endOfInput": "Fin de la entrada",
"exitNano": "Salir (nano)", "exitNano": "Salir (nano)",
"searchHistory": "Historial de búsqueda", "searchHistory": "Historial de búsqueda",
"clipboard": "Portapapeles", "clipboard": "Portapapeles",
+41 -9
View File
@@ -1081,11 +1081,15 @@
"logs": "Logs" "logs": "Logs"
}, },
"actions": { "actions": {
"start": "Commencer", "start": "Démarrer",
"shutdown": "Fermer", "shutdown": "Éteindre",
"reboot": "Redémarrer", "reboot": "Redémarrer",
"forceStop": "Forcer l'arrêt", "forceStop": "Forcer l'arrêt",
"working": "Fonctionnement..." "working": "En cours...",
"startPending": "Démarrage...",
"shutdownPending": "Extinction...",
"rebootPending": "Redémarrage...",
"stopPending": "Arrêt..."
}, },
"confirm": { "confirm": {
"forceStopTitle": "Forcer l'arrêt", "forceStopTitle": "Forcer l'arrêt",
@@ -1134,7 +1138,8 @@
"saveCustomCommandFailed": "Impossible d'enregistrer la commande de mise à jour personnalisée : {message}", "saveCustomCommandFailed": "Impossible d'enregistrer la commande de mise à jour personnalisée : {message}",
"removeCustomCommandConfirm": "Supprimer la commande de mise à jour personnalisée pour \"{name}\" ?", "removeCustomCommandConfirm": "Supprimer la commande de mise à jour personnalisée pour \"{name}\" ?",
"removeCustomCommandFailed": "Impossible de supprimer la commande de mise à jour personnalisée : {message}", "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": { "backupModal": {
"title": "Sauvegarde {type} {vmid} ({name})", "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.", "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.", "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", "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": { "bulkUpdate": {
"title": "Mise à jour groupée", "title": "Mise à jour groupée",
@@ -1484,7 +1499,10 @@
"saveFailed": "Impossible d’enregistrer la configuration de mise à jour groupée.", "saveFailed": "Impossible d’enregistrer la configuration de mise à jour groupée.",
"deleteFailed": "Impossible de supprimer 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.", "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": { "appEditor": {
"methodOciImage": "Image OCI (installée par ProxMenux)", "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é.", "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", "loadExample": "Charger un exemple",
"startFromScratch": "Repartir de zéro" "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": { "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.", "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).", "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.", "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.", "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.", "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.", "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": { "lxcTerminal": {
"title": "Terminal : {name} (ID : {id})", "title": "Terminal : {name} (ID : {id})",
"errors": { "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": { "commands": {
"listFiles": "Lister tous les fichiers avec des détails", "listFiles": "Lister tous les fichiers avec des détails",
@@ -5225,7 +5256,8 @@
"yes": "Oui", "yes": "Oui",
"yourInput": "Votre contribution :", "yourInput": "Votre contribution :",
"submit": "Soumettre", "submit": "Soumettre",
"ok": "OK" "ok": "OK",
"endOfInput": "Fin de la saisie"
}, },
"apps": { "apps": {
"searchPlaceholder": "Search app or CT…", "searchPlaceholder": "Search app or CT…",
+39 -7
View File
@@ -1085,7 +1085,11 @@
"shutdown": "Spegni", "shutdown": "Spegni",
"reboot": "Riavvia", "reboot": "Riavvia",
"forceStop": "Forza arresto", "forceStop": "Forza arresto",
"working": "Operazione in corso..." "working": "Operazione in corso...",
"startPending": "Avvio...",
"shutdownPending": "Spegnimento...",
"rebootPending": "Riavvio...",
"stopPending": "Arresto..."
}, },
"confirm": { "confirm": {
"forceStopTitle": "Forza arresto", "forceStopTitle": "Forza arresto",
@@ -1134,7 +1138,8 @@
"saveCustomCommandFailed": "Impossibile salvare il comando di aggiornamento personalizzato: {message}", "saveCustomCommandFailed": "Impossibile salvare il comando di aggiornamento personalizzato: {message}",
"removeCustomCommandConfirm": "Rimuovere il comando di aggiornamento personalizzato per \"{name}\"?", "removeCustomCommandConfirm": "Rimuovere il comando di aggiornamento personalizzato per \"{name}\"?",
"removeCustomCommandFailed": "Impossibile rimuovere il comando di aggiornamento personalizzato: {message}", "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": { "backupModal": {
"title": "Backup {type} {vmid} ({name})", "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.", "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.", "checkCooldown": "Un controllo è già in corso o è stato richiesto di recente. Riprovare tra {seconds} secondi.",
"checkStale": "Controllo obsoleto", "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": { "bulkUpdate": {
"title": "Aggiornamento in blocco", "title": "Aggiornamento in blocco",
@@ -1484,7 +1499,10 @@
"saveFailed": "Impossibile salvare la configurazione di aggiornamento in blocco.", "saveFailed": "Impossibile salvare la configurazione di aggiornamento in blocco.",
"deleteFailed": "Impossibile rimuovere 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.", "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": { "appEditor": {
"methodOciImage": "Immagine OCI (installata da ProxMenux)", "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.", "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", "loadExample": "Carica esempio",
"startFromScratch": "Inizia da zero" "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": { "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.", "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).", "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.", "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.", "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.", "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.", "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": { "lxcTerminal": {
"title": "Terminale: {name} (ID: {id})", "title": "Terminale: {name} (ID: {id})",
"errors": { "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": { "commands": {
"listFiles": "Elenca tutti i file con dettagli", "listFiles": "Elenca tutti i file con dettagli",
@@ -5225,7 +5256,8 @@
"yes": "Sì", "yes": "Sì",
"yourInput": "Input:", "yourInput": "Input:",
"submit": "Invia", "submit": "Invia",
"ok": "OK" "ok": "OK",
"endOfInput": "Fine dell'input"
}, },
"apps": { "apps": {
"searchPlaceholder": "Cerca app o CT…", "searchPlaceholder": "Cerca app o CT…",
+41 -9
View File
@@ -1081,11 +1081,15 @@
"logs": "Logs" "logs": "Logs"
}, },
"actions": { "actions": {
"start": "Começar", "start": "Iniciar",
"shutdown": "Desligar", "shutdown": "Desligar",
"reboot": "Reinício", "reboot": "Reiniciar",
"forceStop": "Forçar parada", "forceStop": "Forçar parada",
"working": "Trabalhando..." "working": "Processando...",
"startPending": "Iniciando...",
"shutdownPending": "Desligando...",
"rebootPending": "Reiniciando...",
"stopPending": "Parando..."
}, },
"confirm": { "confirm": {
"forceStopTitle": "Forçar parada", "forceStopTitle": "Forçar parada",
@@ -1134,7 +1138,8 @@
"saveCustomCommandFailed": "Não foi possível salvar o comando de atualização personalizado: {message}", "saveCustomCommandFailed": "Não foi possível salvar o comando de atualização personalizado: {message}",
"removeCustomCommandConfirm": "Remover o comando de atualização personalizado para \"{name}\"?", "removeCustomCommandConfirm": "Remover o comando de atualização personalizado para \"{name}\"?",
"removeCustomCommandFailed": "não foi possível remover o comando de atualização personalizado: {message}", "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": { "backupModal": {
"title": "Backup {type} {vmid} ({name})", "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.", "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.", "checkCooldown": "Já há uma verificação em execução ou pedida recentemente. Tente novamente dentro de {seconds} segundos.",
"checkStale": "Verificação desatualizada", "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": { "bulkUpdate": {
"title": "Atualização em bloco", "title": "Atualização em bloco",
@@ -1484,7 +1499,10 @@
"saveFailed": "Não foi possível guardar a configuração de atualização em bloco.", "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.", "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.", "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": { "appEditor": {
"methodOciImage": "Imagem OCI (instalada pelo ProxMenux)", "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.", "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", "loadExample": "Exemplo de carregamento",
"startFromScratch": "Comece do zero" "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": { "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.", "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).", "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.", "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.", "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.", "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.", "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": { "lxcTerminal": {
"title": "Terminal: {name} (ID: {id})", "title": "Terminal: {name} (ID: {id})",
"errors": { "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": { "commands": {
"listFiles": "Liste todos os arquivos com detalhes", "listFiles": "Liste todos os arquivos com detalhes",
@@ -5225,7 +5256,8 @@
"yes": "Sim", "yes": "Sim",
"yourInput": "Sua entrada:", "yourInput": "Sua entrada:",
"submit": "Enviar", "submit": "Enviar",
"ok": "OK" "ok": "OK",
"endOfInput": "Fim da entrada"
}, },
"apps": { "apps": {
"searchPlaceholder": "Search app or CT…", "searchPlaceholder": "Search app or CT…",
+38 -6
View File
@@ -1106,7 +1106,11 @@
"shutdown": "Vypnúť", "shutdown": "Vypnúť",
"reboot": "Reštartovať", "reboot": "Reštartovať",
"forceStop": "Vynútiť vypnutie", "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": { "confirm": {
"forceStopTitle": "Vynútiť vypnutie", "forceStopTitle": "Vynútiť vypnutie",
@@ -1150,6 +1154,7 @@
"unknown": "Neznáma chyba", "unknown": "Neznáma chyba",
"backupStartFailed": "Zálohu sa nepodarilo spustiť: {message}", "backupStartFailed": "Zálohu sa nepodarilo spustiť: {message}",
"controlFailed": "Akcia {action} pre VM {vmid} zlyhala: {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.", "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.", "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}", "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.", "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.", "dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.",
"osPackagesTitle": "Balíky systému", "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.", "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.", "checkCooldown": "Kontrola už prebieha alebo sa práve vykonala. Skúste znova o {seconds} sekúnd.",
"checkStale": "Zastaraná kontrola", "checkStale": "Zastaraná kontrola",
@@ -1388,6 +1400,9 @@
"applyUpdate": "Aktualizovať", "applyUpdate": "Aktualizovať",
"upToDate": "Aktuálne", "upToDate": "Aktuálne",
"runUpdater": "Spustiť aktualizáciu", "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}", "terminalTitle": "Aktualizácia — CT {vmid}",
"terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...", "terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...",
"terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...", "terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...",
@@ -1489,9 +1504,12 @@
"notConfigured": "Nenakonfigurované.", "notConfigured": "Nenakonfigurované.",
"configure": "Nastaviť", "configure": "Nastaviť",
"edit": "Upraviť", "edit": "Upraviť",
"apply": "Použiť aktualizácie", "apply": "Spustiť hromadnú aktualizáciu",
"osTarget": "Operačný systém", "osTarget": "Operačný systém",
"osRequired": "Povinné pre hromadnú aktualizáciu.", "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}", "includesDependencies": "Zahŕňa aj deklarované závislosti: {names}",
"noMethod": "Nie je dostupná žiadna spustiteľná metóda aktualizácie.", "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.", "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.", "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", "loadExample": "Načítať príklad",
"startFromScratch": "Začať od začiatku" "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": { "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.", "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).", "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.", "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.", "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.", "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.", "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": { "lxcTerminal": {
"title": "Terminál: {name} (ID: {id})", "title": "Terminál: {name} (ID: {id})",
"errors": { "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": { "commands": {
"listFiles": "Zobraziť podrobnosti o súboroch", "listFiles": "Zobraziť podrobnosti o súboroch",
@@ -5224,7 +5255,8 @@
"yes": "Áno", "yes": "Áno",
"yourInput": "Tvoj vstup:", "yourInput": "Tvoj vstup:",
"submit": "Odoslať", "submit": "Odoslať",
"ok": "OK" "ok": "OK",
"endOfInput": "Koniec zadávania"
}, },
"apps": { "apps": {
"searchPlaceholder": "Hľadať aplikáciu, VM alebo LXC…", "searchPlaceholder": "Hľadať aplikáciu, VM alebo LXC…",
+41 -9
View File
@@ -1081,11 +1081,15 @@
"logs": "Loggar" "logs": "Loggar"
}, },
"actions": { "actions": {
"start": "Start", "start": "Starta",
"shutdown": "Stängning", "shutdown": "Stäng av",
"reboot": "Starta om", "reboot": "Starta om",
"forceStop": "Tvinga stopp", "forceStop": "Tvinga stopp",
"working": "Arbetssätt..." "working": "Arbetar...",
"startPending": "Startar...",
"shutdownPending": "Stänger av...",
"rebootPending": "Startar om...",
"stopPending": "Stoppar..."
}, },
"confirm": { "confirm": {
"forceStopTitle": "Tvinga stopp", "forceStopTitle": "Tvinga stopp",
@@ -1134,7 +1138,8 @@
"saveCustomCommandFailed": "Kunde inte spara det anpassade uppdateringskommandot: {message}", "saveCustomCommandFailed": "Kunde inte spara det anpassade uppdateringskommandot: {message}",
"removeCustomCommandConfirm": "Ta bort det anpassade uppdateringskommandot för \"{name}\"?", "removeCustomCommandConfirm": "Ta bort det anpassade uppdateringskommandot för \"{name}\"?",
"removeCustomCommandFailed": "Kunde inte ta bort det anpassade uppdateringskommandot: {message}", "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": { "backupModal": {
"title": "Säkerhetskopiera {type} {vmid} ({name})", "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.", "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.", "checkCooldown": "En kontroll körs redan eller har nyligen begärts. Försök igen om {seconds} sekunder.",
"checkStale": "Föråldrad kontroll", "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": { "bulkUpdate": {
"title": "Gruppuppdatering", "title": "Gruppuppdatering",
@@ -1484,7 +1499,10 @@
"saveFailed": "Det gick inte att spara gruppuppdateringskonfigurationen.", "saveFailed": "Det gick inte att spara gruppuppdateringskonfigurationen.",
"deleteFailed": "Det gick inte att ta bort gruppuppdateringskonfigurationen.", "deleteFailed": "Det gick inte att ta bort gruppuppdateringskonfigurationen.",
"planFailed": "Det gick inte att förbereda gruppuppdateringen. Kontrollera otillgängliga metoder.", "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": { "appEditor": {
"methodOciImage": "OCI-avbild (installerad av ProxMenux)", "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.", "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", "loadExample": "Ladda exempel",
"startFromScratch": "Börja från början" "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": { "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.", "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).", "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.", "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.", "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.", "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.", "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": { "lxcTerminal": {
"title": "Terminal: {name} (ID: {id})", "title": "Terminal: {name} (ID: {id})",
"errors": { "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": { "commands": {
"listFiles": "Lista alla filer med detaljer", "listFiles": "Lista alla filer med detaljer",
@@ -5225,7 +5256,8 @@
"yes": "Ja", "yes": "Ja",
"yourInput": "Din inmatning:", "yourInput": "Din inmatning:",
"submit": "Skicka", "submit": "Skicka",
"ok": "OK" "ok": "OK",
"endOfInput": "Slut på inmatning"
}, },
"apps": { "apps": {
"searchPlaceholder": "Search app or CT…", "searchPlaceholder": "Search app or CT…",
+1
View File
@@ -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/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_observation.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_update_finalization.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/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/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" cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
+164 -3
View File
@@ -6729,6 +6729,11 @@ def _get_lxc_update_status_map() -> dict:
'managed_oci_app': it.get('_managed_oci_app'), 'managed_oci_app': it.get('_managed_oci_app'),
'os_family': it.get('_os_family'), '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 return out
@@ -6843,10 +6848,15 @@ def get_proxmox_vms():
continue continue
vm_type = 'lxc' if resource.get('type') == 'lxc' else 'qemu' 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 = { vm_data = {
'vmid': resource.get('vmid'), 'vmid': resource.get('vmid'),
'name': resource.get('name', f"VM-{resource.get('vmid')}"), 'name': resource.get('name', f"VM-{resource.get('vmid')}"),
'status': resource.get('status', 'unknown'), 'status': vm_status,
'type': vm_type, 'type': vm_type,
'cpu': resource.get('cpu', 0), 'cpu': resource.get('cpu', 0),
'mem': resource.get('mem', 0), 'mem': resource.get('mem', 0),
@@ -6901,7 +6911,7 @@ def get_proxmox_vms():
# detail-aware clients. The cache is still warmed # detail-aware clients. The cache is still warmed
# at startup and invalidated by LXC lifecycle events. # at startup and invalidated by LXC lifecycle events.
vm_data['ips'] = [] 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_ip_info = _get_lxc_ip_info_cached(resource.get('vmid'))
lxc_ips = _normalise_lxc_ips(lxc_ip_info) lxc_ips = _normalise_lxc_ips(lxc_ip_info)
vm_data['ips'] = lxc_ips vm_data['ips'] = lxc_ips
@@ -13676,19 +13686,57 @@ def api_lxc_updates_check(vmid):
'retry_after': retry}), 429, {'Retry-After': str(retry)} 'retry_after': retry}), 429, {'Retry-After': str(retry)}
_lxc_update_check_attempts[vmid] = now _lxc_update_check_attempts[vmid] = now
_lxc_update_check_inflight.add(vmid) _lxc_update_check_inflight.add(vmid)
operation_lock = None
try: try:
import lxc_check_job
operation_lock = lxc_check_job.acquire(vmid)
item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False) item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False)
if item is None: if item is None:
return jsonify({'error': 'No supported running LXC detected'}), 409 return jsonify({'error': 'No supported running LXC detected'}), 409
_vm_cache_invalidate(vmid, _lxc_os_info_cache) _vm_cache_invalidate(vmid, _lxc_os_info_cache)
return jsonify({'vmid': vmid, 'update_check': _get_lxc_update_status_map().get(str(vmid))}) 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: except Exception as exc:
return jsonify({'error': str(exc)}), 500 return jsonify({'error': str(exc)}), 500
finally: finally:
if operation_lock is not None:
operation_lock.close()
with _lxc_update_check_lock: with _lxc_update_check_lock:
_lxc_update_check_inflight.discard(vmid) _lxc_update_check_inflight.discard(vmid)
@app.route('/api/vms/<int:vmid>/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/<int:vmid>/updates/refresh-check/<job_id>', 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 ──────────────────────────────────────────── # ─── LXC Update Detection toggle ────────────────────────────────────────────
# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt # 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 # list --upgradable` scan entirely. The Notifications section keeps its own
@@ -14265,6 +14313,7 @@ def api_vm_apps_dismiss(vmid):
# ─── LXC update finalization ──────────────────────────────────────────────── # ─── LXC update finalization ────────────────────────────────────────────────
_LXC_APPLY_UPDATES_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh" _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_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}$') _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 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): 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 return
run_id = _normalise_lxc_update_run_id(params.get('RUN_ID'), create=False) run_id = _normalise_lxc_update_run_id(params.get('RUN_ID'), create=False)
if not run_id: if not run_id:
@@ -15831,6 +15882,8 @@ def api_vm_config_set(vmid):
@require_auth @require_auth
def api_vm_control(vmid): def api_vm_control(vmid):
"""Control VM/LXC (start, stop, shutdown, reboot)""" """Control VM/LXC (start, stop, shutdown, reboot)"""
operation_lock = None
task_identity_confirmed = False
try: try:
data = request.get_json() data = request.get_json()
action = data.get('action') # start, stop, shutdown, reboot 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' vm_type = 'lxc' if vm_info.get('type') == 'lxc' else 'qemu'
node = vm_info.get('node', 'pve') 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 # Execute action
control_result = subprocess.run( control_result = subprocess.run(
@@ -15860,6 +15920,37 @@ def api_vm_control(vmid):
capture_output=True, text=True, timeout=30) capture_output=True, text=True, timeout=30)
if control_result.returncode == 0: 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 # Invalidate VM resources cache so the next /api/vms call
# returns fresh status instead of the pre-action snapshot. # returns fresh status instead of the pre-action snapshot.
_pvesh_cache['cluster_resources_vm_time'] = 0 _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 # (lxc_ip_info, os_info) — a start/stop flips those, so
# drop the modal caches for this vmid. # drop the modal caches for this vmid.
_vm_cache_invalidate(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({ return jsonify({
'success': True, 'success': True,
'vmid': vmid, 'vmid': vmid,
'action': action, 'action': action,
'task': task,
'message': f'Successfully executed {action} on {vm_info.get("name")}' 'message': f'Successfully executed {action} on {vm_info.get("name")}'
}) })
else: else:
if operation_lock is not None:
lxc_check_job.cancel_control(operation_lock)
# `pvesh` failed → fire the matching vm_fail / ct_fail # `pvesh` failed → fire the matching vm_fail / ct_fail
# notification so the user gets paged on their channels # notification so the user gets paged on their channels
# too, not just an in-dashboard alert. Previously this # too, not just an in-dashboard alert. Previously this
@@ -15925,8 +16024,70 @@ def api_vm_control(vmid):
else: else:
return jsonify({'error': 'Failed to get VM details'}), 500 return jsonify({'error': 'Failed to get VM details'}), 500
except Exception as e: 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 return jsonify({'error': str(e)}), 500
finally:
if operation_lock is not None:
operation_lock.close()
@app.route('/api/vms/<int:vmid>/control-task/<path: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/<int:vmid>/description', methods=['PUT']) @app.route('/api/vms/<int:vmid>/description', methods=['PUT'])
@app.route('/api/vms/<int:vmid>/config', methods=['PUT']) # legacy alias @app.route('/api/vms/<int:vmid>/config', methods=['PUT']) # legacy alias
@require_auth @require_auth
+301
View File
@@ -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'}
+29 -1
View File
@@ -1796,6 +1796,22 @@ def _store_update_result(item: dict, result: dict) -> None:
item["update_check"][extra_key] = result[extra_key] 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]: def refresh_oci_app(app_id: str) -> Optional[dict]:
"""Read one OCI-managed application again and store what it reports. """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 _nvidia_cache["fetched_at"] = 0
updates_available: list[dict] = [] updates_available: list[dict] = []
successful_lxc_checks: list[tuple[int, int]] = []
with _lock: with _lock:
reg = _read_registry() reg = _read_registry()
items = reg.get("items", []) items = reg.get("items", [])
@@ -1854,6 +1871,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
checker = _CHECKERS.get(it.get("type")) checker = _CHECKERS.get(it.get("type"))
if not checker: if not checker:
continue continue
check_started_ns = time.time_ns() if it.get('type') == 'lxc' else None
try: try:
result = checker(it) result = checker(it)
except Exception as e: except Exception as e:
@@ -1863,13 +1881,20 @@ def check_for_updates(force: bool = False) -> list[dict]:
"last_check": _now_iso(), "error": str(e)} "last_check": _now_iso(), "error": str(e)}
_store_update_result(it, result) _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"]: if it["update_check"]["available"]:
updates_available.append(it) updates_available.append(it)
reg["items"] = items reg["items"] = items
reg["last_check_run"] = _now_iso() 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 return updates_available
@@ -1945,6 +1970,7 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di
else: else:
_merge_detected_entry(target, entry, now) _merge_detected_entry(target, entry, now)
check_started_ns = time.time_ns()
try: try:
result = (_check_lxc_updates(target) if refresh_package_cache else result = (_check_lxc_updates(target) if refresh_package_cache else
_check_lxc_updates(target, refresh_package_cache=False)) _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 reg["last_targeted_refresh"] = now
if not _write_registry(reg): if not _write_registry(reg):
raise OSError("could not persist LXC update check") 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) return dict(target)
@@ -36,6 +36,7 @@ class LxcVmIpInventoryTests(unittest.TestCase):
"_get_lxc_app_watch_map": lambda: {}, "_get_lxc_app_watch_map": lambda: {},
"_get_lxc_docker_inventory_map": lambda: {}, "_get_lxc_docker_inventory_map": lambda: {},
"get_proxmox_node_name": lambda: "pve", "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_list_search_notes": lambda _resources, _node: {},
"get_cached_vm_disk": lambda _vmid: None, "get_cached_vm_disk": lambda _vmid: None,
"_guest_lifecycle_lock": threading.RLock(), "_guest_lifecycle_lock": threading.RLock(),
+9 -3
View File
@@ -3620,7 +3620,7 @@
"Memory optimizations removed": "Speicheroptimierungen entfernt", "Memory optimizations removed": "Speicheroptimierungen entfernt",
"Memory restored.": "Speicher wiederhergestellt.", "Memory restored.": "Speicher wiederhergestellt.",
"Memory settings optimized successfully": "Speichereinstellungen erfolgreich optimiert", "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.", "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 & Notifications": "Nachrichten und Benachrichtigungen",
"Messaging & Queues": "Messaging & Queues", "Messaging & Queues": "Messaging & Queues",
@@ -3817,6 +3817,7 @@
"NICs:": "Netzwerkkarten:", "NICs:": "Netzwerkkarten:",
"NOT FOUND": "NICHT GEFUNDEN", "NOT FOUND": "NICHT GEFUNDEN",
"NOTE: The host directory and its contents will remain unchanged.": "HINWEIS: Das Hostverzeichnis und sein Inhalt bleiben unverändert.", "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.", "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)": "NVIDIEN (CUDA)",
"NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; offizielles GPU-Bild)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; offizielles GPU-Bild)",
@@ -3932,6 +3933,7 @@
"Network Restart Failed": "Netzwerkneustart fehlgeschlagen", "Network Restart Failed": "Netzwerkneustart fehlgeschlagen",
"Network Restarted": "Netzwerk neu gestartet", "Network Restarted": "Netzwerk neu gestartet",
"Network Tools": "Netzwerk-Tools", "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 access:": "Netzwerkzugriff:",
"Network bridge": "Netzbrücke", "Network bridge": "Netzbrücke",
"Network configuration backed up": "Netzwerkkonfiguration gesichert", "Network configuration backed up": "Netzwerkkonfiguration gesichert",
@@ -4070,6 +4072,7 @@
"No Shares Found": "Keine Aktien gefunden", "No Shares Found": "Keine Aktien gefunden",
"No Shares to Test": "Keine Freigaben zum Testen", "No Shares to Test": "Keine Freigaben zum Testen",
"No Storage Found": "Kein Speicher gefunden", "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 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 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.", "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 containers require Proxmox VE 9.1 or later.": "OCI-Container erfordern Proxmox VE 9.1 oder höher.",
"OCI management": "OCI-Verwaltung", "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 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 (Technology Preview)": "OCI manager Apps (Technology Preview)",
"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 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 integrity mismatch": "OCI Unstimmigkeiten bei Metadaten",
"OCI metadata too large": "OCI-Metadaten zu groß", "OCI metadata too large": "OCI-Metadaten zu groß",
"OCI stack management": "OCI-Stackmanagement", "OCI stack management": "OCI-Stackmanagement",
@@ -4510,6 +4513,7 @@
"Pass /dev/video11 to the LXC": "Pass /dev/video11 zum LXC", "Pass /dev/video11 to the LXC": "Pass /dev/video11 zum LXC",
"Pass /dev/video12 to the LXC": "Pass /dev/video12 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 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?", "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):", "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.", "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 LibreDB Studio administrator": "Passwort des LibreDB Studio-Administrators",
"Password of the MariaDB user": "Passwort des MariaDB-Benutzers", "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 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 NetBox admin account": "Passwort des NetBox Admin Accounts",
"Password of the OpenList admin user": "Passwort des OpenList-Admin-Benutzers", "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)", "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 Accelerators:": "USB-Beschleuniger:",
"USB bus directory": "USB-Busverzeichnis", "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 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 device to attach": "USB-Gerät zum Anschließen",
"USB disk target": "USB-Festplattenziel", "USB disk target": "USB-Festplattenziel",
"USB drives mounted now:": "Jetzt gemountete USB-Laufwerke:", "USB drives mounted now:": "Jetzt gemountete USB-Laufwerke:",
+8 -2
View File
@@ -3813,6 +3813,7 @@
"NICs:": "NIC:", "NICs:": "NIC:",
"NOT FOUND": "NO ENCONTRADO", "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.", "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.", "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)": "NVIDIA (CUDA)",
"NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; imagen oficial para GPU)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; imagen oficial para GPU)",
@@ -3928,6 +3929,7 @@
"Network Restart Failed": "Error al reiniciar la red", "Network Restart Failed": "Error al reiniciar la red",
"Network Restarted": "Red reiniciada", "Network Restarted": "Red reiniciada",
"Network Tools": "Herramientas de red", "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 access:": "Acceso a la red:",
"Network bridge": "Puente de red", "Network bridge": "Puente de red",
"Network configuration backed up": "Backup de la configuración de red creado", "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 Found": "No se encontraron recursos compartidos",
"No Shares to Test": "Sin recursos compartidos que probar", "No Shares to Test": "Sin recursos compartidos que probar",
"No Storage Found": "No se encontró almacenamiento", "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 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 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.", "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 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": "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 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 (Technology Preview)": "OCI manager Apps (Technology Preview)",
"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 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 integrity mismatch": "Desigualdad de la integridad de los metadatos de la OCI",
"OCI metadata too large": "OCI metadatos demasiado grandes", "OCI metadata too large": "OCI metadatos demasiado grandes",
"OCI stack management": "Gestión de pilas OCI", "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/video11 to the LXC": "Pasar /dev/video11 al LXC",
"Pass /dev/video12 to the LXC": "Pasar /dev/video12 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 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?", "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):", "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.", "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 LibreDB Studio administrator": "Contraseña del administrador de LibreDB Studio",
"Password of the MariaDB user": "Contraseña del usuario de MariaDB", "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 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 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 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)", "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 Accelerators:": "Aceleradores USB:",
"USB bus directory": "Directorio del bus 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 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 device to attach": "Dispositivo USB que añadir",
"USB disk target": "Destino de disco USB", "USB disk target": "Destino de disco USB",
"USB drives mounted now:": "Unidades USB montadas ahora:", "USB drives mounted now:": "Unidades USB montadas ahora:",
+8 -2
View File
@@ -3817,6 +3817,7 @@
"NICs:": "Cartes réseau :", "NICs:": "Cartes réseau :",
"NOT FOUND": "NON TROUVÉ", "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.", "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.", "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)": "NVIDIA (CUDA)",
"NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; image officielle du GPU)", "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 Restart Failed": "Échec du redémarrage du réseau",
"Network Restarted": "Réseau redémarré", "Network Restarted": "Réseau redémarré",
"Network Tools": "Outils réseau", "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 access:": "Accès au réseau :",
"Network bridge": "Pont réseau", "Network bridge": "Pont réseau",
"Network configuration backed up": "Configuration réseau sauvegardée", "Network configuration backed up": "Configuration réseau sauvegardée",
@@ -4070,6 +4072,7 @@
"No Shares Found": "Aucun partage trouvé", "No Shares Found": "Aucun partage trouvé",
"No Shares to Test": "aucun partage à tester", "No Shares to Test": "aucun partage à tester",
"No Storage Found": "Aucun stockage trouvé", "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 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 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.", "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 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": "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 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 (Technology Preview)": "OCI manager Apps (Technology Preview)",
"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 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 integrity mismatch": "Inadéquation de l'intégrité des métadonnées OCI",
"OCI metadata too large": "Métadonnées OCI trop grandes", "OCI metadata too large": "Métadonnées OCI trop grandes",
"OCI stack management": "Gestion de la pile OCI", "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/video11 to the LXC": "Passez /dev/video11 au LXC",
"Pass /dev/video12 to the LXC": "Passer /dev/video12 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 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?", "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) :", "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.", "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 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 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 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 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 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)", "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 Accelerators:": "Accélérateurs USB :",
"USB bus directory": "Répertoire de bus 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 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 device to attach": "Périphérique USB à connecter",
"USB disk target": "cible du disque USB", "USB disk target": "cible du disque USB",
"USB drives mounted now:": "Clés USB montées maintenant :", "USB drives mounted now:": "Clés USB montées maintenant :",
+8 -2
View File
@@ -3817,6 +3817,7 @@
"NICs:": "NIC:", "NICs:": "NIC:",
"NOT FOUND": "NON TROVATO", "NOT FOUND": "NON TROVATO",
"NOTE: The host directory and its contents will remain unchanged.": "NOTA: la directory host e il suo contenuto rimarranno invariati.", "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.", "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)": "NVIDIA (CUDA)",
"NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; immagine GPU ufficiale)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; immagine GPU ufficiale)",
@@ -3932,6 +3933,7 @@
"Network Restart Failed": "Riavvio rete non riuscito", "Network Restart Failed": "Riavvio rete non riuscito",
"Network Restarted": "Rete riavviata", "Network Restarted": "Rete riavviata",
"Network Tools": "Strumenti rete", "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 access:": "Accesso rete:",
"Network bridge": "Bridge di rete", "Network bridge": "Bridge di rete",
"Network configuration backed up": "Backup della configurazione di rete creato", "Network configuration backed up": "Backup della configurazione di rete creato",
@@ -4070,6 +4072,7 @@
"No Shares Found": "Nessuna condivisione trovata", "No Shares Found": "Nessuna condivisione trovata",
"No Shares to Test": "Nessuna condivisione da testare", "No Shares to Test": "Nessuna condivisione da testare",
"No Storage Found": "Nessuno storage trovato", "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 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 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.", "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 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": "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 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 (Technology Preview)": "OCI manager Apps (Technology Preview)",
"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 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 integrity mismatch": "Verifica integrità metadati OCI non superata",
"OCI metadata too large": "Metadati OCI troppo grandi", "OCI metadata too large": "Metadati OCI troppo grandi",
"OCI stack management": "Gestione stack OCI", "OCI stack management": "Gestione stack OCI",
@@ -4510,6 +4513,7 @@
"Pass /dev/video11 to the LXC": "Passa /dev/video11 al container LXC", "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 /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 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?", "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):", "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.", "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 LibreDB Studio administrator": "Password amministratore LibreDB Studio",
"Password of the MariaDB user": "Password dell'utente MariaDB", "Password of the MariaDB user": "Password dell'utente MariaDB",
"Password of the MineOS web interface user": "Password utente interfaccia web MineOS", "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 NetBox admin account": "Password account amministratore NetBox",
"Password of the OpenList admin user": "Password amministratore OpenList", "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)", "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 Accelerators:": "Acceleratori USB:",
"USB bus directory": "Directory bus 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 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 device to attach": "Dispositivo USB da collegare",
"USB disk target": "Destinazione disco USB", "USB disk target": "Destinazione disco USB",
"USB drives mounted now:": "Unità USB montate ora:", "USB drives mounted now:": "Unità USB montate ora:",
+8 -2
View File
@@ -3817,6 +3817,7 @@
"NICs:": "Placas de rede:", "NICs:": "Placas de rede:",
"NOT FOUND": "NÃO ENCONTRADO", "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.", "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.", "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)": "NVIDIA (CUDA)",
"NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; imagem oficial da GPU)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; imagem oficial da GPU)",
@@ -3932,6 +3933,7 @@
"Network Restart Failed": "Falha ao reiniciar a rede", "Network Restart Failed": "Falha ao reiniciar a rede",
"Network Restarted": "Rede reiniciada", "Network Restarted": "Rede reiniciada",
"Network Tools": "Ferramentas de rede", "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 access:": "Acesso à rede:",
"Network bridge": "Ponte de rede", "Network bridge": "Ponte de rede",
"Network configuration backed up": "Configuração de rede com backup", "Network configuration backed up": "Configuração de rede com backup",
@@ -4070,6 +4072,7 @@
"No Shares Found": "Nenhum compartilhamento encontrado", "No Shares Found": "Nenhum compartilhamento encontrado",
"No Shares to Test": "Sem compartilhamentos para testar", "No Shares to Test": "Sem compartilhamentos para testar",
"No Storage Found": "Nenhum armazenamento encontrado", "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 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 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.", "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 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": "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 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 (Technology Preview)": "OCI manager Apps (Technology Preview)",
"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 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 integrity mismatch": "Incompatibilidade da integridade dos metadados OCI",
"OCI metadata too large": "Metadados OCI demasiado grandes", "OCI metadata too large": "Metadados OCI demasiado grandes",
"OCI stack management": "Gestão de pilhas OCI", "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/video11 to the LXC": "Passe /dev/video11 para o LXC",
"Pass /dev/video12 to the LXC": "Passe /dev/video12 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 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?", "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):", "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.", "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 LibreDB Studio administrator": "Senha do administrador do LibreDB Studio",
"Password of the MariaDB user": "Senha do usuário MariaDB", "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 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 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 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)", "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 Accelerators:": "Aceleradores USB:",
"USB bus directory": "Directório do barramento 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 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 device to attach": "dispositivo USB para conectar",
"USB disk target": "destino de disco USB", "USB disk target": "destino de disco USB",
"USB drives mounted now:": "unidades USB montadas agora:", "USB drives mounted now:": "unidades USB montadas agora:",
+8
View File
@@ -3817,6 +3817,7 @@
"NICs:": "Sieťové karty:", "NICs:": "Sieťové karty:",
"NOT FOUND": "NENÁJDENÉ", "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.", "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.", "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)": "NVIDIA (CUDA)",
"NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; oficiálny obraz GPU)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA; oficiálny obraz GPU)",
@@ -3932,6 +3933,7 @@
"Network Restart Failed": "Reštart siete zlyhal", "Network Restart Failed": "Reštart siete zlyhal",
"Network Restarted": "Sieť bola reštartovaná", "Network Restarted": "Sieť bola reštartovaná",
"Network Tools": "Sieťové nástroje", "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 access:": "Sieťový prístup:",
"Network bridge": "Sieťový most", "Network bridge": "Sieťový most",
"Network configuration backed up": "Nastavenie siete bolo zálohované", "Network configuration backed up": "Nastavenie siete bolo zálohované",
@@ -4070,6 +4072,7 @@
"No Shares Found": "Nenašli sa žiadne zdieľania", "No Shares Found": "Nenašli sa žiadne zdieľania",
"No Shares to Test": "Žiadne zdieľania na testovanie", "No Shares to Test": "Žiadne zdieľania na testovanie",
"No Storage Found": "Nenašlo sa žiadne úložisko", "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 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 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ľ.", "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 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": "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 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 (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 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 integrity mismatch": "Nepomer integrity metaúdajov OCI",
"OCI metadata too large": "Metaúdaje OCI príliš veľké", "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/video11 to the LXC": "Prešiel /dev/video11 na LXC",
"Pass /dev/video12 to the LXC": "Prechod /dev/video12 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 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ť?", "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):", "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ú.", "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 LibreDB Studio administrator": "Heslo správcu LibreDB Studio",
"Password of the MariaDB user": "Heslo používateľa MariaDB", "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 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 NetBox admin account": "Heslo admin účtu NetBox",
"Password of the OpenList admin user": "Heslo užívateľa OpenList admin", "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)", "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 Accelerators:": "USB akcelerátory:",
"USB bus directory": "Adresár USB zbernice", "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 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 device to attach": "Zariadenie USB na pripojenie",
"USB disk target": "Cieľ na USB disku", "USB disk target": "Cieľ na USB disku",
"USB drives mounted now:": "Aktuálne pripojené USB disky:", "USB drives mounted now:": "Aktuálne pripojené USB disky:",
+8 -2
View File
@@ -3817,6 +3817,7 @@
"NICs:": "NIC:er:", "NICs:": "NIC:er:",
"NOT FOUND": "HITTADE INTE", "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.", "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.", "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)": "NVIDIA (CUDA)",
"NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA, officiell GPU-bild)", "NVIDIA (CUDA; official GPU image)": "NVIDIA (CUDA, officiell GPU-bild)",
@@ -3932,6 +3933,7 @@
"Network Restart Failed": "Omstart av nätverket misslyckades", "Network Restart Failed": "Omstart av nätverket misslyckades",
"Network Restarted": "Nätverket har startat om", "Network Restarted": "Nätverket har startat om",
"Network Tools": "Nätverksverktyg", "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 access:": "Nätverksåtkomst:",
"Network bridge": "Nätverksbro", "Network bridge": "Nätverksbro",
"Network configuration backed up": "Nätverkskonfiguration säkerhetskopierad", "Network configuration backed up": "Nätverkskonfiguration säkerhetskopierad",
@@ -4070,6 +4072,7 @@
"No Shares Found": "Inga aktier hittades", "No Shares Found": "Inga aktier hittades",
"No Shares to Test": "Inga aktier att testa", "No Shares to Test": "Inga aktier att testa",
"No Storage Found": "Ingen lagring hittades", "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 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 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.", "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 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": "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 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 (Technology Preview)": "OCI manager Apps (Technology Preview)",
"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 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 integrity mismatch": "OCI metadata integritet mismatch",
"OCI metadata too large": "OCI metadata för stor", "OCI metadata too large": "OCI metadata för stor",
"OCI stack management": "OCI stackhantering", "OCI stack management": "OCI stackhantering",
@@ -4510,6 +4513,7 @@
"Pass /dev/video11 to the LXC": "Pass /dev/video11 till LXC", "Pass /dev/video11 to the LXC": "Pass /dev/video11 till LXC",
"Pass /dev/video12 to the LXC": "Pass /dev/video12 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 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?", "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):", "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.", "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 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 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 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 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 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)", "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 Accelerators:": "USB-acceleratorer:",
"USB bus directory": "USB buss katalog", "USB bus directory": "USB buss katalog",
"USB bus numbers can change after reconnecting or rebooting.": "USB-bussnummer kan ändras efter återanslutning eller omstart.", "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 device to attach": "USB-enhet att ansluta",
"USB disk target": "USB-diskmål", "USB disk target": "USB-diskmål",
"USB drives mounted now:": "USB-enheter monterade nu:", "USB drives mounted now:": "USB-enheter monterade nu:",
+1 -1
View File
@@ -2,7 +2,7 @@
<!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. --> <!-- Generated from oci/catalog/verification.json by .github/scripts/oci_validation.py. Do not edit by hand. -->
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. 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.
+469
View File
@@ -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
}
}
+469
View File
@@ -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
}
}
+2 -1
View File
@@ -12,6 +12,7 @@
"makemkv", "makemkv",
"mkvtoolnix", "mkvtoolnix",
"nextcloud-stack", "nextcloud-stack",
"nut-server",
"open-webui", "open-webui",
"open-webui-cuda", "open-webui-cuda",
"open-webui-ollama", "open-webui-ollama",
@@ -22,7 +23,7 @@
"stremio", "stremio",
"tandoor" "tandoor"
], ],
"generated_count": 21, "generated_count": 22,
"failed": [], "failed": [],
"failed_count": 0 "failed_count": 0
} }
+32
View File
@@ -7154,6 +7154,38 @@
"hidden": false, "hidden": false,
"template_id": "image-node-red" "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", "id": "nzbfast",
"source_app_id": "nzbfast", "source_app_id": "nzbfast",
+6 -6
View File
@@ -29,7 +29,7 @@ from .installer import (
run_remote_install, run_remote_install,
run_remote_rclone_mount, 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] 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}") lines.append(f"{label + ':':<17} {value}")
row(translate("Source"), publisher(item)) 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): if is_tested(item):
row(translate("Status"), translate("Verified")) row(translate("Status"), translate("Verified"))
row(translate("Architectures"), _display_architectures(ui)) 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"): if result.get("log"):
console.msg_note(f"{translate('Installation log:')} {result['log']}") console.msg_note(f"{translate('Installation log:')} {result['log']}")
print() 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.")) "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()] for number, key in category_by_index.items()]
selection = "search" selection = "search"
while True: while True:
selection = ui.choose(translate("Select a category or search for applications:"), options, selection, selection = ui.choose(translate("Select a category or search for applications:"), ReadableTags(options),
size=MENU_SIZE) selection, size=MENU_SIZE)
if selection is None: if selection is None:
return 0 return 0
try: try:
@@ -612,7 +612,7 @@ def _template_summary_text(template: dict[str, Any]) -> str:
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="oci_manager_apps.sh", 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 = parser.add_subparsers(dest="command")
subparsers.add_parser("sync", help="Refresh the index from the image sources") subparsers.add_parser("sync", help="Refresh the index from the image sources")
list_parser = subparsers.add_parser("list", help="List applications") list_parser = subparsers.add_parser("list", help="List applications")
+2 -2
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from . import host from . import host
from .i18n import translate 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') 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.')) ui.message(translate('No selectable USB device was found. Type the host node by hand.'))
return ui.ask(manual_prompt, default or '/dev/ttyACM0') return ui.ask(manual_prompt, default or '/dev/ttyACM0')
options.append(('manual', translate('Another node, typed by hand'))) 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: if selected is None:
raise UserCancelled(translate('Device configuration cancelled')) raise UserCancelled(translate('Device configuration cancelled'))
return ui.ask(manual_prompt, default or '/dev/ttyACM0') if selected == 'manual' else selected return ui.ask(manual_prompt, default or '/dev/ttyACM0') if selected == 'manual' else selected
+19 -4
View File
@@ -18,7 +18,7 @@ from typing import Any
from . import host from . import host
from . import network as access from . import network as access
from .i18n import source_text, translate 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 .custom_mounts import ask_custom_mounts
from .extra_devices import device_permissions 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) 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: def external_services_notice(template: dict[str, Any]) -> str | None:
"""What an application needs already running on the network, as a database """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 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')}") options = [(row["storage"], f"{row.get('type', '')} {host.gib(row.get('avail'))} GB {translate('free')}")
for row in rows] for row in rows]
names = [name for name, _ in options] 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: if selected is None:
raise UserCancelled(text) raise UserCancelled(text)
return selected return selected
@@ -289,7 +302,7 @@ def ask_bridge(ui, text: str, default: str, mode: str = ADVANCED_MODE) -> str:
return ui.ask(text, default) return ui.ask(text, default)
options = [(row["iface"], row.get("cidr") or (row.get("comments") or "").strip() or "-") for row in rows] options = [(row["iface"], row.get("cidr") or (row.get("comments") or "").strip() or "-") for row in rows]
names = [name for name, _ in options] 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: if selected is None:
raise UserCancelled(text) raise UserCancelled(text)
return selected 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 vendor_id in item.get("drm_vendor_ids", []) for node in host.gpus()[vendor]]
if nodes and item["host_path_default"] not in nodes: if nodes and item["host_path_default"] not in nodes:
item = {**item, "host_path_default": nodes[0]} item = {**item, "host_path_default": nodes[0]}
if item.get("purpose") == "ups":
item = ups_device_request(item, advanced)
if not advanced: if not advanced:
host_path = item["host_path_default"] 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 from .extra_devices import choose_usb_device
host_path = choose_usb_device(ui, translate(item["path_prompt"]), item["host_path_default"], host_path = choose_usb_device(ui, translate(item["path_prompt"]), item["host_path_default"],
{device.get("host_path") for device in devices}) {device.get("host_path") for device in devices})
+29 -21
View File
@@ -254,27 +254,33 @@ def _interactive_management(project, ui):
recover_automatically(project) recover_automatically(project)
offer_recovery(project, ui) offer_recovery(project, ui)
carry_records(project, mount_stopped=False, shown=True) carry_records(project, mount_stopped=False, shown=True)
rows = saved_inventory(project) # After each operation the list is shown again. Every operation leaves
if not rows: # the copies of the records up to date, so they are not all refreshed again.
ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management')) first = True
return while True:
# Same layout as the catalog lists; only an unusual state is shown. rows = saved_inventory(project)
tag_width = max(len(str(r['vmid'])) for r in rows) if not rows:
header = f" {'CT':<{tag_width + 2}}{translate('Application')[:32]:<32} {translate('Image')}" if first:
options = [] ui.message(translate('No registered OCI containers are available for selection on this host.'), translate('OCI management'))
for r in rows: return
line = f"{(r['title'] or r['hostname'])[:32]:<32} " first = False
if r['status'] == 'installed': # Same layout as the catalog lists; only an unusual state is shown.
line += r['image'][:30] tag_width = max(len(str(r['vmid'])) for r in rows)
else: header = f" {'CT':<{tag_width + 2}}{translate('Application')[:32]:<32} {translate('Image')}"
line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn" options = []
options.append((str(r['vmid']), f"{line:<72}")) for r in rows:
selection = ui.choose(header, options, size=menu_size(), colors=True, line = f"{(r['title'] or r['hostname'])[:32]:<32} "
title=translate('Manage installed OCI applications')) if r['status'] == 'installed':
if selection is None: line += r['image'][:30]
return else:
row = next(r for r in rows if str(r['vmid']) == selection) line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn"
manage_instance(project, ui, row) 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): 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)) done.update(oci_watchdog.set_watchdog(instances.ROOT, vmid, enabled))
except (OSError, ValueError, KeyError): except (OSError, ValueError, KeyError):
return False 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 return True
+23 -3
View File
@@ -107,7 +107,7 @@ class BacktrackUI:
raise UserCancelled(translate('Wizard cancelled')) raise UserCancelled(translate('Wizard cancelled'))
APP_TITLE = translate("OCI manager Apps (beta)") APP_TITLE = translate("OCI manager Apps (Technology Preview)")
@dataclass @dataclass
@@ -190,6 +190,11 @@ class TerminalUI:
# Width of the windows of the installer: questions, choices and notices. The # 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. # final summary and the page of an application, which show tables, are wider.
MENU_WIDTH = 72 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 SUMMARY_WIDTH = 78
DETAIL_WINDOW_WIDTH = 90 DETAIL_WINDOW_WIDTH = 90
@@ -279,6 +284,19 @@ class DialogUI:
def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None, def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None, show_tags: bool = True, title: str | None = None, show_tags: bool = True,
size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None: 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: if size:
widget = ["--colors"] if colors else [] widget = ["--colors"] if colors else []
widget += ["--default-item", default] if default else [] widget += ["--default-item", default] if default else []
@@ -286,7 +304,8 @@ class DialogUI:
for tag, label in options: for tag, label in options:
widget += [tag, label] widget += [tag, label]
result = self._run(widget, title) 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)) terminal = shutil.get_terminal_size((100, 30))
tag_width = max((len(tag) for tag, _ in options), default=0) + 2 if show_tags else 0 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, 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: for tag, label in options:
widget += [tag, label] widget += [tag, label]
result = self._run(widget, title) 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, def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None) -> str | None: title: str | None = None) -> str | None:
+60
View File
@@ -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()
+32
View File
@@ -90,5 +90,37 @@ class UsbDevicesTests(unittest.TestCase):
[("/dev/ttyACM0", "/dev/ttyACM0")]) [("/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__": if __name__ == "__main__":
unittest.main() unittest.main()
+30 -14
View File
@@ -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. # 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}" 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() { _proxmenux_vfio_bind_write_udev_rule() {
# Always nuke the obsolete init-top hook from earlier attempts (if it # 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 # still exists) so a stale copy in initramfs can't run alongside the
@@ -470,19 +498,7 @@ _proxmenux_vfio_bind_write_udev_rule() {
fi fi
mkdir -p "$(dirname "$PROXMENUX_VFIO_BIND_UDEV_RULE")" mkdir -p "$(dirname "$PROXMENUX_VFIO_BIND_UDEV_RULE")"
{ _proxmenux_vfio_bind_render_udev_rule > "$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"
udevadm control --reload-rules >/dev/null 2>&1 || true udevadm control --reload-rules >/dev/null 2>&1 || true
} }
@@ -549,7 +565,7 @@ _proxmenux_vfio_bind_add_bdfs() {
changed=true changed=true
fi fi
done done
if $changed; then if $changed || ! _proxmenux_vfio_bind_udev_rule_current; then
_proxmenux_vfio_bind_write_udev_rule _proxmenux_vfio_bind_write_udev_rule
_proxmenux_nvidia_vfio_policy_sync || true _proxmenux_nvidia_vfio_policy_sync || true
_proxmenux_mark_host_config_changed _proxmenux_mark_host_config_changed
+11 -2
View File
@@ -96,20 +96,29 @@ def evaluate(suite, apply=False):
pve = [] pve = []
ceph = [] ceph = []
debian = False debian = False
wrong_pve = []
wrong_ceph = []
for path, index, row in entries: for path, index, row in entries:
if not row['Enabled'] or 'deb' not in row['Types']: if not row['Enabled'] or 'deb' not in row['Types']:
continue continue
components = set(row['Components']) components = set(row['Components'])
if components & PVE_CHANNELS: if components & PVE_CHANNELS:
if suite not in row['Suites']: 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)) pve.append((path, index, row))
if 'main' in components and suite in row['Suites'] and not components & PVE_CHANNELS: if 'main' in components and suite in row['Suites'] and not components & PVE_CHANNELS:
debian = True debian = True
if 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']): if 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']):
if suite not in row['Suites']: 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)) 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): if any(set(row['Components']) & {'pve-no-subscription', 'pve-test', 'pvetest'} for _, _, row in pve):
return 'preserve' return 'preserve'
+5
View File
@@ -339,6 +339,11 @@ evaluate_host_reboot_requirement() {
needs_change=true needs_change=true
fi fi
done 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 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_drm pre: vfio-pci" "$vfio_conf" || needs_change=true
_file_has_exact_line "softdep nvidia_modeset pre: vfio-pci" "$vfio_conf" || needs_change=true _file_has_exact_line "softdep nvidia_modeset pre: vfio-pci" "$vfio_conf" || needs_change=true
@@ -67,6 +67,28 @@ grep -q 'ids=8086:7ad0' "$PROXMENUX_VFIO_CONF"
! grep -qi '10de:228b' "$PROXMENUX_VFIO_CONF" ! grep -qi '10de:228b' "$PROXMENUX_VFIO_CONF"
_proxmenux_all_nvidia_in_vfio _proxmenux_all_nvidia_in_vfio
[[ -f "$PROXMENUX_NVIDIA_VFIO_BLACKLIST" ]] [[ -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, # 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 # remove the global NVIDIA blacklist, retain NVIDIA softdeps, and restore any
+22 -1
View File
@@ -63,6 +63,13 @@ ALLOW_HELPER_WITH_CUSTOM="${ALLOW_HELPER_WITH_CUSTOM:-0}"
DOCKER_STANDALONE_TARGETS="${DOCKER_STANDALONE_TARGETS:-}" DOCKER_STANDALONE_TARGETS="${DOCKER_STANDALONE_TARGETS:-}"
UPDATE_DOCKER_ENGINE="${UPDATE_DOCKER_ENGINE:-0}" 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 if [[ ! "$VMID" =~ ^[1-9][0-9]*$ ]]; then
echo "ERROR: VMID must be a positive integer." >&2 echo "ERROR: VMID must be a positive integer." >&2
exit 1 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 # 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. # UI or the scheduler. The descriptor remains open for this process.
LOCK_DIR="${PROXMENUX_LOCK_DIR:-/run/lock}" 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 if ! flock -n 9; then
echo "ERROR: another ProxMenux update is already running for CT $VMID." >&2 echo "ERROR: another ProxMenux update is already running for CT $VMID." >&2
exit 7 exit 7
fi 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) STARTED_AT=$(date -Iseconds)
NODE=$(hostname) NODE=$(hostname)
echo "=== ProxMenux LXC update — CT $VMID on $NODE ===" echo "=== ProxMenux LXC update — CT $VMID on $NODE ==="
@@ -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"
+1 -1
View File
@@ -73,7 +73,7 @@ show_menu() {
4 "$(translate "Disk Manager")" \ 4 "$(translate "Disk Manager")" \
5 "$(translate "Storage & Share Manager")" \ 5 "$(translate "Storage & Share Manager")" \
6 "$(translate "Proxmox VE Helper Scripts")" \ 6 "$(translate "Proxmox VE Helper Scripts")" \
o "$(translate "OCI manager Apps (beta)")" \ o "$(translate "OCI manager Apps (Technology Preview)")" \
7 "$(translate "Network Management")" \ 7 "$(translate "Network Management")" \
8 "$(translate "Security")" \ 8 "$(translate "Security")" \
9 "$(translate "Utilities and Tools")" \ 9 "$(translate "Utilities and Tools")" \
+2 -2
View File
@@ -36,8 +36,8 @@
"keywords": "" "keywords": ""
}, },
{ {
"title": "OCI manager Apps (beta)", "title": "OCI manager Apps (Technology Preview)",
"menu": "OCI manager Apps (beta)", "menu": "OCI manager Apps (Technology Preview)",
"script": "oci/oci_manager_apps.sh", "script": "oci/oci_manager_apps.sh",
"keywords": "docker container image contenedor imagen apps" "keywords": "docker container image contenedor imagen apps"
}, },
+27 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ========================================================== # ==========================================================
# ProxMenux - OCI manager Apps (beta) # ProxMenux - OCI manager Apps (Technology Preview)
# ========================================================== # ==========================================================
# Author : MacRimi # Author : MacRimi
# Copyright : (c) 2024 MacRimi # Copyright : (c) 2024 MacRimi
@@ -37,7 +37,7 @@ ensure_oci_dependencies() {
[[ ${#missing[@]} -eq 0 ]] && return 0 [[ ${#missing[@]} -eq 0 ]] && return 0
show_proxmenux_logo show_proxmenux_logo
msg_title "$(translate "OCI manager Apps (beta)")" msg_title "$(translate "OCI manager Apps (Technology Preview)")"
msg_info "$(translate "Installing required packages...")" msg_info "$(translate "Installing required packages...")"
apt-get update >/dev/null 2>&1 apt-get update >/dev/null 2>&1
local status local status
@@ -56,6 +56,31 @@ ensure_oci_dependencies() {
stop_spinner 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 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.")" msg_error "$(translate "The OCI engine is not installed. Update ProxMenux and try again.")"
exit 1 exit 1
+21 -29
View File
@@ -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 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 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" "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 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 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 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" "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" "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() { function select_linux_cloudinit() {
# The maintained Debian and Ubuntu helpers select their own release.
local CLOUDINIT_OPTIONS=( local CLOUDINIT_OPTIONS=(
"1" "Arch Linux (Cloud-Init automated) │ Helper-Scripts" "1" "Arch Linux (Cloud-Init automated) │ Helper-Scripts"
"2" "Debian 13 (Cloud-Init automated) │ Helper-Scripts" "2" "Debian (Cloud-Init automated) │ Helper-Scripts"
"3" "Debian 12 (Cloud-Init automated) │ Helper-Scripts" "3" "Ubuntu (Cloud-Init automated) │ Helper-Scripts"
"4" "Ubuntu 22.04 (Cloud-Init automated) │ Helper-Scripts" "4" "$(translate "Return to Main Menu")"
"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")"
) )
local script_selection local script_selection script_url script_content
script_selection=$(dialog --backtitle "ProxMenux" --title "$(translate "Cloud-Init Automated Installers")" \ 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 \ --menu "\n$(translate "Select a pre-configured Linux VM script to execute:")" 20 78 10 \
"${CLOUDINIT_OPTIONS[@]}" 3>&1 1>&2 2>&3) "${CLOUDINIT_OPTIONS[@]}" 3>&1 1>&2 2>&3)
@@ -190,34 +187,29 @@ function select_linux_cloudinit() {
case "$script_selection" in case "$script_selection" in
1) 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) 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) 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) 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
;; ;;
*)
return 1
;;
esac 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...")" msg_success "$(translate "Press Enter to return to menu...")"
read -r read -r
+4 -3
View File
@@ -100,7 +100,7 @@ function resolve_truenas_core_iso() {
} }
function resolve_omv_iso() { function resolve_omv_iso() {
local default_ver="8.1.1" local default_ver="8.3.1"
local detected_ver="" local detected_ver=""
if _has_curl; then if _has_curl; then
@@ -157,13 +157,14 @@ function resolve_xigmanas_iso() {
} }
function resolve_rockstor_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 detected_file=""
local base_url="https://rockstor.com/downloads/installer/leap/15.6/x86_64" local base_url="https://rockstor.com/downloads/installer/leap/15.6/x86_64"
if _has_curl; then if _has_curl; then
detected_file=$( 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' \ | grep -Eo 'Rockstor-Leap15\.6-generic\.x86_64-[0-9]+\.[0-9]+\.[0-9]+-[0-9]+\.install\.iso' \
| _latest_version_from_lines | _latest_version_from_lines
) )
+25 -23
View File
@@ -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 # Create VM
# ========================================================== # ==========================================================
@@ -269,18 +288,9 @@ function create_vm() {
local ISO_DIR="/var/lib/vz/template/iso" 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 if download_vm_iso "$ISO_URL" "$ISO_PATH"; 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
ISO_VOLID="${ISO_VOLID:-local:iso/$(basename "$ISO_PATH")}" ISO_VOLID="${ISO_VOLID:-local:iso/$(basename "$ISO_PATH")}"
msg_ok "$(translate "ISO image downloaded")" msg_ok "$(translate "ISO image downloaded")"
else else
@@ -662,21 +672,13 @@ fi
case "$VIRTIO_OPTION" in case "$VIRTIO_OPTION" in
1) 1)
if [[ -f "$VIRTIO_DIR/virtio-win.iso" ]]; then if [[ ! -f "$VIRTIO_DIR/virtio-win.iso" || ! -s "$VIRTIO_DIR/virtio-win.iso" ]] \
if whiptail --title "ProxMenux" --yesno "$(translate "A VirtIO ISO already exists. Do you want to overwrite it?")" 10 60; then || 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 download_vm_iso "$VIRTIO_DOWNLOAD_URL" "$VIRTIO_DIR/virtio-win.iso"; then
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
msg_ok "$(translate "VirtIO driver ISO downloaded successfully.")" msg_ok "$(translate "VirtIO driver ISO downloaded successfully.")"
else else
msg_error "$(translate "Failed to download VirtIO driver ISO.")" msg_error "$(translate "Failed to download VirtIO driver ISO.")"
continue
fi fi
fi fi
+19
View File
@@ -67,6 +67,10 @@ EOF
cat >"$MOCK_BIN/python3" <<'EOF' cat >"$MOCK_BIN/python3" <<'EOF'
#!/bin/bash #!/bin/bash
cat >/dev/null cat >/dev/null
if [[ "${1:-}" == '-c' ]]; then
[[ "${MOCK_CHECK_FAIL:-0}" != '1' ]]
exit $?
fi
if [[ "${2:-}" == 'protect-update-command' ]]; then if [[ "${2:-}" == 'protect-update-command' ]]; then
printf '%s' "$UPDATE_COMMAND" printf '%s' "$UPDATE_COMMAND"
exit 0 exit 0
@@ -82,6 +86,7 @@ EOF
cat >"$MOCK_BIN/vzdump" <<'EOF' cat >"$MOCK_BIN/vzdump" <<'EOF'
#!/bin/bash #!/bin/bash
echo "vzdump $*" >>"$MOCK_LOG" echo "vzdump $*" >>"$MOCK_LOG"
[[ "${MOCK_BACKUP_FAIL:-0}" != '1' ]]
EOF EOF
chmod +x "$MOCK_BIN"/* 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" [[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater" [[ $(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" echo "apply_updates.sh: all deterministic tests passed"
+348
View File
@@ -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()
@@ -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()
@@ -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') 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') 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')
@@ -138,6 +138,7 @@ esac
notification = Mock() notification = Mock()
ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification, ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification,
_LXC_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'), _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_run_id=lambda value, **_: value,
_normalise_lxc_update_targets=lambda values, _: values, _normalise_lxc_update_targets=lambda values, _: values,
_normalise_lxc_update_labels=lambda values: values, _normalise_lxc_update_labels=lambda values: values,
+56
View File
@@ -22,6 +22,7 @@ from flask import Flask, jsonify
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "AppImage/scripts")) sys.path.insert(0, str(ROOT / "AppImage/scripts"))
import managed_installs as managed import managed_installs as managed
import lxc_check_job
SERVER = ROOT / "AppImage/scripts/flask_server.py" SERVER = ROOT / "AppImage/scripts/flask_server.py"
FUNCTIONS = { FUNCTIONS = {
@@ -50,6 +51,9 @@ class ObservedStateRoutesTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.temp = tempfile.TemporaryDirectory() self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup) 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 = Path(self.temp.name) / "100.conf"
self.conf.write_text("onboot: 0\nnet0: name=eth0,ip=dhcp\ntags: old\n") 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)) 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) self.assertEqual(response.status_code, 200, response.json)
return response.json[0] 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): def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self):
first = self.row() first = self.row()
self.assertEqual(first["ip"], "10.100.100.222") 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(managed.get_registry()["items"][0]["_os_family"], "debian")
self.assertEqual(self.row()["update_check"]["count"], 0) 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): def test_existing_global_refresh_route_keeps_its_collector_contract(self):
with patch.object(managed, "_DETECTORS", [managed._detect_lxc_containers]), \ with patch.object(managed, "_DETECTORS", [managed._detect_lxc_containers]), \
patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh: 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") self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian")
def test_existing_lifecycle_refresh_keeps_index_refresh_default(self): 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: with patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh:
item = managed.refresh_lxc(100) item = managed.refresh_lxc(100)
refresh.assert_called_once_with("100", "debian") refresh.assert_called_once_with("100", "debian")
self.assertEqual(item["update_check"]["_count"], 0) self.assertEqual(item["update_check"]["_count"], 0)
self.assertFalse(marker.exists())
def test_other_lxc_has_independent_ip_cache_and_manual_rate_limit(self): def test_other_lxc_has_independent_ip_cache_and_manual_rate_limit(self):
conf200 = Path(self.temp.name) / "200.conf" conf200 = Path(self.temp.name) / "200.conf"
+370 -10
View File
@@ -19,6 +19,7 @@ const requests = []
const helpers = compile(fs.readFileSync(path.join(root, "lib/lxc-update-observation.ts"), "utf8"), { const helpers = compile(fs.readFileSync(path.join(root, "lib/lxc-update-observation.ts"), "utf8"), {
exports: {}, require: () => ({ fetchApi: async (...args) => { requests.push(args); return {} } }), 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") assert.equal(requests.length, 0, "loading helpers never schedules a package check")
const now = Date.parse("2026-10-13T12:00:00Z") const now = Date.parse("2026-10-13T12:00:00Z")
const recent = { last_check: "2026-10-13T11:59: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 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) const tree = ts.createSourceFile("vm.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
assert.equal(tree.parseDiagnostics.length, 0) 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 definitions = []
const preferenceDefinitions = []
const status = [] const status = []
let detailEffect let detailEffect
let resultEffect
let resultTimeout
let osApplyButton
let bulkApplyButton
let osSection
function walk(node) { 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" if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
&& node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") { && node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") {
detailEffect = node.arguments[0].getText(tree) 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)) { if (ts.isVariableDeclaration(node)) {
const name = node.name.getText(tree) const name = node.name.getText(tree)
if (wanted.has(name)) definitions.push(`const ${node.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)) if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
status.push(`const ${node.getText(tree)};`) status.push(`const ${node.getText(tree)};`)
} }
ts.forEachChild(node, walk) ts.forEachChild(node, walk)
} }
walk(tree) walk(tree)
assert.equal(definitions.length, 3) assert.equal(definitions.length, 5)
assert.equal(status.length, 3) assert.equal(status.length, 3)
assert.ok(detailEffect) assert.ok(detailEffect)
let checking = null let checking = null
@@ -73,27 +100,61 @@ const context = {
Badge: "Badge", Clock: "Clock", ArrowUpCircle: "Arrow", ChevronRight: "Chevron", Badge: "Badge", Clock: "Clock", ArrowUpCircle: "Arrow", ChevronRight: "Chevron",
t: (key, args) => key + (args ? JSON.stringify(args) : ""), t: (key, args) => key + (args ? JSON.stringify(args) : ""),
isLxcUpdateStale: o => helpers.isLxcUpdateStale(o, now), 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 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" } } }, fetchApi: async endpoint => { requests.push([endpoint]); return { config: { onboot: 1 }, os_info: { id: "debian" } } },
mutate: async () => { polls++ }, mutate: async () => { polls++ },
selectedVMRef: { current: { vmid: 100 } }, selectedVMRef: { current: { vmid: 100 } },
osCheckRequestRef: { current: null },
selectedVM: { vmid: 100 }, selectedVM: { vmid: 100 },
setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 }, setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 },
clearInterval: id => { context.clearedTimer = id }, 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 }, setVMDetails: value => { details = value },
setOsCheckingVmid: value => { checking = value }, setOsCheckingVmid: value => { checking = value },
setOsCheckError: value => { displayedError = 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") + ` const ui = compile(definitions.join("\n") + `
exports.check = checkOsUpdates; exports.check = checkOsUpdates;
exports.aggregate = getAggregateUpdateCheck; exports.aggregate = getAggregateUpdateCheck;
exports.badge = renderLxcUpdateBadge; exports.badge = renderLxcUpdateBadge;
exports.osStatus = (uc) => { ${status.join("\n")} return { osUpdateStatusKnown, hasOsUpdates }; }; exports.osStatus = (uc) => { ${status.join("\n")} return { osUpdateStatusKnown, hasOsUpdates }; };
exports.revalidateDetails = ${detailEffect}; 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) `, context)
const legacy = { last_check: "2026-10-12T00:00:00Z", available: true, count: 51, stale: true } 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") 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: true, count: 2 }).props.children[1], 2)
assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null) assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null)
assert.equal(requests.length, 0) 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 () => { ;(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) 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[0][1].method, "POST")
assert.equal(requests[1][0], "/api/vms/100") 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(details.os_info.id, "debian")
assert.equal(checking, null) 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 const before = requests.length
errorToThrow = { status: 429, body: { retry_after: 60 } } errorToThrow = { status: 429, body: { retry_after: 60 } }
await ui.check(100) await ui.check(100)
assert.match(displayedError, /checkCooldown.*60/) assert.match(displayedError, /checkCooldown.*60/)
assert.equal(checking, null) assert.equal(checking, null)
assert.equal(requests.length, before) assert.equal(requests.length, before)
assert.equal(polls, 1) assert.equal(polls, 2)
// Switching guests while a request runs must not replace another modal. // Switching guests while a request runs must not replace another modal.
errorToThrow = null errorToThrow = null
context.selectedVMRef.current = { vmid: 200 } context.selectedVMRef.current = { vmid: 200 }
@@ -143,9 +467,45 @@ assert.equal(requests.length, 0)
assert.equal(context.clearedTimer, 1) assert.equal(context.clearedTimer, 1)
for (const locale of ["en", "sk"]) { for (const locale of ["en", "sk"]) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`))).vmLxc.updates 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.ok(messages[key]?.trim())
assert.match(messages.checkCooldown, /\{seconds\}/) 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 }) })().catch(error => { console.error(error); process.exitCode = 1 })
+132
View File
@@ -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})
+183
View File
@@ -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()
+135
View File
@@ -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 })
+19
View File
@@ -468,6 +468,25 @@ class RepositoryPolicyTest(unittest.TestCase):
self.run_policy(apply=True) self.run_policy(apply=True)
self.assertFalse(any(c[1] in ('create', 'set') for c in self.calls)) 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): def test_ceph_enterprise_mirror_is_disabled_without_choosing_ceph_channel(self):
self.repositories['files'].append({'path': '/etc/apt/sources.list.d/storage.list', self.repositories['files'].append({'path': '/etc/apt/sources.list.d/storage.list',
'repositories': [repo('enterprise', uri='https://mirror.example/ceph-squid')]}) 'repositories': [repo('enterprise', uri='https://mirror.example/ceph-squid')]})
@@ -441,7 +441,7 @@
"history": { "history": {
"heading": "History", "heading": "History",
"body1": "Every dispatch <em>attempt</em> the dispatcher actually performs is recorded in the <code>notification_history</code> SQLite table. Each row stores the timestamp (<code>sent_at</code>), channel, event type, severity, title, rendered message body, a <code>success</code> flag and — when the send failed — the error returned by the provider in <code>error_message</code>. Burst-aggregated events appear as a single row with the <code>burst_*</code> event type. Events suppressed by the cooldown stage are not logged: they never become a dispatch attempt.", "body1": "Every dispatch <em>attempt</em> the dispatcher actually performs is recorded in the <code>notification_history</code> SQLite table. Each row stores the timestamp (<code>sent_at</code>), channel, event type, severity, title, rendered message body, a <code>success</code> flag and — when the send failed — the error returned by the provider in <code>error_message</code>. Burst-aggregated events appear as a single row with the <code>burst_*</code> 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 <em>Clear</em> button that wipes the table.", "body2": "Settings → Notifications has a <em>Notification history</em> 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 <code>GET /api/notifications/history</code> with optional <code>limit</code>, <code>offset</code>, <code>severity</code> and <code>channel</code> query parameters, and can be cleared with <code>DELETE /api/notifications/history</code>." "body3": "The same data is exposed at <code>GET /api/notifications/history</code> with optional <code>limit</code>, <code>offset</code>, <code>severity</code> and <code>channel</code> query parameters, and can be cleared with <code>DELETE /api/notifications/history</code>."
}, },
"api": { "api": {
+10 -2
View File
@@ -33,7 +33,7 @@
{ {
"id": "where", "id": "where",
"title": "Where it is", "title": "Where it is",
"intro": "OCI manager Apps opens from option <strong>OCI manager Apps (beta)</strong> of the ProxMenux main menu, on the Proxmox node and as root. Its first screen offers:", "intro": "OCI manager Apps opens from option <strong>OCI manager Apps (Technology Preview)</strong> of the ProxMenux main menu, on the Proxmox node and as root. Its first screen offers:",
"blocks": [ "blocks": [
{ {
"list": { "list": {
@@ -54,13 +54,21 @@
} }
}, },
{ {
"p": "Selecting an application shows its description, the image it runs and two installation modes: <strong>Install with default settings</strong>, which asks almost nothing, and <strong>Install with advanced settings</strong>, 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: <strong>Install with default settings</strong>, which asks only what every installation decides (storage, where the data goes, address and start), and <strong>Install with advanced settings</strong>, 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 <strong>Verified</strong> in that view and is marked with ✓ in the lists, whether it was tested in the ProxMenux lab or by the community." "p": "An application that has been tested for real shows <strong>Verified</strong> 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 <strong>Technology Preview</strong> 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 <strong>Verified</strong> and carry a ✓ in the lists." }
]
},
{ {
"id": "translation", "id": "translation",
"title": "What Docker expresses and what OCI manager Apps turns it into", "title": "What Docker expresses and what OCI manager Apps turns it into",
@@ -441,7 +441,7 @@
"history": { "history": {
"heading": "Historial", "heading": "Historial",
"body1": "Cada <em>intento</em> de despacho que el dispatcher realmente ejecuta se registra en la tabla SQLite <code>notification_history</code>. Cada fila guarda el timestamp (<code>sent_at</code>), canal, tipo de evento, severidad, título, cuerpo de mensaje renderizado, un flag <code>success</code> y — cuando el envío falló — el error devuelto por el proveedor en <code>error_message</code>. Los eventos agregados en ráfaga aparecen como una única fila con el tipo de evento <code>burst_*</code>. Los eventos suprimidos por la etapa de cooldown no se loguean: nunca se convierten en un intento de despacho.", "body1": "Cada <em>intento</em> de despacho que el dispatcher realmente ejecuta se registra en la tabla SQLite <code>notification_history</code>. Cada fila guarda el timestamp (<code>sent_at</code>), canal, tipo de evento, severidad, título, cuerpo de mensaje renderizado, un flag <code>success</code> y — cuando el envío falló — el error devuelto por el proveedor en <code>error_message</code>. Los eventos agregados en ráfaga aparecen como una única fila con el tipo de evento <code>burst_*</code>. 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 <em>Clear</em> que borra la tabla.", "body2": "Settings → Notifications tiene una sección <em>Historial de notificaciones</em>, 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 <code>GET /api/notifications/history</code> con parámetros opcionales <code>limit</code>, <code>offset</code>, <code>severity</code> y <code>channel</code>, y se pueden borrar con <code>DELETE /api/notifications/history</code>." "body3": "Los mismos datos se exponen en <code>GET /api/notifications/history</code> con parámetros opcionales <code>limit</code>, <code>offset</code>, <code>severity</code> y <code>channel</code>, y se pueden borrar con <code>DELETE /api/notifications/history</code>."
}, },
"api": { "api": {
+10 -2
View File
@@ -33,7 +33,7 @@
{ {
"id": "where", "id": "where",
"title": "Dónde se encuentra", "title": "Dónde se encuentra",
"intro": "OCI manager Apps se abre desde la opción <strong>OCI manager Apps (beta)</strong> 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 <strong>OCI manager Apps (Technology Preview)</strong> del menú principal de ProxMenux, en el nodo Proxmox y como root. Su primera pantalla ofrece:",
"blocks": [ "blocks": [
{ {
"list": { "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: <strong>Instalar con la configuración predeterminada</strong>, que apenas hace preguntas, e <strong>Instalar con la configuración avanzada</strong>, 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: <strong>Instalar con la configuración predeterminada</strong>, que pregunta solo lo que decide toda instalación (almacenamiento, dónde van los datos, dirección e inicio), e <strong>Instalar con la configuración avanzada</strong>, 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 <strong>Verificado</strong> 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." "p": "Una aplicación probada en real muestra <strong>Verificado</strong> 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 <strong>Technology Preview</strong> 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 <strong>Verified</strong> y llevan una ✓ en las listas." }
]
},
{ {
"id": "translation", "id": "translation",
"title": "Lo que Docker expresa y en qué lo convierte OCI manager Apps", "title": "Lo que Docker expresa y en qué lo convierte OCI manager Apps",