mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 20:32:15 +00:00
Merge remote-tracking branch 'origin/develop' into HEAD
This commit is contained in:
@@ -38,6 +38,18 @@ DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:"
|
||||
# provider, then restore the exact source spelling afterwards. Keep longer
|
||||
# terms first so ``gasket`` cannot consume part of ``gasket-dkms``.
|
||||
PROTECTED_TECHNICAL_TERMS = (
|
||||
# Product names, kept whole. Left to a translator, "Proxmox Backup Server"
|
||||
# became "Proxmox Sicherungsserver" and "Serveur de sauvegarde Proxmox",
|
||||
# and when a provider rightly handed it back unchanged the string was
|
||||
# retried and then dropped as untranslated.
|
||||
"Proxmox Backup Server",
|
||||
"Proxmox Datacenter Manager",
|
||||
"Proxmox Mail Gateway",
|
||||
"Proxmox VE",
|
||||
"ProxMenux Monitor",
|
||||
"ProxMenux Optimizations",
|
||||
"Secure Gateway",
|
||||
"Tailscale",
|
||||
"google/gasket-driver",
|
||||
"feranick/gasket-driver",
|
||||
"libedgetpu1-std",
|
||||
|
||||
@@ -73,6 +73,24 @@ TECHNICAL_TERMS = (
|
||||
"DKMS",
|
||||
"SSH",
|
||||
"API",
|
||||
"Flask",
|
||||
"SQLite",
|
||||
"Prometheus",
|
||||
"Grafana",
|
||||
"Uptime Kuma",
|
||||
"PWA",
|
||||
"Fail2Ban",
|
||||
"Tailscale",
|
||||
"JavaScript",
|
||||
"TypeScript",
|
||||
"Next.js",
|
||||
"Coral",
|
||||
"rclone",
|
||||
"journald",
|
||||
"gasket",
|
||||
"apex",
|
||||
"Alpine",
|
||||
"OpenVINO",
|
||||
)
|
||||
|
||||
PROTECTED_PATTERNS = (
|
||||
@@ -88,9 +106,13 @@ PROTECTED_PATTERNS = (
|
||||
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
|
||||
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
|
||||
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
|
||||
# Identifiers and encoded delimiters remain literal even outside <code>.
|
||||
re.compile(r"(?<![A-Za-z0-9_])_?[A-Za-z][A-Za-z0-9]*(?:_[A-Za-z0-9]+)+(?![A-Za-z0-9_])"),
|
||||
re.compile(r"\b[A-Za-z0-9]*(?:[a-z][A-Z])[A-Za-z0-9]*\b"),
|
||||
re.compile(r"&(?:[A-Za-z]+|#[0-9]+|#x[0-9A-Fa-f]+);"),
|
||||
)
|
||||
LITERAL_TAG_RE = re.compile(
|
||||
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
|
||||
@@ -109,16 +131,23 @@ ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
|
||||
BARE_SENTINELS = False
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
||||
NON_TRANSLATABLE_KEYS = {
|
||||
"accent",
|
||||
"categoryslug",
|
||||
"command",
|
||||
"code",
|
||||
"href",
|
||||
"icon",
|
||||
"iconbg",
|
||||
"id",
|
||||
"menucmd",
|
||||
"path",
|
||||
"route",
|
||||
"slug",
|
||||
"src",
|
||||
"url",
|
||||
"color",
|
||||
"tone",
|
||||
"variant",
|
||||
}
|
||||
SOURCE_STATE_VERSION = 1
|
||||
|
||||
@@ -186,12 +215,34 @@ def should_copy(source: str, path: tuple[str | int, ...]) -> bool:
|
||||
if not source.strip() or not re.search(r"[A-Za-z]", source):
|
||||
return True
|
||||
last = str(path[-1]).lower() if path else ""
|
||||
# These fields describe the surrounding snippet; they are prose, even
|
||||
# though their names end in "Code".
|
||||
if last in {"aftercode", "beforecode"}:
|
||||
return False
|
||||
# This UI breadcrumb is prose, rather than a filesystem path.
|
||||
if last == "uipath" and "<strong>" in source:
|
||||
return False
|
||||
if last == "flow" and source in {"loader", "dedicated"}:
|
||||
return True
|
||||
# These exact values drive rendering/navigation; similarly named visible
|
||||
# labels remain translatable.
|
||||
if last == "branch" and source in {"main", "develop", "feature/*"}:
|
||||
return True
|
||||
if last == "kind" and source in {"internal", "external"}:
|
||||
return True
|
||||
if last == "key" and source in {"linux", "nas", "windows"}:
|
||||
return True
|
||||
if last in NON_TRANSLATABLE_KEYS or any(
|
||||
last.endswith(suffix)
|
||||
for suffix in ("url", "href", "path", "command", "code", "icon")
|
||||
):
|
||||
return True
|
||||
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/[A-Za-z0-9_./:@%+=-]+", source):
|
||||
if re.fullmatch(r"https?://\S+", source) or re.fullmatch(r"/\S+", source):
|
||||
return True
|
||||
# Standalone literal blocks contain examples, not prose. Separators between
|
||||
# several blocks do not make their command/configuration contents translatable.
|
||||
without_literals = LITERAL_TAG_RE.sub("", source)
|
||||
if without_literals != source and not re.search(r"[A-Za-z]", ANY_TAG_RE.sub("", without_literals)):
|
||||
return True
|
||||
if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source):
|
||||
return True
|
||||
@@ -202,6 +253,15 @@ def leaf_token(path: tuple[str | int, ...]) -> str:
|
||||
return json.dumps(path, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def literal_values_match(source: Any, target: Any) -> bool:
|
||||
"""Literal commands and rendering metadata must follow the source too."""
|
||||
return all(
|
||||
not should_copy(leaf.source, leaf.path)
|
||||
or get_at_path(target, leaf.path) == leaf.source
|
||||
for leaf in iter_leaves(source)
|
||||
)
|
||||
|
||||
|
||||
def source_fingerprints(source: Any) -> dict[str, str]:
|
||||
return {
|
||||
leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20]
|
||||
@@ -240,7 +300,7 @@ def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]:
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
original = match.group(1)
|
||||
if original.lower() in {"code", "kbd", "pre"}:
|
||||
if original.lower() in {"code", "codenw", "codexs", "kbd", "pre"}:
|
||||
return match.group(0)
|
||||
key = original.lower()
|
||||
internal = names.get(key)
|
||||
@@ -329,11 +389,14 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
||||
|
||||
|
||||
def restore_text(text: str, mapping: dict[str, str]) -> str:
|
||||
missing = [token for token in mapping if token not in text]
|
||||
# A local model may append a digit to a bare sentinel. A substring match
|
||||
# would silently restore the prefix and leak the extra digit into prose.
|
||||
patterns = {token: re.compile(re.escape(token) + r"(?![0-9])") for token in mapping}
|
||||
missing = [token for token, pattern in patterns.items() if not pattern.search(text)]
|
||||
if missing:
|
||||
raise ValueError(f"translation provider changed protected token {missing[0]}")
|
||||
for token, original in mapping.items():
|
||||
text = text.replace(token, original)
|
||||
text = patterns[token].sub(lambda match: original, text)
|
||||
return text
|
||||
|
||||
|
||||
@@ -347,6 +410,11 @@ def validate_contract(source: str, target: str) -> None:
|
||||
raise ValueError("literal code changed")
|
||||
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
||||
raise ValueError("placeholder contract changed")
|
||||
# Bare numeric protection markers once leaked a trailing zero just
|
||||
# outside an otherwise intact code span. Literal/ICU checks cannot see it.
|
||||
residue = re.compile(r'(?:</(?:code|codeNw|codeXs|pre|kbd)>|<strong>)(0+)(?=\s|[;:/<().,-]|$)')
|
||||
if len(residue.findall(target)) > len(residue.findall(source)):
|
||||
raise ValueError("unexpected numeric residue next to literal markup")
|
||||
|
||||
|
||||
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
||||
@@ -822,7 +890,10 @@ def main() -> int:
|
||||
leaves = pending_leaves(source, target, queued_tokens)
|
||||
total_strings += len(iter_leaves(source))
|
||||
missing_strings += len(leaves)
|
||||
schema_changed = not schema_matches(source, target)
|
||||
schema_changed = (
|
||||
not schema_matches(source, target)
|
||||
or not literal_values_match(source, target)
|
||||
)
|
||||
if leaves or schema_changed:
|
||||
pending.append(
|
||||
(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check active terminal translations offline, without generating any text.
|
||||
|
||||
Validates formatting fields and literal commands/identifiers. Missing values
|
||||
can retain English fallback; --require-complete makes them errors. This does
|
||||
not establish the accuracy or fluency of the translated prose.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from build_translation_cache import (
|
||||
DEFAULT_LANGUAGES, STRUCTURAL_LITERAL_RE, extract_catalog_texts,
|
||||
extract_python_texts, extract_translate_texts,
|
||||
)
|
||||
from build_web_docs_i18n import read_json, validate_contract
|
||||
|
||||
|
||||
def active_sources(root: Path) -> list[str]:
|
||||
# Match build-translation-cache.yml, including launchers outside scripts/.
|
||||
extras = [root / name for name in ('menu', 'install_proxmenux.sh', 'install_proxmenux_beta.sh')]
|
||||
texts = extract_translate_texts(root / 'scripts', extras)
|
||||
texts += extract_translate_texts(root / 'oci/remote')
|
||||
texts += extract_python_texts([root / 'scripts', root / 'oci/src', root / 'oci/remote'])
|
||||
texts += extract_catalog_texts([root / 'oci/catalog'])
|
||||
return sorted({text for text in texts if '$' not in text and '`' not in text})
|
||||
|
||||
|
||||
def validate_message(source: str, target: str, *, title_separator: bool = False) -> None:
|
||||
validate_contract(source, target)
|
||||
for match in STRUCTURAL_LITERAL_RE.finditer(source):
|
||||
literal = match.group()
|
||||
# A page title may replace its decorative separator with a dash.
|
||||
# In command examples, a pipe remains a shell operator.
|
||||
if title_separator and literal == '|':
|
||||
continue
|
||||
# DNS hostnames are case-insensitive. Paths, field names and command
|
||||
# names are not, so do not case-fold those to hide a broken example.
|
||||
is_hostname = re.fullmatch(r'[A-Za-z0-9.-]+\.(?:com|org|net|io)', literal)
|
||||
if literal not in target and not (is_hostname and literal.casefold() in target.casefold()):
|
||||
raise ValueError(f'literal command or identifier changed: {literal}')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--root', type=Path, default=Path('.'))
|
||||
parser.add_argument('--languages', default=','.join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument('--require-complete', action='store_true')
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
texts = active_sources(root)
|
||||
errors = 0
|
||||
for language in args.languages.split(','):
|
||||
if language not in DEFAULT_LANGUAGES:
|
||||
parser.error(f'Unsupported locale: {language}')
|
||||
catalog = read_json(root / 'lang' / f'{language}.json') or {}
|
||||
missing = 0
|
||||
for source in texts:
|
||||
target = catalog.get(source)
|
||||
if not isinstance(target, str) or not target.strip():
|
||||
missing += 1
|
||||
continue
|
||||
try:
|
||||
validate_message(source, target)
|
||||
except ValueError as error:
|
||||
print(f'[{language}] {source!r}: {error}')
|
||||
errors += 1
|
||||
print(f'[{language}] active={len(texts)} missing={missing}')
|
||||
if args.require_complete and missing:
|
||||
errors += 1
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check documentation translation structure and literal contracts offline.
|
||||
|
||||
Missing locales/leaves are allowed by default because the website falls back
|
||||
to English. Use --require-complete when validating a catalog completion PR.
|
||||
This checks data integrity, not translation fluency or linguistic accuracy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from build_web_docs_i18n import (
|
||||
DEFAULT_LANGUAGES,
|
||||
get_at_path,
|
||||
iter_leaves,
|
||||
literal_values_match,
|
||||
read_json,
|
||||
schema_matches,
|
||||
source_files,
|
||||
)
|
||||
from check_terminal_catalogs import validate_message
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--messages-dir", type=Path, default=Path("web/messages"))
|
||||
parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES))
|
||||
parser.add_argument("--require-complete", action="store_true")
|
||||
args = parser.parse_args()
|
||||
root = args.messages_dir.resolve()
|
||||
files = source_files(root / "en", ".")
|
||||
errors = 0
|
||||
for language in args.languages.split(","):
|
||||
language = language.strip()
|
||||
if language not in DEFAULT_LANGUAGES:
|
||||
parser.error(f"Unsupported locale: {language}")
|
||||
missing = checked = present_files = 0
|
||||
for file in files:
|
||||
relative = file.relative_to(root / "en")
|
||||
source = read_json(file)
|
||||
target = read_json(root / language / relative)
|
||||
if target is not None:
|
||||
present_files += 1
|
||||
if args.require_complete and not schema_matches(source, target):
|
||||
print(f"[{language}] {relative}: source/target schema differs")
|
||||
errors += 1
|
||||
for leaf in iter_leaves(source):
|
||||
localized = get_at_path(target, leaf.path)
|
||||
if not isinstance(localized, str) or (leaf.source.strip() and not localized.strip()):
|
||||
missing += 1
|
||||
continue
|
||||
try:
|
||||
validate_message(
|
||||
leaf.source, localized,
|
||||
title_separator=bool(
|
||||
leaf.path and leaf.path[0] == "meta"
|
||||
and leaf.path[-1] in ("title", "ogTitle", "twitterTitle")
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
print(f"[{language}] {relative}:{'.'.join(map(str, leaf.path))}: {exc}")
|
||||
errors += 1
|
||||
checked += 1
|
||||
if args.require_complete and not literal_values_match(source, target):
|
||||
print(f"[{language}] {relative}: literal commands or rendering metadata differ")
|
||||
errors += 1
|
||||
print(f"[{language}] files={present_files}/{len(files)} strings={checked} missing={missing}")
|
||||
if args.require_complete and missing:
|
||||
errors += 1
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -13,6 +13,7 @@ import sys
|
||||
import tempfile
|
||||
|
||||
PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_native_app_discovery.py",
|
||||
"tests/test_audit_presentation.py",
|
||||
"tests/test_audit_safety_wording.py",
|
||||
"tests/test_audit_policy.py",
|
||||
@@ -24,6 +25,7 @@ PYTHON_FILES = (
|
||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||
)
|
||||
NODE_FILES = (
|
||||
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
|
||||
"tests/lxc_updates/test_docker_delegated_ui.cjs",
|
||||
"tests/test_audit_diagnostic_document.cjs",
|
||||
"tests/test_audit_policy.cjs",
|
||||
|
||||
@@ -60,6 +60,124 @@ class WebDocsI18nTests(unittest.TestCase):
|
||||
target = {"title": "Actualizaciones", "url": "https://example.com"}
|
||||
self.assertEqual(MODULE.pending_leaves(source, target), [])
|
||||
|
||||
def test_rendering_metadata_is_copied_but_visible_labels_are_translated(self):
|
||||
for key, value in (("tone", "amber"), ("variant", "source"),
|
||||
("accent", "border-blue-300 bg-blue-50"),
|
||||
("iconBg", "bg-blue-100 text-blue-700"),
|
||||
("color", "green")):
|
||||
with self.subTest(key=key):
|
||||
self.assertTrue(MODULE.should_copy(value, ("steps", 0, key)))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
self.assertEqual(MODULE.merge_tree({key: value}, {key: "translated"}, {}), {key: value})
|
||||
self.assertFalse(MODULE.should_copy("Translation", ("type",)))
|
||||
self.assertFalse(MODULE.should_copy("English", ("lang",)))
|
||||
for value in ("loader", "dedicated"):
|
||||
self.assertTrue(MODULE.should_copy(value, ("cards", 0, "flow")))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
|
||||
def test_literal_only_examples_and_api_routes_are_not_translation_gaps(self):
|
||||
for source in ("<code>apt update</code>",
|
||||
"<code>net0</code> / <code>net1</code>.",
|
||||
"/api/task-log/<upid>", "/api/vms/<vmid>/config"):
|
||||
with self.subTest(source=source):
|
||||
self.assertTrue(MODULE.should_copy(source, ("cell",)))
|
||||
self.assertFalse(MODULE.should_copy("Run <code>apt update</code> first.", ("body",)))
|
||||
self.assertFalse(MODULE.should_copy("After running the command, check its output.", ("afterCode",)))
|
||||
self.assertFalse(MODULE.should_copy("Before running the command, read this.", ("beforeCode",)))
|
||||
self.assertTrue(MODULE.should_copy("curl http://localhost:8008/api/health", ("curlCode",)))
|
||||
|
||||
def test_inline_identifiers_and_encoded_delimiters_round_trip(self):
|
||||
source = "Set RS_SKIP_PATHS, call _rs_apply_hot and keep localStorage at <host>."
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
for literal in ("RS_SKIP_PATHS", "_rs_apply_hot", "localStorage", "<", ">"):
|
||||
self.assertIn(literal, mapping.values())
|
||||
self.assertNotIn(literal, protected)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_custom_code_spans_keep_commands_literal(self):
|
||||
for tag in ("codeNw", "codeXs"):
|
||||
source = f"Run <{tag}>pct config 100</{tag}>."
|
||||
with self.subTest(tag=tag):
|
||||
protected, mapping = MODULE.protect_rich_tags(source)
|
||||
self.assertEqual(protected, source)
|
||||
self.assertEqual(mapping, {})
|
||||
with self.assertRaisesRegex(ValueError, "literal code changed"):
|
||||
MODULE.validate_contract(source, source.replace("config", "configuration"))
|
||||
with self.assertRaisesRegex(ValueError, "numeric residue"):
|
||||
MODULE.validate_contract(source, source.replace(f"</{tag}>", f"</{tag}>0"))
|
||||
self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100</{tag}>", ("example",)))
|
||||
|
||||
def test_navigation_identifiers_are_distinct_from_visible_labels(self):
|
||||
values = {
|
||||
"categorySlug": ["system", "network"],
|
||||
"menuCmd": ["menu"],
|
||||
"branch": ["main", "develop", "feature/*"],
|
||||
"kind": ["internal", "external"],
|
||||
"key": ["nas", "linux", "windows"],
|
||||
}
|
||||
for key, items in values.items():
|
||||
for value in items:
|
||||
with self.subTest(key=key, value=value):
|
||||
self.assertTrue(MODULE.should_copy(value, ("cards", 0, key)))
|
||||
self.assertFalse(MODULE.should_copy(value, ("label",)))
|
||||
self.assertFalse(MODULE.should_copy("Translation", ("kind",)))
|
||||
self.assertFalse(MODULE.should_copy("Storage", ("branch",)))
|
||||
|
||||
def test_ui_breadcrumb_is_prose_while_paths_remain_literal(self):
|
||||
source = "In the UI: <strong>Datacenter → Backup → Retention</strong>:"
|
||||
self.assertFalse(MODULE.should_copy(source, ("retention", "uiPath")))
|
||||
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("uiPath",)))
|
||||
self.assertTrue(MODULE.should_copy("/etc/pve/storage.cfg", ("configPath",)))
|
||||
|
||||
def test_bare_sentinel_with_added_digit_is_rejected(self):
|
||||
with mock.patch.object(MODULE, "BARE_SENTINELS", True):
|
||||
protected, mapping = MODULE.protect_text("ProxMenux collects metrics.")
|
||||
token = next(iter(mapping))
|
||||
with self.assertRaisesRegex(ValueError, "changed protected token"):
|
||||
MODULE.restore_text(protected.replace(token, token + "0"), mapping)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), "ProxMenux collects metrics.")
|
||||
|
||||
def test_numeric_residue_outside_intact_literal_is_rejected(self):
|
||||
examples = [
|
||||
('Run <code>pct config</code> first.', 'Spustite <code>pct config</code>0 najprv.'),
|
||||
('<strong><code>Restart=on-failure</code></strong> restarts the service.',
|
||||
'<strong>0<code>Restart=on-failure</code></strong> reštartuje službu.'),
|
||||
]
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaisesRegex(ValueError, 'numeric residue'):
|
||||
MODULE.validate_contract(source, target)
|
||||
MODULE.validate_contract('The result is <code>echo</code>0.',
|
||||
'Výsledok je <code>echo</code>0.')
|
||||
|
||||
def test_product_and_kernel_module_names_are_protected(self):
|
||||
source = "Flask uses SQLite and journald; rclone works with Coral, gasket, apex and Alpine."
|
||||
protected, mapping = MODULE.protect_text(source)
|
||||
for name in ("Flask", "SQLite", "journald", "rclone", "Coral", "gasket", "apex", "Alpine"):
|
||||
self.assertIn(name, mapping.values())
|
||||
self.assertNotIn(name, protected)
|
||||
self.assertEqual(MODULE.restore_text(protected, mapping), source)
|
||||
|
||||
def test_literal_only_changes_sync_without_retranslating_human_prose(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
source = root / "messages/en/common.json"
|
||||
target = root / "messages/de/common.json"
|
||||
source.parent.mkdir(parents=True)
|
||||
target.parent.mkdir(parents=True)
|
||||
source.write_text(json.dumps({"title": "Updates", "code": "tool --version=2", "tone": "blue"}))
|
||||
target.write_text(json.dumps({"title": "Aktualisierungen", "code": "tool --version=1", "tone": "blau"}))
|
||||
provider = mock.Mock(side_effect=AssertionError("Existing prose must not be retranslated"))
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(json.loads(target.read_text()), {
|
||||
"title": "Aktualisierungen", "code": "tool --version=2", "tone": "blue",
|
||||
})
|
||||
provider.assert_not_called()
|
||||
before = target.stat().st_mtime_ns
|
||||
self.assertEqual(self.run_generator(root, provider), 0)
|
||||
self.assertEqual(target.stat().st_mtime_ns, before)
|
||||
provider.assert_not_called()
|
||||
|
||||
def test_changed_source_text_is_pending_even_with_existing_translation(self):
|
||||
source = {"title": "Updated installation guidance"}
|
||||
target = {"title": "Vorherige Installationsanleitung"}
|
||||
|
||||
@@ -80,165 +80,74 @@ class CommandDescriptionsTests(unittest.TestCase):
|
||||
self.assertEqual(catalog("sv")["terminal"]["commandDescriptions"][index],
|
||||
FIXTURE["locales"]["sv"][index])
|
||||
|
||||
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
|
||||
def assert_completed_catalogs_are_stable(self, *, future_messages=None):
|
||||
workflow = (ROOT / ".github/workflows/build-i18n-messages.yml").read_text()
|
||||
languages = re.search(r'LANGS="\$\{LANGS:-(.*?)\}"', workflow).group(1)
|
||||
self.assertEqual(languages.split(","), list(MODULE.DEFAULT_LANGUAGES))
|
||||
locales = ["en", *languages.split(",")]
|
||||
shipped = {ROOT / f"AppImage/messages/{lang}/common.json": catalog(lang) for lang in locales}
|
||||
original_bytes = {path: path.read_bytes() for path in shipped}
|
||||
|
||||
def strings(node, path=()):
|
||||
# Independent traversal: every existing nonempty string, including
|
||||
# explicit English and obsolete human keys, must survive completion.
|
||||
if isinstance(node, str) and node:
|
||||
yield path, node
|
||||
elif isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
yield from strings(value, (*path, key))
|
||||
elif isinstance(node, list):
|
||||
for index, value in enumerate(node):
|
||||
yield from strings(value, (*path, index))
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
for lang in ["en", *languages.split(",")]:
|
||||
path = root / lang / "common.json"
|
||||
path.parent.mkdir()
|
||||
shutil.copyfile(ROOT / f"AppImage/messages/{lang}/common.json", path)
|
||||
# This steady-state fixture assumes generation already finished.
|
||||
# Seed only intentional message additions; keep the
|
||||
# recovered command arrays and the repo catalogs exact. Runtime
|
||||
# missing-key fallback is covered by the JSX seam tests and by
|
||||
# test_storage_messages.cjs and test_health_text.cjs.
|
||||
if lang != "en":
|
||||
temporary = json.loads(path.read_text())
|
||||
for section, key in (
|
||||
("archives", "emptyMessage"),
|
||||
("archives", "formerJob"),
|
||||
("archives", "formerJobTitle"),
|
||||
("archives", "hiddenOtherNodes"),
|
||||
("archives", "scopeAllNodes"),
|
||||
("archives", "scopeAriaLabel"),
|
||||
("archives", "scopeShowAll"),
|
||||
("archives", "scopeThisNode"),
|
||||
("destinations", "backupsKeptMessage"),
|
||||
("destinations", "localAttachHelpMessage"),
|
||||
):
|
||||
temporary["backup"][section].setdefault(
|
||||
key, catalog("en")["backup"][section][key])
|
||||
temporary["storage"].setdefault(
|
||||
"savedSmartData", catalog("en")["storage"]["savedSmartData"])
|
||||
report = temporary["storage"]["smartReport"]
|
||||
source_report = catalog("en")["storage"]["smartReport"]
|
||||
for key in ("passedAssessment", "noReallocatedSectorsReported",
|
||||
"passedMeaningTitle", "passedMeaning"):
|
||||
report.setdefault(key, source_report[key])
|
||||
for key in ("passedTitle", "passedText"):
|
||||
report["recommendations"].setdefault(
|
||||
key, source_report["recommendations"][key])
|
||||
# New health-popup messages deliberately use runtime English
|
||||
# fallback until each locale is translated. Model generator
|
||||
# steady state only in this disposable copy, as for thermal keys.
|
||||
for key in (
|
||||
'certificateExpired',
|
||||
'certificateExpiresDays',
|
||||
'certificateInconclusive',
|
||||
'cpuCheckFailed',
|
||||
'cpuHighSustained',
|
||||
'cpuSustained',
|
||||
'fail2banCheckUnavailable',
|
||||
'fail2banInactive',
|
||||
'failedLoginsWithinThreshold',
|
||||
'kernelPackagesMore',
|
||||
'kernelPveUpdates',
|
||||
'loginCheckUnavailable',
|
||||
'memoryCheckFailed',
|
||||
'memoryPressure',
|
||||
'noFailedLogins',
|
||||
'noSwapConfigured',
|
||||
'noTemperatureSensor',
|
||||
'nonRunningKernelUpdates',
|
||||
'pendingPackageUpdates',
|
||||
'pendingSecurityUpdates',
|
||||
'pendingSecurityUpdatesDays',
|
||||
'pendingSecurityUpdatesUnpatched',
|
||||
'pveVersionAvailable',
|
||||
'ramHighSustained',
|
||||
'ramSustained',
|
||||
'runningKernelUpdate',
|
||||
'securityCheckUnavailable',
|
||||
'sensorHighSamplesSpan',
|
||||
'swapRamPressure',
|
||||
'systemNotUpdatedMonths',
|
||||
'systemNotUpdatedYear',
|
||||
'temperatureElevated',
|
||||
'unknownDetail',
|
||||
'updatesAptError',
|
||||
'updatesAptTimeout',
|
||||
'updatesCheckUnavailable',
|
||||
'uptimeKernelAdvice',
|
||||
'uptimeUnavailable',
|
||||
):
|
||||
temporary["healthStatus"]["details"].setdefault(
|
||||
key, catalog("en")["healthStatus"]["details"][key])
|
||||
# New thermal messages also await automatic translation.
|
||||
# Seed only these intentional additions in the temporary copy.
|
||||
for key in (
|
||||
"temperatureNoDisks", "temperatureNormal", "temperatureHighCount",
|
||||
"temperatureCriticalCount", "temperatureUnavailable",
|
||||
"temperatureIncomplete", "temperatureUnavailableCount",
|
||||
):
|
||||
temporary["storage"].setdefault(key, catalog("en")["storage"][key])
|
||||
# Every shipped locale carries the temperature alert fallback
|
||||
# keys; the seed only guards a catalog that lacks them. The
|
||||
# runtime fallback is tested separately.
|
||||
source_fallback = catalog("en")["runtime"]["notifications"]["fallback"]
|
||||
fallback = temporary["runtime"]["notifications"]["fallback"]
|
||||
for key in ("temperatureAlertTitle", "temperatureAlertBody",
|
||||
"recordedReason", "recordedDetails"):
|
||||
fallback.setdefault(key, source_fallback[key])
|
||||
# Slovak remains the exact upstream catalog; model the
|
||||
# pending outcome-key generator additions in disposable
|
||||
# copies rather than modifying its curated values.
|
||||
if lang == 'sk':
|
||||
local = temporary['runtime']['notifications']
|
||||
source = catalog('en')['runtime']['notifications']
|
||||
for key, value in source['backup'].items():
|
||||
local.setdefault('backup', {}).setdefault(key, value)
|
||||
local['channels']['email']['status'].setdefault(
|
||||
'unconfirmed', source['channels']['email']['status']['unconfirmed'])
|
||||
local['channels']['email']['status'].setdefault(
|
||||
'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings'])
|
||||
path.write_text(json.dumps(temporary, ensure_ascii=False))
|
||||
# Model steady state after the bot fills these intentional new
|
||||
# messages; keep repository locales and all other leaves intact.
|
||||
if lang != "en":
|
||||
messages = json.loads(path.read_text())
|
||||
english = catalog("en")
|
||||
for section, key in (("encryption", "pbsHelp"),
|
||||
("keyfileActions", "pveKeyDescription")):
|
||||
messages["backup"][section].setdefault(key, english["backup"][section][key])
|
||||
for key in ("sshUserHelpMessage", "sshKeyHelpMessage", "sshKeySetupTitle",
|
||||
"sshKeySetupHelp", "sshAuthorizedKeyHelp"):
|
||||
messages["backup"]["destinations"].setdefault(
|
||||
key, english["backup"]["destinations"][key])
|
||||
messages["backup"]["actions"].setdefault(
|
||||
"prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
|
||||
# New LXC observation labels intentionally ship only EN/SK.
|
||||
# Model the bot's future output in temporary catalogs, just
|
||||
# like the other pending keys above; never rewrite locales.
|
||||
messages["scriptTerminal"].setdefault("endOfInput", english["scriptTerminal"]["endOfInput"])
|
||||
for key in ("noShell", "enterFailed"):
|
||||
messages["lxcTerminal"]["errors"].setdefault(key, english["lxcTerminal"]["errors"][key])
|
||||
# The notification history panel texts await the bot as well.
|
||||
for key, value in english["settings"]["notifications"]["history"].items():
|
||||
messages["settings"]["notifications"].setdefault("history", {}).setdefault(key, value)
|
||||
for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown",
|
||||
"checkStale", "checkStaleHelp", "checkBeforeUpdate",
|
||||
"startBeforeCheck", "notCheckedYet",
|
||||
"checkStillRunning", "refreshCheckHelp",
|
||||
"checkInProgress", "checkSystemPackages", "runCustomCommand",
|
||||
"installedVersionUnverified", "versionStatusUnknown"):
|
||||
messages["vmLxc"]["updates"].setdefault(
|
||||
key, english["vmLxc"]["updates"][key])
|
||||
for key in ("startPending", "shutdownPending", "rebootPending", "stopPending"):
|
||||
messages["vmLxc"]["actions"].setdefault(
|
||||
key, english["vmLxc"]["actions"][key])
|
||||
for key in ("osDockerScope", "dockerImageMethod", "unverifiedTargetsHelp"):
|
||||
messages["vmLxc"]["bulkUpdate"].setdefault(
|
||||
key, english["vmLxc"]["bulkUpdate"][key])
|
||||
messages["vmLxc"]["errors"].setdefault(
|
||||
"controlNotConfirmed", english["vmLxc"]["errors"]["controlNotConfirmed"])
|
||||
path.write_text(json.dumps(messages, ensure_ascii=False))
|
||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
||||
root = Path(directory).resolve()
|
||||
for path in shipped:
|
||||
destination = root / path.parent.name / "common.json"
|
||||
destination.parent.mkdir()
|
||||
shutil.copyfile(path, destination)
|
||||
if future_messages is not None:
|
||||
source = json.loads((root / "en/common.json").read_text())
|
||||
source["futureFeatureFixture"] = future_messages
|
||||
(root / "en/common.json").write_text(json.dumps(source))
|
||||
|
||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||
"--messages-dir", str(root), "--languages", languages, "--sleep", "0"]
|
||||
output = io.StringIO()
|
||||
# Establish steady state with the real generator and a deterministic
|
||||
# offline provider. Translation happens only in disposable catalogs;
|
||||
# adding an EN-only feature never needs a hand-maintained seed list.
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=lambda text, lang, *_: f"TEST {lang} {text}") as seed, \
|
||||
mock.patch.object(MODULE, "write_json", wraps=MODULE.write_json) as initial_writer, \
|
||||
redirect_stdout(output), redirect_stderr(output):
|
||||
self.assertEqual(MODULE.main(), 0, output.getvalue())
|
||||
self.assertNotRegex(output.getvalue(), r"(?m)^Completed with [0-9]+ translation failures")
|
||||
self.assertTrue(all(call.args[0].is_relative_to(root) for call in initial_writer.call_args_list))
|
||||
for path, original in shipped.items():
|
||||
completed = json.loads((root / path.parent.name / "common.json").read_text())
|
||||
for keys, value in strings(original):
|
||||
actual = completed
|
||||
for key in keys:
|
||||
actual = actual[key]
|
||||
self.assertEqual(actual, value, f"{path.parent.name}: {keys}")
|
||||
# The recovered arrays remain exact, including frozen English.
|
||||
if path.parent.name != "en":
|
||||
self.assertEqual(completed["terminal"]["commandDescriptions"],
|
||||
FIXTURE["locales"].get(path.parent.name,
|
||||
original["terminal"]["commandDescriptions"]))
|
||||
if future_messages is not None and path.parent.name != "en":
|
||||
self.assertEqual(completed["futureFeatureFixture"], {
|
||||
"title": f"TEST {path.parent.name} Future feature {{count}}",
|
||||
"items": [f"TEST {path.parent.name} First item", {"label": f"TEST {path.parent.name} Second item"}],
|
||||
"enabled": False, "count": 2, "empty": [],
|
||||
})
|
||||
if future_messages is not None:
|
||||
self.assertGreaterEqual(seed.call_count, 3 * len(languages.split(",")))
|
||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
||||
output = io.StringIO()
|
||||
# Keep the original strict assertion: once completed, any provider
|
||||
# call or catalog write is a regression. No production locale edits.
|
||||
with mock.patch.object(sys, "argv", argv), \
|
||||
mock.patch.object(MODULE, "translate_one", side_effect=AssertionError("provider forbidden")) as provider, \
|
||||
mock.patch.object(MODULE, "write_json", side_effect=AssertionError("writes forbidden")) as writer, \
|
||||
@@ -250,6 +159,17 @@ class CommandDescriptionsTests(unittest.TestCase):
|
||||
self.assertEqual(before, {p: p.read_bytes() for p in before})
|
||||
for lang in languages.split(","):
|
||||
self.assertIn(f"{lang}: nothing to translate", output.getvalue())
|
||||
self.assertEqual(original_bytes, {path: path.read_bytes() for path in shipped})
|
||||
|
||||
def test_workflow_default_catalogs_rerun_without_provider_or_writes(self):
|
||||
self.assert_completed_catalogs_are_stable()
|
||||
|
||||
def test_new_english_feature_preserves_curated_values_and_reruns_without_writes(self):
|
||||
self.assert_completed_catalogs_are_stable(future_messages={
|
||||
"title": "Future feature {count}",
|
||||
"items": ["First item", {"label": "Second item"}],
|
||||
"enabled": False, "count": 2, "empty": [],
|
||||
})
|
||||
|
||||
def test_actual_consumer_lookup_null_fallback_and_explicit_english(self):
|
||||
node = require_node()
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Keep the canonical conduct policy and its English website source in sync."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DocumentationMarkdownTests(unittest.TestCase):
|
||||
def test_english_conduct_body_matches_canonical_policy(self):
|
||||
source = json.loads((ROOT / 'web/messages/en/docs/about/code-of-conduct.json').read_text(encoding='utf-8'))
|
||||
self.assertEqual(source['contentMarkdown'], (ROOT / 'CODE_OF_CONDUCT.md').read_text(encoding='utf-8'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Keep machine generation explicit without losing automatic validation."""
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class DocumentationReviewWorkflowTests(unittest.TestCase):
|
||||
def workflow(self, name):
|
||||
return yaml.load(
|
||||
(ROOT / '.github/workflows' / name).read_text(encoding='utf-8'),
|
||||
Loader=yaml.BaseLoader,
|
||||
)
|
||||
|
||||
def test_generation_requires_explicit_dispatch(self):
|
||||
triggers = self.workflow('build-web-docs-i18n.yml')['on']
|
||||
self.assertEqual(set(triggers), {'workflow_dispatch'})
|
||||
|
||||
def test_website_changes_keep_automatic_offline_validation(self):
|
||||
triggers = self.workflow('test-i18n.yml')['on']
|
||||
for event in ('pull_request', 'push'):
|
||||
with self.subTest(event=event):
|
||||
self.assertIn('web/**', triggers[event]['paths'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Every shipped terminal language must be selectable during installation."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
ROOT = SCRIPTS.parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
from build_translation_cache import DEFAULT_LANGUAGES
|
||||
|
||||
|
||||
class InstallerLanguageTests(unittest.TestCase):
|
||||
def test_both_installers_offer_every_shipped_language(self):
|
||||
expected = {'en', *DEFAULT_LANGUAGES}
|
||||
for name in ('install_proxmenux.sh', 'install_proxmenux_beta.sh'):
|
||||
with self.subTest(installer=name):
|
||||
source = (ROOT / name).read_text(encoding='utf-8')
|
||||
function = re.search(r'^select_language\(\) \{(.*?)^\}', source, re.M | re.S)
|
||||
self.assertIsNotNone(function, 'Missing installer language selector')
|
||||
offered = re.findall(r'^\s*"([a-z]{2})"\s+"[^"]+"', function.group(1), re.M)
|
||||
self.assertEqual(set(offered), expected)
|
||||
self.assertEqual(len(offered), len(expected), 'Duplicate language option')
|
||||
# English is the source text in the scripts, not a JSON catalog.
|
||||
for language in set(offered) - {'en'}:
|
||||
self.assertTrue((ROOT / 'lang' / f'{language}.json').is_file(), language)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -4,9 +4,13 @@ lives: the scripts it launches are the ones the menus launch."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
@@ -42,11 +46,13 @@ class IndexTests(unittest.TestCase):
|
||||
self.assertEqual(unknown, [])
|
||||
self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
|
||||
|
||||
def test_every_script_a_menu_launches_can_be_found(self):
|
||||
def test_every_script_a_pve_menu_launches_can_be_found(self):
|
||||
indexed = {item["script"] for item in self.index}
|
||||
launched = set()
|
||||
for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
|
||||
if menu.name in ("main_menu.sh", "lxc_menu.sh"):
|
||||
# PBS replaces the PVE main menu and does not use its search.
|
||||
# The exclusive platform dispatch is executed in the test below.
|
||||
if menu.name in ("main_menu.sh", "lxc_menu.sh", "pbs_menu.sh"):
|
||||
continue
|
||||
launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
|
||||
launched -= {"menus/main_menu.sh"}
|
||||
@@ -54,6 +60,27 @@ class IndexTests(unittest.TestCase):
|
||||
launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
|
||||
self.assertEqual(sorted(launched - indexed), [])
|
||||
|
||||
def test_pbs_replaces_the_searchable_pve_menu(self):
|
||||
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
||||
dispatch = menu[menu.rindex('source "$LOCAL_SCRIPTS/global/platform-functions.sh"'):]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
scripts = Path(directory)
|
||||
(scripts / "global").mkdir()
|
||||
(scripts / "menus").mkdir()
|
||||
helper = scripts / "global/platform-functions.sh"
|
||||
helper.write_text(
|
||||
"source " + shlex.quote(str(SCRIPTS / "global/platform-functions.sh")) + "\n"
|
||||
'pmx_platform_field() { printf "%s" "$TEST_PROXMENUX_PRODUCT"; }\n')
|
||||
(scripts / "menus/pbs_menu.sh").write_text('printf "PBS menu\\n"\n')
|
||||
harness = 'show_menu() { printf "PVE search menu\\n"; }\n' + dispatch
|
||||
for product, expected in (("pve", "PVE search menu"), ("pbs", "PBS menu")):
|
||||
with self.subTest(product=product):
|
||||
result = subprocess.run(["bash", "-e", "-c", harness], text=True, capture_output=True,
|
||||
env={**os.environ, "LOCAL_SCRIPTS": str(scripts),
|
||||
"TEST_PROXMENUX_PRODUCT": product})
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), expected)
|
||||
|
||||
def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
|
||||
menu = (SCRIPTS / "menus/main_menu.sh").read_text()
|
||||
self.assertIn('search "$(translate "Search options and applications")"', menu)
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import ModuleType, SimpleNamespace
|
||||
@@ -52,7 +53,8 @@ class InventoryMessages(unittest.TestCase):
|
||||
state = ModuleType('oci_installation_state')
|
||||
state.parse_config = lambda raw: dict(line.split(': ', 1) for line in raw.decode().splitlines())
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda text: text or '',
|
||||
'translate': translate, 're': re}
|
||||
'translate': translate, 're': re, 'subprocess': subprocess,
|
||||
'_selected_configuration': remover.guest_config}
|
||||
summary = extracted(MENU, '_removal_summary', scope)
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
|
||||
@@ -56,7 +56,8 @@ class RemovalWordings(unittest.TestCase):
|
||||
state = ModuleType('oci_installation_state')
|
||||
state.parse_config = lambda raw: {}
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
||||
'translate': translated or (lambda value: value), 're': re}
|
||||
'translate': translated or (lambda value: value), 're': re,
|
||||
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
return extract(MENU, '_removal_summary', scope)(Path('/inert'), 101)
|
||||
@@ -87,7 +88,8 @@ class RemovalWordings(unittest.TestCase):
|
||||
with patch.dict(sys.modules, {'oci_instances': instances, 'oci_remove': remover,
|
||||
'oci_installation_state': state}):
|
||||
scope = {'sys': SimpleNamespace(path=[]), 'source_text': lambda value: value or '',
|
||||
'translate': lambda value: value, 're': re}
|
||||
'translate': lambda value: value, 're': re,
|
||||
'_selected_configuration': remover.guest_config, 'subprocess': subprocess}
|
||||
preview = extract(MENU, '_removal_summary', scope)(Path('/inert'), 102)
|
||||
self.assertIn(WHOLE, preview)
|
||||
|
||||
|
||||
@@ -42,13 +42,13 @@ class SelectionSetupWording(TestCase):
|
||||
shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [],
|
||||
_clean_orphans=lambda project: None,
|
||||
recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None,
|
||||
carry_records=lambda project, **options: {},
|
||||
carry_records=lambda project, **options: {}, _records_unread=lambda project: False,
|
||||
translate=lambda s: s)
|
||||
fn(Path('/fixture'), ui)
|
||||
ui.message.assert_called_once_with(EMPTY, 'OCI management')
|
||||
ui.choose.assert_not_called()
|
||||
|
||||
def _menu(self, inventories, selections):
|
||||
def _menu(self, inventories, selections, unread=True):
|
||||
rows = iter(inventories)
|
||||
ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections))
|
||||
refresh = Mock(return_value={})
|
||||
@@ -57,7 +57,8 @@ class SelectionSetupWording(TestCase):
|
||||
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,
|
||||
carry_records=refresh, _records_unread=lambda project: unread,
|
||||
manage_instance=manage, menu_size=lambda: 10,
|
||||
STATUS_LABELS={}, translate=lambda s: s)
|
||||
fn(Path('/fixture'), ui)
|
||||
return ui, refresh, manage
|
||||
@@ -67,7 +68,14 @@ class SelectionSetupWording(TestCase):
|
||||
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)
|
||||
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True,
|
||||
heading='Manage installed OCI applications')
|
||||
|
||||
def test_the_list_opens_without_the_sync_notice_once_every_configuration_is_cached(self):
|
||||
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
|
||||
_, refresh, _ = self._menu([[row]], [None], unread=False)
|
||||
refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=False,
|
||||
heading='Manage installed OCI applications')
|
||||
|
||||
def test_removing_the_last_application_returns_without_a_message(self):
|
||||
row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'}
|
||||
|
||||
@@ -3,6 +3,7 @@ import ast
|
||||
from contextlib import nullcontext
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import runpy
|
||||
@@ -129,7 +130,9 @@ class UpdateWording(unittest.TestCase):
|
||||
reconcile = ModuleType('oci_instance_reconcile')
|
||||
reconcile.propose = lambda *args: None
|
||||
scope = {'sys': SimpleNamespace(path=[], executable='python3'),
|
||||
'os': os, 'tempfile': tempfile, 'Path': Path,
|
||||
'translate': lambda text: text, 'check_selected': lambda project, row: row,
|
||||
'_selected_configuration': lambda vmid: instances.command('pct', 'config', str(vmid)),
|
||||
'images': SimpleNamespace(offer_removal=lambda *args: None),
|
||||
'carry_records': lambda project, **options: {},
|
||||
'_work_backup_ready': lambda project, ui, vmid: True,
|
||||
|
||||
@@ -17,13 +17,15 @@ MENU = ROOT / 'oci/src/proxmenux_oci/management.py'
|
||||
CHOICE = 'Recover the previous installation'
|
||||
BEFORE = 'The operation stopped halfway. Choose "{choice}" for this container in the OCI management menu.'
|
||||
AFTER = 'The recovery did not complete. Review the log and choose "{choice}" again for this container in the OCI management menu.'
|
||||
REGISTRY_ADVICE = ('Check the image reference and registry access. If the registry is unavailable, '
|
||||
'try again later. For a private image, sign in from Registry accounts in the OCI menu.')
|
||||
NEW_KEYS = (
|
||||
'The Compose file describes several services:',
|
||||
'Only one service at a time can be installed this way.',
|
||||
'No docker run command was given',
|
||||
'Image selection was cancelled',
|
||||
'Could not inspect the image in its registry:',
|
||||
'Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.',
|
||||
REGISTRY_ADVICE,
|
||||
BEFORE, AFTER,
|
||||
)
|
||||
|
||||
@@ -117,16 +119,16 @@ class OciWordingTests(TestCase):
|
||||
self.assertEqual(report('example:latest'), (True, 'The image is in its registry: amd64'))
|
||||
self.assertEqual(run.call_count, 1)
|
||||
|
||||
def test_failed_registry_inspection_has_neutral_advice(self):
|
||||
def test_failed_registry_inspection_points_to_registry_accounts(self):
|
||||
ui = SimpleNamespace(message=Mock())
|
||||
fn = function(CUSTOM, 'explore', read_definition=lambda _: ('definition', 'source'),
|
||||
ignored_settings=lambda _: [], template_from_compose=lambda _: {
|
||||
'container_contract': {'image': {'reference': 'example:latest'}}},
|
||||
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'))
|
||||
describe=lambda _: 'SUMMARY', registry_report=lambda _: (False, 'INSPECTION FAILED'),
|
||||
_registry_refused=lambda _: False)
|
||||
fn(ui)
|
||||
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n'
|
||||
'Check the image reference and registry access. If the registry is unavailable, '
|
||||
'try again later. A private registry needs credentials, which are not supported yet.')
|
||||
self.assertEqual(ui.message.call_args.args[0], 'SUMMARY\n\nINSPECTION FAILED\n\n' + REGISTRY_ADVICE)
|
||||
self.assertNotIn('not supported yet', ui.message.call_args.args[0])
|
||||
|
||||
def test_recovery_hint_quotes_translated_choice_in_all_locales(self):
|
||||
# Pin the real translated management choice, not an English AST constant alone.
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Offline regressions for copyable commands and runtime format fields."""
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
ROOT = SCRIPTS.parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
SPEC = importlib.util.spec_from_file_location('check_terminal_catalogs', SCRIPTS / 'check_terminal_catalogs.py')
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC and SPEC.loader
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
class TerminalCatalogsTests(unittest.TestCase):
|
||||
def test_extractor_includes_launchers_remote_scripts_and_catalog_text(self):
|
||||
fixtures = {
|
||||
'menu': 'translate "Main launcher"',
|
||||
'install_proxmenux.sh': 'translate "Stable installer"',
|
||||
'install_proxmenux_beta.sh': 'translate "Beta installer"',
|
||||
'scripts/check.sh': 'translate "Host shell script"',
|
||||
'scripts/check.py': 'translate("Host Python script")',
|
||||
'oci/src/check.py': 'N_("OCI Python script")',
|
||||
'oci/remote/check.py': 'translate("Remote Python script")',
|
||||
'oci/remote/check.sh': 'translate "Remote shell script"',
|
||||
'oci/catalog/app.json': json.dumps({'catalog_ui': {'tagline': {'en_US': 'Application tagline'}}}),
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for name, content in fixtures.items():
|
||||
file = root / name
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(content, encoding='utf-8')
|
||||
self.assertEqual(set(MODULE.active_sources(root)), {
|
||||
'Main launcher', 'Stable installer', 'Beta installer',
|
||||
'Host shell script', 'Host Python script', 'OCI Python script',
|
||||
'Remote Python script', 'Remote shell script', 'Application tagline',
|
||||
})
|
||||
|
||||
def test_runtime_format_field_cannot_be_translated(self):
|
||||
source = 'Kimai has no default account. Enter the container with: pct enter {main_vmid}'
|
||||
with self.assertRaisesRegex(ValueError, 'placeholder'):
|
||||
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main vmid}')
|
||||
MODULE.validate_message(source, 'Kimai nemá účet: pct enter {main_vmid}')
|
||||
|
||||
def test_copyable_command_and_environment_variable_are_literal(self):
|
||||
examples = [
|
||||
('Example: pct config 110 | grep unprivileged', 'Ejemplo: configuración pct 110 | grep sin privilegios'),
|
||||
("Use 'pct restore' to recover the disks.", "Verwenden Sie 'pct restart', um die Datenträger wiederherzustellen."),
|
||||
('Invalid ALLOWED_HOSTS value', 'Valeur HÔTES INDÉSIRABLES'),
|
||||
]
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
|
||||
MODULE.validate_message(source, target)
|
||||
MODULE.validate_message('Example: pct config 110 | grep unprivileged',
|
||||
'Ejemplo: pct config 110 | grep unprivileged')
|
||||
|
||||
def test_dns_capitalization_does_not_relax_file_path_case(self):
|
||||
MODULE.validate_message('Cannot reach download.proxmox.com.',
|
||||
'Download.proxmox.com ist nicht erreichbar.')
|
||||
with self.assertRaisesRegex(ValueError, 'literal command or identifier'):
|
||||
MODULE.validate_message('Check /etc/pve/storage.cfg', 'Prüfen Sie /etc/PVE/storage.cfg')
|
||||
|
||||
def test_shipped_active_catalogs_keep_literal_contracts(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, '-B', str(SCRIPTS / 'check_terminal_catalogs.py'),
|
||||
'--root', str(ROOT), '--require-complete'],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
for locale in MODULE.DEFAULT_LANGUAGES:
|
||||
self.assertIn(f'[{locale}] active=', result.stdout)
|
||||
self.assertNotIn('missing=1', result.stdout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -15,7 +15,7 @@ class VmBackupJobsWiring(TestCase):
|
||||
source = ROUTES.read_text()
|
||||
routes = re.findall(r'@vm_backup_bp\.route\("([^"]+)", methods=\["(\w+)"\]\)\n@(\w+)\ndef (\w+)', source)
|
||||
self.assertEqual(len(routes), source.count('@vm_backup_bp.route('))
|
||||
self.assertEqual(len(routes), 8)
|
||||
self.assertEqual(len(routes), 10)
|
||||
for path, method, guard, name in routes:
|
||||
expected = 'require_auth' if method == 'GET' else 'require_admin_scope'
|
||||
self.assertEqual(guard, expected, f'{method} {path} ({name})')
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Validate complete catalogs without rejecting intentional English fallback."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "check_web_docs_catalogs.py"
|
||||
|
||||
|
||||
class WebDocsCatalogTests(unittest.TestCase):
|
||||
def run_check(self, root, *options):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(root),
|
||||
"--languages", "de", *options],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
|
||||
def write_catalog(self, root, locale, content):
|
||||
file = root / locale / "common.json"
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(json.dumps(content), encoding="utf-8")
|
||||
|
||||
def test_partial_catalog_keeps_fallback_but_complete_mode_reports_gap(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"title": "Updates", "body": "Check the host."})
|
||||
self.write_catalog(root, "de", {"title": "Aktualisierungen"})
|
||||
partial = self.run_check(root)
|
||||
self.assertEqual(partial.returncode, 0, partial.stdout + partial.stderr)
|
||||
self.assertIn("missing=1", partial.stdout)
|
||||
complete = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(complete.returncode, 1, complete.stdout + complete.stderr)
|
||||
self.assertIn("schema differs", complete.stdout)
|
||||
|
||||
def test_translated_code_is_rejected_in_both_modes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"body": "Run <code>apt update</code>."})
|
||||
self.write_catalog(root, "de", {"body": "Führe <code>apt aktualisieren</code> aus."})
|
||||
for options in ((), ("--require-complete",)):
|
||||
with self.subTest(options=options):
|
||||
result = self.run_check(root, *options)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
|
||||
def test_bare_commands_and_operators_are_not_prose(self):
|
||||
examples = [
|
||||
("Restore with pct restore.", "Mit pct restart wiederherstellen."),
|
||||
("pveversion | grep pve-manager", "pveversion grep pve-manager"),
|
||||
("cd /opt/my-app && ./update.sh", "cd /opt/meine-app && ./update.sh"),
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for source, target in examples:
|
||||
with self.subTest(source=source):
|
||||
self.write_catalog(root, "en", {"body": source})
|
||||
self.write_catalog(root, "de", {"body": target})
|
||||
result = self.run_check(root)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("literal command or identifier", result.stdout)
|
||||
|
||||
def test_title_separator_can_change_without_relaxing_body_pipes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"meta": {"title": "Storage | ProxMenux"}})
|
||||
self.write_catalog(root, "de", {"meta": {"title": "Speicher — ProxMenux"}})
|
||||
result = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.write_catalog(root, "en", {"meta": {"description": "pveversion | grep pve-manager"}})
|
||||
self.write_catalog(root, "de", {"meta": {"description": "pveversion grep pve-manager"}})
|
||||
result = self.run_check(root)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
|
||||
def test_shipped_web_catalogs_keep_literal_contracts(self):
|
||||
messages = SCRIPT.parents[2] / "web" / "messages"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-B", str(SCRIPT), "--messages-dir", str(messages)],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_literal_metadata_is_required_but_labels_can_be_localized(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
self.write_catalog(root, "en", {"label": "Updates", "tone": "blue"})
|
||||
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blau"})
|
||||
invalid = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(invalid.returncode, 1, invalid.stdout + invalid.stderr)
|
||||
self.assertIn("rendering metadata differ", invalid.stdout)
|
||||
self.write_catalog(root, "de", {"label": "Aktualisierungen", "tone": "blue"})
|
||||
valid = self.run_check(root, "--require-complete")
|
||||
self.assertEqual(valid.returncode, 0, valid.stdout + valid.stderr)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Exercise the static-export language repair on real HTML files."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_command_descriptions import require_node
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = ROOT / "web/scripts/localize-export.mjs"
|
||||
|
||||
|
||||
class WebExportLanguageTests(unittest.TestCase):
|
||||
def test_locales_nested_pages_preserved_content_and_idempotence(self):
|
||||
node = require_node()
|
||||
html = '<!doctype html><html lang="en" class="dark"><body><h1>Text</h1><script>const example=\'<html lang="en">\';</script></body></html>'
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
locales = ["en", "es", "de", "fr", "it", "pt", "sk", "sv"]
|
||||
for locale in locales:
|
||||
file = root / "out" / locale / "docs/monitor/index.html"
|
||||
file.parent.mkdir(parents=True)
|
||||
file.write_text(html)
|
||||
for relative in ["out/index.html", "out/assets/index.html"]:
|
||||
file = root / relative
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(html)
|
||||
result = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("8 localized pages", result.stdout)
|
||||
for locale in locales:
|
||||
actual = (root / "out" / locale / "docs/monitor/index.html").read_text()
|
||||
self.assertEqual(actual, html.replace('<html lang="en"', f'<html lang="{locale}"', 1))
|
||||
for relative in ["out/index.html", "out/assets/index.html"]:
|
||||
self.assertEqual((root / relative).read_text(), html)
|
||||
tracked = root / "out/de/docs/monitor/index.html"
|
||||
before = tracked.stat().st_mtime_ns
|
||||
second = subprocess.run([node, str(SCRIPT)], cwd=root, capture_output=True, text=True)
|
||||
self.assertEqual(second.returncode, 0, second.stderr)
|
||||
self.assertEqual(tracked.stat().st_mtime_ns, before)
|
||||
@@ -1,14 +1,9 @@
|
||||
name: Build web documentation translations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
paths:
|
||||
- 'web/messages/en/*.json'
|
||||
- 'web/messages/en/**/*.json'
|
||||
- '.github/scripts/build_web_docs_i18n.py'
|
||||
- '.github/scripts/build_translation_cache.py'
|
||||
- '.github/workflows/build-web-docs-i18n.yml'
|
||||
# Source and locale corrections are reviewed together. An automatic run
|
||||
# would treat the changed English fingerprints as stale translations and
|
||||
# overwrite those reviewed locale strings with provider output.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
languages:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Apprise Slack and Matrix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-apprise-services.yml'
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'AppImage/components/notification-settings.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/notification_channels.py'
|
||||
- 'AppImage/components/apprise-service-help.tsx'
|
||||
- 'tests/test_apprise_services.py'
|
||||
- 'tests/test_apprise_service_help.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
apprise-services:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
apprise: ['1.7.0', '2.0.1']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'apprise==${{ matrix.apprise }}'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test real provider plugins with mocked HTTP
|
||||
run: python -B -m unittest discover -s tests -p test_apprise_services.py -v
|
||||
- name: Render provider help and locale fallback
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_apprise_service_help.cjs
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Backup storage retention
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-backup-storage-retention.yml'
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/components/backup-tab.tsx'
|
||||
- 'AppImage/components/vm-backup-jobs.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/flask_vm_backup_routes.py'
|
||||
- 'AppImage/components/backup-storage-retention.tsx'
|
||||
- 'AppImage/lib/backup-retention.ts'
|
||||
- 'tests/test_vm_backup_jobs_api.py'
|
||||
- 'tests/test_backup_storage_retention.cjs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
storage-retention:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'Flask==3.1.2'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test native job and storage configuration APIs
|
||||
run: python -B -m unittest discover -s tests -p test_vm_backup_jobs_api.py -v
|
||||
- name: Test shared retention properties
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_backup_storage_retention.cjs
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Figurine installation
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-figurine.yml'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- '.github/workflows/test-figurine.yml'
|
||||
- 'tests/test_figurine_installation.py'
|
||||
- 'scripts/post_install/customizable_post_install.sh'
|
||||
- 'AppImage/scripts/post_install_versions.py'
|
||||
- 'AppImage/scripts/flask_proxmenux_routes.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
installer-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Check shell syntax and isolated installer regressions
|
||||
run: |
|
||||
bash -n scripts/post_install/customizable_post_install.sh
|
||||
python3 -B -m unittest discover -s tests -p test_figurine_installation.py -v
|
||||
- name: Install ARM user-mode emulation for release smoke tests
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends qemu-user
|
||||
- name: Verify and run the pinned AMD64, ARM64 and ARMv7 release binaries
|
||||
env:
|
||||
FIGURINE_VERSION: '2.1.1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
scratch="$(mktemp -d)"
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
while read -r arch sha; do
|
||||
archive="figurine_linux_${arch}_v${FIGURINE_VERSION}.tar.gz"
|
||||
target="$scratch/$arch"
|
||||
mkdir -p "$target"
|
||||
curl --fail --location --retry 3 --output "$target/$archive" \
|
||||
"https://github.com/arsham/figurine/releases/download/v${FIGURINE_VERSION}/$archive"
|
||||
printf '%s %s\n' "$sha" "$target/$archive" | sha256sum --check
|
||||
tar -xf "$target/$archive" -C "$target"
|
||||
runner=()
|
||||
case "$arch" in
|
||||
arm64) runner=(qemu-aarch64) ;;
|
||||
arm) runner=(qemu-arm) ;;
|
||||
esac
|
||||
"${runner[@]}" "$target/deploy/figurine" --version | tee "$target/version"
|
||||
grep -F "figurine version v$FIGURINE_VERSION" "$target/version"
|
||||
"${runner[@]}" "$target/deploy/figurine" -f "3d.flf" "ProxMenux-test" > "$target/banner"
|
||||
test -s "$target/banner"
|
||||
printf 'PASS: %s version and login banner\n' "$arch"
|
||||
done <<'ASSETS'
|
||||
amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a
|
||||
arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10
|
||||
arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d
|
||||
ASSETS
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Guest backup archives
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-guest-backups.yml'
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
- 'AppImage/components/virtual-machines.tsx'
|
||||
- 'AppImage/lib/guest-backup-archives.ts'
|
||||
- 'AppImage/messages/**'
|
||||
- 'tests/test_guest_backup_archives.*'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/guest_backup_archives.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/jwt_middleware.py'
|
||||
- 'AppImage/components/guest-backup-list.tsx'
|
||||
- 'AppImage/components/virtual-machines.tsx'
|
||||
- 'AppImage/lib/guest-backup-archives.ts'
|
||||
- 'tests/test_guest_backup_archives.*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
guest-backups:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Install fixture dependencies
|
||||
run: |
|
||||
python -m pip install 'Flask==3.1.2'
|
||||
npm ci --prefix .github/ci/offline-node --ignore-scripts --no-audit --no-fund
|
||||
- name: Test native deletion, protection, authorization and cache invalidation
|
||||
run: python -B -m unittest discover -s tests -p test_guest_backup_archives.py -v
|
||||
- name: Test frontend deletion and completion handling
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/.github/ci/offline-node/node_modules
|
||||
run: node tests/test_guest_backup_archives.cjs
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Offline i18n tests
|
||||
name: Offline script regressions
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -9,15 +9,20 @@ on:
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'web/**'
|
||||
- 'CONTRIBUTING.md'
|
||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'lang/*.json'
|
||||
- 'menu'
|
||||
- 'install_proxmenux.sh'
|
||||
- 'install_proxmenux_beta.sh'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- 'oci/remote/**/*.sh'
|
||||
- 'oci/remote/**/*.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
@@ -27,21 +32,28 @@ on:
|
||||
- 'AppImage/messages/**'
|
||||
- 'AppImage/lib/i18n/**'
|
||||
- 'AppImage/components/terminal-panel.tsx'
|
||||
- 'web/**'
|
||||
- 'CONTRIBUTING.md'
|
||||
# Scheduler consumers and the offline extractor's .sh/.func inputs.
|
||||
- 'scripts/**/*.sh'
|
||||
- 'scripts/**/*.func'
|
||||
- 'scripts/**/*.py'
|
||||
- 'lang/*.json'
|
||||
- 'menu'
|
||||
- 'install_proxmenux.sh'
|
||||
- 'install_proxmenux_beta.sh'
|
||||
- 'oci/src/**/*.py'
|
||||
- 'oci/catalog/**/*.json'
|
||||
- 'oci/remote/**/*.sh'
|
||||
- 'oci/remote/**/*.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Preserve this check ID for existing branch protection rules. This suite
|
||||
# includes menu, download and scheduler regressions as well as i18n tests.
|
||||
offline-i18n:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
@@ -61,7 +73,7 @@ jobs:
|
||||
test -x /usr/bin/jq
|
||||
- name: Install OCI wording test dependency
|
||||
run: python3 -m pip install 'PyYAML>=6,<7'
|
||||
- name: Run complete offline script test suite
|
||||
- name: Run offline i18n and script regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s .github/scripts/tests -v
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Disk policy and host monitor regressions
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-lvm-disk-policy.yml'
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/flask_notification_routes.py'
|
||||
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||
- 'tests/test_lvm_disk_read_policy.py'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/lvm_device_scope.py'
|
||||
- 'AppImage/scripts/disk_temperature_history.py'
|
||||
- 'AppImage/scripts/flask_server.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/flask_notification_routes.py'
|
||||
- 'AppImage/scripts/proxmox_storage_monitor.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/scripts/tests/test_host_backup_node.py'
|
||||
- 'AppImage/scripts/tests/test_proxmox_node_name.py'
|
||||
- 'AppImage/scripts/tests/test_pve_notifications_cfg_writes.py'
|
||||
- 'tests/test_lvm_disk_read_policy.py'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
disk-policy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Test automatic LVM readers with mocked inventory and policy
|
||||
run: python -I -B -m unittest discover -s tests -p test_lvm_disk_read_policy.py -v
|
||||
- name: Test backup node ownership and former jobs
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_host_backup_node.py -v
|
||||
- name: Test local node lookup without Proxmox API commands
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_proxmox_node_name.py -v
|
||||
- name: Test notification configuration writes against a cluster filesystem fixture
|
||||
run: python -I -B -m unittest discover -s AppImage/scripts/tests -p test_pve_notifications_cfg_writes.py -v
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Offline OCI registry tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'oci/**'
|
||||
- '.github/workflows/test-oci-registries.yml'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'oci/**'
|
||||
- '.github/workflows/test-oci-registries.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
registry-accounts:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Install OCI test dependencies
|
||||
run: python3 -m pip install -r oci/requirements.txt
|
||||
- name: Run OCI image reuse and resource edit regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
|
||||
- name: Run carried record, recovery and configuration cache regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v
|
||||
- name: Run read-only volume preparation regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
test -x /bin/bash
|
||||
test -x /usr/bin/jq
|
||||
python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v
|
||||
- name: Run offline registry account tests
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
|
||||
- name: Run OCI Modify latency regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_latency.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_menu_performance.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_work_backup.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||
- name: Run OCI modification menu regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_recreation.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v
|
||||
- name: Run external OCI settings regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
|
||||
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- 'AppImage/package-lock.json'
|
||||
- 'AppImage/scripts/*.py'
|
||||
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
- 'AppImage/hooks/**'
|
||||
@@ -32,6 +33,7 @@ on:
|
||||
- 'AppImage/package-lock.json'
|
||||
- 'AppImage/scripts/*.py'
|
||||
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||
- 'AppImage/app/**'
|
||||
- 'AppImage/components/**'
|
||||
- 'AppImage/hooks/**'
|
||||
@@ -56,7 +58,9 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Run nine qualified Python files
|
||||
- name: Install native discovery route fixture dependency
|
||||
run: python3 -m pip install 'Flask>=3,<4'
|
||||
- name: Run qualified Python files
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
|
||||
@@ -82,7 +86,7 @@ jobs:
|
||||
run: |
|
||||
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||
ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules
|
||||
- name: Run eleven qualified Node files
|
||||
- name: Run qualified Node files
|
||||
env:
|
||||
NODE_PATH: ${{ github.workspace }}/AppImage/node_modules
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
name: PBS support
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-pbs.yml'
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'AppImage/scripts/tests/test_pbs.py'
|
||||
- 'AppImage/scripts/health_monitor.py'
|
||||
- 'AppImage/scripts/auth_manager.py'
|
||||
- 'AppImage/scripts/build_appimage.sh'
|
||||
- 'AppImage/components/pbs-*.tsx'
|
||||
- 'AppImage/components/proxmox-dashboard.tsx'
|
||||
- 'AppImage/tests/pbs-navigation.cjs'
|
||||
- 'scripts/global/proxmox_platform.py'
|
||||
- 'scripts/global/platform-functions.sh'
|
||||
- 'scripts/global/repository-functions.sh'
|
||||
- 'scripts/menus/pbs_menu.sh'
|
||||
- 'scripts/utilities/pbs_update.sh'
|
||||
- 'scripts/global/update-pbs.sh'
|
||||
- 'scripts/menus/menu_post_install.sh'
|
||||
- 'scripts/post_install/*.sh'
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'AppImage/scripts/*pbs*.py'
|
||||
- 'scripts/global/proxmox_platform.py'
|
||||
- '.github/workflows/test-pbs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
pbs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python: ['3.11', '3.13']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- name: Install test dependencies
|
||||
run: python -m pip install Flask PyJWT cryptography pyotp segno
|
||||
- name: Platform, real TLS, APIs, health and AppImage packaging
|
||||
run: |
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v
|
||||
python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v
|
||||
for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do
|
||||
bash -n "$script"
|
||||
done
|
||||
- uses: actions/setup-node@v6
|
||||
if: matrix.python == '3.11'
|
||||
with:
|
||||
node-version: '22.14.0'
|
||||
- name: Dashboard platform routing
|
||||
if: matrix.python == '3.11'
|
||||
run: |
|
||||
npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund
|
||||
NODE_PATH="$PWD/.github/ci/offline-node/node_modules" node AppImage/tests/pbs-navigation.cjs
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
// These are examples, never defaults: opening help must not replace a saved
|
||||
// destination or put placeholder credentials into the notification settings.
|
||||
export function AppriseServiceHelp() {
|
||||
const t = useT()
|
||||
return <details className="rounded-md border border-border bg-muted/20 p-2 text-xs">
|
||||
<summary className="cursor-pointer font-medium">{t("settings.notifications.ui.appriseServiceHelp")}</summary>
|
||||
<div className="grid gap-3 pt-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<h4 className="font-medium">Slack</h4>
|
||||
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseSlackSteps")}</p>
|
||||
<code className="block break-all rounded bg-background p-2 text-[10px]">https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C</code>
|
||||
<a href="https://appriseit.com/services/slack/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||
{t("settings.notifications.ui.appriseSlackDocs")} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
<h4 className="font-medium">Matrix</h4>
|
||||
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseMatrixSteps")}</p>
|
||||
<code className="block break-all rounded bg-background p-2 text-[10px]">matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com</code>
|
||||
<a href="https://appriseit.com/services/matrix/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
|
||||
{t("settings.notifications.ui.appriseMatrixDocs")} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-muted-foreground">{t("settings.notifications.ui.appriseServiceFinish")}</p>
|
||||
</details>
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { HardDrive, Loader2, Pencil, RefreshCw } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { buildRetention, parseRetention, RETENTION } from "../lib/backup-retention"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface StorageRetention { id: string; type: string; prune_backups: string; digest: string }
|
||||
const fetcher = (url: string) => fetchApi<{ storages: StorageRetention[] }>(url)
|
||||
|
||||
export function BackupStorageRetention() {
|
||||
const t = useT()
|
||||
const { data, error, mutate, isValidating } = useSWR("/api/vm-backup-storages", fetcher)
|
||||
const [editing, setEditing] = useState<StorageRetention | null>(null)
|
||||
const [fields, setFields] = useState<Record<string, string>>({})
|
||||
const [keepAll, setKeepAll] = useState(false)
|
||||
const [useDefault, setUseDefault] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [saveError, setSaveError] = useState("")
|
||||
|
||||
function edit(storage: StorageRetention) {
|
||||
const policy = parseRetention(storage.prune_backups)
|
||||
setFields(policy.fields)
|
||||
setKeepAll(policy.keepAll)
|
||||
setUseDefault(!storage.prune_backups)
|
||||
setSaveError("")
|
||||
setMessage("")
|
||||
setEditing(storage)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!editing || busy) return
|
||||
const prune = useDefault ? "" : buildRetention(fields, keepAll)
|
||||
if (!useDefault && !prune) return setSaveError(t("backup.storageRetention.choosePolicy"))
|
||||
setBusy(true)
|
||||
setSaveError("")
|
||||
try {
|
||||
await fetchApi(`/api/vm-backup-storages/${encodeURIComponent(editing.id)}`, {
|
||||
method: "PUT", body: JSON.stringify({ digest: editing.digest, prune_backups: prune }),
|
||||
})
|
||||
setMessage(t("backup.storageRetention.saved"))
|
||||
setEditing(null)
|
||||
await mutate()
|
||||
} catch (error) {
|
||||
setSaveError(`${t("backup.storageRetention.saveFailed")} ${error instanceof Error ? error.message : ""}`)
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return <Card className="border-border bg-card/50">
|
||||
<CardHeader className="pb-3 flex-row items-center justify-between gap-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><HardDrive className="h-4 w-4" />{t("backup.storageRetention.title")}</CardTitle>
|
||||
<Button variant="ghost" size="sm" disabled={isValidating} onClick={() => { void mutate().catch(() => {}) }}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isValidating ? "animate-spin" : ""}`} />{t("backup.storageRetention.refresh")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.description")}</p>
|
||||
{message && <p role="status" className="text-xs text-emerald-500">{message}</p>}
|
||||
{error ? <p role="alert" className="text-xs text-destructive">{t("backup.storageRetention.loadFailed")}</p> : !data ?
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> : !data.storages.length ?
|
||||
<p className="text-xs text-muted-foreground">{t("backup.storageRetention.empty")}</p> :
|
||||
data.storages.map(storage => <div key={storage.id} className="flex flex-wrap gap-2 items-center border rounded-lg p-3">
|
||||
<span className="font-medium text-sm">{storage.id}</span><Badge variant="outline">{storage.type}</Badge>
|
||||
<span className="text-xs font-mono text-muted-foreground break-all">{storage.prune_backups || t("backup.storageRetention.default")}</span>
|
||||
<Button variant="outline" size="sm" className="ml-auto gap-1" onClick={() => edit(storage)} disabled={!storage.digest || busy}>
|
||||
<Pencil className="h-3 w-3" />{t("backup.storageRetention.edit")}
|
||||
</Button>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
<Dialog open={!!editing} onOpenChange={open => { if (!open && !busy) setEditing(null) }}>
|
||||
<DialogContent className="bg-accent [&_input]:bg-background">
|
||||
<DialogHeader><DialogTitle>{t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })}</DialogTitle>
|
||||
<DialogDescription>{t("backup.storageRetention.effect")}</DialogDescription></DialogHeader>
|
||||
<Label className="flex items-center gap-2"><Checkbox checked={useDefault} disabled={busy} onCheckedChange={checked => {
|
||||
setUseDefault(checked === true)
|
||||
if (checked) { setKeepAll(false); setFields({}) }
|
||||
}} />{t("backup.storageRetention.useDefault")}</Label>
|
||||
<Label className="flex items-center gap-2"><Checkbox checked={keepAll} disabled={useDefault || busy} onCheckedChange={checked => {
|
||||
setKeepAll(checked === true)
|
||||
if (checked) setFields({})
|
||||
}} />{t("backup.storageRetention.keepAll")}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{RETENTION.map(key => <div key={key} className="space-y-1">
|
||||
<Label htmlFor={`storage-retention-${key}`}>{t(`backup.retention.keep${key[0].toUpperCase()}${key.slice(1)}`)}</Label>
|
||||
<Input id={`storage-retention-${key}`} type="number" min={1} max={99999} step={1} disabled={useDefault || keepAll || busy}
|
||||
value={fields[key] ?? ""} onChange={event => setFields(previous => ({ ...previous, [key]: event.target.value }))} />
|
||||
</div>)}
|
||||
</div>
|
||||
{saveError && <p role="alert" className="text-xs text-destructive break-words">{saveError}</p>}
|
||||
<DialogFooter><Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>{t("backup.storageRetention.cancel")}</Button>
|
||||
<Button disabled={busy} onClick={save}>{busy && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}{t("backup.storageRetention.save")}</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import { useState } from "react"
|
||||
import { DatabaseBackup } from "lucide-react"
|
||||
import { HostBackup } from "./host-backup"
|
||||
import { VmBackupJobs } from "./vm-backup-jobs"
|
||||
import { PBSPanel } from "./pbs-panel"
|
||||
import { BackupStorageRetention } from "./backup-storage-retention"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
type BackupView = "host" | "guests"
|
||||
type BackupView = "host" | "guests" | "pbs"
|
||||
|
||||
export function BackupTab() {
|
||||
const t = useT()
|
||||
@@ -29,7 +31,7 @@ export function BackupTab() {
|
||||
className="flex w-full rounded-lg border border-border bg-muted/40 p-1 gap-1
|
||||
sm:inline-flex sm:w-auto"
|
||||
>
|
||||
{(["host", "guests"] as const).map((key) => (
|
||||
{(["host", "guests", "pbs"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
@@ -41,7 +43,7 @@ export function BackupTab() {
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/60"
|
||||
}`}
|
||||
>
|
||||
{t(`backup.viewSwitch.${key}`)}
|
||||
{key === "pbs" ? t("pbs.title") : t(`backup.viewSwitch.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -51,7 +53,8 @@ export function BackupTab() {
|
||||
<div className={view === "host" ? "" : "hidden"}>
|
||||
<HostBackup />
|
||||
</div>
|
||||
{view === "guests" && <VmBackupJobs />}
|
||||
{view === "guests" && <><VmBackupJobs /><BackupStorageRetention /></>}
|
||||
{view === "pbs" && <PBSPanel />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Archive, Clock, Loader2, LockKeyhole, RefreshCw, Trash2 } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { deleteGuestBackups, type GuestBackup } from "../lib/guest-backup-archives"
|
||||
|
||||
export function GuestBackupList({ vmid, backups, loading, onRefresh, storageColor }: {
|
||||
vmid: number
|
||||
backups: GuestBackup[]
|
||||
loading: boolean
|
||||
onRefresh: () => Promise<void>
|
||||
storageColor?: (storage: string) => { bg: string; text: string; border: string }
|
||||
}) {
|
||||
const t = useT()
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [confirm, setConfirm] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => { mounted.current = false }
|
||||
}, [])
|
||||
// A fresh list can remove a selection or reveal a newly protected archive.
|
||||
useEffect(() => {
|
||||
setSelected(previous => previous.filter(volid => backups.some(backup => backup.volid === volid && !backup.protected)))
|
||||
}, [backups])
|
||||
|
||||
const chosen = backups.filter(backup => selected.includes(backup.volid) && !backup.protected)
|
||||
const selectable = backups.filter(backup => !backup.protected)
|
||||
const disabled = loading || busy || refreshing
|
||||
const storageClass = (storage: string) => {
|
||||
const color = storageColor?.(storage)
|
||||
return color ? `${color.bg} ${color.text} ${color.border}` : ""
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
setRefreshing(true)
|
||||
setError("")
|
||||
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
|
||||
finally { if (mounted.current) setRefreshing(false) }
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!chosen.length || busy) return
|
||||
setBusy(true)
|
||||
setError("")
|
||||
setMessage("")
|
||||
try {
|
||||
const results = await deleteGuestBackups(vmid, chosen)
|
||||
if (!mounted.current) return
|
||||
const deleted = results.filter(result => result.status === "deleted").length
|
||||
const unconfirmed = results.length - deleted
|
||||
setMessage(t("vmLxc.backups.deletedCount", { count: deleted }))
|
||||
if (unconfirmed) setError(t("vmLxc.backups.unconfirmedCount", { count: unconfirmed }))
|
||||
setSelected([])
|
||||
} catch (error) {
|
||||
if (mounted.current) setError(`${t("vmLxc.backups.deleteFailed")} ${error instanceof Error ? error.message : ""}`)
|
||||
} finally {
|
||||
// A timeout can hide a successful native task. Always bypass both caches.
|
||||
try { await onRefresh() } catch { if (mounted.current) setError(t("vmLxc.backups.refreshFailed")) }
|
||||
if (mounted.current) { setBusy(false); setConfirm(false) }
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className="flex items-center justify-between gap-2 mb-3 shrink-0 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
|
||||
<Badge variant="secondary" className="text-xs h-5">{backups.length}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" disabled={disabled} onClick={refresh}>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />{t("vmLxc.backups.refresh")}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs gap-1" disabled={disabled || !chosen.length} onClick={() => setConfirm(true)}>
|
||||
{busy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||||
{t("vmLxc.backups.deleteSelected", { count: chosen.length })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{message && <p role="status" className="text-xs text-muted-foreground mb-2">{message}</p>}
|
||||
{error && <p role="alert" className="text-xs text-destructive mb-2 break-words">{error}</p>}
|
||||
{loading ? <div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" /><span className="text-sm">{t("vmLxc.backups.loading")}</span>
|
||||
</div> : backups.length === 0 ? <div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
|
||||
<Archive className="h-12 w-12 mb-3 opacity-30" /><span className="text-sm">{t("vmLxc.backups.empty")}</span>
|
||||
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
|
||||
</div> : <>
|
||||
<label className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
|
||||
<Checkbox checked={selectable.length > 0 && chosen.length === Math.min(20, selectable.length)} disabled={disabled || !selectable.length}
|
||||
onCheckedChange={checked => setSelected(checked ? selectable.slice(0, 20).map(backup => backup.volid) : [])} />
|
||||
{t("vmLxc.backups.selectHint")}
|
||||
</label>
|
||||
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
|
||||
{backups.map(backup => <label key={backup.volid} className="flex items-center gap-2 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors">
|
||||
<Checkbox aria-label={`${t("vmLxc.backups.selectBackup")} ${backup.date} ${backup.storage}`} checked={selected.includes(backup.volid)}
|
||||
disabled={disabled || !!backup.protected || (chosen.length >= 20 && !selected.includes(backup.volid))}
|
||||
onCheckedChange={checked => setSelected(previous => checked ? [...previous, backup.volid] : previous.filter(id => id !== backup.volid))} />
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 shrink-0" />
|
||||
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm min-w-0 truncate" title={backup.volid}>{backup.date}</span>
|
||||
{backup.protected && <Badge variant="secondary" className="gap-1 text-xs"><LockKeyhole className="h-3 w-3" />{t("vmLxc.backups.protected")}</Badge>}
|
||||
<Badge variant="outline" className={`text-xs ml-auto shrink-0 ${storageClass(backup.storage)}`}>{backup.storage}</Badge>
|
||||
<Badge variant="outline" className="font-mono shrink-0">{backup.size_human}</Badge>
|
||||
</label>)}
|
||||
</div>
|
||||
</>}
|
||||
<Dialog open={confirm} onOpenChange={value => { if (!busy) setConfirm(value) }}>
|
||||
<DialogContent onEscapeKeyDown={event => { if (busy) event.preventDefault() }} onPointerDownOutside={event => { if (busy) event.preventDefault() }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("vmLxc.backups.confirmTitle", { count: chosen.length })}</DialogTitle>
|
||||
<DialogDescription>{t("vmLxc.backups.confirmDescription", { vmid })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="max-h-48 overflow-auto text-xs space-y-2 break-all">
|
||||
{chosen.map(backup => <li key={backup.volid}>{backup.volid}</li>)}
|
||||
</ul>
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">{t("vmLxc.backups.deleting")}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setConfirm(false)}>{t("vmLxc.backups.cancel")}</Button>
|
||||
<Button variant="destructive" disabled={busy || !chosen.length} onClick={remove}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin mr-2" />}{t("vmLxc.backups.confirmDelete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { useT } from "@/lib/i18n/provider"
|
||||
import { useIsPbs } from "@/lib/use-platform"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
@@ -119,6 +120,7 @@ const CATEGORIES = [
|
||||
|
||||
export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) {
|
||||
const t = useT()
|
||||
const isPbs = useIsPbs()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [healthData, setHealthData] = useState<HealthDetails | null>(null)
|
||||
const [dismissedItems, setDismissedItems] = useState<DismissedError[]>([])
|
||||
@@ -460,6 +462,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
return originalStatus?.toUpperCase() || "UNKNOWN"
|
||||
}
|
||||
|
||||
// Checks the host reports as not applicable (guests on PBS) are not listed or counted.
|
||||
const visibleCategories = CATEGORIES.filter(({ key }) =>
|
||||
!(healthData?.details?.[key as keyof typeof healthData.details] as { not_applicable?: boolean } | undefined)?.not_applicable)
|
||||
const categoryLabel = (key: string) => t(`healthStatus.categories.${isPbs && key === "services" ? "pbsServices" : key}`)
|
||||
|
||||
const getHealthStats = () => {
|
||||
if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 }
|
||||
|
||||
@@ -469,7 +476,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
let critical = 0
|
||||
let unknown = 0
|
||||
|
||||
CATEGORIES.forEach(({ key }) => {
|
||||
visibleCategories.forEach(({ key }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
if (categoryData) {
|
||||
const effectiveStatus = getEffectiveStatus(key, categoryData.status)
|
||||
@@ -481,7 +488,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
}
|
||||
})
|
||||
|
||||
return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown }
|
||||
return { total: visibleCategories.length, healthy, info, warnings, critical, unknown }
|
||||
}
|
||||
|
||||
const stats = getHealthStats()
|
||||
@@ -759,7 +766,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
|
||||
{/* Category List */}
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map(({ key, Icon }) => {
|
||||
{visibleCategories.map(({ key, Icon }) => {
|
||||
const categoryData = healthData.details[key as keyof typeof healthData.details]
|
||||
const originalStatus = categoryData?.status || "UNKNOWN"
|
||||
const status = getEffectiveStatus(key, originalStatus)
|
||||
@@ -784,7 +791,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{t(`healthStatus.categories.${key}`)}</p>
|
||||
<p className="font-medium text-xs sm:text-sm truncate">{categoryLabel(key)}</p>
|
||||
{hasChecks && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
({Object.values(checks).filter(c => c.installed !== false).length})
|
||||
@@ -871,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
{filteredDismissed.map((item) => {
|
||||
const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category)
|
||||
const CatIcon = catMeta?.Icon || BellOff
|
||||
const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category
|
||||
const catLabel = catMeta ? categoryLabel(catMeta.key) : item.category
|
||||
const isPermanent = item.permanent || item.suppression_remaining_hours === -1
|
||||
|
||||
return (
|
||||
@@ -936,7 +943,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
<div key={cs.key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<CatIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5 text-blue-400/70 shrink-0" />
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}</span>
|
||||
<span className="text-[11px] sm:text-xs text-blue-400/80 truncate">{catMeta ? categoryLabel(catMeta.key) : cs.label}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[9px] sm:text-[10px] border-blue-500/30 text-blue-400/80 bg-transparent shrink-0">
|
||||
{durationLabel}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
* Secure Gateway.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
Loader2, Save, RefreshCw, Trash2, Package, ExternalLink,
|
||||
AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff,
|
||||
@@ -93,6 +93,7 @@ interface AppConfig {
|
||||
health_path?: string
|
||||
logo_url?: string
|
||||
helper_slug?: string
|
||||
discovery_id?: string
|
||||
// Set when the application runs inside Docker: registered for its identity
|
||||
// and installed version, updated by the image it comes from.
|
||||
update_via?: string
|
||||
@@ -121,7 +122,18 @@ interface PortDetail {
|
||||
logo_url: string
|
||||
}
|
||||
|
||||
interface NativeDiscovery {
|
||||
vmid: number
|
||||
status: "not_scanned" | "complete" | "partial" | "error" | "busy"
|
||||
stale: boolean
|
||||
candidates: DetectedApp[]
|
||||
}
|
||||
|
||||
interface DetectedApp {
|
||||
discovery_id?: string
|
||||
source?: "native_metadata"
|
||||
installed_version?: string | null
|
||||
evidence?: Array<{ kind: string; value: string }>
|
||||
slug: string
|
||||
name: string
|
||||
logo_url?: string | null
|
||||
@@ -462,12 +474,37 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
const [loading, setLoading] = useState(!seed)
|
||||
const [sidecar, setSidecar] = useState<SidecarResponse | null>(seed?.sidecar ?? null)
|
||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
||||
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
|
||||
const nativeRequest = useRef(0)
|
||||
const targetRef = useRef(vmid)
|
||||
targetRef.current = vmid
|
||||
const targetGeneration = useRef(0)
|
||||
const previousTarget = useRef(vmid)
|
||||
if (previousTarget.current !== vmid) {
|
||||
previousTarget.current = vmid
|
||||
targetGeneration.current += 1
|
||||
}
|
||||
useEffect(() => {
|
||||
const generation = targetGeneration.current
|
||||
const request = ++nativeRequest.current
|
||||
setNativeDiscovery(null)
|
||||
setEditing(null)
|
||||
setDetectionNotice(null)
|
||||
setSearchingApplications(false)
|
||||
setSaving(false)
|
||||
if (managed) return
|
||||
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`).then(result => {
|
||||
if (targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& nativeRequest.current === request) setNativeDiscovery(result)
|
||||
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
|
||||
return () => { targetGeneration.current += 1 }
|
||||
}, [vmid, managed])
|
||||
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchingApplications, setSearchingApplications] = useState(false)
|
||||
const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null)
|
||||
// Editor state
|
||||
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null)
|
||||
const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null)
|
||||
const [binaryArgsInput, setBinaryArgsInput] = useState("")
|
||||
const [commandArgvInput, setCommandArgvInput] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -522,6 +559,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
// `initialData` or the shared cache, the sidecar is already
|
||||
// populated and the user shouldn't see a flash of "Loading…"
|
||||
// while we revalidate.
|
||||
const generation = targetGeneration.current
|
||||
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||
if (!sidecar) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -533,14 +572,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
// the "Loading applications…" flash that used to show while a
|
||||
// second, racing fetch caught up.
|
||||
const bundle = await fetchLxcApps(vmid)
|
||||
if (bundle) {
|
||||
if (bundle && isCurrent()) {
|
||||
setSidecar(bundle.sidecar)
|
||||
setSuggestions(bundle.suggestions)
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("vmLxc.appEditor.loadFailed"))
|
||||
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.loadFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (isCurrent()) setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [vmid, managed])
|
||||
@@ -617,7 +656,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
return msg
|
||||
}
|
||||
|
||||
const editorOpen = !!editing
|
||||
const editorOpen = !!editing && editing.targetVmid === vmid
|
||||
|
||||
// The picker catalog is only needed after the user opens the editor.
|
||||
useEffect(() => {
|
||||
@@ -653,8 +692,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
// (`extras[]` from the backend). Both use the same DetectedApp
|
||||
// shape so the empty state renders them uniformly.
|
||||
const detectedList: DetectedApp[] = useMemo(() => {
|
||||
if (!suggestions) return []
|
||||
const out: DetectedApp[] = []
|
||||
const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : [])]
|
||||
if (!suggestions) return out
|
||||
// A ProxMenux OCI install has no helper slug — nothing was installed by a
|
||||
// community script — so its template id identifies it instead.
|
||||
const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id
|
||||
@@ -679,12 +718,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [suggestions])
|
||||
}, [suggestions, nativeDiscovery, vmid])
|
||||
|
||||
// Registered slugs — used to filter the detection list down to
|
||||
// what the user hasn't already registered on this CT.
|
||||
const registeredSlugs = useMemo(
|
||||
() => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]),
|
||||
() => new Set(apps.flatMap((a) => [a.helper_slug, a.discovery_id]).filter(Boolean) as string[]),
|
||||
[apps],
|
||||
)
|
||||
// Dismissed slugs — persisted in the sidecar. Chips the user
|
||||
@@ -750,14 +789,32 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
|
||||
const searchInstalledApplications = async () => {
|
||||
const generation = targetGeneration.current
|
||||
const request = ++nativeRequest.current
|
||||
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& nativeRequest.current === request
|
||||
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
||||
setSearchingApplications(true)
|
||||
setDetectionNotice(null)
|
||||
setError(null)
|
||||
try {
|
||||
let native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`, {
|
||||
method: "POST",
|
||||
})
|
||||
if (!isCurrent()) return
|
||||
setNativeDiscovery(native)
|
||||
if (native.status === "error" || native.status === "busy") {
|
||||
setDetectionNotice({ found: false, text: t("vmLxc.appEditor.nativeScanFailed") })
|
||||
return
|
||||
}
|
||||
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
||||
method: "POST",
|
||||
})
|
||||
if (!isCurrent()) return
|
||||
// Recompose against the refreshed catalog without another native scan.
|
||||
native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
|
||||
if (!isCurrent()) return
|
||||
setNativeDiscovery(native)
|
||||
setSuggestions(result)
|
||||
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
|
||||
|
||||
@@ -765,11 +822,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
if (result.helper_slug) detected.add(result.helper_slug)
|
||||
for (const item of result.extras || []) detected.add(item.slug)
|
||||
for (const item of result.docker_workloads || []) detected.add(item.slug)
|
||||
for (const item of native.candidates) detected.add(item.slug)
|
||||
const visible = [...detected].filter(
|
||||
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
||||
)
|
||||
const newCount = visible.filter((slug) => !before.has(slug)).length
|
||||
if (newCount === 1) {
|
||||
if (native.status === "partial") {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") })
|
||||
} else if (newCount === 1) {
|
||||
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
|
||||
} else if (newCount > 1) {
|
||||
setDetectionNotice({
|
||||
@@ -785,9 +845,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
|
||||
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.detectionFailed"))
|
||||
} finally {
|
||||
setSearchingApplications(false)
|
||||
if (isCurrent()) setSearchingApplications(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,6 +864,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
existing?: AppEntry,
|
||||
opts?: { withTracking?: boolean, preset?: DetectedApp },
|
||||
) => {
|
||||
const generation = targetGeneration.current
|
||||
let seed: AppConfig
|
||||
if (existing) {
|
||||
seed = {
|
||||
@@ -832,6 +893,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
health_path: existing.health_path || "",
|
||||
logo_url: existing.logo_url || "",
|
||||
helper_slug: existing.helper_slug || "",
|
||||
...(existing.discovery_id ? { discovery_id: existing.discovery_id } : {}),
|
||||
// Preserved explicitly: saving replaces the whole record, so dropping
|
||||
// it here would silently un-delegate the app the first time someone
|
||||
// edits one of its ports.
|
||||
@@ -862,7 +924,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
const p = opts.preset
|
||||
seed.name = p.name
|
||||
seed.logo_url = p.logo_url || ""
|
||||
seed.helper_slug = p.slug
|
||||
if (p.discovery_id) {
|
||||
seed.discovery_id = p.discovery_id
|
||||
seed.update_method = "none"
|
||||
seed.update_command = ""
|
||||
} else seed.helper_slug = p.slug
|
||||
// Docker endpoints come from the real published host-port mappings
|
||||
// listed under Web links. Do not pre-save a catalog default such as
|
||||
// 9000; the user explicitly chooses which workload links to add.
|
||||
@@ -943,9 +1009,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
setShowAdvanced(false)
|
||||
}
|
||||
}
|
||||
if (targetRef.current !== vmid || targetGeneration.current !== generation) return
|
||||
setBinaryArgsInput((seed.binary_args || []).join(", "))
|
||||
setCommandArgvInput((seed.command_argv || []).join(", "))
|
||||
setEditing({ appId: existing?.id || null, draft: seed })
|
||||
setEditing({ appId: existing?.id || null, draft: seed, targetVmid: vmid })
|
||||
setDetectorTest(null)
|
||||
setError(null)
|
||||
}, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable])
|
||||
@@ -959,7 +1026,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!editing) return
|
||||
if (!editing || editing.targetVmid !== vmid || targetRef.current !== vmid) return
|
||||
const generation = targetGeneration.current
|
||||
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -973,6 +1042,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
body: JSON.stringify(editing.draft),
|
||||
})
|
||||
if ((r as any).error) throw new Error((r as any).error)
|
||||
if (!isCurrent()) return
|
||||
setSidecar(r)
|
||||
setLxcAppsCached(vmid, r, suggestions)
|
||||
setEditing(null)
|
||||
@@ -980,9 +1050,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
setCommandArgvInput("")
|
||||
onChange?.()
|
||||
} catch (e: any) {
|
||||
setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
||||
if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.saveFailed"))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
if (isCurrent()) setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1283,7 +1353,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
}
|
||||
|
||||
// ── Editor ─────────────────────────────────────────────────────
|
||||
if (editing) {
|
||||
if (editorOpen && editing) {
|
||||
const draft = editing.draft
|
||||
const method = draft.installed_via || ""
|
||||
const isPackaged = method === "dpkg" || method === "apk"
|
||||
@@ -2452,6 +2522,24 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
||||
: t("vmLxc.appEditor.detectedInContainer")}
|
||||
</div>
|
||||
{d.source === "native_metadata" && (
|
||||
<div className="text-xs text-muted-foreground mt-1 break-all">
|
||||
<div>{d.installed_version
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
|
||||
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
|
||||
{(nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
|
||||
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
|
||||
)}
|
||||
{d.evidence?.map((e, index) => <div key={index}>
|
||||
{t(e.kind === "tcp_listener" ? "vmLxc.appEditor.nativeEvidencePort"
|
||||
: e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile"
|
||||
: e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess"
|
||||
: e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory"
|
||||
: "vmLxc.appEditor.nativeEvidenceService")}: {e.value}
|
||||
</div>)}
|
||||
<div>{t("vmLxc.appEditor.nativeRegisterOnly")}</div>
|
||||
</div>
|
||||
)}
|
||||
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
||||
<div className="text-[10px] text-amber-400/90 mt-0.5">
|
||||
{t("vmLxc.appEditor.legacyDetectorUsed")}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import useSWR, { mutate as refreshCache } from "swr"
|
||||
import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Plus, Server, Trash2 } from "lucide-react"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Switch } from "./ui/switch"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface MonitorLink {
|
||||
key: string
|
||||
name: string
|
||||
product: "pve" | "pbs"
|
||||
group: "cluster" | "standalone" | "pbs"
|
||||
url: string | null
|
||||
status: "online" | "offline" | "unknown"
|
||||
native_url?: string | null
|
||||
manual: boolean
|
||||
hidden: boolean
|
||||
custom_url: boolean
|
||||
}
|
||||
interface MonitorLinks { self: { name: string; product: string }; links: MonitorLink[] }
|
||||
|
||||
const GROUPS = ["cluster", "standalone", "pbs"] as const
|
||||
const fetcher = (url: string) => fetchApi<MonitorLinks>(url)
|
||||
const ORIGIN_KEY = "proxmenux-came-from"
|
||||
|
||||
interface Origin { url: string; name: string; product: "pve" | "pbs" }
|
||||
|
||||
// The Monitor a jump came from, as the browser reached it. Only a bare http(s)
|
||||
// origin is accepted, and the parameters leave the address at once.
|
||||
function takeOrigin(): (Origin & { fresh?: boolean }) | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const from = params.get("pmx_from")
|
||||
if (from !== null) {
|
||||
const name = params.get("pmx_name") || ""
|
||||
const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve"
|
||||
for (const key of ["pmx_from", "pmx_name", "pmx_product"]) params.delete(key)
|
||||
const query = params.toString()
|
||||
window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash)
|
||||
try {
|
||||
const parsed = new URL(from)
|
||||
if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) {
|
||||
const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product }
|
||||
try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ }
|
||||
return { ...origin, fresh: true }
|
||||
}
|
||||
} catch { /* not an address */ }
|
||||
}
|
||||
try {
|
||||
const stored = JSON.parse(sessionStorage.getItem(ORIGIN_KEY) || "null")
|
||||
return stored && typeof stored.url === "string" && /^https?:\/\//.test(stored.url) ? stored : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there.
|
||||
function destination(link: MonitorLink): { href: string; native: boolean } | null {
|
||||
if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true }
|
||||
return link.url ? { href: link.url, native: false } : null
|
||||
}
|
||||
|
||||
function ProductIcon({ product, className }: { product: string; className?: string }) {
|
||||
return product === "pbs" ? <DatabaseBackup className={className} /> : <Server className={className} />
|
||||
}
|
||||
|
||||
export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: string; compact?: boolean }) {
|
||||
const t = useT()
|
||||
const [manageOpen, setManageOpen] = useState(false)
|
||||
// The backend keeps the list ready, so the menu opens already filled.
|
||||
const { data, error, isLoading } = useSWR<MonitorLinks>("/api/monitor-links", fetcher, { revalidateOnFocus: false, refreshInterval: 300000 })
|
||||
|
||||
const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null)
|
||||
const [offerOrigin, setOfferOrigin] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState("")
|
||||
useEffect(() => { setOrigin(takeOrigin()) }, [])
|
||||
const originKnown = !!origin && !!data?.links.some(link => link.url === origin.url)
|
||||
useEffect(() => {
|
||||
// Offered once, right after arriving, when this Monitor has no way back yet.
|
||||
if (origin?.fresh && data && !originKnown) setOfferOrigin(true)
|
||||
}, [origin, data, originKnown])
|
||||
|
||||
// The address carries where the jump comes from, so the other Monitor can offer the way back.
|
||||
const withOrigin = (href: string) => {
|
||||
const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" })
|
||||
return `${href}/?${params.toString()}`
|
||||
}
|
||||
const open = (href: string, newTab: boolean) => {
|
||||
if (newTab) window.open(href, "_blank", "noopener,noreferrer")
|
||||
else window.location.assign(href)
|
||||
}
|
||||
const go = (link: MonitorLink, newTab: boolean) => {
|
||||
const target = destination(link)
|
||||
if (!target) return
|
||||
if (target.native) open(target.href, true)
|
||||
else open(withOrigin(target.href), newTab)
|
||||
}
|
||||
const addOrigin = async () => {
|
||||
if (!origin) return
|
||||
setAdding(true); setAddError("")
|
||||
try {
|
||||
await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: origin.name, url: origin.url, product: origin.product }) })
|
||||
await refreshCache("/api/monitor-links")
|
||||
setOfferOrigin(false)
|
||||
} catch (err) {
|
||||
setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
} finally { setAdding(false) }
|
||||
}
|
||||
|
||||
return <div onClick={event => event.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className={`gap-2 border-border bg-transparent hover:bg-secondary ${compact ? "h-8 px-2.5 text-xs" : ""}`}>
|
||||
<Server className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
|
||||
<span className="max-w-[12rem] truncate">{t("status.node", { node: nodeName })}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t("monitorLinks.thisNode")}</DropdownMenuLabel>
|
||||
<DropdownMenuItem disabled className="gap-2 opacity-100">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
<ProductIcon product={data?.self.product || ""} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{nodeName}</span>
|
||||
</DropdownMenuItem>
|
||||
{origin && !originKnown && <DropdownMenuItem className="gap-2" onSelect={() => open(withOrigin(origin.url), false)}>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{t("monitorLinks.backTo", { name: origin.name })}</span>
|
||||
</DropdownMenuItem>}
|
||||
{isLoading && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.checking")}</p>}
|
||||
{error && <p className="px-2 py-1.5 text-xs text-amber-500">{t("monitorLinks.loadFailed")}</p>}
|
||||
{data && data.links.length === 0 && <p className="px-2 py-1.5 text-xs text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data && GROUPS.map(group => {
|
||||
const rows = data.links.filter(link => link.group === group)
|
||||
if (rows.length === 0) return null
|
||||
return <div key={group}>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-medium text-muted-foreground">{t(`monitorLinks.groups.${group}`)}</DropdownMenuLabel>
|
||||
{rows.map(link => {
|
||||
const target = destination(link)
|
||||
return <DropdownMenuItem key={link.key} disabled={!target} className="gap-2" onSelect={() => go(link, false)}>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${link.status === "online" ? "bg-green-500" : "bg-gray-500"}`} />
|
||||
<ProductIcon product={link.product} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{link.name}</span>
|
||||
{target?.native
|
||||
? <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.pbsInterface")}</span>
|
||||
: link.status !== "online" && <span className="shrink-0 text-[11px] text-muted-foreground">{t("monitorLinks.noResponse")}</span>}
|
||||
{target && <button type="button" aria-label={t("monitorLinks.openNewTab")} title={t("monitorLinks.openNewTab")}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={event => { event.preventDefault(); event.stopPropagation(); go(link, true) }}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>}
|
||||
</DropdownMenuItem>
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2" onSelect={() => setManageOpen(true)}>
|
||||
<Plus className="h-4 w-4" />{t("monitorLinks.manage")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{manageOpen && <ManageLinksDialog open={manageOpen} onOpenChange={setManageOpen} />}
|
||||
{origin && <Dialog open={offerOrigin} onOpenChange={setOfferOrigin}>
|
||||
<DialogContent className="max-w-md" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.cameFromTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.cameFromBody", { name: origin.name })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<p className="break-all rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-sm">{origin.url}</p>
|
||||
{addError && <p role="alert" className="text-sm text-red-500">{addError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" disabled={adding} onClick={() => setOfferOrigin(false)}>{t("monitorLinks.notNow")}</Button>
|
||||
<Button disabled={adding} onClick={addOrigin}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const t = useT()
|
||||
const { data, mutate } = useSWR<MonitorLinks>("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false })
|
||||
const [form, setForm] = useState({ name: "", url: "", product: "pve" })
|
||||
const [addresses, setAddresses] = useState<Record<string, string>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const run = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true); setError("")
|
||||
try {
|
||||
await action()
|
||||
await mutate()
|
||||
await refreshCache("/api/monitor-links")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed"))
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
const send = (path: string, method: string, body?: unknown) =>
|
||||
fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) })
|
||||
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto" onClick={event => event.stopPropagation()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitorLinks.dialogTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("monitorLinks.dialogDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 rounded-lg border border-border p-4">
|
||||
<p className="text-sm font-medium">{t("monitorLinks.addTitle")}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1.5fr_auto]">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-name">{t("monitorLinks.name")}</Label>
|
||||
<Input id="monitor-link-name" value={form.name} disabled={busy} onChange={event => setForm({ ...form, name: event.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-url">{t("monitorLinks.address")}</Label>
|
||||
<Input id="monitor-link-url" value={form.url} disabled={busy} placeholder="https://192.168.1.20:8008"
|
||||
onChange={event => setForm({ ...form, url: event.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="monitor-link-product">{t("monitorLinks.type")}</Label>
|
||||
<Select value={form.product} disabled={busy} onValueChange={product => setForm({ ...form, product })}>
|
||||
<SelectTrigger id="monitor-link-product" className="w-full sm:w-56"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pve">Proxmox VE</SelectItem>
|
||||
<SelectItem value="pbs">Proxmox Backup Server</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" disabled={busy || !form.url.trim()} onClick={() => run(async () => {
|
||||
await send("/api/monitor-links", "POST", form)
|
||||
setForm({ name: "", url: "", product: "pve" })
|
||||
})}><Plus className="mr-2 h-4 w-4" />{t("monitorLinks.add")}</Button>
|
||||
</div>
|
||||
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{data?.links.length === 0 && <p className="text-sm text-muted-foreground">{t("monitorLinks.empty")}</p>}
|
||||
{data?.links.map(link => {
|
||||
const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "")
|
||||
return <div key={link.key} className={`space-y-2 rounded-lg border border-border p-3 ${link.hidden ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ProductIcon product={link.product} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{link.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")}</span>
|
||||
{!link.manual && <label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{t("monitorLinks.visible")}
|
||||
<Switch checked={!link.hidden} disabled={busy} className="data-[state=checked]:bg-blue-600"
|
||||
onCheckedChange={checked => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { hidden: !checked }))} />
|
||||
</label>}
|
||||
{link.manual && <Button variant="ghost" size="sm" disabled={busy} aria-label={t("monitorLinks.remove")}
|
||||
onClick={() => run(() => send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "DELETE"))}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={address} disabled={busy} aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${link.url ? `: ${link.url}` : ""}`}
|
||||
onChange={event => setAddresses({ ...addresses, [link.key]: event.target.value })} />
|
||||
<Button variant="outline" size="sm" className="h-10" disabled={busy || addresses[link.key] === undefined}
|
||||
onClick={() => run(async () => {
|
||||
await send(`/api/monitor-links/${encodeURIComponent(link.key)}`, "PATCH", { url: addresses[link.key] })
|
||||
setAddresses(({ [link.key]: _, ...rest }) => rest)
|
||||
})}>{t("monitorLinks.save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { AppriseServiceHelp } from "./apprise-service-help"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
@@ -12,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import {
|
||||
Bell, BellOff, Send, CheckCircle2, XCircle, Loader2,
|
||||
AlertTriangle, Info, Settings2, Zap, Eye, EyeOff,
|
||||
@@ -153,6 +155,8 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
|
||||
}
|
||||
|
||||
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
|
||||
// Guests, vzdump backups and the cluster do not exist on Proxmox Backup Server.
|
||||
const PVE_ONLY_CATEGORIES = new Set(["vm_ct", "backup", "cluster"])
|
||||
|
||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
|
||||
|
||||
@@ -329,6 +333,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
|
||||
export function NotificationSettings() {
|
||||
const t = useT()
|
||||
const isPbs = useIsPbs()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [config, setConfig] = useState<NotificationConfig>(DEFAULT_CONFIG)
|
||||
const [status, setStatus] = useState<ServiceStatus | null>(null)
|
||||
@@ -702,7 +707,7 @@ export function NotificationSettings() {
|
||||
<Label className="text-[11px] text-muted-foreground">{t("settings.notifications.ui.notificationCategories")}</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => {
|
||||
{EVENT_CATEGORIES.filter(cat => cat.key !== "other" && !(isPbs && PVE_ONLY_CATEGORIES.has(cat.key))).map(cat => {
|
||||
const isEnabled = overrides.categories[cat.key] ?? true
|
||||
const isExpanded = expandedCategories.has(`${chName}.${cat.key}`)
|
||||
// Hide the LXC update toggle when the user has disabled the
|
||||
@@ -921,7 +926,7 @@ export function NotificationSettings() {
|
||||
const wasEnabled = originalConfig.enabled
|
||||
const isNowDisabled = !nextConfig.enabled
|
||||
|
||||
if (wasEnabled && isNowDisabled) {
|
||||
if (wasEnabled && isNowDisabled && !isPbs) {
|
||||
try {
|
||||
await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" })
|
||||
} catch {
|
||||
@@ -1216,8 +1221,10 @@ export function NotificationSettings() {
|
||||
setConfig(newConfig)
|
||||
setOriginalConfig(newConfig)
|
||||
|
||||
// 2) Auto-configure PVE webhook
|
||||
try {
|
||||
// 2) Auto-configure PVE webhook; PBS has no /etc/pve notifications
|
||||
if (isPbs) {
|
||||
setWebhookSetup({ status: "idle", fallback_commands: [], error: "" })
|
||||
} else try {
|
||||
const setup = await fetchApi<{
|
||||
configured: boolean
|
||||
secret?: string
|
||||
@@ -2061,6 +2068,7 @@ export function NotificationSettings() {
|
||||
Enable toggle + Test button pattern as the other
|
||||
channels. */}
|
||||
<TabsContent value="apprise" className="space-y-3 pt-2">
|
||||
<AppriseServiceHelp />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarClock, Database } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
export interface PBSDatastore {
|
||||
name: string
|
||||
path?: string
|
||||
total?: number | null
|
||||
used?: number | null
|
||||
avail?: number | null
|
||||
error?: string
|
||||
"backend-type"?: string
|
||||
"estimated-full-date"?: number
|
||||
"gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number }
|
||||
}
|
||||
|
||||
export function datastorePercent(store: PBSDatastore): number | null {
|
||||
if (store.total == null || store.used == null || store.total <= 0) return null
|
||||
return (store.used / store.total) * 100
|
||||
}
|
||||
|
||||
// Same thresholds and colours as the PVE storage cards.
|
||||
export function usageTone(percent: number): { bar: string; text: string; used: string } {
|
||||
if (percent > 90) return { bar: "[&>div]:bg-red-500", text: "text-red-500", used: "text-red-400" }
|
||||
if (percent > 75) return { bar: "[&>div]:bg-yellow-500", text: "text-yellow-500", used: "text-yellow-400" }
|
||||
return { bar: "[&>div]:bg-blue-500", text: "text-muted-foreground", used: "text-blue-400" }
|
||||
}
|
||||
|
||||
const gib = (bytes: number) => formatStorage(bytes / 1024 ** 3)
|
||||
|
||||
// A datastore without a readable capacity keeps its message: it is never shown as empty.
|
||||
export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatastore; compact?: boolean }) {
|
||||
const t = useT()
|
||||
const percent = datastorePercent(store)
|
||||
if (percent == null) {
|
||||
return <div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
</div>
|
||||
<p className="text-xs text-amber-500">{store.error || t("pbs.capacityUnavailable")}</p>
|
||||
</div>
|
||||
}
|
||||
const tone = usageTone(percent)
|
||||
return <div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-medium text-foreground truncate">{store.name}</span>
|
||||
<span className={`text-xs font-semibold tabular-nums ${tone.text}`}>{percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress value={percent} className={`${compact ? "h-1.5" : "h-2"} ${tone.bar}`} />
|
||||
{!compact && <div className="flex justify-between text-xs text-muted-foreground tabular-nums">
|
||||
<span>{gib(store.used!)} / {gib(store.total!)}</span>
|
||||
<span>{t("pbs.free")}: <span className="font-semibold text-green-500">{gib(store.avail ?? store.total! - store.used!)}</span></span>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Same layout as the "Proxmox storage" cards of the PVE Monitor.
|
||||
export function PBSDatastoreCard({ store }: { store: PBSDatastore }) {
|
||||
const t = useT()
|
||||
const percent = datastorePercent(store)
|
||||
const tone = percent == null ? null : usageTone(percent)
|
||||
const fullDate = store["estimated-full-date"]
|
||||
const type = store["backend-type"] || "datastore"
|
||||
return <div className={`border rounded-lg p-4 ${store.error ? "border-red-500/50 bg-red-500/5" : ""}`}>
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 md:gap-3 min-w-0">
|
||||
<Database className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
<h3 className="font-semibold text-base md:text-lg truncate">{store.name}</h3>
|
||||
<Badge className="bg-blue-500/10 text-blue-400 border-blue-500/20">{type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge className={store.error ? "bg-red-500/10 text-red-500 border-red-500/20" : "bg-green-500/10 text-green-500 border-green-500/20"}>
|
||||
{store.error ? t("storage.status.error") : t("storage.status.active")}
|
||||
</Badge>
|
||||
{percent != null && <span className="text-sm font-medium">{percent.toFixed(2)}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
{percent != null && tone ? (
|
||||
<div className="space-y-2">
|
||||
<Progress value={percent} className={`h-2 ${tone.bar}`} />
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.total")}</p>
|
||||
<p className="font-medium">{gib(store.total!)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.used")}</p>
|
||||
<p className={`font-medium ${tone.used}`}>{gib(store.used!)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">{t("storage.available")}</p>
|
||||
<p className="font-medium text-green-400">{gib(store.avail ?? store.total! - store.used!)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{store.error || t("storage.capacityNotReported")}</p>
|
||||
)}
|
||||
{(store.path || fullDate !== undefined) && <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
{store.path && <span className="font-mono truncate" title={store.path}>{store.path}</span>}
|
||||
{fullDate !== undefined && <span className="flex items-center">
|
||||
<CalendarClock className="mr-1.5 h-3.5 w-3.5" />{t("pbs.estimatedFull")}:
|
||||
<span className="ml-1 font-medium text-foreground">{fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")}</span>
|
||||
</span>}
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import {
|
||||
Activity, ArchiveRestore, Database, DatabaseBackup, Download, ListChecks,
|
||||
RefreshCw, Scissors, Server, Settings2, ShieldCheck, Terminal, Trash2,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Button } from "./ui/button"
|
||||
import { Input } from "./ui/input"
|
||||
import { Label } from "./ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
|
||||
interface Connections { local_available: boolean; servers: Connection[] }
|
||||
interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string }
|
||||
interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
|
||||
interface PBSStatus {
|
||||
name: string; version: string | null; fetched_at: number; services: Record<string, string>;
|
||||
datastores: PBSDatastore[] | null; tasks: Task[] | null;
|
||||
sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record<string, string>;
|
||||
}
|
||||
|
||||
const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" }
|
||||
const NEW_CONNECTION = "__new__"
|
||||
const badgeTone = {
|
||||
ok: "bg-green-500/10 text-green-500 border-green-500/20",
|
||||
warn: "bg-amber-500/10 text-amber-500 border-amber-500/20",
|
||||
error: "bg-red-500/10 text-red-500 border-red-500/20",
|
||||
running: "bg-blue-500/10 text-blue-500 border-blue-500/20",
|
||||
muted: "bg-muted text-muted-foreground border-border",
|
||||
}
|
||||
|
||||
const TASK_ICONS: Record<string, typeof Activity> = {
|
||||
backup: DatabaseBackup, reader: ArchiveRestore, verify: ShieldCheck, verificationjob: ShieldCheck,
|
||||
verify_group: ShieldCheck, verify_snapshot: ShieldCheck, prune: Scissors, prunejob: Scissors,
|
||||
garbage_collection: Trash2, sync: RefreshCw, syncjob: RefreshCw, aptupdate: Download,
|
||||
termproxy: Terminal, dircreate: Database, "create-datastore": Database,
|
||||
}
|
||||
|
||||
// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running.
|
||||
function taskState(task: Task): keyof typeof badgeTone {
|
||||
if (!task.status) return task.endtime ? "muted" : "running"
|
||||
if (task.status === "OK") return "ok"
|
||||
if (task.status.startsWith("WARNINGS")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
function jobState(state?: string): keyof typeof badgeTone {
|
||||
if (!state) return "muted"
|
||||
if (state === "ok") return "ok"
|
||||
if (state.toLowerCase().startsWith("warn")) return "warn"
|
||||
return "error"
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "—"
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h`
|
||||
}
|
||||
|
||||
// `localOnly` is the PBS host's own Monitor: no connection picker, the
|
||||
// header Refresh already reloads the page data.
|
||||
export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
const t = useT()
|
||||
const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR<Connections>(
|
||||
"/api/pbs/servers", url => fetchApi<Connections>(url), { revalidateOnFocus: false })
|
||||
const [selected, setSelected] = useState("")
|
||||
const [manage, setManage] = useState(false)
|
||||
const [form, setForm] = useState(emptyForm)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const { data, error: statusError, isLoading, mutate } = useSWR<PBSStatus>(
|
||||
selected ? `/api/pbs/status?server=${encodeURIComponent(selected)}` : null,
|
||||
url => fetchApi<PBSStatus>(url), { refreshInterval: 30000, revalidateOnFocus: false })
|
||||
|
||||
useEffect(() => {
|
||||
if (!connections) return
|
||||
if (selected === "local" && connections.local_available) return
|
||||
if (connections.servers.some(row => row.id === selected)) return
|
||||
setSelected(connections.local_available ? "local" : connections.servers[0]?.id || "")
|
||||
}, [connections, selected])
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
try {
|
||||
const result = await fetchApi<{ id: string }>("/api/pbs/servers", { method: "POST", body: JSON.stringify({
|
||||
...form, id: form.id || undefined, token_secret: form.token_secret || undefined,
|
||||
}) })
|
||||
await reloadConnections()
|
||||
setSelected(result.id)
|
||||
setForm(emptyForm)
|
||||
setMessage(t("pbs.saved"))
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
if (!form.id || !window.confirm(t("pbs.removeConfirm"))) return
|
||||
setBusy(true); setError(""); setMessage("")
|
||||
try {
|
||||
await fetchApi(`/api/pbs/servers/${encodeURIComponent(form.id)}`, { method: "DELETE" })
|
||||
setForm(emptyForm)
|
||||
await reloadConnections()
|
||||
} catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
const now = Date.now() / 1000
|
||||
|
||||
return <div className="space-y-4 md:space-y-6">
|
||||
{!localOnly && <div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="flex items-center text-2xl font-semibold text-foreground"><Server className="mr-2 h-5 w-5" />{t("pbs.title")}</h2>
|
||||
<Select value={selected || undefined} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.local")}</SelectItem>}
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" disabled={isLoading || !selected} onClick={() => mutate()}>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />{t("pbs.refresh")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setManage(!manage)}>
|
||||
<Settings2 className="mr-2 h-4 w-4" />{t("pbs.manage")}
|
||||
</Button>
|
||||
</div>}
|
||||
{(connectionError || statusError) && <p role="alert" className="text-sm text-red-500">
|
||||
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
|
||||
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
|
||||
{manage && !localOnly && <Card>
|
||||
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.permissionsHelp")}</p>
|
||||
<Select value={form.id || NEW_CONNECTION} onValueChange={value => {
|
||||
const row = connections?.servers.find(item => item.id === value)
|
||||
setForm(row ? { ...row, token_secret: "" } : emptyForm)
|
||||
}}>
|
||||
<SelectTrigger aria-label={t("pbs.editConnection")} className="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NEW_CONNECTION}>{t("pbs.add")}</SelectItem>
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
|
||||
<div key={field} className="space-y-2">
|
||||
<Label htmlFor={`pbs-${field}`}>{t(`pbs.${field}`)}</Label>
|
||||
<Input id={`pbs-${field}`} disabled={busy} type={field === "token_secret" ? "password" : field === "url" ? "url" : "text"}
|
||||
autoComplete={field === "token_secret" ? "new-password" : "off"}
|
||||
value={form[field]} onChange={event => setForm({ ...form, [field]: event.target.value })} />
|
||||
</div>)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.tlsHelp")}</p>
|
||||
{error && <p role="alert" className="text-sm text-red-500">{error}</p>}
|
||||
{message && <p role="status" className="text-sm text-green-500">{message}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={busy} onClick={save}>{busy ? t("pbs.testing") : t("pbs.save")}</Button>
|
||||
{form.id && <Button variant="destructive" disabled={busy} onClick={remove}>{t("pbs.remove")}</Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{data && <>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</span>
|
||||
{Object.entries(data.services).map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : badgeTone.error}>{service}: {state}</Badge>)}
|
||||
</div>
|
||||
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Database className="mr-2 h-5 w-5" />{t("pbs.datastores")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{data.datastores?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>}
|
||||
{data.datastores && data.datastores.length > 0 && <div className="space-y-4">
|
||||
{data.datastores.map(store => <PBSDatastoreCard key={store.name} store={store} />)}
|
||||
</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><Activity className="mr-2 h-5 w-5" />{t("pbs.tasks")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{data.tasks?.length === 0 && <p className="text-sm text-muted-foreground">{t("pbs.noTasks")}</p>}
|
||||
{data.tasks && data.tasks.length > 0 && <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.task")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.started")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.duration")}</th>
|
||||
<th className="py-2 font-medium">{t("pbs.status")}</th>
|
||||
</tr></thead>
|
||||
<tbody>{data.tasks.map(row => {
|
||||
const Icon = TASK_ICONS[row.worker_type || ""] || Activity
|
||||
const state = taskState(row)
|
||||
const end = row.endtime ?? (state === "running" ? now : undefined)
|
||||
return <tr key={row.upid} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="text-foreground">{row.worker_type}</span>
|
||||
{row.worker_id && <span className="truncate text-muted-foreground">{row.worker_id}</span>}
|
||||
</span></td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}</td>
|
||||
<td className="whitespace-nowrap py-2 pr-3 tabular-nums text-muted-foreground">{row.starttime && end ? formatDuration(end - row.starttime) : "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={`${badgeTone[state]} max-w-[16rem] truncate`} title={row.status}>
|
||||
{state === "running" ? t("pbs.running") : row.status || "—"}
|
||||
</Badge></td>
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</table></div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><ListChecks className="mr-2 h-5 w-5" />{t("pbs.jobs")}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row })))
|
||||
if (jobs.length === 0) return <p className="text-sm text-muted-foreground">{t("pbs.noJobs")}</p>
|
||||
return <div className="overflow-x-auto"><table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-border text-left text-xs text-muted-foreground">
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.job")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.datastores")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("pbs.schedule")}</th>
|
||||
<th className="py-2 font-medium">{t("pbs.status")}</th>
|
||||
</tr></thead>
|
||||
<tbody>{jobs.map(({ kind, row }) => <tr key={`${kind}-${row.id}`} className="border-b border-border/60 last:border-0">
|
||||
<td className="py-2 pr-3"><span className="text-foreground">{t(`pbs.${kind}`)}</span> <span className="text-muted-foreground">· {row.id}</span></td>
|
||||
<td className="py-2 pr-3 text-muted-foreground">{row.store || "—"}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs text-muted-foreground">{row.schedule || "—"}</td>
|
||||
<td className="py-2"><Badge variant="outline" className={badgeTone[jobState(row["last-run-state"])]}>{row["last-run-state"] || "—"}</Badge></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { Security } from "./security"
|
||||
import { Profile } from "./profile"
|
||||
import { About } from "./about"
|
||||
import { BackupTab } from "./backup-tab"
|
||||
import { PBSPanel } from "./pbs-panel"
|
||||
import { MonitorSwitcher } from "./monitor-switcher"
|
||||
import { OnboardingCarousel } from "./onboarding-carousel"
|
||||
import { HealthStatusModal } from "./health-status-modal"
|
||||
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
|
||||
@@ -45,7 +47,9 @@ import {
|
||||
ClipboardCheck,
|
||||
Info,
|
||||
DatabaseBackup,
|
||||
Database,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
} from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { ThemeToggle } from "./theme-toggle"
|
||||
@@ -97,9 +101,34 @@ const _dashboardPrefetchFetcher = (url: string) => fetchApi(url)
|
||||
|
||||
export function ProxmoxDashboard() {
|
||||
const t = useT()
|
||||
useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
const { order: tabOrder } = useTabOrder()
|
||||
const { data, error } = useSWR<{ product: string }>("/api/platform", url => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
|
||||
if (!data && !error) return <p role="status" className="p-8">{t("app.loading")}</p>
|
||||
if (data?.product === "pbs") return <PBSDashboard />
|
||||
return <PVEDashboard />
|
||||
}
|
||||
|
||||
function PVEDashboard() {
|
||||
return <MonitorDashboard product="pve" />
|
||||
}
|
||||
|
||||
function PBSDashboard() {
|
||||
return <MonitorDashboard product="pbs" />
|
||||
}
|
||||
|
||||
type Product = "pve" | "pbs"
|
||||
type TopTab = TabId | "datastores"
|
||||
|
||||
// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the
|
||||
// same header, health and administration with its own top-level sections.
|
||||
const PBS_TABS: TopTab[] = ["overview", "datastores", "terminal", "admin"]
|
||||
|
||||
function MonitorDashboard({ product }: { product: Product }) {
|
||||
const t = useT()
|
||||
const isPbs = product === "pbs"
|
||||
useSWR(isPbs ? null : "/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
useSWR(isPbs ? null : "/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false })
|
||||
const { order: userTabOrder } = useTabOrder()
|
||||
const tabOrder: TopTab[] = isPbs ? PBS_TABS : userTabOrder
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus>({
|
||||
status: "healthy",
|
||||
uptime: "Loading...",
|
||||
@@ -117,8 +146,8 @@ export function ProxmoxDashboard() {
|
||||
// runs post-hydration; the tiny flash is acceptable and matches the
|
||||
// pattern next-themes uses for the same reason.
|
||||
useEffect(() => {
|
||||
setActiveTab(firstActualTab())
|
||||
}, [])
|
||||
setActiveTab(isPbs ? "overview" : firstActualTab())
|
||||
}, [isPbs])
|
||||
const [infoCount, setInfoCount] = useState(0)
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false)
|
||||
const [showNavigation, setShowNavigation] = useState(true)
|
||||
@@ -391,6 +420,7 @@ export function ProxmoxDashboard() {
|
||||
const getActiveTabLabel = () => {
|
||||
switch (activeTab) {
|
||||
case "overview": return t("navigation.overview")
|
||||
case "datastores": return t("pbs.datastores")
|
||||
case "apps": return t("navigation.apps")
|
||||
case "vms": return t("navigation.virtualMachines")
|
||||
case "storage": return t("navigation.storage")
|
||||
@@ -410,8 +440,9 @@ export function ProxmoxDashboard() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<OnboardingCarousel />
|
||||
<ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />
|
||||
{/* Both describe the PVE Monitor. */}
|
||||
{!isPbs && <OnboardingCarousel />}
|
||||
{!isPbs && <ReleaseNotesModal open={showReleaseNotes} onClose={() => setShowReleaseNotes(false)} />}
|
||||
|
||||
{!isServerConnected && (
|
||||
<div className="bg-red-500/10 border-b border-red-500/20 px-6 py-3">
|
||||
@@ -464,22 +495,17 @@ export function ProxmoxDashboard() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">{t("app.title")}</h1>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t("app.description")}</p>
|
||||
<p className="text-xs md:text-sm text-muted-foreground">{t(isPbs ? "app.descriptionPbs" : "app.description")}</p>
|
||||
<div className="lg:hidden flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Server className="h-3 w-3" />
|
||||
<span className="truncate">{t("status.node", { node: displayServerName })}</span>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span className="truncate">{t("status.uptime", { uptime: displayUptime })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden lg:flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-foreground">{t("status.node", { node: displayServerName })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MonitorSwitcher nodeName={displayServerName} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
@@ -580,9 +606,7 @@ export function ProxmoxDashboard() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{t("status.uptime", { uptime: displayUptime })}
|
||||
</span>
|
||||
<MonitorSwitcher nodeName={displayServerName} compact />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -623,7 +647,7 @@ export function ProxmoxDashboard() {
|
||||
{ value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false },
|
||||
{ value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false },
|
||||
{ value: "about", label: t("navigation.about"), Icon: Info, default: false },
|
||||
]
|
||||
].filter(item => !isPbs || item.value !== "audit")
|
||||
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
|
||||
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
|
||||
const isNodeActive = activeNodeItem !== undefined
|
||||
@@ -689,9 +713,10 @@ export function ProxmoxDashboard() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
const renderTop = (id: TabId) => {
|
||||
const renderTop = (id: TopTab) => {
|
||||
switch (id) {
|
||||
case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "datastores": return renderDirect("datastores", Database, t("pbs.datastores"))
|
||||
case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps"))
|
||||
case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup"))
|
||||
@@ -701,7 +726,7 @@ export function ProxmoxDashboard() {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<TabsList className="hidden lg:grid w-full grid-cols-7 bg-card border border-border">
|
||||
<TabsList className={`hidden lg:grid w-full ${isPbs ? "grid-cols-4" : "grid-cols-7"} bg-card border border-border`}>
|
||||
{tabOrder.map(renderTop)}
|
||||
</TabsList>
|
||||
)
|
||||
@@ -762,6 +787,7 @@ export function ProxmoxDashboard() {
|
||||
{tabOrder.map((id): React.ReactNode => {
|
||||
switch (id) {
|
||||
case "overview": return btn("overview", LayoutDashboard, t("navigation.overview"))
|
||||
case "datastores": return btn("datastores", Database, t("pbs.datastores"))
|
||||
case "apps": return btn("apps", Grid3x3, t("navigation.apps"))
|
||||
case "vms": return btn("vms", Boxes, t("navigation.virtualMachines"))
|
||||
case "backup": return btn("backup", DatabaseBackup, t("navigation.backup"))
|
||||
@@ -777,7 +803,7 @@ export function ProxmoxDashboard() {
|
||||
<React.Fragment key="admin">
|
||||
{btn("logs", ScrollText, t("navigation.systemLogs"))}
|
||||
{btn("security", ShieldCheck, t("navigation.security"))}
|
||||
{btn("audit", ClipboardCheck, t("navigation.audit"))}
|
||||
{!isPbs && btn("audit", ClipboardCheck, t("navigation.audit"))}
|
||||
{btn("settings", SettingsIcon, t("navigation.settings"))}
|
||||
{btn("about", Info, t("navigation.about"))}
|
||||
</React.Fragment>
|
||||
@@ -811,9 +837,16 @@ export function ProxmoxDashboard() {
|
||||
5 s / 59 s refresh intervals keep the data fresh in the
|
||||
background — reopening the tab is instant. */}
|
||||
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
|
||||
<SystemOverview key={`overview-${componentKey}`} />
|
||||
<SystemOverview key={`overview-${componentKey}`} product={product} />
|
||||
</TabsContent>
|
||||
|
||||
{isPbs && (
|
||||
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
|
||||
<PBSPanel key={`datastores-${componentKey}`} localOnly />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{!isPbs && <>
|
||||
<TabsContent value="apps" className="space-y-4 md:space-y-6 mt-0">
|
||||
<AppsDashboard key={`apps-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -839,11 +872,13 @@ export function ProxmoxDashboard() {
|
||||
<TabsContent value="hardware" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Hardware key={`hardware-${componentKey}`} />
|
||||
</TabsContent>
|
||||
</>}
|
||||
|
||||
<TabsContent value="logs" className="space-y-4 md:space-y-6 mt-0">
|
||||
<SystemLogs key={`logs-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
{!isPbs && <>
|
||||
<TabsContent value="audit" className="space-y-4 md:space-y-6 mt-0">
|
||||
<AuditReport key={`audit-${componentKey}`} />
|
||||
</TabsContent>
|
||||
@@ -851,13 +886,14 @@ export function ProxmoxDashboard() {
|
||||
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
|
||||
<BackupTab key={`backup-${componentKey}`} />
|
||||
</TabsContent>
|
||||
</>}
|
||||
|
||||
<TabsContent value="terminal" className="mt-0">
|
||||
<TerminalPanel key={`terminal-${componentKey}`} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4 md:space-y-6 mt-0">
|
||||
<Security key={`security-${componentKey}`} />
|
||||
<Security key={`security-${componentKey}`} hostType={product} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Profile tab — not surfaced in the top tabs nav. The only
|
||||
|
||||
@@ -66,7 +66,7 @@ function validatePasswordStrength(pw: string, t: (key: string) => string): strin
|
||||
return null
|
||||
}
|
||||
|
||||
export function Security() {
|
||||
export function Security({ hostType = "pve" }: { hostType?: "pve" | "pbs" } = {}) {
|
||||
const { language, t } = useI18n()
|
||||
const st = (key: string, params?: Record<string, string | number>) => t(`securityPage.${key}`, params)
|
||||
const interfaceTypeLabel = (type: string) =>
|
||||
@@ -292,9 +292,11 @@ export function Security() {
|
||||
checkAuthStatus()
|
||||
loadApiTokens()
|
||||
loadSslStatus()
|
||||
loadFirewallStatus()
|
||||
loadNetworkInterfaces()
|
||||
loadSecurityTools()
|
||||
if (hostType === "pve") {
|
||||
loadFirewallStatus()
|
||||
loadNetworkInterfaces()
|
||||
loadSecurityTools()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadFirewallStatus = async () => {
|
||||
@@ -2778,6 +2780,7 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
)}
|
||||
|
||||
{/* ── Proxmox VE Security Group ── */}
|
||||
{hostType === "pve" && <>
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-orange-500">Proxmox VE</h2>
|
||||
<div className="flex-1 h-px bg-orange-500/20" />
|
||||
@@ -4833,6 +4836,8 @@ ${(report.sections && report.sections.length > 0) ? `
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>}
|
||||
|
||||
<TwoFactorSetup
|
||||
open={show2FASetup}
|
||||
onClose={() => setShow2FASetup(false)}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Input } from "./ui/input"
|
||||
import { getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import type { LanguageCode } from "../lib/i18n/languages"
|
||||
import { NavTabOrderCard } from "./nav-tab-order-card"
|
||||
|
||||
@@ -312,6 +313,7 @@ interface NetworkInterface {
|
||||
|
||||
export function Settings() {
|
||||
const { language, setLanguage, t } = useI18n()
|
||||
const isPbs = useIsPbs()
|
||||
const tFallback = (key: string, fallback: string) => {
|
||||
const translated = t(key)
|
||||
return translated === key ? fallback : translated
|
||||
@@ -1124,7 +1126,7 @@ export function Settings() {
|
||||
</Card>
|
||||
|
||||
{/* Navigation Tab Order — user-orderable top-level tabs */}
|
||||
<NavTabOrderCard />
|
||||
{!isPbs && <NavTabOrderCard />}
|
||||
|
||||
{/* Network Units Settings */}
|
||||
<Card>
|
||||
@@ -1280,7 +1282,7 @@ export function Settings() {
|
||||
|
||||
{/* Per-category rows */}
|
||||
<div className="divide-y divide-border/50">
|
||||
{suppressionCategories.map((cat) => {
|
||||
{suppressionCategories.filter(cat => !isPbs || healthCategoryKey(cat) !== "vms").map((cat) => {
|
||||
const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse
|
||||
const effectiveHours = getEffectiveHours(cat)
|
||||
const isCustomMode = effectiveHours === -2 || (cat.key in customValues)
|
||||
@@ -1288,7 +1290,8 @@ export function Settings() {
|
||||
const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2
|
||||
const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours
|
||||
const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key)
|
||||
const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label)
|
||||
const categoryKey = isPbs && healthCategoryKey(cat) === "services" ? "pbsServices" : healthCategoryKey(cat)
|
||||
const catLabel = tFallback(`settings.healthMonitor.categories.${categoryKey}`, cat.label)
|
||||
|
||||
return (
|
||||
<div key={cat.key}>
|
||||
@@ -1496,6 +1499,7 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!isPbs && <>
|
||||
{/* Remote Storage Exclusions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1678,6 +1682,7 @@ export function Settings() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Network Interface Exclusions */}
|
||||
<Card>
|
||||
@@ -1875,6 +1880,7 @@ export function Settings() {
|
||||
is re-enabled). */}
|
||||
<LxcUpdateDetection />
|
||||
|
||||
{!isPbs && <>
|
||||
{/* GitHub API — optional authentication for app upstream checks. */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1981,15 +1987,16 @@ export function Settings() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>}
|
||||
|
||||
{/* Notification Settings */}
|
||||
<NotificationSettings />
|
||||
{/* Notification Settings (PVE only for now) */}
|
||||
{!isPbs && <NotificationSettings />}
|
||||
|
||||
{/* Issue #195: snippets storage selector. Only renders when more
|
||||
than one storage advertises content=snippets — on a typical
|
||||
standalone host with just `local` there's nothing to choose,
|
||||
so showing an empty selector would be noise. */}
|
||||
{snippetsCandidates.length > 1 && (
|
||||
{!isPbs && snippetsCandidates.length > 1 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react"
|
||||
import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react"
|
||||
import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage"
|
||||
import { NodeMetricsCharts } from "./node-metrics-charts"
|
||||
import { NetworkTrafficChart } from "./network-traffic-chart"
|
||||
import { TemperatureDetailModal } from "./temperature-detail-modal"
|
||||
@@ -185,9 +187,17 @@ const getUnitsSettings = (): "Bytes" | "Bits" => {
|
||||
return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes"
|
||||
}
|
||||
|
||||
export function SystemOverview() {
|
||||
export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) {
|
||||
const t = useT()
|
||||
const { language } = useI18n()
|
||||
const isPbs = product === "pbs"
|
||||
// On Proxmox Backup Server the datastores take the place of guests and PVE storage.
|
||||
const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>(
|
||||
isPbs ? "/api/pbs/status?server=local" : null,
|
||||
(url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url),
|
||||
{ refreshInterval: 60000, revalidateOnFocus: false },
|
||||
)
|
||||
const datastores = pbsStatus?.datastores ?? null
|
||||
const [systemData, setSystemData] = useState<SystemData | null>(null)
|
||||
const [vmData, setVmData] = useState<VMData[]>([])
|
||||
const [storageData, setStorageData] = useState<StorageData | null>(null)
|
||||
@@ -212,8 +222,8 @@ export function SystemOverview() {
|
||||
const fetchAllData = async () => {
|
||||
const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([
|
||||
fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))),
|
||||
fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
|
||||
Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() =>
|
||||
(isPbs ? Promise.resolve([]) : fetchVMData()).finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))),
|
||||
Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()]).finally(() =>
|
||||
setLoadingStates((prev) => ({ ...prev, storage: false })),
|
||||
),
|
||||
fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))),
|
||||
@@ -248,12 +258,13 @@ export function SystemOverview() {
|
||||
}, 5000)
|
||||
|
||||
const vmInterval = setInterval(async () => {
|
||||
if (isPbs) return
|
||||
const data = await fetchVMData()
|
||||
setVmData(data)
|
||||
}, 59000)
|
||||
|
||||
const storageInterval = setInterval(async () => {
|
||||
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()])
|
||||
const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()])
|
||||
if (storage) setStorageData(storage)
|
||||
if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage)
|
||||
}, 59000)
|
||||
@@ -278,7 +289,7 @@ export function SystemOverview() {
|
||||
clearInterval(networkInterval)
|
||||
window.removeEventListener("networkUnitChanged" as any, handleUnitChange)
|
||||
}
|
||||
}, [t])
|
||||
}, [t, isPbs])
|
||||
|
||||
if (!hasAttemptedLoad || loadingStates.system) {
|
||||
return (
|
||||
@@ -527,7 +538,46 @@ export function SystemOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */}
|
||||
{isPbs ? (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("pbs.datastores")}</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!datastores && !pbsError ? (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-20"></div>
|
||||
<div className="h-5 bg-muted rounded w-32"></div>
|
||||
</div>
|
||||
) : !datastores ? (
|
||||
<p className="text-sm text-amber-500">{t("pbs.loadFailed")}</p>
|
||||
) : datastores.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.noDatastores")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-end justify-between">
|
||||
<span className="text-4xl font-bold leading-none text-foreground">{datastores.length}</span>
|
||||
{datastores.some(store => store.error) ? (
|
||||
<Badge variant="outline" className="bg-amber-500/10 text-amber-500 border-amber-500/20">
|
||||
{t("pbs.datastoresUnavailable", { count: datastores.filter(store => store.error).length })}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="bg-green-500/10 text-green-500 border-green-500/20">
|
||||
{t("pbs.datastoresOnline")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{datastores.slice(0, 2).map(store => <DatastoreUsageRow key={store.name} store={store} compact />)}
|
||||
{datastores.length > 2 && (
|
||||
<p className="text-xs text-muted-foreground">{t("pbs.moreDatastores", { count: datastores.length - 2 })}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{t("overview.activeVmLxc")}</CardTitle>
|
||||
@@ -565,6 +615,7 @@ export function SystemOverview() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={`bg-card border-border ${systemData.temperature > 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`}
|
||||
@@ -708,7 +759,16 @@ export function SystemOverview() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
{isPbs ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("pbs.datastores")}</div>
|
||||
{!datastores ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}</div>
|
||||
) : datastores.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground text-sm">{t("pbs.noDatastores")}</div>
|
||||
) : datastores.map(store => <DatastoreUsageRow key={store.name} store={store} />)}
|
||||
</div>
|
||||
) : vmLxcStorages && vmLxcStorages.length > 0 ? (
|
||||
<div className="space-y-2 pb-3 border-b border-border">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.vmLxcStorage")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -744,7 +804,7 @@ export function SystemOverview() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{localStorage && (
|
||||
{!isPbs && localStorage && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">{t("overview.localStorageSystem")}</div>
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -899,7 +959,7 @@ export function SystemOverview() {
|
||||
<span className="text-foreground">{formatSystemUptime(systemData.uptime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t("overview.proxmoxVersion")}</span>
|
||||
<span className="text-muted-foreground">{t(isPbs ? "overview.pbsVersion" : "overview.proxmoxVersion")}</span>
|
||||
<span className="text-foreground">{systemData.proxmox_version || t("app.notAvailable")}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Badge } from "./ui/badge"
|
||||
import { Progress } from "./ui/progress"
|
||||
import { Button } from "./ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog"
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Pin, Tag as TagIcon } from 'lucide-react'
|
||||
import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Database, Shield, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, Search, Pin, Tag as TagIcon } from 'lucide-react'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
import { Switch } from "./ui/switch"
|
||||
@@ -23,6 +23,8 @@ import { MetricsView } from "./metrics-dialog"
|
||||
import { LxcTerminalModal } from "./lxc-terminal-modal"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { LxcAppPanel, ThemeAwareLogo, ociImageLabel } from "./lxc-app-panel"
|
||||
import { GuestBackupList } from "./guest-backup-list"
|
||||
import type { GuestBackup } from "../lib/guest-backup-archives"
|
||||
import { OciConsoleLogPanel } from "./oci-console-log-panel"
|
||||
import { AppUpdaterEditor, type AppUpdateMethod } from "./app-updater-editor"
|
||||
import { formatStorage } from "../lib/utils"
|
||||
@@ -35,6 +37,23 @@ import DOMPurify from "dompurify"
|
||||
import { marked } from "marked"
|
||||
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
||||
|
||||
// A schedule record predating the structured reason code still contains one
|
||||
// of these exact diagnostic messages. Recognise only those known historical
|
||||
// values so an already-recorded safe skip localizes after an upgrade, while
|
||||
// unrelated freeform diagnostics remain verbatim for troubleshooting.
|
||||
const LEGACY_STOPPED_SCHEDULE_REASONS = new Set([
|
||||
"container is stopped; scheduled update was skipped",
|
||||
"container was stopped before scheduled update could begin",
|
||||
])
|
||||
|
||||
const scheduleReasonCodeFromPersisted = (reasonCode: unknown, reason: unknown): string | null => {
|
||||
if (typeof reasonCode === "string" && reasonCode) return reasonCode
|
||||
if (typeof reason === "string" && LEGACY_STOPPED_SCHEDULE_REASONS.has(reason.toLowerCase())) {
|
||||
return "stopped_container"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Sent by /api/vms only for LXC rows, only when the user has enabled
|
||||
// `lxc_updates_available` notifications. The Monitor populates this
|
||||
// from managed_installs registry → frontend uses it to render the
|
||||
@@ -382,16 +401,7 @@ interface BackupStorage {
|
||||
avail_human?: string
|
||||
}
|
||||
|
||||
interface VMBackup {
|
||||
volid: string
|
||||
storage: string
|
||||
type: string
|
||||
size: number
|
||||
size_human: string
|
||||
timestamp: number
|
||||
date: string
|
||||
notes?: string
|
||||
}
|
||||
type VMBackup = GuestBackup
|
||||
|
||||
// Sprint 13.29: shape returned by /api/lxc/<vmid>/mount-points. Lives
|
||||
// next to VMBackup since both are LXC-modal data structures.
|
||||
@@ -953,7 +963,6 @@ export function VirtualMachines() {
|
||||
const [showBackupModal, setShowBackupModal] = useState(false)
|
||||
const [backupMode, setBackupMode] = useState<string>("snapshot")
|
||||
const [backupProtected, setBackupProtected] = useState(false)
|
||||
const [backupNotification, setBackupNotification] = useState<string>("auto")
|
||||
const [backupNotes, setBackupNotes] = useState<string>("{{guestname}}")
|
||||
const [backupPbsChangeMode, setBackupPbsChangeMode] = useState<string>("default")
|
||||
|
||||
@@ -1598,7 +1607,7 @@ export function VirtualMachines() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchVmBackups = async (vmid: number) => {
|
||||
const fetchVmBackups = async (vmid: number, force = false) => {
|
||||
// Stale-while-revalidate with a 6-hour freshness gate.
|
||||
//
|
||||
// 1. If we already have backups (bulk hydration or a previous
|
||||
@@ -1615,15 +1624,16 @@ export function VirtualMachines() {
|
||||
// 3. Loading spinner only on the true first load.
|
||||
const GATE_MS = 6 * 60 * 60 * 1000
|
||||
const seed = vmModalCacheRef.current.backups.get(vmid)
|
||||
const isStale = !seed || (Date.now() - seed.fetchedAt) > GATE_MS
|
||||
const isStale = force || !seed || (Date.now() - seed.fetchedAt) > GATE_MS
|
||||
if (!seed) setLoadingBackups(true)
|
||||
try {
|
||||
const url = isStale
|
||||
? `/api/vms/${vmid}/backups?fresh=1`
|
||||
: `/api/vms/${vmid}/backups`
|
||||
const response = await fetchApi<{ backups?: VMBackup[] }>(url)
|
||||
const response = await fetchApi<{ backups?: VMBackup[]; error?: string }>(url)
|
||||
if (response.error || !Array.isArray(response.backups)) throw new Error(response.error || "Backup inventory unavailable")
|
||||
if (response.backups) {
|
||||
setVmBackups(response.backups)
|
||||
if (selectedVMRef.current?.vmid === vmid) setVmBackups(response.backups)
|
||||
vmModalCacheRef.current.backups.set(vmid, { backups: response.backups, fetchedAt: Date.now() })
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1631,9 +1641,10 @@ export function VirtualMachines() {
|
||||
// Only clear the visible list if we had nothing to show
|
||||
// in the first place — a transient network hiccup must not
|
||||
// wipe the stale-but-useful view the user is looking at.
|
||||
if (!seed) setVmBackups([])
|
||||
if (!seed && selectedVMRef.current?.vmid === vmid) setVmBackups([])
|
||||
if (force) throw error
|
||||
} finally {
|
||||
if (!seed) setLoadingBackups(false)
|
||||
if (!seed && selectedVMRef.current?.vmid === vmid) setLoadingBackups(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1686,7 +1697,6 @@ export function VirtualMachines() {
|
||||
// Reset modal to defaults
|
||||
setBackupMode("snapshot")
|
||||
setBackupProtected(false)
|
||||
setBackupNotification("auto")
|
||||
setBackupNotes("{{guestname}}")
|
||||
setBackupPbsChangeMode("default")
|
||||
// Auto-select first storage if none selected
|
||||
@@ -1710,7 +1720,6 @@ export function VirtualMachines() {
|
||||
mode: backupMode,
|
||||
compress: "zstd",
|
||||
protected: backupProtected,
|
||||
notification: backupNotification,
|
||||
notes: backupNotes,
|
||||
pbs_change_detection: backupPbsChangeMode
|
||||
}),
|
||||
@@ -1867,40 +1876,6 @@ export function VirtualMachines() {
|
||||
setTerminalOpen(true)
|
||||
}
|
||||
|
||||
const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
try {
|
||||
const data = await fetchApi(`/api/vms/${vmid}/logs`)
|
||||
|
||||
// Format logs as plain text
|
||||
let logText = `=== ${t("vmLxc.logs.header", { name: vmName, vmid })} ===\n`
|
||||
logText += `${t("vmLxc.logs.node")}: ${data.node}\n`
|
||||
logText += `${t("vmLxc.logs.type")}: ${data.type}\n`
|
||||
logText += `${t("vmLxc.logs.totalLines")}: ${data.log_lines}\n`
|
||||
logText += `${t("vmLxc.logs.generated")}: ${new Date().toISOString()}\n`
|
||||
logText += `\n${"=".repeat(80)}\n\n`
|
||||
|
||||
if (data.logs && Array.isArray(data.logs)) {
|
||||
data.logs.forEach((log: any) => {
|
||||
if (typeof log === "object" && log.t) {
|
||||
logText += `${log.t}\n`
|
||||
} else if (typeof log === "string") {
|
||||
logText += `${log}\n`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const blob = new Blob([logText], { type: "text/plain" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${vmName}-${vmid}-logs.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error("Error downloading logs:", error)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
@@ -2077,6 +2052,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const [scheduleLastRunAt, setScheduleLastRunAt] = useState<string | null>(null)
|
||||
const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState<string | null>(null)
|
||||
const [scheduleLastRunReason, setScheduleLastRunReason] = useState<string | null>(null)
|
||||
const [scheduleLastRunReasonCode, setScheduleLastRunReasonCode] = useState<string | null>(null)
|
||||
const [scheduleLastRunLog, setScheduleLastRunLog] = useState<string | null>(null)
|
||||
const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false)
|
||||
const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState<string[]>([])
|
||||
@@ -2153,6 +2129,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setScheduleLastRunAt(s.last_run_at || null)
|
||||
setScheduleLastRunStatus(s.last_run_status || null)
|
||||
setScheduleLastRunReason(s.last_run_reason || null)
|
||||
setScheduleLastRunReasonCode(scheduleReasonCodeFromPersisted(s.last_run_reason_code, s.last_run_reason))
|
||||
setScheduleLastRunLog(s.last_run_log || null)
|
||||
setScheduleLastRunRebootRequired(s.last_run_reboot_required === true)
|
||||
setScheduleLastRunRebootPackages(Array.isArray(s.last_run_reboot_packages)
|
||||
@@ -2430,6 +2407,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const renderScheduleRunDetails = (indented = false) => {
|
||||
if (!scheduleLastRunAt || !selectedVM) return null
|
||||
const spacing = indented ? "pl-4" : ""
|
||||
const scheduleLastRunReasonText = scheduleLastRunReasonCode === "stopped_container"
|
||||
? selectedVM.status === "stopped"
|
||||
? t("vmLxc.scheduled.reasonStoppedContainer")
|
||||
: t("vmLxc.scheduled.reasonStoppedContainerHistorical")
|
||||
: scheduleLastRunReason
|
||||
return (
|
||||
<div className={`space-y-1.5 ${spacing}`}>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
@@ -2443,14 +2425,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
: scheduleLastRunStatus === "deferred"
|
||||
? t("vmLxc.scheduled.runDeferred")
|
||||
: scheduleLastRunStatus === "skipped"
|
||||
? t("vmLxc.scheduled.runSkipped")
|
||||
? t("vmLxc.scheduled.runSkippedStatus")
|
||||
: t("vmLxc.scheduled.runFailed")}
|
||||
</span></>
|
||||
)}
|
||||
</div>
|
||||
{scheduleLastRunReason && (
|
||||
{scheduleLastRunReasonText && (
|
||||
<div className="text-xs text-muted-foreground break-words">
|
||||
{scheduleLastRunReason}
|
||||
{scheduleLastRunReasonText}
|
||||
</div>
|
||||
)}
|
||||
{scheduleLastRunRebootRequired && (
|
||||
@@ -2844,8 +2826,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
onClick?: () => void,
|
||||
) => {
|
||||
if (!uc) return null
|
||||
const stale = uc.stale === true || (!!uc.last_check && isLxcUpdateStale(uc))
|
||||
if (!stale && (!uc.available || !uc.count || uc.count <= 0)) return null
|
||||
if (!uc.available || !uc.count || uc.count <= 0) return null
|
||||
const last = uc.last_check
|
||||
? new Date(uc.last_check).toLocaleString()
|
||||
: "—"
|
||||
@@ -2893,8 +2874,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
space — matters most on the LXC card row where uptime,
|
||||
IP and other chips compete for width. Count alone with
|
||||
the up-arrow reads unambiguously as "pending updates". */}
|
||||
{stale ? <Clock className={iconSize} /> : <ArrowUpCircle className={iconSize} />}
|
||||
{stale ? t("vmLxc.updates.checkStale") : uc.count}
|
||||
<ArrowUpCircle className={iconSize} />
|
||||
{uc.count}
|
||||
{/* Chevron only when the badge is wired up as a clickable
|
||||
shortcut — its absence on the dashboard card avoids
|
||||
implying interactivity where there isn't any (the whole
|
||||
@@ -7436,7 +7417,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
|
||||
{/* Backups Tab */}
|
||||
{activeModalTab === "backups" && (
|
||||
{activeModalTab === "backups" && selectedVM && (
|
||||
/* Backups tab layout: the Card fills the whole tab
|
||||
body so the backup list can scroll inside its
|
||||
bordered card instead of leaving empty space
|
||||
@@ -7471,48 +7452,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border/50 mb-4 shrink-0" />
|
||||
|
||||
{/* Backup List */}
|
||||
<div className="flex items-center justify-between mb-3 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">{t("vmLxc.backups.available")}</span>
|
||||
<Badge variant="secondary" className="text-xs h-5">{vmBackups.length}</Badge>
|
||||
</div>
|
||||
|
||||
{loadingBackups ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground min-h-0">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm">{t("vmLxc.backups.loading")}</span>
|
||||
</div>
|
||||
) : vmBackups.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground min-h-0">
|
||||
<Archive className="h-12 w-12 mb-3 opacity-30" />
|
||||
<span className="text-sm">{t("vmLxc.backups.empty")}</span>
|
||||
<span className="text-xs mt-1">{t("vmLxc.backups.emptyHint")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 flex-1 overflow-y-auto min-h-0 pr-1 -mr-1">
|
||||
{vmBackups.map((backup, index) => (
|
||||
<div
|
||||
key={`backup-${backup.volid}-${index}`}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<Clock className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="text-sm text-foreground">{backup.date}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ml-auto flex-shrink-0 ${getStorageColor(backup.storage).bg} ${getStorageColor(backup.storage).text} ${getStorageColor(backup.storage).border}`}
|
||||
>
|
||||
{backup.storage}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono ml-2 flex-shrink-0">
|
||||
{backup.size_human}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<GuestBackupList
|
||||
key={selectedVM.vmid}
|
||||
vmid={selectedVM.vmid}
|
||||
backups={vmBackups}
|
||||
loading={loadingBackups}
|
||||
onRefresh={async () => {
|
||||
vmModalCacheRef.current.backups.delete(selectedVM.vmid)
|
||||
await fetchVmBackups(selectedVM.vmid, true)
|
||||
}}
|
||||
storageColor={getStorageColor}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -7865,25 +7815,6 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Row */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm flex items-center gap-1.5">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
{t("vmLxc.backupModal.notification")}
|
||||
</Label>
|
||||
<Select value={backupNotification} onValueChange={setBackupNotification}>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("vmLxc.backupModal.useGlobalSettings")}</SelectItem>
|
||||
<SelectItem value="always">{t("vmLxc.backupModal.alwaysNotify")}</SelectItem>
|
||||
<SelectItem value="failure">{t("vmLxc.backupModal.notifyOnFailure")}</SelectItem>
|
||||
<SelectItem value="never">{t("vmLxc.backupModal.neverNotify")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Protected Checkbox */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { cancelButtonClass } from "../lib/utils"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
import { RETENTION, parseRetention, buildRetention } from "../lib/backup-retention"
|
||||
|
||||
interface VmBackupJob {
|
||||
id: string
|
||||
@@ -70,24 +71,6 @@ const EMPTY_FORM: FormState = {
|
||||
mode: "snapshot", compress: "zstd", retention: {}, keepAll: false, notes: "{{guestname}}", attached: [],
|
||||
}
|
||||
|
||||
// The limits a job can keep, in the order Proxmox lists them.
|
||||
const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
|
||||
|
||||
const parseRetention = (prune: string) => {
|
||||
const fields: Record<string, string> = {}
|
||||
let keepAll = false
|
||||
for (const part of prune.split(",")) {
|
||||
const [key, value] = part.trim().split("=")
|
||||
if (key === "keep-all") keepAll = value === "1"
|
||||
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
|
||||
}
|
||||
return { fields, keepAll }
|
||||
}
|
||||
|
||||
const buildRetention = (fields: Record<string, string>, keepAll: boolean) => {
|
||||
const parts = RETENTION.filter((key) => Number(fields[key]) > 0).map((key) => `keep-${key}=${Number(fields[key])}`)
|
||||
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
|
||||
}
|
||||
|
||||
const isOn = (value: unknown) => value === 1 || value === true || value === "1"
|
||||
const jobEnabled = (job: VmBackupJob) => job.enabled === undefined || isOn(job.enabled)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Native Proxmox retention properties, shared by storage and job editors.
|
||||
export const RETENTION = ["last", "hourly", "daily", "weekly", "monthly", "yearly"] as const
|
||||
|
||||
export function parseRetention(prune: string) {
|
||||
const fields: Record<string, string> = {}
|
||||
let keepAll = false
|
||||
for (const part of prune.split(",")) {
|
||||
const [key, value] = part.trim().split("=")
|
||||
if (key === "keep-all") keepAll = value === "1"
|
||||
else if (key?.startsWith("keep-") && Number(value) > 0) fields[key.slice(5)] = String(Number(value))
|
||||
}
|
||||
return { fields, keepAll }
|
||||
}
|
||||
|
||||
export function buildRetention(fields: Record<string, string>, keepAll: boolean) {
|
||||
const parts = RETENTION.filter(key => Number(fields[key]) > 0).map(key => `keep-${key}=${Number(fields[key])}`)
|
||||
return parts.length ? parts.join(",") : keepAll ? "keep-all=1" : ""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fetchApi } from './api-config'
|
||||
|
||||
export interface GuestBackup {
|
||||
volid: string
|
||||
storage: string
|
||||
type: string
|
||||
size: number
|
||||
size_human: string
|
||||
timestamp: number
|
||||
date: string
|
||||
notes?: string
|
||||
protected?: boolean
|
||||
}
|
||||
|
||||
export interface BackupDeletionResult {
|
||||
volid: string
|
||||
status: 'deleted' | 'pending' | 'unconfirmed'
|
||||
task?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Exposed separately so callers can invalidate their inventory even after a
|
||||
// lost POST response. Never retry the destructive request automatically.
|
||||
export async function deleteGuestBackups(vmid: number, backups: GuestBackup[]): Promise<BackupDeletionResult[]> {
|
||||
const response = await fetchApi<{ results: BackupDeletionResult[] }>(`/api/vms/${vmid}/backups/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ confirm: true, backups: backups.map(({ volid, storage }) => ({ volid, storage })) }),
|
||||
// Up to 20 archives, checked first and then deleted four at a time.
|
||||
signal: AbortSignal.timeout(300_000),
|
||||
})
|
||||
if (!Array.isArray(response.results) || response.results.length !== backups.length ||
|
||||
response.results.some((result, index) => result.volid !== backups[index].volid ||
|
||||
!['deleted', 'pending', 'unconfirmed'].includes(result.status))) throw new Error('deletionUnconfirmed')
|
||||
|
||||
return Promise.all(response.results.map(async result => {
|
||||
if (result.status !== 'pending' || !result.task) return result
|
||||
const deadline = Date.now() + 120_000
|
||||
let failures = 0
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
try {
|
||||
const status = await fetchApi<BackupDeletionResult>(
|
||||
`/api/vms/${vmid}/backups/delete-status?task=${encodeURIComponent(result.task!)}`,
|
||||
{ signal: AbortSignal.timeout(10_000) },
|
||||
)
|
||||
if (status.volid !== result.volid || !['deleted', 'pending', 'unconfirmed'].includes(status.status)) break
|
||||
if (status.status !== 'pending') return status
|
||||
failures = 0
|
||||
} catch {
|
||||
if (++failures >= 3) break
|
||||
}
|
||||
}
|
||||
return { ...result, status: 'unconfirmed', error: 'deletionUnconfirmed' }
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import useSWR from "swr"
|
||||
import { fetchApi } from "./api-config"
|
||||
|
||||
// Shares the dashboard's /api/platform request through the SWR cache.
|
||||
export function useIsPbs(): boolean {
|
||||
const { data } = useSWR<{ product: string }>("/api/platform", (url: string) => fetchApi<{ product: string }>(url), { revalidateOnFocus: false })
|
||||
return data?.product === "pbs"
|
||||
}
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Unbekannt",
|
||||
"notAvailable": "N/A",
|
||||
"serverOffline": "Server offline",
|
||||
"supportProject": "Unterstützen Sie das Projekt und tragen Sie dazu bei"
|
||||
"supportProject": "Unterstützen Sie das Projekt und tragen Sie dazu bei",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/A",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} läuft",
|
||||
"runningCountFew": "{count} laufen",
|
||||
"runningCountMany": "{count} laufen"
|
||||
"runningCountMany": "{count} laufen",
|
||||
"pbsVersion": "PBS-Version:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Speicherdaten werden geladen...",
|
||||
@@ -1110,7 +1112,21 @@
|
||||
"available": "Verfügbare Backups",
|
||||
"loading": "Backups werden geladen...",
|
||||
"empty": "Keine Backups gefunden",
|
||||
"emptyHint": "Erstellen Sie Ihr erstes Backup über die Schaltfläche oben"
|
||||
"emptyHint": "Erstellen Sie Ihr erstes Backup über die Schaltfläche oben",
|
||||
"refresh": "Aktualisieren",
|
||||
"refreshFailed": "Sicherungen konnten nicht aktualisiert werden. Aktualisieren Sie erneut, um ihren aktuellen Zustand zu bestätigen.",
|
||||
"deleteSelected": "Ausgewählte löschen ({count})",
|
||||
"deletedCount": "{count} Sicherung(en) gelöscht.",
|
||||
"unconfirmedCount": "Die Löschung von {count} Sicherung(en) wurde nicht bestätigt. Aktualisieren Sie die Liste und prüfen Sie die Proxmox-Aufgaben, bevor Sie es erneut versuchen.",
|
||||
"deleteFailed": "Die Löschung konnte nicht bestätigt werden. Aktualisieren Sie die Liste und prüfen Sie die Proxmox-Aufgaben, bevor Sie es erneut versuchen.",
|
||||
"selectHint": "Ungeschützte Sicherungen auswählen (bis zu 20 pro Durchgang)",
|
||||
"selectBackup": "Sicherung auswählen",
|
||||
"protected": "Geschützt",
|
||||
"confirmTitle": "{count} Sicherung(en) löschen?",
|
||||
"confirmDescription": "Diese Sicherungsarchive für VM/CT {vmid} werden dauerhaft gelöscht. Geschützte Sicherungen sind ausgeschlossen. Der Gast wird dadurch nicht gelöscht.",
|
||||
"deleting": "Warten, bis Proxmox die ausgewählten Archive gelöscht hat…",
|
||||
"cancel": "Abbrechen",
|
||||
"confirmDelete": "Sicherungen löschen"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Ausstehende Paketaktualisierungen",
|
||||
@@ -1302,7 +1318,10 @@
|
||||
"Donnerstag",
|
||||
"Freitag",
|
||||
"Samstag"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "Lauf übersprungen",
|
||||
"reasonStoppedContainer": "Container ist gestoppt; das geplante Update wurde übersprungen.",
|
||||
"reasonStoppedContainerHistorical": "Der Container wurde zum geplanten Aktualisierungszeitpunkt gestoppt."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "Host-Cron erkannt",
|
||||
@@ -1718,7 +1737,17 @@
|
||||
"portCategoryCustomPlaceholder": "Geben Sie eine Kategorie ein und drücken Sie Enter (Esc zum Abbrechen)",
|
||||
"portCustomUrlPlaceholder": "Benutzerdefinierte URL (z. B. https://vault.example.com) überschreibt IP:port",
|
||||
"stackMemberTitle": "Teil einer Multi-Container-Anwendung",
|
||||
"stackMemberBody": "Dieser Container gehört zu einer Anwendung, deren Hauptcontainer {primary} ist. Die Anwendung, ihre Weblinks und ihre Version werden dort angezeigt."
|
||||
"stackMemberBody": "Dieser Container gehört zu einer Anwendung, deren Hauptcontainer {primary} ist. Die Anwendung, ihre Weblinks und ihre Version werden dort angezeigt.",
|
||||
"nativeEvidencePort": "Lauschender TCP-Port",
|
||||
"nativeEvidenceFile": "Installierte Datei",
|
||||
"nativeEvidenceProcess": "Laufende ausführbare Datei",
|
||||
"nativeEvidenceDirectory": "Arbeitsverzeichnis",
|
||||
"nativeEvidenceService": "Dienstdefinition",
|
||||
"nativeScanFailed": "Die Anwendungssuche konnte nicht abgeschlossen werden. Frühere Ergebnisse sind möglicherweise veraltet; versuchen Sie es erneut.",
|
||||
"nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.",
|
||||
"nativeVersionUnknown": "Installierte Version unbekannt",
|
||||
"nativeEvidenceStale": "Die Hinweise sind möglicherweise veraltet oder unvollständig.",
|
||||
"nativeRegisterOnly": "Bestätigen Sie die Registrierung im Editor. Eine bekannte Version wird als manueller Wert gespeichert; es wird kein Updater eingerichtet."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "Virtuelle Maschinen und Container filtern",
|
||||
@@ -1814,7 +1843,8 @@
|
||||
"logs": "Systemprotokolle",
|
||||
"updates": "Systemaktualisierungen",
|
||||
"security": "Sicherheit & Zertifikate",
|
||||
"temperature": "Temperatur"
|
||||
"temperature": "Temperatur",
|
||||
"pbsServices": "PBS Services"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -2075,7 +2105,7 @@
|
||||
"webhookUrl": "Webhook-URL",
|
||||
"enableEmail": "E-Mail aktivieren",
|
||||
"smtpHost": "SMTP-Host",
|
||||
"port": "Hafen",
|
||||
"port": "Port",
|
||||
"tlsMode": "TLS-Modus",
|
||||
"noTls": "Keine (Port 25)",
|
||||
"username": "Benutzername",
|
||||
@@ -2108,7 +2138,13 @@
|
||||
"inactiveFooter": "Aktivieren Sie Benachrichtigungen, um System-, Gesundheits- und Sicherheitswarnungen über die konfigurierten Kanäle zu erhalten.",
|
||||
"notificationLanguage": "Benachrichtigungssprache",
|
||||
"selectNotificationLanguage": "Benachrichtigungssprache auswählen",
|
||||
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist."
|
||||
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist.",
|
||||
"appriseServiceHelp": "Slack und Matrix einrichten",
|
||||
"appriseSlackSteps": "Erstellen Sie in Ihrer Slack-App einen eingehenden Webhook für den Zielkanal und fügen Sie dessen vollständige HTTPS-URL in das Feld Apprise-URL ein. Eine Umwandlung ist nicht erforderlich.",
|
||||
"appriseSlackDocs": "Slack-URL-Formate und Einrichtung",
|
||||
"appriseMatrixSteps": "Verwenden Sie ein Benachrichtigungskonto, das in den Raum schreiben darf. Ersetzen Sie den Beispielbenutzer, das Passwort, den Homeserver und die vollständige Raum-ID; kodieren Sie Sonderzeichen in Zugangsdaten für URLs. Das Schema matrixs verwendet HTTPS. Informationen zur Unterstützung von Tokens, SSO und verschlüsselten Räumen in Ihrer installierten Apprise-Version finden Sie in der Dokumentation.",
|
||||
"appriseMatrixDocs": "Matrix-URL-Formate und Einrichtung",
|
||||
"appriseServiceFinish": "Aktivieren Sie Apprise, geben Sie Ihre URL ein, wählen Sie Benachrichtigungskategorien oder einzelne Ereignisse aus, speichern Sie und verwenden Sie anschließend Test senden. Diese Beispiele ändern Ihre gespeicherte URL nicht. Dieser Apprise-Kanal hat ein Ziel; verwenden Sie die Apprise-API für mehrere Dienste gleichzeitig."
|
||||
},
|
||||
"ai": {
|
||||
"title": "KI-Verbesserung",
|
||||
@@ -2542,9 +2578,9 @@
|
||||
"twoFactor": {
|
||||
"title": "Zwei-Faktor-Authentifizierung",
|
||||
"hint": "Fügen Sie nach dem Passwort einen Einmalcode aus einer Authentifizierungs-App hinzu.",
|
||||
"enable": "Aktivieren Sie 2FA",
|
||||
"enable": "2FA aktivieren",
|
||||
"enabled": "2FA ist aktiviert.",
|
||||
"disable": "Deaktivieren Sie 2FA",
|
||||
"disable": "2FA deaktivieren",
|
||||
"disableTitle": "Deaktivieren Sie die Zwei-Faktor-Authentifizierung",
|
||||
"disableDescription": "Bestätigen Sie mit Ihrem Passwort und einem aktuellen 2FA-Code oder Backup-Code.",
|
||||
"codeOrBackup": "2FA-Code oder Backup-Code",
|
||||
@@ -2687,7 +2723,7 @@
|
||||
"interfaceOptional": "Schnittstelle (optional)",
|
||||
"applyTo": "Gelten",
|
||||
"commentOptional": "Kommentar (optional)",
|
||||
"port": "Hafen",
|
||||
"port": "Port",
|
||||
"source": "Quelle",
|
||||
"level": "Ebene",
|
||||
"interface": "Schnittstelle",
|
||||
@@ -2979,7 +3015,7 @@
|
||||
"needQrOrKey": "Ein QR-Code oder manueller Schlüssel aus diesem Setup.",
|
||||
"needBackupCodes": "Backup-Codes werden an einem sicheren Ort gespeichert.",
|
||||
"starting": "Beginnt...",
|
||||
"startSetup": "Starten Sie die Einrichtung",
|
||||
"startSetup": "Einrichtung starten",
|
||||
"scanTitle": "Scannen Sie den QR-Code",
|
||||
"scanDescription": "Öffnen Sie Ihre Authentifizierungs-App und scannen Sie diesen Code.",
|
||||
"qrCodeAlt": "2FA QR-Code",
|
||||
@@ -4753,6 +4789,24 @@
|
||||
"thursday": "Donnerstag",
|
||||
"tuesday": "Dienstag",
|
||||
"wednesday": "Mittwoch"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Aufbewahrung im Speicher",
|
||||
"keepAll": "Alle Sicherungen behalten",
|
||||
"description": "In Proxmox gespeicherte Standardaufbewahrung für Sicherungen. Sicherungsaufträge mit einer eigenen Richtlinie überschreiben diesen Standard.",
|
||||
"refresh": "Aktualisieren",
|
||||
"saved": "Aufbewahrungsrichtlinie des Speichers gespeichert. Es wurde keine Bereinigung ausgeführt.",
|
||||
"choosePolicy": "Wählen Sie Alle behalten oder mindestens eine Aufbewahrungsanzahl größer als null.",
|
||||
"saveFailed": "Aufbewahrungsrichtlinie des Speichers konnte nicht gespeichert werden. Aktualisieren Sie die Liste, falls die Konfiguration geändert wurde.",
|
||||
"loadFailed": "Aufbewahrungsrichtlinie des Speichers konnte nicht geladen werden.",
|
||||
"empty": "Keine Sicherungsspeicher gefunden.",
|
||||
"default": "Proxmox-Standard",
|
||||
"edit": "Bearbeiten",
|
||||
"editTitle": "Aufbewahrung für {storage}",
|
||||
"effect": "Gilt für zukünftige Sicherungsläufe, die diese Speicherrichtlinie übernehmen. Das Speichern löscht keine vorhandenen Sicherungen. Geschützte Sicherungen bleiben geschützt.",
|
||||
"cancel": "Abbrechen",
|
||||
"save": "Aufbewahrung speichern",
|
||||
"useDefault": "Proxmox-Standard verwenden (keine Richtlinie für diesen Speicher)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4783,7 +4837,8 @@
|
||||
"services": "PVE-Dienste",
|
||||
"logs": "Systemprotokolle",
|
||||
"updates": "Systemaktualisierungen",
|
||||
"security": "Sicherheit & Zertifikate"
|
||||
"security": "Sicherheit & Zertifikate",
|
||||
"pbsServices": "PBS-Dienste"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "CPU-Auslastung",
|
||||
@@ -7227,5 +7282,99 @@
|
||||
"diagnosticsOmitted": "Weitere Diagnosezeilen oder Text ausgelassen: {count}. Originalbericht bleibt erhalten."
|
||||
}
|
||||
}
|
||||
},
|
||||
"pbs": {
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "PBS-Server",
|
||||
"chooseServer": "PBS-Server auswählen",
|
||||
"local": "Lokaler PBS",
|
||||
"refresh": "Aktualisieren",
|
||||
"manage": "PBS-Verbindungen verwalten",
|
||||
"loadFailed": "PBS-Status konnte nicht geladen werden.",
|
||||
"loading": "PBS-Daten werden geladen…",
|
||||
"noServer": "Fügen Sie eine PBS-Verbindung hinzu, um Datenspeicher, Aufgaben und Zeitpläne anzuzeigen.",
|
||||
"permissionsHelp": "Aktivieren Sie die Monitor-Authentifizierung, bevor Sie Zugangsdaten speichern. Verwenden Sie ein eigenes PBS-API-Token mit der Rolle Audit auf / sowohl für den Benutzer als auch für die Token-ACLs, jeweils mit Vererbung. Dieses Panel liest PBS-Daten ausschließlich.",
|
||||
"editConnection": "Zu bearbeitende Verbindung",
|
||||
"add": "Neue Verbindung hinzufügen",
|
||||
"name": "Name",
|
||||
"url": "HTTPS-URL (für den nativen PBS-Port :8007 angeben)",
|
||||
"token_id": "API-Token-ID (user@realm!token)",
|
||||
"token_secret": "API-Token-Geheimnis (leer lassen, um das gespeicherte Geheimnis beizubehalten)",
|
||||
"fingerprint": "SHA256-Zertifikatfingerabdruck (optional)",
|
||||
"tlsHelp": "Lassen Sie den Fingerabdruck für die normale Prüfung von Zertifizierungsstelle und Hostname leer. Kopieren Sie bei einem selbstsignierten Zertifikat dessen SHA256-Fingerabdruck aus der PBS-Konsole oder einem anderen vertrauenswürdigen Kanal. Der Fingerabdruck wird vor der Übermittlung von Zugangsdaten geprüft.",
|
||||
"saved": "PBS-Verbindung geprüft und gespeichert.",
|
||||
"saveFailed": "PBS-Verbindung konnte nicht gespeichert werden.",
|
||||
"testing": "Verbindung wird geprüft…",
|
||||
"save": "Prüfen und speichern",
|
||||
"remove": "Verbindung entfernen",
|
||||
"removeConfirm": "Diese gespeicherte PBS-Verbindung und ihr Token aus Monitor entfernen? PBS-Daten und -Aufträge bleiben unverändert.",
|
||||
"sampled": "Erfasst am",
|
||||
"datastores": "Datenspeicher",
|
||||
"total": "Dateisystemkapazität",
|
||||
"used": "Belegt",
|
||||
"free": "Verfügbar",
|
||||
"noDatastores": "Keine zugänglichen Datenspeicher. Prüfen Sie die Datenspeicherkonfiguration und die Berechtigungen des Benutzers und Tokens.",
|
||||
"tasks": "Letzte Aufgaben",
|
||||
"noTasks": "Es wurden keine aktuellen Aufgaben zurückgegeben.",
|
||||
"task": "Aufgabe",
|
||||
"started": "Gestartet",
|
||||
"status": "Status",
|
||||
"running": "Wird ausgeführt",
|
||||
"jobs": "Wartungszeitpläne",
|
||||
"job": "Auftrag",
|
||||
"schedule": "Zeitplan",
|
||||
"sync": "Synchronisierung",
|
||||
"verify": "Verifizierung",
|
||||
"prune": "Bereinigung",
|
||||
"overview": "Übersicht",
|
||||
"logs": "Systemprotokolle",
|
||||
"terminal": "Terminal",
|
||||
"security": "Sicherheit",
|
||||
"logout": "Abmelden",
|
||||
"navigation": "PBS-Navigation",
|
||||
"cpu": "CPU",
|
||||
"memory": "Arbeitsspeicher",
|
||||
"uptime": "Betriebszeit",
|
||||
"capacityUnavailable": "Kapazität nicht verfügbar.",
|
||||
"datastoresUnavailable": "{count} nicht verfügbar",
|
||||
"datastoresOnline": "Alle verfügbar",
|
||||
"moreDatastores": "+{count} weitere Datenspeicher",
|
||||
"estimatedFull": "Voraussichtlich voll",
|
||||
"estimatedFullUnknown": "Noch nicht genügend Daten",
|
||||
"duration": "Dauer",
|
||||
"noJobs": "Es sind keine Synchronisierungs-, Überprüfungs- oder Löschzeitpläne konfiguriert."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"standalone": "Eigenständige Knoten",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Dieser Knoten",
|
||||
"checking": "Knoten werden überprüft…",
|
||||
"loadFailed": "Die Knotenliste konnte nicht geladen werden.",
|
||||
"empty": "Noch keine anderen Knoten. Fügen Sie einen hinzu, um seinen Monitor von hier aus zu öffnen.",
|
||||
"noResponse": "Keine Antwort",
|
||||
"pbsInterface": "PBS-Schnittstelle",
|
||||
"openNewTab": "In einem neuen Tab öffnen",
|
||||
"manage": "Knoten hinzufügen oder verwalten…",
|
||||
"dialogTitle": "Verknüpfte Knoten",
|
||||
"dialogDescription": "Clusterknoten und konfigurierte Proxmox Backup Server werden automatisch erkannt. Jeder Monitor behält seine eigene Anmeldung.",
|
||||
"addTitle": "Einen Knoten hinzufügen",
|
||||
"name": "Name",
|
||||
"address": "Adresse überwachen",
|
||||
"type": "Geben Sie ein",
|
||||
"add": "Hinzufügen",
|
||||
"save": "Speichern",
|
||||
"remove": "Entfernen",
|
||||
"visible": "Sichtbar",
|
||||
"autoAddress": "Automatisch",
|
||||
"detected": "Erkannt",
|
||||
"added": "Hinzugefügt",
|
||||
"saveFailed": "Die Knotenliste konnte nicht gespeichert werden.",
|
||||
"backTo": "Zurück zu {name}",
|
||||
"cameFromTitle": "Monitorknoten hinzufügen?",
|
||||
"cameFromBody": "{name} zu den verknüpften Knoten dieses Monitors hinzufügen.",
|
||||
"notNow": "Nicht jetzt"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,70 @@
|
||||
{
|
||||
"pbs": {
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "Servidor PBS",
|
||||
"chooseServer": "Selecciona un servidor PBS",
|
||||
"local": "PBS local",
|
||||
"refresh": "Actualizar",
|
||||
"manage": "Gestionar conexiones PBS",
|
||||
"loadFailed": "No se pudo consultar el estado de PBS.",
|
||||
"loading": "Cargando datos de PBS…",
|
||||
"noServer": "Añade una conexión PBS para ver datastores, tareas y programaciones.",
|
||||
"permissionsHelp": "Activa la autenticación de Monitor antes de guardar credenciales. Usa un token API dedicado de PBS con el rol Audit en / para el usuario y el token, con propagación. Este panel solo consulta datos de PBS.",
|
||||
"editConnection": "Conexión que deseas editar",
|
||||
"add": "Añadir una conexión",
|
||||
"name": "Nombre",
|
||||
"url": "URL HTTPS (incluye :8007 para el puerto nativo de PBS)",
|
||||
"token_id": "ID del token API (usuario@realm!token)",
|
||||
"token_secret": "Secreto del token API (vacío para conservar el guardado)",
|
||||
"fingerprint": "Huella SHA256 del certificado (opcional)",
|
||||
"tlsHelp": "Deja la huella vacía para verificar la CA y el nombre del servidor. Si el certificado es autofirmado, copia su huella SHA256 desde la consola de PBS u otro canal fiable. Se verifica antes de enviar las credenciales.",
|
||||
"saved": "Conexión PBS verificada y guardada.",
|
||||
"saveFailed": "No se pudo guardar la conexión PBS.",
|
||||
"testing": "Verificando conexión…",
|
||||
"save": "Verificar y guardar",
|
||||
"remove": "Eliminar conexión",
|
||||
"removeConfirm": "¿Eliminar de Monitor esta conexión PBS y su token guardado? Los datos y las tareas de PBS no se modifican.",
|
||||
"sampled": "Consulta realizada a las",
|
||||
"datastores": "Datastores",
|
||||
"total": "Capacidad del sistema de archivos",
|
||||
"used": "Usado",
|
||||
"free": "Disponible",
|
||||
"noDatastores": "No hay datastores accesibles. Revisa su configuración y los permisos del usuario y del token.",
|
||||
"tasks": "Tareas recientes",
|
||||
"noTasks": "No se han devuelto tareas recientes.",
|
||||
"task": "Tarea",
|
||||
"started": "Inicio",
|
||||
"status": "Estado",
|
||||
"running": "En ejecución",
|
||||
"jobs": "Programaciones de mantenimiento",
|
||||
"job": "Tarea",
|
||||
"schedule": "Programación",
|
||||
"sync": "Sincronización",
|
||||
"verify": "Verificación",
|
||||
"prune": "Prune",
|
||||
"overview": "Resumen",
|
||||
"logs": "Registros del sistema",
|
||||
"terminal": "Terminal",
|
||||
"security": "Seguridad",
|
||||
"logout": "Cerrar sesión",
|
||||
"navigation": "Navegación de PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"uptime": "Tiempo de actividad",
|
||||
"capacityUnavailable": "Capacidad no disponible.",
|
||||
"datastoresUnavailable": "{count} no disponibles",
|
||||
"datastoresOnline": "Todos disponibles",
|
||||
"moreDatastores": "+{count} datastores más",
|
||||
"estimatedFull": "Lleno previsto",
|
||||
"estimatedFullUnknown": "Aún no hay datos suficientes",
|
||||
"duration": "Duración",
|
||||
"noJobs": "No hay programaciones de sincronización, verificación ni prune."
|
||||
},
|
||||
"_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.",
|
||||
"app": {
|
||||
"title": "ProxMenux Monitor",
|
||||
"description": "Proxmox System Dashboard",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard",
|
||||
"loading": "Cargando...",
|
||||
"connecting": "Conexión al ProxMenux Monitor",
|
||||
"unknown": "Desconocido",
|
||||
@@ -140,6 +202,7 @@
|
||||
"dayMany": "{count} días"
|
||||
},
|
||||
"proxmoxVersion": "Versión Proxmox:",
|
||||
"pbsVersion": "Versión PBS:",
|
||||
"kernel": "Núcleo:",
|
||||
"availableUpdates": "Actualizaciones disponibles:",
|
||||
"packages": "paquetes",
|
||||
@@ -607,7 +670,7 @@
|
||||
"controllerBusyTime": "Minutos totales que el controlador pasó procesando comandos de E/S. Los valores altos indican una carga de trabajo pesada y sostenida.",
|
||||
"errorLogEntries": "Número de entradas en el registro de información de errores. A menudo incluye artefactos benignos de autoevaluación; verifique con Errores de Medios.",
|
||||
"warningTempTime": "Total de minutos transcurridos por encima del umbral de temperatura de advertencia. Provoca una limitación del rendimiento. El cero es ideal.",
|
||||
"criticalTempTime": "Minutos totales pasados por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
|
||||
"criticalTempTime": "Minutos totales pasados por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
|
||||
"warningCompositeTemperatureTime": "Minutos totales que la temperatura compuesta superó el umbral de advertencia.",
|
||||
"criticalCompositeTemperatureTime": "Minutos totales la temperatura compuesta superó el umbral crítico. Debe ser cero.",
|
||||
"thermalManagementT1TransCount": "Número de veces que la unidad entró en aceleración térmica ligera (T1). Indica problemas de enfriamiento.",
|
||||
@@ -1110,7 +1173,21 @@
|
||||
"available": "Backups disponibles",
|
||||
"loading": "Cargando backups...",
|
||||
"empty": "No se encontraron backups",
|
||||
"emptyHint": "Crea tu primer backup con el botón de arriba"
|
||||
"emptyHint": "Crea tu primer backup con el botón de arriba",
|
||||
"refresh": "Actualizar",
|
||||
"refreshFailed": "No se pudieron actualizar los backups. Vuelve a actualizar para confirmar su estado actual.",
|
||||
"deleteSelected": "Eliminar los seleccionados ({count})",
|
||||
"deletedCount": "{count} backup(s) eliminado(s).",
|
||||
"unconfirmedCount": "No se ha confirmado la eliminación de {count} backup(s). Actualiza la lista y comprueba las tareas de Proxmox antes de volver a intentarlo.",
|
||||
"deleteFailed": "No se pudo confirmar la eliminación. Actualiza la lista y comprueba las tareas de Proxmox antes de volver a intentarlo.",
|
||||
"selectHint": "Selecciona backups sin protección (hasta 20 por lote)",
|
||||
"selectBackup": "Seleccionar backup",
|
||||
"protected": "Protegido",
|
||||
"confirmTitle": "¿Eliminar {count} backup(s)?",
|
||||
"confirmDescription": "Los archivos de backup de la VM/CT {vmid} se eliminarán de forma permanente. Los backups protegidos quedan fuera. La VM o el contenedor no se eliminan.",
|
||||
"deleting": "Esperando a que Proxmox termine de eliminar los archivos seleccionados…",
|
||||
"cancel": "Cancelar",
|
||||
"confirmDelete": "Eliminar backups"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Actualizaciones de paquetes pendientes",
|
||||
@@ -1302,7 +1379,10 @@
|
||||
"jueves",
|
||||
"viernes",
|
||||
"sábado"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "ejecución omitida",
|
||||
"reasonStoppedContainer": "El contenedor está parado; se ha omitido la actualización programada.",
|
||||
"reasonStoppedContainerHistorical": "El contenedor estaba parado a la hora de la actualización programada."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron del host detectado",
|
||||
@@ -1643,6 +1723,16 @@
|
||||
"searchApplications": "Buscar aplicaciones",
|
||||
"searchingApplications": "Buscando aplicaciones…",
|
||||
"noApplicationsDetected": "No se han detectado aplicaciones.",
|
||||
"nativeEvidencePort": "Puerto TCP a la escucha",
|
||||
"nativeEvidenceFile": "Archivo instalado",
|
||||
"nativeEvidenceProcess": "Ejecutable en marcha",
|
||||
"nativeEvidenceDirectory": "Directorio de trabajo",
|
||||
"nativeEvidenceService": "Definición del servicio",
|
||||
"nativeScanFailed": "La búsqueda de aplicaciones no ha podido terminar. Lo detectado antes puede estar desactualizado; vuelve a intentarlo.",
|
||||
"nativeScanPartial": "La búsqueda de aplicaciones está incompleta. Revisa lo que se ha encontrado y vuelve a intentarlo.",
|
||||
"nativeVersionUnknown": "Versión instalada desconocida",
|
||||
"nativeEvidenceStale": "Lo detectado puede estar desactualizado o incompleto.",
|
||||
"nativeRegisterOnly": "Confirma el registro en el editor. Una versión conocida se guarda como valor manual; no se configura ningún actualizador.",
|
||||
"noNewApplicationsDetected": "No se han detectado aplicaciones nuevas.",
|
||||
"oneNewApplicationDetected": "Se ha detectado una aplicación nueva.",
|
||||
"newApplicationsDetected": "Se han detectado {count} aplicaciones nuevas.",
|
||||
@@ -1811,6 +1901,7 @@
|
||||
"network": "Interfaces de red",
|
||||
"vms": "Máquinas virtuales y contenedores",
|
||||
"services": "Servicios PVE",
|
||||
"pbsServices": "Servicios PBS",
|
||||
"logs": "Registros del sistema",
|
||||
"updates": "Actualizaciones del sistema",
|
||||
"security": "Seguridad y certificados",
|
||||
@@ -2108,7 +2199,13 @@
|
||||
"inactiveFooter": "Habilite notificaciones para recibir alertas del sistema, de salud y de seguridad a través de los canales configurados.",
|
||||
"notificationLanguage": "Idioma de notificación",
|
||||
"selectNotificationLanguage": "Seleccione el idioma de notificación",
|
||||
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada."
|
||||
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada.",
|
||||
"appriseServiceHelp": "Configuración de Slack y Matrix",
|
||||
"appriseSlackSteps": "Crea un webhook entrante en tu aplicación de Slack para el canal de destino y pega su URL HTTPS completa en URL de Apprise. No hace falta convertirla.",
|
||||
"appriseSlackDocs": "Formatos de URL y configuración de Slack",
|
||||
"appriseMatrixSteps": "Utiliza una cuenta de notificaciones que pueda publicar en la sala. Sustituye el usuario, la contraseña, el servidor de origen y el ID completo de la sala del ejemplo; codifica para URL los caracteres especiales de las credenciales. El esquema matrixs utiliza HTTPS. Consulta en la documentación la compatibilidad con tokens, SSO y salas cifradas de tu versión de Apprise.",
|
||||
"appriseMatrixDocs": "Formatos de URL y configuración de Matrix",
|
||||
"appriseServiceFinish": "Activa Apprise, introduce tu URL, elige las categorías de notificación o los eventos individuales, guarda y utiliza Enviar prueba. Estos ejemplos no cambian tu URL guardada. Este canal de Apprise tiene un único destino; utiliza la API de Apprise para varios servicios a la vez."
|
||||
},
|
||||
"ai": {
|
||||
"title": "mejora de la IA",
|
||||
@@ -2576,7 +2673,7 @@
|
||||
"expires": "Vence",
|
||||
"selfSignedWarning": "Los certificados autofirmados pueden mostrar una advertencia en el navegador. Esto es normal para las configuraciones locales de Proxmox.",
|
||||
"configuring": "Configurando...",
|
||||
"useProxmoxCertificate": "Utilice el certificado Proxmox",
|
||||
"useProxmoxCertificate": "Usar certificado de Proxmox",
|
||||
"noProxmoxCertificate": "No se detectó ningún certificado Proxmox.",
|
||||
"useCustomCertificate": "Usar certificado personalizado",
|
||||
"customPaths": "Rutas de certificados personalizadas",
|
||||
@@ -3563,8 +3660,8 @@
|
||||
"short": {
|
||||
"hour": "Pasada 1 hora",
|
||||
"day": "Pasadas 24h",
|
||||
"week": "Pasados los 7 días",
|
||||
"month": "Pasados los 30 días",
|
||||
"week": "Pasados los 7 días",
|
||||
"month": "Pasados los 30 días",
|
||||
"year": "Último 1 año"
|
||||
},
|
||||
"last": {
|
||||
@@ -4753,6 +4850,24 @@
|
||||
"thursday": "Jueves",
|
||||
"tuesday": "Martes",
|
||||
"wednesday": "Miércoles"
|
||||
},
|
||||
"storageRetention": {
|
||||
"useDefault": "Usar el valor por defecto de Proxmox (sin política en este almacenamiento)",
|
||||
"title": "Retención del almacenamiento",
|
||||
"keepAll": "Conservar todos los backups",
|
||||
"description": "Retención por defecto de los backups, guardada en Proxmox. Las tareas de backup con su propia política tienen prioridad sobre ella.",
|
||||
"refresh": "Actualizar",
|
||||
"saved": "Retención guardada. No se ha purgado ningún backup.",
|
||||
"choosePolicy": "Elige Conservar todos o al menos un número de retención mayor que cero.",
|
||||
"saveFailed": "No se pudo guardar la retención del almacenamiento. Actualiza la lista si ha cambiado la configuración.",
|
||||
"loadFailed": "No se pudo cargar la retención del almacenamiento.",
|
||||
"empty": "No hay almacenamientos de backups.",
|
||||
"default": "Valor por defecto de Proxmox",
|
||||
"edit": "Editar",
|
||||
"editTitle": "Retención de {storage}",
|
||||
"effect": "Se aplica a los próximos backups que hereden la política de este almacenamiento. Guardar no elimina los backups existentes, y los protegidos siguen protegidos.",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar retención"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4780,7 +4895,8 @@
|
||||
"disks": "E/S de disco y errores",
|
||||
"network": "Interfaces de red",
|
||||
"vms": "Máquinas virtuales y contenedores",
|
||||
"services": "servicios PVE",
|
||||
"services": "Servicios PVE",
|
||||
"pbsServices": "Servicios PBS",
|
||||
"logs": "Registros del sistema",
|
||||
"updates": "Actualizaciones del sistema",
|
||||
"security": "Seguridad y certificados"
|
||||
@@ -7227,5 +7343,38 @@
|
||||
"diagnosticsOmitted": "Líneas o texto de diagnóstico omitidos: {count}. Se conserva el informe original."
|
||||
}
|
||||
}
|
||||
},
|
||||
"monitorLinks": {
|
||||
"thisNode": "Este nodo",
|
||||
"checking": "Comprobando nodos…",
|
||||
"loadFailed": "No se pudo cargar la lista de nodos.",
|
||||
"empty": "Aún no hay otros nodos. Añade uno para abrir su Monitor desde aquí.",
|
||||
"groups": {
|
||||
"cluster": "Clúster",
|
||||
"standalone": "Nodos independientes",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"noResponse": "Sin respuesta",
|
||||
"pbsInterface": "Interfaz de PBS",
|
||||
"openNewTab": "Abrir en una pestaña nueva",
|
||||
"manage": "Añadir o gestionar nodos…",
|
||||
"dialogTitle": "Nodos enlazados",
|
||||
"dialogDescription": "Los nodos del clúster y los Proxmox Backup Server configurados se detectan automáticamente. Cada Monitor mantiene su propio inicio de sesión.",
|
||||
"addTitle": "Añadir un nodo",
|
||||
"name": "Nombre",
|
||||
"address": "Dirección del Monitor",
|
||||
"type": "Tipo",
|
||||
"add": "Añadir",
|
||||
"save": "Guardar",
|
||||
"remove": "Eliminar",
|
||||
"visible": "Visible",
|
||||
"autoAddress": "Automática",
|
||||
"detected": "Detectado",
|
||||
"added": "Añadido",
|
||||
"saveFailed": "No se pudo guardar la lista de nodos.",
|
||||
"backTo": "Volver a {name}",
|
||||
"cameFromTitle": "¿Añadir nodo Monitor?",
|
||||
"cameFromBody": "Añade {name} a los nodos enlazados de este Monitor.",
|
||||
"notNow": "Ahora no"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Inconnu",
|
||||
"notAvailable": "N/A",
|
||||
"serverOffline": "Serveur hors ligne",
|
||||
"supportProject": "Soutenir et contribuer au projet"
|
||||
"supportProject": "Soutenir et contribuer au projet",
|
||||
"descriptionPbs": "Tableau de bord Proxmox Backup Server"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/A",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} en cours d'exécution",
|
||||
"runningCountFew": "{count} en cours d'exécution",
|
||||
"runningCountMany": "{count} en cours d'exécution"
|
||||
"runningCountMany": "{count} en cours d'exécution",
|
||||
"pbsVersion": "Version PBS :"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Chargement des données de stockage...",
|
||||
@@ -1110,7 +1112,21 @@
|
||||
"available": "Sauvegardes disponibles",
|
||||
"loading": "Chargement des sauvegardes...",
|
||||
"empty": "Aucune sauvegarde trouvée",
|
||||
"emptyHint": "Créez votre première sauvegarde en utilisant le bouton ci-dessus"
|
||||
"emptyHint": "Créez votre première sauvegarde en utilisant le bouton ci-dessus",
|
||||
"refresh": "Actualiser",
|
||||
"refreshFailed": "Impossible d'actualiser les sauvegardes. Actualisez à nouveau pour confirmer leur état actuel.",
|
||||
"deleteSelected": "Supprimer la sélection ({count})",
|
||||
"deletedCount": "{count} sauvegarde(s) supprimée(s).",
|
||||
"unconfirmedCount": "La suppression de {count} sauvegarde(s) n'a pas été confirmée. Actualisez la liste et vérifiez les tâches Proxmox avant de réessayer.",
|
||||
"deleteFailed": "La suppression n'a pas pu être confirmée. Actualisez la liste et vérifiez les tâches Proxmox avant de réessayer.",
|
||||
"selectHint": "Sélectionnez les sauvegardes non protégées (jusqu'à 20 par lot)",
|
||||
"selectBackup": "Sélectionner la sauvegarde",
|
||||
"protected": "Protégée",
|
||||
"confirmTitle": "Supprimer {count} sauvegarde(s) ?",
|
||||
"confirmDescription": "Ces archives de sauvegarde de la VM/CT {vmid} seront définitivement supprimées. Les sauvegardes protégées sont exclues. Cela ne supprime pas l'invité.",
|
||||
"deleting": "En attente de la fin de la suppression des archives sélectionnées par Proxmox…",
|
||||
"cancel": "Annuler",
|
||||
"confirmDelete": "Supprimer les sauvegardes"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Mises à jour des packages en attente",
|
||||
@@ -1302,7 +1318,10 @@
|
||||
"Jeudi",
|
||||
"Vendredi",
|
||||
"Samedi"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "exécution ignorée",
|
||||
"reasonStoppedContainer": "Le conteneur est arrêté ; la mise à jour programmée a été ignorée.",
|
||||
"reasonStoppedContainerHistorical": "Le conteneur était arrêté à l'heure de la mise à jour programmée."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron hôte détecté",
|
||||
@@ -1718,7 +1737,17 @@
|
||||
"portCategoryCustomPlaceholder": "Tapez une catégorie et appuyez sur Entrée (Esc pour annuler)",
|
||||
"portCustomUrlPlaceholder": "URL personnalisée (par exemple https://vault.example.com) — remplace IP:port",
|
||||
"stackMemberTitle": "Fait partie d'une application multi-conteneurs",
|
||||
"stackMemberBody": "Ce conteneur fait partie d'une application dont le conteneur principal est {primary}. L'application, ses liens web et sa version y sont affichés."
|
||||
"stackMemberBody": "Ce conteneur fait partie d'une application dont le conteneur principal est {primary}. L'application, ses liens web et sa version y sont affichés.",
|
||||
"nativeEvidencePort": "Port TCP en écoute",
|
||||
"nativeEvidenceFile": "Fichier installé",
|
||||
"nativeEvidenceProcess": "Exécutable en cours d'exécution",
|
||||
"nativeEvidenceDirectory": "Répertoire de travail",
|
||||
"nativeEvidenceService": "Définition du service",
|
||||
"nativeScanFailed": "La recherche d'applications n'a pas pu se terminer. Les résultats précédents peuvent être obsolètes ; réessayez.",
|
||||
"nativeScanPartial": "La recherche d'applications est incomplète. Vérifiez les éléments trouvés et réessayez.",
|
||||
"nativeVersionUnknown": "Version installée inconnue",
|
||||
"nativeEvidenceStale": "Les éléments trouvés peuvent être obsolètes ou incomplets.",
|
||||
"nativeRegisterOnly": "Confirmez l'enregistrement dans l'éditeur. Une version connue est enregistrée comme valeur manuelle ; aucun outil de mise à jour n'est configuré."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "Filtrer les machines virtuelles et les conteneurs",
|
||||
@@ -1814,7 +1843,8 @@
|
||||
"logs": "Journaux système",
|
||||
"updates": "Mises à jour du système",
|
||||
"security": "Sécurité et certificats",
|
||||
"temperature": "Température"
|
||||
"temperature": "Température",
|
||||
"pbsServices": "Services PBS"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -2108,7 +2138,13 @@
|
||||
"inactiveFooter": "Activez les notifications pour recevoir des alertes système, de santé et de sécurité via les canaux configurés.",
|
||||
"notificationLanguage": "Langue de notification",
|
||||
"selectNotificationLanguage": "Sélectionnez la langue de notification",
|
||||
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée."
|
||||
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée.",
|
||||
"appriseServiceHelp": "Configuration de Slack et Matrix",
|
||||
"appriseSlackSteps": "Créez un webhook entrant dans votre application Slack pour le canal cible, puis collez son URL HTTPS complète dans le champ URL Apprise. Aucune conversion n'est nécessaire.",
|
||||
"appriseSlackDocs": "Formats d'URL et configuration de Slack",
|
||||
"appriseMatrixSteps": "Utilisez un compte de notification autorisé à publier dans le salon. Remplacez l'utilisateur, le mot de passe, le serveur d'origine et l'identifiant complet du salon de l'exemple ; encodez les caractères spéciaux des identifiants pour les URL. Le schéma matrixs utilise HTTPS. Consultez la documentation pour la prise en charge des jetons, du SSO et des salons chiffrés dans votre version d'Apprise.",
|
||||
"appriseMatrixDocs": "Formats d'URL et configuration de Matrix",
|
||||
"appriseServiceFinish": "Activez Apprise, saisissez votre URL, choisissez les catégories de notification ou les événements individuels, enregistrez, puis utilisez Envoyer un test. Ces exemples ne modifient pas votre URL enregistrée. Ce canal Apprise possède une destination ; utilisez l'API Apprise pour plusieurs services à la fois."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Amélioration de l'IA",
|
||||
@@ -2977,7 +3013,7 @@
|
||||
"youWillNeed": "Vous aurez besoin de :",
|
||||
"needApp": "Une application d'authentification telle que Aegis, 1Password, Bitwarden ou Google Authenticator.",
|
||||
"needQrOrKey": "Un code QR ou une clé manuelle de cette configuration.",
|
||||
"needBackupCodes": "Codes de sauvegarde enregistrés dans un endroit sûr.",
|
||||
"needBackupCodes": "Codes de secours enregistrés dans un endroit sûr.",
|
||||
"starting": "Départ...",
|
||||
"startSetup": "Démarrer la configuration",
|
||||
"scanTitle": "Scannez le code QR",
|
||||
@@ -2990,10 +3026,10 @@
|
||||
"verifying": "Vérification...",
|
||||
"verifyAndEnable": "Vérifier et activer",
|
||||
"enabledTitle": "2FA est activé.",
|
||||
"enabledDescription": "Enregistrez les codes de sauvegarde avant de fermer cette fenêtre.",
|
||||
"saveCodesTitle": "Enregistrez vos codes de sauvegarde",
|
||||
"enabledDescription": "Enregistrez les codes de secours avant de fermer cette fenêtre.",
|
||||
"saveCodesTitle": "Enregistrez vos codes de secours",
|
||||
"saveCodesDescription": "Chaque code peut être utilisé une fois si vous perdez l'accès à votre application d'authentification.",
|
||||
"backupCodes": "Codes de sauvegarde",
|
||||
"backupCodes": "Codes de secours",
|
||||
"copyAll": "Copier tout",
|
||||
"finish": "Finition"
|
||||
},
|
||||
@@ -4753,6 +4789,24 @@
|
||||
"thursday": "Jeudi",
|
||||
"tuesday": "Mardi",
|
||||
"wednesday": "Mercredi"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Rétention du stockage",
|
||||
"keepAll": "Conserver toutes les sauvegardes",
|
||||
"description": "Rétention par défaut des sauvegardes enregistrée dans Proxmox. Les tâches de sauvegarde avec une politique explicite remplacent cette valeur par défaut.",
|
||||
"refresh": "Actualiser",
|
||||
"saved": "Rétention du stockage enregistrée. Aucune purge n'a été exécutée.",
|
||||
"choosePolicy": "Choisissez Tout conserver ou au moins un nombre de rétention supérieur à zéro.",
|
||||
"saveFailed": "Impossible d'enregistrer la rétention du stockage. Actualisez la liste si la configuration a changé.",
|
||||
"loadFailed": "Impossible de charger la rétention du stockage.",
|
||||
"empty": "Aucun stockage de sauvegarde trouvé.",
|
||||
"default": "Valeur par défaut de Proxmox",
|
||||
"edit": "Modifier",
|
||||
"editTitle": "Rétention de {storage}",
|
||||
"effect": "S'applique aux futures sauvegardes qui héritent de cette politique de stockage. L'enregistrement ne supprime pas les sauvegardes existantes. Les sauvegardes protégées restent protégées.",
|
||||
"cancel": "Annuler",
|
||||
"save": "Enregistrer la rétention",
|
||||
"useDefault": "Utiliser la valeur par défaut de Proxmox (aucune politique sur ce stockage)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4783,7 +4837,8 @@
|
||||
"services": "Services PVE",
|
||||
"logs": "Journaux système",
|
||||
"updates": "Mises à jour du système",
|
||||
"security": "Sécurité et certificats"
|
||||
"security": "Sécurité et certificats",
|
||||
"pbsServices": "services PBS"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "Utilisation du processeur",
|
||||
@@ -7227,5 +7282,99 @@
|
||||
"diagnosticsOmitted": "Lignes ou texte de diagnostic omis : {count}. Rapport original conservé."
|
||||
}
|
||||
}
|
||||
},
|
||||
"pbs": {
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "Serveur PBS",
|
||||
"chooseServer": "Choisir un serveur PBS",
|
||||
"local": "PBS local",
|
||||
"refresh": "Actualiser",
|
||||
"manage": "Gérer les connexions PBS",
|
||||
"loadFailed": "Impossible de charger l'état de PBS.",
|
||||
"loading": "Chargement des données PBS…",
|
||||
"noServer": "Ajoutez une connexion PBS pour afficher les magasins de données, les tâches et les planifications.",
|
||||
"permissionsHelp": "Activez l'authentification Monitor avant d'enregistrer des identifiants. Utilisez un jeton API PBS dédié avec le rôle Audit sur / pour les ACL de l'utilisateur et du jeton, avec propagation. Ce panneau accède aux données PBS uniquement en lecture.",
|
||||
"editConnection": "Connexion à modifier",
|
||||
"add": "Ajouter une connexion",
|
||||
"name": "Nom",
|
||||
"url": "URL HTTPS (inclure :8007 pour le port natif de PBS)",
|
||||
"token_id": "Identifiant du jeton API (user@realm!token)",
|
||||
"token_secret": "Secret du jeton API (laisser vide pour conserver le secret enregistré)",
|
||||
"fingerprint": "Empreinte SHA256 du certificat (facultative)",
|
||||
"tlsHelp": "Laissez l'empreinte vide pour la vérification habituelle de l'autorité de certification et du nom d'hôte. Pour un certificat autosigné, copiez son empreinte SHA256 depuis la console PBS ou un autre canal de confiance. L'empreinte est vérifiée avant l'envoi des identifiants.",
|
||||
"saved": "Connexion PBS vérifiée et enregistrée.",
|
||||
"saveFailed": "Impossible d'enregistrer la connexion PBS.",
|
||||
"testing": "Vérification de la connexion…",
|
||||
"save": "Vérifier et enregistrer",
|
||||
"remove": "Supprimer la connexion",
|
||||
"removeConfirm": "Supprimer de Monitor cette connexion PBS enregistrée et son jeton ? Les données et tâches PBS restent inchangées.",
|
||||
"sampled": "Relevé effectué à",
|
||||
"datastores": "Magasins de données",
|
||||
"total": "Capacité du système de fichiers",
|
||||
"used": "Utilisé",
|
||||
"free": "Disponible",
|
||||
"noDatastores": "Aucun magasin de données accessible. Vérifiez leur configuration et les permissions de l'utilisateur et du jeton.",
|
||||
"tasks": "Tâches récentes",
|
||||
"noTasks": "Aucune tâche récente n'a été renvoyée.",
|
||||
"task": "Tâche",
|
||||
"started": "Démarrée",
|
||||
"status": "État",
|
||||
"running": "En cours",
|
||||
"jobs": "Planifications de maintenance",
|
||||
"job": "Tâche planifiée",
|
||||
"schedule": "Planification",
|
||||
"sync": "Synchronisation",
|
||||
"verify": "Vérification",
|
||||
"prune": "Purge",
|
||||
"overview": "Vue d'ensemble",
|
||||
"logs": "Journaux système",
|
||||
"terminal": "Terminal",
|
||||
"security": "Sécurité",
|
||||
"logout": "Se déconnecter",
|
||||
"navigation": "Navigation PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Mémoire",
|
||||
"uptime": "Durée de fonctionnement",
|
||||
"capacityUnavailable": "Capacité non disponible.",
|
||||
"datastoresUnavailable": "{count} indisponible",
|
||||
"datastoresOnline": "Tous disponibles",
|
||||
"moreDatastores": "+{count} banques de données supplémentaires",
|
||||
"estimatedFull": "Estimé complet",
|
||||
"estimatedFullUnknown": "Pas encore assez de données",
|
||||
"duration": "Durée",
|
||||
"noJobs": "Aucune planification de synchronisation, de vérification ou d'élagage n'est configurée."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"standalone": "nœuds autonomes",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Ce nœud",
|
||||
"checking": "Vérification des nœuds…",
|
||||
"loadFailed": "Impossible de charger la liste des nœuds.",
|
||||
"empty": "Aucun autre nœud pour l'instant. Ajoutez-en un pour ouvrir son Monitor depuis ici.",
|
||||
"noResponse": "Pas de réponse",
|
||||
"pbsInterface": "interface PBS",
|
||||
"openNewTab": "Ouvrir dans un nouvel onglet",
|
||||
"manage": "Ajouter ou gérer des nœuds…",
|
||||
"dialogTitle": "nœuds liés",
|
||||
"dialogDescription": "Les nœuds du cluster et les Proxmox Backup Server configurés sont détectés automatiquement. Chaque Monitor conserve sa propre connexion.",
|
||||
"addTitle": "ajouter un nœud",
|
||||
"name": "Nom",
|
||||
"address": "Adresse du moniteur",
|
||||
"type": "Type",
|
||||
"add": "Ajouter",
|
||||
"save": "Enregistrer",
|
||||
"remove": "Supprimer",
|
||||
"visible": "Visible",
|
||||
"autoAddress": "Automatique",
|
||||
"detected": "Détecté",
|
||||
"added": "ajouté",
|
||||
"saveFailed": "Impossible d'enregistrer la liste des nœuds.",
|
||||
"backTo": "Retour à {name}",
|
||||
"cameFromTitle": "Ajouter un nœud Moniteur ?",
|
||||
"cameFromBody": "Ajoutez {name} aux nœuds liés de ce moniteur.",
|
||||
"notNow": "Pas maintenant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Sconosciuto",
|
||||
"notAvailable": "N/A",
|
||||
"serverOffline": "Server offline",
|
||||
"supportProject": "Sostieni e contribuisci al progetto"
|
||||
"supportProject": "Sostieni e contribuisci al progetto",
|
||||
"descriptionPbs": "Dashboard di Proxmox Backup Server"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/A",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} in esecuzione",
|
||||
"runningCountFew": "{count} in esecuzione",
|
||||
"runningCountMany": "{count} in esecuzione"
|
||||
"runningCountMany": "{count} in esecuzione",
|
||||
"pbsVersion": "Versione PBS:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Caricamento dati storage...",
|
||||
@@ -1110,7 +1112,21 @@
|
||||
"available": "Backup disponibili",
|
||||
"loading": "Caricamento backup...",
|
||||
"empty": "Nessun backup trovato",
|
||||
"emptyHint": "Crea il primo backup con il pulsante in alto"
|
||||
"emptyHint": "Crea il primo backup con il pulsante in alto",
|
||||
"refresh": "Aggiorna",
|
||||
"refreshFailed": "Impossibile aggiornare i backup. Aggiorna di nuovo per confermarne lo stato attuale.",
|
||||
"deleteSelected": "Elimina selezionati ({count})",
|
||||
"deletedCount": "{count} backup eliminati.",
|
||||
"unconfirmedCount": "L'eliminazione di {count} backup non è stata confermata. Aggiorna l'elenco e controlla le attività di Proxmox prima di riprovare.",
|
||||
"deleteFailed": "Impossibile confermare l'eliminazione. Aggiorna l'elenco e controlla le attività di Proxmox prima di riprovare.",
|
||||
"selectHint": "Seleziona i backup non protetti (fino a 20 per gruppo)",
|
||||
"selectBackup": "Seleziona backup",
|
||||
"protected": "Protetto",
|
||||
"confirmTitle": "Eliminare {count} backup?",
|
||||
"confirmDescription": "Questi archivi di backup della VM/CT {vmid} verranno eliminati definitivamente. I backup protetti sono esclusi. Questo non elimina l'ospite.",
|
||||
"deleting": "In attesa che Proxmox termini l'eliminazione degli archivi selezionati…",
|
||||
"cancel": "Annulla",
|
||||
"confirmDelete": "Elimina backup"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Aggiornamenti pacchetti in sospeso",
|
||||
@@ -1302,7 +1318,10 @@
|
||||
"Giovedì",
|
||||
"Venerdì",
|
||||
"Sabato"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "esecuzione saltata",
|
||||
"reasonStoppedContainer": "Il contenitore è fermo; l'aggiornamento pianificato è stato saltato.",
|
||||
"reasonStoppedContainerHistorical": "Il contenitore era fermo all'ora dell'aggiornamento pianificato."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron host rilevato",
|
||||
@@ -1718,7 +1737,17 @@
|
||||
"portCategoryCustomPlaceholder": "Digita una categoria e premi Enter (Esc per annullare)",
|
||||
"portCustomUrlPlaceholder": "URL personalizzato (ad es. https://vault.example.com) — sostituisce IP:port",
|
||||
"stackMemberTitle": "Parte di un'applicazione multi-container",
|
||||
"stackMemberBody": "Questo container supporta un’applicazione il cui container principale è {primary}. L’applicazione, i suoi collegamenti web e la sua versione sono mostrati lì."
|
||||
"stackMemberBody": "Questo container supporta un’applicazione il cui container principale è {primary}. L’applicazione, i suoi collegamenti web e la sua versione sono mostrati lì.",
|
||||
"nativeEvidencePort": "Porta TCP in ascolto",
|
||||
"nativeEvidenceFile": "File installato",
|
||||
"nativeEvidenceProcess": "Eseguibile in esecuzione",
|
||||
"nativeEvidenceDirectory": "Directory di lavoro",
|
||||
"nativeEvidenceService": "Definizione del servizio",
|
||||
"nativeScanFailed": "La ricerca delle applicazioni non è stata completata. I risultati precedenti potrebbero non essere aggiornati; riprova.",
|
||||
"nativeScanPartial": "La ricerca delle applicazioni è incompleta. Controlla gli elementi trovati e riprova.",
|
||||
"nativeVersionUnknown": "Versione installata sconosciuta",
|
||||
"nativeEvidenceStale": "Gli elementi trovati potrebbero non essere aggiornati o essere incompleti.",
|
||||
"nativeRegisterOnly": "Conferma la registrazione nell'editor. Una versione nota viene salvata come valore manuale; non viene configurato alcun aggiornamento automatico."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "Filtra macchine virtuali e container",
|
||||
@@ -1814,7 +1843,8 @@
|
||||
"logs": "Log di sistema",
|
||||
"updates": "Aggiornamenti di sistema",
|
||||
"security": "Sicurezza e certificati",
|
||||
"temperature": "Temperatura"
|
||||
"temperature": "Temperatura",
|
||||
"pbsServices": "Servizi PBS"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -2108,7 +2138,13 @@
|
||||
"inactiveFooter": "Abilita le notifiche per ricevere avvisi di sistema, integrità e sicurezza attraverso i canali configurati.",
|
||||
"notificationLanguage": "Lingua notifiche",
|
||||
"selectNotificationLanguage": "Seleziona lingua notifiche",
|
||||
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata."
|
||||
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata.",
|
||||
"appriseServiceHelp": "Configurazione di Slack e Matrix",
|
||||
"appriseSlackSteps": "Crea un webhook in ingresso nell'app Slack per il canale di destinazione, quindi incolla il suo URL HTTPS completo nel campo URL di Apprise. Non è necessaria alcuna conversione.",
|
||||
"appriseSlackDocs": "Formati URL e configurazione di Slack",
|
||||
"appriseMatrixSteps": "Usa un account di notifica autorizzato a pubblicare nella stanza. Sostituisci utente, password, homeserver e ID completo della stanza dell'esempio; codifica per URL i caratteri speciali nelle credenziali. Lo schema matrixs usa HTTPS. Consulta la documentazione per il supporto di token, SSO e stanze cifrate nella versione di Apprise installata.",
|
||||
"appriseMatrixDocs": "Formati URL e configurazione di Matrix",
|
||||
"appriseServiceFinish": "Abilita Apprise, inserisci il tuo URL, scegli le categorie di notifica o i singoli eventi, salva e poi usa Invia test. Questi esempi non modificano l'URL salvato. Questo canale Apprise ha una sola destinazione; usa l'API Apprise per più servizi contemporaneamente."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Miglioramento con IA",
|
||||
@@ -4753,6 +4789,24 @@
|
||||
"thursday": "Giovedì",
|
||||
"tuesday": "Martedì",
|
||||
"wednesday": "Mercoledì"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Conservazione dell'archiviazione",
|
||||
"keepAll": "Mantieni tutti i backup",
|
||||
"description": "Conservazione predefinita dei backup salvata in Proxmox. Le attività di backup con una politica esplicita hanno la precedenza su questo valore.",
|
||||
"refresh": "Aggiorna",
|
||||
"saved": "Politica di conservazione dell'archiviazione salvata. Non è stata eseguita alcuna pulizia.",
|
||||
"choosePolicy": "Scegli Mantieni tutti oppure almeno un numero di conservazione maggiore di zero.",
|
||||
"saveFailed": "Impossibile salvare la politica di conservazione dell'archiviazione. Aggiorna l'elenco se la configurazione è cambiata.",
|
||||
"loadFailed": "Impossibile caricare la politica di conservazione dell'archiviazione.",
|
||||
"empty": "Nessuno spazio di archiviazione dei backup trovato.",
|
||||
"default": "Valore predefinito di Proxmox",
|
||||
"edit": "Modifica",
|
||||
"editTitle": "Conservazione per {storage}",
|
||||
"effect": "Si applica alle future esecuzioni dei backup che ereditano questa politica di archiviazione. Il salvataggio non elimina i backup esistenti. I backup protetti rimangono protetti.",
|
||||
"cancel": "Annulla",
|
||||
"save": "Salva conservazione",
|
||||
"useDefault": "Usa l'impostazione predefinita di Proxmox (nessuna policy su questo storage)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4783,7 +4837,8 @@
|
||||
"services": "Servizi PVE",
|
||||
"logs": "Log di sistema",
|
||||
"updates": "Aggiornamenti di sistema",
|
||||
"security": "Sicurezza e certificati"
|
||||
"security": "Sicurezza e certificati",
|
||||
"pbsServices": "servizi PBS"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "Utilizzo CPU",
|
||||
@@ -7227,5 +7282,99 @@
|
||||
"diagnosticsOmitted": "Righe o testo diagnostico omessi: {count}. Il report originale è conservato."
|
||||
}
|
||||
}
|
||||
},
|
||||
"pbs": {
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "Server PBS",
|
||||
"chooseServer": "Scegli un server PBS",
|
||||
"local": "PBS locale",
|
||||
"refresh": "Aggiorna",
|
||||
"manage": "Gestisci le connessioni PBS",
|
||||
"loadFailed": "Impossibile caricare lo stato di PBS.",
|
||||
"loading": "Caricamento dei dati PBS…",
|
||||
"noServer": "Aggiungi una connessione PBS per visualizzare datastore, attività e pianificazioni.",
|
||||
"permissionsHelp": "Abilita l'autenticazione di Monitor prima di salvare le credenziali. Usa un token API PBS dedicato con il ruolo Audit su / sia nelle ACL dell'utente sia in quelle del token, con propagazione. Questo pannello legge soltanto i dati PBS.",
|
||||
"editConnection": "Connessione da modificare",
|
||||
"add": "Aggiungi una nuova connessione",
|
||||
"name": "Nome",
|
||||
"url": "URL HTTPS (includere :8007 per la porta nativa di PBS)",
|
||||
"token_id": "ID del token API (user@realm!token)",
|
||||
"token_secret": "Segreto del token API (lasciare vuoto per mantenere quello salvato)",
|
||||
"fingerprint": "Impronta SHA256 del certificato (facoltativa)",
|
||||
"tlsHelp": "Lascia vuota l'impronta per la normale verifica dell'autorità di certificazione e del nome host. Per un certificato autofirmato, copia la sua impronta SHA256 dalla console PBS o da un altro canale attendibile. L'impronta viene verificata prima dell'invio delle credenziali.",
|
||||
"saved": "Connessione PBS verificata e salvata.",
|
||||
"saveFailed": "Impossibile salvare la connessione PBS.",
|
||||
"testing": "Verifica della connessione…",
|
||||
"save": "Verifica e salva",
|
||||
"remove": "Rimuovi connessione",
|
||||
"removeConfirm": "Rimuovere da Monitor questa connessione PBS salvata e il suo token? I dati e le attività PBS non vengono modificati.",
|
||||
"sampled": "Rilevato alle",
|
||||
"datastores": "Datastore",
|
||||
"total": "Capacità del filesystem",
|
||||
"used": "Usato",
|
||||
"free": "Disponibile",
|
||||
"noDatastores": "Nessun datastore accessibile. Controlla la configurazione dei datastore e i permessi dell'utente e del token.",
|
||||
"tasks": "Attività recenti",
|
||||
"noTasks": "Non sono state restituite attività recenti.",
|
||||
"task": "Attività",
|
||||
"started": "Avviata",
|
||||
"status": "Stato",
|
||||
"running": "In esecuzione",
|
||||
"jobs": "Pianificazioni di manutenzione",
|
||||
"job": "Attività pianificata",
|
||||
"schedule": "Pianificazione",
|
||||
"sync": "Sincronizzazione",
|
||||
"verify": "Verifica",
|
||||
"prune": "Pulizia",
|
||||
"overview": "Panoramica",
|
||||
"logs": "Registri di sistema",
|
||||
"terminal": "Terminale",
|
||||
"security": "Sicurezza",
|
||||
"logout": "Disconnetti",
|
||||
"navigation": "Navigazione PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memoria",
|
||||
"uptime": "Tempo di attività",
|
||||
"capacityUnavailable": "Capacità non disponibile.",
|
||||
"datastoresUnavailable": "{count} non disponibile",
|
||||
"datastoresOnline": "Tutti disponibili",
|
||||
"moreDatastores": "+{count} altri archivi dati",
|
||||
"estimatedFull": "Pieno stimato",
|
||||
"estimatedFullUnknown": "dati non ancora sufficienti",
|
||||
"duration": "Durata",
|
||||
"noJobs": "non è configurata alcuna pianificazione di sincronizzazione, verifica o eliminazione."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "cluster",
|
||||
"standalone": "nodi autonomi",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "questo nodo",
|
||||
"checking": "Controllo dei nodi…",
|
||||
"loadFailed": "impossibile caricare l'elenco dei nodi.",
|
||||
"empty": "Ancora nessun altro nodo. Aggiungine uno per aprire il suo Monitor da qui.",
|
||||
"noResponse": "Nessuna risposta",
|
||||
"pbsInterface": "interfaccia PBS",
|
||||
"openNewTab": "apri in una nuova scheda",
|
||||
"manage": "aggiungi o gestisci nodi...",
|
||||
"dialogTitle": "nodi collegati",
|
||||
"dialogDescription": "I nodi del cluster e i Proxmox Backup Server configurati vengono rilevati automaticamente. Ogni Monitor mantiene il proprio accesso.",
|
||||
"addTitle": "aggiungi un nodo",
|
||||
"name": "Nome",
|
||||
"address": "monitora l'indirizzo",
|
||||
"type": "digita",
|
||||
"add": "aggiungi",
|
||||
"save": "salva",
|
||||
"remove": "rimuovere",
|
||||
"visible": "visibile",
|
||||
"autoAddress": "automatico",
|
||||
"detected": "rilevato",
|
||||
"added": "aggiunto",
|
||||
"saveFailed": "impossibile salvare l'elenco dei nodi.",
|
||||
"backTo": "torna a {name}",
|
||||
"cameFromTitle": "aggiungi il nodo Monitor?",
|
||||
"cameFromBody": "aggiungi {name} ai nodi collegati di questo monitor.",
|
||||
"notNow": "Non ora"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Desconhecido",
|
||||
"notAvailable": "N/D",
|
||||
"serverOffline": "Servidor off-line",
|
||||
"supportProject": "Apoie e contribua para o projeto"
|
||||
"supportProject": "Apoie e contribua para o projeto",
|
||||
"descriptionPbs": "Painel do Proxmox Backup Server"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/D",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} em execução",
|
||||
"runningCountFew": "{count} em execução",
|
||||
"runningCountMany": "{count} em execução"
|
||||
"runningCountMany": "{count} em execução",
|
||||
"pbsVersion": "Versão PBS:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Carregando dados de armazenamento...",
|
||||
@@ -353,7 +355,7 @@
|
||||
"downloadJson": "Baixar JSON",
|
||||
"delete": "Excluir",
|
||||
"confirmDelete": "Excluir este registro de teste?",
|
||||
"note": "Os resultados dos testes são armazenados localmente e usados para gerar relatórios SMART detalhados."
|
||||
"note": "Os resultados dos testes são armazenados localmente e usados para gerar relatórios SMART detalhados."
|
||||
},
|
||||
"smartReport": {
|
||||
"title": "Relatório de saúde SMART",
|
||||
@@ -595,8 +597,8 @@
|
||||
"availableSpare": "Porcentagem de blocos NAND sobressalentes restantes para substituição de blocos defeituosos. Um alerta é acionado abaixo do limite.",
|
||||
"availableSpareThreshold": "Mínimo definido pelo fabricante para peças sobressalentes disponíveis. Abaixo disso, a unidade sinaliza um aviso crítico.",
|
||||
"percentageUsed": "Impulsione a estimativa da resistência consumida com base nos ciclos de gravação reais versus nominais. 100% significa que o TBW avaliado foi atingido; a unidade pode continuar funcionando além disso.",
|
||||
"mediaErrors": "Erros de leitura/gravação irrecuperáveis no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
|
||||
"mediaIntegrityErrors": "Erros irrecuperáveis detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
|
||||
"mediaErrors": "Erros de leitura/gravação irrecuperáveis no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
|
||||
"mediaIntegrityErrors": "Erros irrecuperáveis detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
|
||||
"unsafeShutdowns": "Perdas de energia sem descarga ou desligamento adequado. Contagens muito altas correm o risco de corrupção de metadados e problemas de firmware.",
|
||||
"nvmePowerCycles": "Ciclos totais de ligar/desligar. A ciclagem frequente aumenta o desgaste do conector e do capacitor.",
|
||||
"nvmePowerOnHours": "Total de horas acumuladas em que o inversor esteve ligado desde a fabricação.",
|
||||
@@ -624,7 +626,7 @@
|
||||
"reallocatedSectorCount": "Setores defeituosos substituídos por setores sobressalentes do pool de reserva. Uma contagem crescente significa degradação da unidade.",
|
||||
"retiredBlockCount": "Blocos NAND retirados devido a desgaste ou falha (SSD). Semelhante à contagem de setores realocados para HDDs.",
|
||||
"reallocatedEventCount": "Número de operações de remapeamento realizadas. Cada evento significa que um setor defeituoso foi substituído.",
|
||||
"currentPendingSector": "Setores instáveis esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
|
||||
"currentPendingSector": "Setores instáveis esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
|
||||
"pendingSectors": "Setores esperando para serem remapeados. Eles podem ser resolvidos ou realocados.",
|
||||
"offlineUncorrectable": "Setores que falharam durante a verificação offline e não puderam ser corrigidos. Indica possível perda de dados.",
|
||||
"offlineUncorrectableSectorCount": "Setores incorrigíveis encontrados durante a verificação em segundo plano. Os dados sobre estes setores são perdidos.",
|
||||
@@ -667,7 +669,7 @@
|
||||
"endToEndError": "Corrupção de dados detectada entre o cache do controlador e a interface do host. Deve ser sempre zero.",
|
||||
"endToEndErrorDetectionCount": "Número de erros de paridade no caminho de dados. Diferente de zero indica problemas no controlador.",
|
||||
"wearLevelingCount": "Média de ciclos de apagamento por bloco NAND (SSD). VALOR mais baixo significa mais desgaste consumido.",
|
||||
"wearRangeDelta": "Diferença entre blocos mais usados e menos usados (SSD). Valores altos indicam desgaste irregular.",
|
||||
"wearRangeDelta": "Diferença entre blocos mais usados e menos usados (SSD). Valores altos indicam desgaste irregular.",
|
||||
"mediaWearoutIndicator": "Estimativa de vida útil restante do SSD Intel. Começa em 100 e diminui para 0 conforme a resistência é consumida.",
|
||||
"ssdLifeLeft": "Porcentagem estimada de vida útil restante do SSD com base no desgaste da NAND.",
|
||||
"percentLifetimeRemain": "Porcentagem estimada de vida útil restante. 100 significa novo; 0 significa fim da vida útil nominal.",
|
||||
@@ -744,7 +746,7 @@
|
||||
"nvmeWarningTitle": "Aviso crítico NVMe ativo (0x{value})",
|
||||
"nvmeWarningText": "O controlador NVMe gerou um alerta. Faça backup dos dados imediatamente e investigue mais a fundo.",
|
||||
"nvmeMediaTitle": "Erros de mídia NVMe detectados ({count})",
|
||||
"nvmeMediaText": "Erros irrecuperáveis de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
|
||||
"nvmeMediaText": "Erros irrecuperáveis de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
|
||||
"unsafeTitle": "Contagem alta de desligamentos inseguros ({count})",
|
||||
"unsafeText": "Perdas frequentes de energia aumentam o risco de corrupção do firmware. Garanta energia estável ou use um UPS.",
|
||||
"seagateTitle": "Seagate Raw_Read_Error_Rate - Comportamento normal",
|
||||
@@ -1084,7 +1086,7 @@
|
||||
"start": "Iniciar",
|
||||
"shutdown": "Desligar",
|
||||
"reboot": "Reiniciar",
|
||||
"forceStop": "Forçar parada",
|
||||
"forceStop": "Forçar paragem",
|
||||
"working": "Processando...",
|
||||
"startPending": "Iniciando...",
|
||||
"shutdownPending": "Desligando...",
|
||||
@@ -1092,7 +1094,7 @@
|
||||
"stopPending": "Parando..."
|
||||
},
|
||||
"confirm": {
|
||||
"forceStopTitle": "Forçar parada",
|
||||
"forceStopTitle": "Forçar paragem",
|
||||
"rebootTitle": "Reinício",
|
||||
"forceStopDescription": "Isso ignora a sequência de desligamento do sistema operacional convidado e pode corromper bancos de dados ou sistemas de arquivos em execução. O convidado é morto imediatamente.",
|
||||
"rebootDescription": "Isso força uma reinicialização sem esperar que o sistema operacional convidado libere as gravações pendentes. Use um desligamento elegante quando possível.",
|
||||
@@ -1110,7 +1112,21 @@
|
||||
"available": "Backups disponíveis",
|
||||
"loading": "Carregando backups...",
|
||||
"empty": "Nenhum backup encontrado",
|
||||
"emptyHint": "Crie seu primeiro backup usando o botão acima"
|
||||
"emptyHint": "Crie seu primeiro backup usando o botão acima",
|
||||
"refresh": "Atualizar",
|
||||
"refreshFailed": "Não foi possível atualizar as cópias de segurança. Atualize novamente para confirmar o seu estado atual.",
|
||||
"deleteSelected": "Eliminar selecionadas ({count})",
|
||||
"deletedCount": "{count} cópia(s) de segurança eliminada(s).",
|
||||
"unconfirmedCount": "A eliminação de {count} cópia(s) de segurança não foi confirmada. Atualize a lista e verifique as tarefas do Proxmox antes de tentar novamente.",
|
||||
"deleteFailed": "Não foi possível confirmar a eliminação. Atualize a lista e verifique as tarefas do Proxmox antes de tentar novamente.",
|
||||
"selectHint": "Selecione cópias de segurança sem proteção (até 20 por lote)",
|
||||
"selectBackup": "Selecionar cópia de segurança",
|
||||
"protected": "Protegida",
|
||||
"confirmTitle": "Eliminar {count} cópia(s) de segurança?",
|
||||
"confirmDescription": "Estes arquivos de cópia de segurança da VM/CT {vmid} serão eliminados permanentemente. As cópias protegidas estão excluídas. Isto não elimina o convidado.",
|
||||
"deleting": "À espera que o Proxmox termine de eliminar os arquivos selecionados…",
|
||||
"cancel": "Cancelar",
|
||||
"confirmDelete": "Eliminar cópias de segurança"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Atualizações de pacote pendentes",
|
||||
@@ -1302,7 +1318,10 @@
|
||||
"Quinta-feira",
|
||||
"Sexta-feira",
|
||||
"Sábado"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "execução ignorada",
|
||||
"reasonStoppedContainer": "O contêiner está parado; a atualização agendada foi ignorada.",
|
||||
"reasonStoppedContainerHistorical": "O contêiner estava parado no horário da atualização agendada."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "cron do host detectado",
|
||||
@@ -1718,7 +1737,17 @@
|
||||
"portCategoryCustomPlaceholder": "Digite uma categoria e pressione Enter (Esc para cancelar)",
|
||||
"portCustomUrlPlaceholder": "URL personalizada (por exemplo, https://vault.example.com) — substitui IP:port",
|
||||
"stackMemberTitle": "Parte de um aplicativo multicontêiner",
|
||||
"stackMemberBody": "Este contêiner faz parte de um aplicativo cujo contêiner principal é {primary}. O aplicativo, seus links da web e sua versão são mostrados lá."
|
||||
"stackMemberBody": "Este contêiner faz parte de um aplicativo cujo contêiner principal é {primary}. O aplicativo, seus links da web e sua versão são mostrados lá.",
|
||||
"nativeEvidencePort": "Porta TCP em escuta",
|
||||
"nativeEvidenceFile": "Arquivo instalado",
|
||||
"nativeEvidenceProcess": "Executável em execução",
|
||||
"nativeEvidenceDirectory": "Diretório de trabalho",
|
||||
"nativeEvidenceService": "Definição do serviço",
|
||||
"nativeScanFailed": "A pesquisa de aplicativos não pôde ser concluída. Os resultados anteriores podem estar desatualizados; tente novamente.",
|
||||
"nativeScanPartial": "A pesquisa de aplicativos está incompleta. Revise os itens encontrados e tente novamente.",
|
||||
"nativeVersionUnknown": "Versão instalada desconhecida",
|
||||
"nativeEvidenceStale": "Os itens encontrados podem estar desatualizados ou incompletos.",
|
||||
"nativeRegisterOnly": "Confirme o registro no editor. Uma versão conhecida é salva como valor manual; nenhum atualizador é configurado."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "Filtrar máquinas virtuais e contêineres",
|
||||
@@ -1768,7 +1797,7 @@
|
||||
},
|
||||
"healthMonitor": {
|
||||
"title": "Monitor de Saúde",
|
||||
"description": "Configure por quanto tempo os alertas dispensados permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
|
||||
"description": "Configure por quanto tempo os alertas dispensados permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
|
||||
"category": "Categoria",
|
||||
"suppressionDuration": "Duração da Supressão",
|
||||
"hours": "Horas",
|
||||
@@ -1778,7 +1807,7 @@
|
||||
"footerNote": "Essas configurações se aplicam quando você descarta um aviso do Health Monitor. Alertas críticos de temperatura da CPU sempre aparecem para proteger seu hardware.",
|
||||
"activeSuppressions": "Supressões Ativas",
|
||||
"activeSuppressionsDescription": "Alertas que você ocultou do Health Monitor. As demissões permanentes só podem ser revertidas aqui. A edição requer o Health Monitor",
|
||||
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados do Health Monitor aparecerão aqui.",
|
||||
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados do Health Monitor aparecerão aqui.",
|
||||
"permanent": "Permanente",
|
||||
"daysRemaining": "{count}d restantes",
|
||||
"hoursRemaining": "{count}h restantes",
|
||||
@@ -1814,7 +1843,8 @@
|
||||
"logs": "Registros do sistema",
|
||||
"updates": "Atualizações do sistema",
|
||||
"security": "Segurança e Certificados",
|
||||
"temperature": "Temperatura"
|
||||
"temperature": "Temperatura",
|
||||
"pbsServices": "Serviços PBS"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -2078,8 +2108,8 @@
|
||||
"port": "Porta",
|
||||
"tlsMode": "Modo TLS",
|
||||
"noTls": "Nenhum (porta 25)",
|
||||
"username": "Nome de usuário",
|
||||
"password": "Senha",
|
||||
"username": "Nome de utilizador",
|
||||
"password": "Palavra-passe",
|
||||
"appPassword": "Senha do aplicativo",
|
||||
"fromAddress": "Do endereço",
|
||||
"toAddresses": "Para endereços (separados por vírgula)",
|
||||
@@ -2108,7 +2138,13 @@
|
||||
"inactiveFooter": "Habilite notificações para receber alertas de sistema, integridade e segurança por meio dos canais configurados.",
|
||||
"notificationLanguage": "Idioma de notificação",
|
||||
"selectNotificationLanguage": "Selecione o idioma de notificação",
|
||||
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada."
|
||||
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada.",
|
||||
"appriseServiceHelp": "Configuração do Slack e do Matrix",
|
||||
"appriseSlackSteps": "Crie um webhook de entrada na sua aplicação Slack para o canal de destino e cole o seu URL HTTPS completo no campo URL do Apprise. Não é necessária qualquer conversão.",
|
||||
"appriseSlackDocs": "Formatos de URL e configuração do Slack",
|
||||
"appriseMatrixSteps": "Utilize uma conta de notificações que possa publicar na sala. Substitua o utilizador, a palavra-passe, o homeserver e o ID completo da sala do exemplo; codifique os caracteres especiais das credenciais para URL. O esquema matrixs usa HTTPS. Consulte a documentação sobre o suporte de tokens, SSO e salas cifradas na versão do Apprise instalada.",
|
||||
"appriseMatrixDocs": "Formatos de URL e configuração do Matrix",
|
||||
"appriseServiceFinish": "Ative o Apprise, introduza o seu URL, escolha as categorias de notificação ou os eventos individuais, guarde e utilize Enviar teste. Estes exemplos não alteram o URL guardado. Este canal do Apprise tem um destino; utilize a API do Apprise para vários serviços em simultâneo."
|
||||
},
|
||||
"ai": {
|
||||
"title": "Aprimoramento de IA",
|
||||
@@ -2333,8 +2369,8 @@
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "Faça login para acessar seu painel",
|
||||
"username": "Nome de usuário",
|
||||
"password": "Senha",
|
||||
"username": "Nome de utilizador",
|
||||
"password": "Palavra-passe",
|
||||
"usernamePlaceholder": "Digite seu nome de usuário",
|
||||
"passwordPlaceholder": "Digite sua senha",
|
||||
"rememberMe": "Lembrar",
|
||||
@@ -2375,9 +2411,9 @@
|
||||
"skipFailed": "Falha ao ignorar a autenticação",
|
||||
"savePreferenceFailed": "Falha ao salvar preferência",
|
||||
"setupFailed": "Falha ao configurar a autenticação",
|
||||
"username": "Nome de usuário",
|
||||
"username": "Nome de utilizador",
|
||||
"usernamePlaceholder": "Digite o nome de usuário",
|
||||
"password": "Senha",
|
||||
"password": "Palavra-passe",
|
||||
"passwordPlaceholder": "Digite a senha",
|
||||
"confirmPassword": "Confirme sua senha",
|
||||
"confirmPasswordPlaceholder": "Confirme sua senha",
|
||||
@@ -2520,9 +2556,9 @@
|
||||
"enableHint": "Adicione um nome de usuário e uma senha antes de expor este painel fora da sua rede privada.",
|
||||
"enable": "Habilitar autenticação",
|
||||
"setupTitle": "Criar login",
|
||||
"username": "Nome de usuário",
|
||||
"username": "Nome de utilizador",
|
||||
"usernamePlaceholder": "administrador",
|
||||
"password": "Senha",
|
||||
"password": "Palavra-passe",
|
||||
"passwordPlaceholder": "Digite a senha",
|
||||
"confirmPassword": "Confirme sua senha",
|
||||
"confirmPasswordPlaceholder": "Digite a senha novamente",
|
||||
@@ -2542,7 +2578,7 @@
|
||||
"twoFactor": {
|
||||
"title": "Autenticação de dois fatores",
|
||||
"hint": "Adicione um código único de um aplicativo autenticador após a senha.",
|
||||
"enable": "Habilitar 2FA",
|
||||
"enable": "Ativar 2FA",
|
||||
"enabled": "2FA está habilitado.",
|
||||
"disable": "Desativar 2FA",
|
||||
"disableTitle": "Desative a autenticação de dois fatores",
|
||||
@@ -3564,7 +3600,7 @@
|
||||
"hour": "Passada 1h",
|
||||
"day": "Passadas 24 horas",
|
||||
"week": "Passado 7 dias",
|
||||
"month": "Passados 30 dias",
|
||||
"month": "Passados 30 dias",
|
||||
"year": "Último 1 ano"
|
||||
},
|
||||
"last": {
|
||||
@@ -4016,7 +4052,7 @@
|
||||
},
|
||||
"backends": {
|
||||
"borgDescription": "Repositório Borg para backups de host desduplicados.",
|
||||
"borgDescriptionTimerOnly": "Os destinos Borg são usados pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
|
||||
"borgDescriptionTimerOnly": "Os destinos Borg são usados pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
|
||||
"borgShortDescription": "Repositório Borg desduplicado",
|
||||
"local": "Local",
|
||||
"localDescription": "Pasta neste host ou em uma unidade USB montada.",
|
||||
@@ -4247,7 +4283,7 @@
|
||||
"onCalendarExpression": "Expressão OnCalendar",
|
||||
"ownerLabel": "Proprietário",
|
||||
"passphrase": "Senha",
|
||||
"password": "Senha",
|
||||
"password": "Palavra-passe",
|
||||
"pathLabel": "Caminho",
|
||||
"pbsRepository": "Repositório PBS",
|
||||
"profileLabel": "Perfil",
|
||||
@@ -4280,7 +4316,7 @@
|
||||
"timeOfDay": "Hora do dia",
|
||||
"type": "Tipo",
|
||||
"used": "Usado",
|
||||
"username": "Nome de usuário",
|
||||
"username": "Nome de utilizador",
|
||||
"vmsLxcs": "VMs/LXCs",
|
||||
"when": "Quando",
|
||||
"whenLabel": "Quando",
|
||||
@@ -4544,7 +4580,7 @@
|
||||
"customPickDescriptionMiddle": "através do",
|
||||
"destructiveTitle": "A restauração completa REMOVERÁ o seguinte (criado após o backup)",
|
||||
"noPathsMatch": "Nenhum caminho corresponde ao filtro.",
|
||||
"noRestorablePaths": "Este backup não contém caminhos restauráveis — apenas a restauração completa é significativa aqui.",
|
||||
"noRestorablePaths": "Este backup não contém caminhos restauráveis — apenas a restauração completa é significativa aqui.",
|
||||
"olderThanTargetKernel": "é mais antigo que o kernel alvo",
|
||||
"operatorConfigDescription": "As seguintes configurações criadas pelo operador serão mescladas no destino automaticamente, sem alterar os padrões vinculados ao kernel. É executado na restauração completa e personalizada.",
|
||||
"operatorConfigTitle": "Configuração do operador reaplicada por meio de mesclagem independente de kernel",
|
||||
@@ -4753,6 +4789,24 @@
|
||||
"thursday": "Quinta-feira",
|
||||
"tuesday": "Terça-feira",
|
||||
"wednesday": "Quarta-feira"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Retenção do armazenamento",
|
||||
"keepAll": "Manter todas as cópias de segurança",
|
||||
"description": "Retenção predefinida de cópias de segurança guardada no Proxmox. As tarefas de cópia com uma política explícita substituem este valor predefinido.",
|
||||
"refresh": "Atualizar",
|
||||
"saved": "Retenção do armazenamento guardada. Não foi executada nenhuma limpeza.",
|
||||
"choosePolicy": "Escolha Manter todas ou pelo menos uma quantidade de retenção superior a zero.",
|
||||
"saveFailed": "Não foi possível guardar a retenção do armazenamento. Atualize a lista se a configuração tiver mudado.",
|
||||
"loadFailed": "Não foi possível carregar a retenção do armazenamento.",
|
||||
"empty": "Não foram encontrados armazenamentos de cópias de segurança.",
|
||||
"default": "Predefinição do Proxmox",
|
||||
"edit": "Editar",
|
||||
"editTitle": "Retenção para {storage}",
|
||||
"effect": "Aplica-se às futuras cópias de segurança que herdem esta política de armazenamento. Guardar não elimina as cópias existentes. As cópias protegidas permanecem protegidas.",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar retenção",
|
||||
"useDefault": "Use o padrão Proxmox (nenhuma política neste armazenamento)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4783,7 +4837,8 @@
|
||||
"services": "Serviços PVE",
|
||||
"logs": "Registros do sistema",
|
||||
"updates": "Atualizações do sistema",
|
||||
"security": "Segurança e certificados"
|
||||
"security": "Segurança e certificados",
|
||||
"pbsServices": "serviços PBS"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "Uso da CPU",
|
||||
@@ -4909,7 +4964,7 @@
|
||||
},
|
||||
"noIssues": "Nenhum problema detectado",
|
||||
"updateNow": "Atualizar agora",
|
||||
"dismissedItems": "Itens dispensados ({count})",
|
||||
"dismissedItems": "Itens dispensados ({count})",
|
||||
"permanent": "Permanente",
|
||||
"dismissed": "Dispensado",
|
||||
"wasStatus": "era {status}",
|
||||
@@ -4946,7 +5001,7 @@
|
||||
"hint": "PNG, JPEG, WebP ou GIF. Até 2 MB. Use uma imagem quadrada ou corte-a primeiro para obter o melhor resultado no cabeçalho."
|
||||
},
|
||||
"username": {
|
||||
"label": "Nome de usuário",
|
||||
"label": "Nome de utilizador",
|
||||
"help": "Este é o nome de login. Para alterá-lo, desative a autenticação e configure-a novamente em Segurança."
|
||||
},
|
||||
"displayName": {
|
||||
@@ -7179,7 +7234,7 @@
|
||||
"latency": "Latência",
|
||||
"event": "Evento",
|
||||
"sourceIp": "IP de origem",
|
||||
"username": "Nome de usuário",
|
||||
"username": "Nome de utilizador",
|
||||
"service": "Serviço",
|
||||
"jail": "Jail",
|
||||
"failures": "Falhas",
|
||||
@@ -7227,5 +7282,99 @@
|
||||
"diagnosticsOmitted": "Linhas ou texto de diagnóstico omitidos: {count}. Relatório original preservado."
|
||||
}
|
||||
}
|
||||
},
|
||||
"pbs": {
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "Servidor PBS",
|
||||
"chooseServer": "Escolher um servidor PBS",
|
||||
"local": "PBS local",
|
||||
"refresh": "Atualizar",
|
||||
"manage": "Gerir ligações PBS",
|
||||
"loadFailed": "Não foi possível carregar o estado do PBS.",
|
||||
"loading": "A carregar dados do PBS…",
|
||||
"noServer": "Adicione uma ligação PBS para ver os armazenamentos de dados, tarefas e agendamentos.",
|
||||
"permissionsHelp": "Ative a autenticação do Monitor antes de guardar credenciais. Utilize um token API PBS dedicado com a função Audit em / nas ACL do utilizador e do token, com propagação. Este painel apenas lê dados do PBS.",
|
||||
"editConnection": "Ligação a editar",
|
||||
"add": "Adicionar uma nova ligação",
|
||||
"name": "Nome",
|
||||
"url": "URL HTTPS (inclua :8007 para a porta nativa do PBS)",
|
||||
"token_id": "ID do token API (user@realm!token)",
|
||||
"token_secret": "Segredo do token API (deixe vazio para manter o segredo guardado)",
|
||||
"fingerprint": "Impressão digital SHA256 do certificado (opcional)",
|
||||
"tlsHelp": "Deixe a impressão digital vazia para a verificação normal da autoridade de certificação e do nome de anfitrião. Para um certificado autoassinado, copie a sua impressão digital SHA256 da consola PBS ou de outro canal de confiança. A impressão digital é verificada antes do envio de credenciais.",
|
||||
"saved": "Ligação PBS verificada e guardada.",
|
||||
"saveFailed": "Não foi possível guardar a ligação PBS.",
|
||||
"testing": "A verificar a ligação…",
|
||||
"save": "Verificar e guardar",
|
||||
"remove": "Remover ligação",
|
||||
"removeConfirm": "Remover do Monitor esta ligação PBS guardada e o respetivo token? Os dados e as tarefas PBS não são afetados.",
|
||||
"sampled": "Recolhido em",
|
||||
"datastores": "Armazenamentos de dados",
|
||||
"total": "Capacidade do sistema de ficheiros",
|
||||
"used": "Utilizado",
|
||||
"free": "Disponível",
|
||||
"noDatastores": "Nenhum armazenamento de dados acessível. Verifique a configuração e as permissões do utilizador e do token.",
|
||||
"tasks": "Tarefas recentes",
|
||||
"noTasks": "Não foram devolvidas tarefas recentes.",
|
||||
"task": "Tarefa",
|
||||
"started": "Iniciada",
|
||||
"status": "Estado",
|
||||
"running": "Em execução",
|
||||
"jobs": "Agendamentos de manutenção",
|
||||
"job": "Tarefa agendada",
|
||||
"schedule": "Agendamento",
|
||||
"sync": "Sincronização",
|
||||
"verify": "Verificação",
|
||||
"prune": "Limpeza",
|
||||
"overview": "Visão geral",
|
||||
"logs": "Registos do sistema",
|
||||
"terminal": "Terminal",
|
||||
"security": "Segurança",
|
||||
"logout": "Terminar sessão",
|
||||
"navigation": "Navegação PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Memória",
|
||||
"uptime": "Tempo de atividade",
|
||||
"capacityUnavailable": "Capacidade não disponível.",
|
||||
"datastoresUnavailable": "{count} indisponível",
|
||||
"datastoresOnline": "Todos disponíveis",
|
||||
"moreDatastores": "+{count} mais armazenamentos de dados",
|
||||
"estimatedFull": "estimativa completa",
|
||||
"estimatedFullUnknown": "Not enough data yet",
|
||||
"duration": "Duração",
|
||||
"noJobs": "nenhuma programação de sincronização, verificação ou remoção está configurada."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"standalone": "nós autônomos",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Este nó",
|
||||
"checking": "Verificando nós…",
|
||||
"loadFailed": "não foi possível carregar a lista de nós.",
|
||||
"empty": "Ainda não há outros nós. Adicione um para abrir o seu Monitor a partir daqui.",
|
||||
"noResponse": "Sem resposta",
|
||||
"pbsInterface": "interface PBS",
|
||||
"openNewTab": "abrir em uma nova guia",
|
||||
"manage": "Adicionar ou gerenciar nós…",
|
||||
"dialogTitle": "nós vinculados",
|
||||
"dialogDescription": "Os nós do cluster e os Proxmox Backup Server configurados são detectados automaticamente. Cada Monitor mantém o seu próprio login.",
|
||||
"addTitle": "Adicionar um nó",
|
||||
"name": "Nome",
|
||||
"address": "endereço do monitor",
|
||||
"type": "Tipo",
|
||||
"add": "Adicionar",
|
||||
"save": "Salvar",
|
||||
"remove": "Remover",
|
||||
"visible": "Visível",
|
||||
"autoAddress": "Automático",
|
||||
"detected": "Detectado",
|
||||
"added": "Adicionado",
|
||||
"saveFailed": "não foi possível salvar a lista de nós.",
|
||||
"backTo": "Voltar para {name}",
|
||||
"cameFromTitle": "Adicionar nó Monitor?",
|
||||
"cameFromBody": "Adicione {name} aos nós vinculados deste Monitor.",
|
||||
"notNow": "agora não"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"unknown": "Neznáme",
|
||||
"notAvailable": "Nedostupné",
|
||||
"serverOffline": "Server je offline",
|
||||
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami"
|
||||
"supportProject": "Podporte projekt alebo prispejte vlastnými úpravami",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "Nedostupné",
|
||||
@@ -164,7 +165,8 @@
|
||||
"errors": {
|
||||
"serverUnavailableTitle": "Server Monitora nie je dostupný",
|
||||
"serverUnavailableDescription": "Nedá sa pripojiť k serveru Monitora. Skontrolujte, či služba beží, a skúste to znova."
|
||||
}
|
||||
},
|
||||
"pbsVersion": "Verzia PBS:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Načítavam úložiská...",
|
||||
@@ -1131,7 +1133,21 @@
|
||||
"available": "Dostupné zálohy",
|
||||
"loading": "Načítavam zálohy...",
|
||||
"empty": "Nenašli sa žiadne zálohy",
|
||||
"emptyHint": "Prvú zálohu vytvoríte tlačidlom vyššie"
|
||||
"emptyHint": "Prvú zálohu vytvoríte tlačidlom vyššie",
|
||||
"refresh": "Obnoviť",
|
||||
"refreshFailed": "Nepodarilo sa obnoviť zálohy. Obnovte ich znova, aby ste potvrdili ich aktuálny stav.",
|
||||
"deleteSelected": "Odstrániť vybrané ({count})",
|
||||
"deletedCount": "Odstránené zálohy: {count}.",
|
||||
"unconfirmedCount": "Odstránenie záloh ({count}) nebolo potvrdené. Pred ďalším pokusom obnovte zoznam a skontrolujte úlohy v Proxmoxe.",
|
||||
"deleteFailed": "Odstránenie sa nepodarilo potvrdiť. Pred ďalším pokusom obnovte zoznam a skontrolujte úlohy v Proxmoxe.",
|
||||
"selectHint": "Vyberte nechránené zálohy (najviac 20 v jednej dávke)",
|
||||
"selectBackup": "Vybrať zálohu",
|
||||
"protected": "Chránená",
|
||||
"confirmTitle": "Odstrániť zálohy ({count})?",
|
||||
"confirmDescription": "Tieto archívy záloh pre VM/CT {vmid} budú natrvalo odstránené. Chránené zálohy sú vylúčené. Hosť sa tým neodstráni.",
|
||||
"deleting": "Čaká sa, kým Proxmox dokončí odstránenie vybraných archívov…",
|
||||
"cancel": "Zrušiť",
|
||||
"confirmDelete": "Odstrániť zálohy"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Čakajúce aktualizácie balíkov",
|
||||
@@ -1288,6 +1304,9 @@
|
||||
"runFailed": "✗ zlyhalo",
|
||||
"runDeferred": "odložené z bezpečnostných dôvodov",
|
||||
"runSkipped": "nič nečaká",
|
||||
"runSkippedStatus": "spustenie bolo preskočené",
|
||||
"reasonStoppedContainer": "Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.",
|
||||
"reasonStoppedContainerHistorical": "Kontajner bol v čase plánovanej aktualizácie vypnutý.",
|
||||
"releaseDelaySummary": "odklad {days} deň/dní",
|
||||
"releaseDelayLabel": "Počkať po novej verzii aplikácie",
|
||||
"releaseDelayNone": "Bez čakacej doby",
|
||||
@@ -1665,6 +1684,16 @@
|
||||
"searchApplications": "Hľadať aplikácie",
|
||||
"searchingApplications": "Hľadajú sa aplikácie…",
|
||||
"noApplicationsDetected": "Neboli zistené žiadne aplikácie.",
|
||||
"nativeEvidencePort": "Otvorený TCP port",
|
||||
"nativeEvidenceFile": "Nainštalovaný súbor",
|
||||
"nativeEvidenceProcess": "Spustený program",
|
||||
"nativeEvidenceDirectory": "Pracovný priečinok",
|
||||
"nativeEvidenceService": "Definícia služby",
|
||||
"nativeScanFailed": "Vyhľadávanie aplikácií sa nepodarilo dokončiť. Predchádzajúce dôkazy môžu byť zastarané; skús to znova.",
|
||||
"nativeScanPartial": "Vyhľadávanie aplikácií je neúplné. Skontroluj dostupné dôkazy a skús to znova.",
|
||||
"nativeVersionUnknown": "Nainštalovaná verzia nie je známa",
|
||||
"nativeEvidenceStale": "Dôkazy môžu byť zastarané alebo neúplné.",
|
||||
"nativeRegisterOnly": "Registráciu potvrď v editore. Známa verzia je ručný údaj; aktualizátor nie je nastavený.",
|
||||
"noNewApplicationsDetected": "Neboli zistené žiadne nové aplikácie.",
|
||||
"oneNewApplicationDetected": "Bola zistená jedna nová aplikácia.",
|
||||
"newApplicationsDetected": "Boli zistené {count} nové aplikácie.",
|
||||
@@ -1813,7 +1842,8 @@
|
||||
"logs": "Systémové záznamy",
|
||||
"updates": "Aktualizácie systému",
|
||||
"security": "Bezpečnosť a certifikáty",
|
||||
"temperature": "Teplota"
|
||||
"temperature": "Teplota",
|
||||
"pbsServices": "Služby PBS"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -2107,7 +2137,13 @@
|
||||
"inactiveFooter": "Zapnite notifikácie a dostávajte upozornenia o systéme, stave a bezpečnosti cez nastavené kanály.",
|
||||
"notificationLanguage": "Jazyk notifikácií",
|
||||
"selectNotificationLanguage": "Vyberte jazyk notifikácií",
|
||||
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá."
|
||||
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá.",
|
||||
"appriseServiceHelp": "Nastavenie Slack a Matrix",
|
||||
"appriseSlackSteps": "V aplikácii Slack vytvorte prichádzajúci webhook pre cieľový kanál a vložte jeho úplnú HTTPS URL do poľa URL Apprise. Nie je potrebný žiadny prevod.",
|
||||
"appriseSlackDocs": "Formáty URL a nastavenie Slack",
|
||||
"appriseMatrixSteps": "Použite účet pre oznámenia, ktorý môže odosielať správy do miestnosti. Nahraďte vzorového používateľa, heslo, domovský server a úplné ID miestnosti; špeciálne znaky v prihlasovacích údajoch zakódujte pre URL. Schéma matrixs používa HTTPS. Podporu tokenov, SSO a šifrovaných miestností v nainštalovanej verzii Apprise nájdete v dokumentácii.",
|
||||
"appriseMatrixDocs": "Formáty URL a nastavenie Matrix",
|
||||
"appriseServiceFinish": "Povoľte Apprise, zadajte svoju URL, vyberte kategórie oznámení alebo jednotlivé udalosti, uložte a potom použite Odoslať test. Tieto príklady nemenia uloženú URL. Tento kanál Apprise má jeden cieľ; pre viac služieb naraz použite API Apprise."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI vylepšenie",
|
||||
@@ -4752,6 +4788,24 @@
|
||||
"thursday": "štvrtok",
|
||||
"tuesday": "utorok",
|
||||
"wednesday": "streda"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Uchovávanie záloh úložiska",
|
||||
"keepAll": "Ponechať všetky zálohy",
|
||||
"description": "Predvolená politika uchovávania záloh uložená v Proxmoxe. Zálohovacie úlohy s vlastnou politikou majú pred týmto predvoleným nastavením prednosť.",
|
||||
"refresh": "Obnoviť",
|
||||
"saved": "Politika uchovávania záloh úložiska bola uložená. Prerezávanie sa nevykonalo.",
|
||||
"choosePolicy": "Vyberte Ponechať všetky alebo aspoň jeden počet uchovávaných záloh väčší ako nula.",
|
||||
"saveFailed": "Nepodarilo sa uložiť politiku uchovávania záloh úložiska. Ak sa konfigurácia zmenila, obnovte zoznam.",
|
||||
"loadFailed": "Nepodarilo sa načítať politiku uchovávania záloh úložiska.",
|
||||
"empty": "Nenašli sa žiadne úložiská záloh.",
|
||||
"default": "Predvolené nastavenie Proxmoxu",
|
||||
"edit": "Upraviť",
|
||||
"editTitle": "Uchovávanie záloh pre {storage}",
|
||||
"effect": "Platí pre budúce zálohovania, ktoré preberajú túto politiku úložiska. Uložením sa existujúce zálohy neodstránia. Chránené zálohy zostanú chránené.",
|
||||
"cancel": "Zrušiť",
|
||||
"save": "Uložiť uchovávanie záloh",
|
||||
"useDefault": "Použite predvolené nastavenie Proxmox (žiadne pravidlá pre toto úložisko)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4782,7 +4836,8 @@
|
||||
"services": "Služby PVE",
|
||||
"logs": "Systémové záznamy",
|
||||
"updates": "Aktualizácie systému",
|
||||
"security": "Bezpečnosť a certifikáty"
|
||||
"security": "Bezpečnosť a certifikáty",
|
||||
"pbsServices": "Služby PBS"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "Využitie CPU",
|
||||
@@ -7226,5 +7281,99 @@
|
||||
"diagnosticsOmitted": "Ďalšie diagnostické riadky alebo text vynechaný: {count}. Pôvodná správa sa zachovala."
|
||||
}
|
||||
}
|
||||
},
|
||||
"pbs": {
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "Server PBS",
|
||||
"chooseServer": "Vybrať server PBS",
|
||||
"local": "Miestny PBS",
|
||||
"refresh": "Obnoviť",
|
||||
"manage": "Spravovať pripojenia PBS",
|
||||
"loadFailed": "Nepodarilo sa načítať stav PBS.",
|
||||
"loading": "Načítavajú sa údaje PBS…",
|
||||
"noServer": "Pridajte pripojenie PBS na zobrazenie dátových úložísk, úloh a plánov.",
|
||||
"permissionsHelp": "Pred uložením prihlasovacích údajov povoľte overovanie v Monitore. Použite vyhradený API token PBS s rolou Audit na / v ACL používateľa aj tokenu, so zapnutým dedením. Tento panel údaje PBS iba číta.",
|
||||
"editConnection": "Pripojenie na úpravu",
|
||||
"add": "Pridať nové pripojenie",
|
||||
"name": "Názov",
|
||||
"url": "HTTPS URL (pre pôvodný port PBS uveďte :8007)",
|
||||
"token_id": "ID API tokenu (user@realm!token)",
|
||||
"token_secret": "Tajný kľúč API tokenu (ponechajte prázdne, ak chcete zachovať uložený kľúč)",
|
||||
"fingerprint": "Odtlačok SHA256 certifikátu (voliteľný)",
|
||||
"tlsHelp": "Pre bežné overenie certifikačnej autority a názvu hostiteľa ponechajte odtlačok prázdny. Pri certifikáte s vlastným podpisom skopírujte jeho odtlačok SHA256 z konzoly PBS alebo iného dôveryhodného kanála. Odtlačok sa kontroluje pred odoslaním prihlasovacích údajov.",
|
||||
"saved": "Pripojenie PBS bolo overené a uložené.",
|
||||
"saveFailed": "Nepodarilo sa uložiť pripojenie PBS.",
|
||||
"testing": "Overuje sa pripojenie…",
|
||||
"save": "Overiť a uložiť",
|
||||
"remove": "Odstrániť pripojenie",
|
||||
"removeConfirm": "Odstrániť z Monitora toto uložené pripojenie PBS a jeho token? Údaje a úlohy PBS zostanú nezmenené.",
|
||||
"sampled": "Údaje získané",
|
||||
"datastores": "Dátové úložiská",
|
||||
"total": "Kapacita súborového systému",
|
||||
"used": "Použité",
|
||||
"free": "Dostupné",
|
||||
"noDatastores": "Žiadne dostupné dátové úložiská. Skontrolujte konfiguráciu úložísk a oprávnenia používateľa a tokenu.",
|
||||
"tasks": "Nedávne úlohy",
|
||||
"noTasks": "Neboli vrátené žiadne nedávne úlohy.",
|
||||
"task": "Úloha",
|
||||
"started": "Spustené",
|
||||
"status": "Stav",
|
||||
"running": "Prebieha",
|
||||
"jobs": "Plány údržby",
|
||||
"job": "Plánovaná úloha",
|
||||
"schedule": "Plán",
|
||||
"sync": "Synchronizácia",
|
||||
"verify": "Overenie",
|
||||
"prune": "Prerezávanie",
|
||||
"overview": "Prehľad",
|
||||
"logs": "Systémové záznamy",
|
||||
"terminal": "Terminál",
|
||||
"security": "Zabezpečenie",
|
||||
"logout": "Odhlásiť sa",
|
||||
"navigation": "Navigácia PBS",
|
||||
"cpu": "CPU",
|
||||
"memory": "Pamäť",
|
||||
"uptime": "Čas prevádzky",
|
||||
"capacityUnavailable": "Kapacita nie je k dispozícii.",
|
||||
"datastoresUnavailable": "{count} nie je k dispozícii",
|
||||
"datastoresOnline": "Všetky dostupné",
|
||||
"moreDatastores": "+{count} ďalších dátových úložísk",
|
||||
"estimatedFull": "Odhadovaný plný",
|
||||
"estimatedFullUnknown": "Zatiaľ nie je dostatok údajov",
|
||||
"duration": "Trvanie",
|
||||
"noJobs": "Nie sú nakonfigurované žiadne plány synchronizácie, overovania ani odstraňovania."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Cluster",
|
||||
"standalone": "Samostatné uzly",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Tento uzol",
|
||||
"checking": "Kontrolujú sa uzly…",
|
||||
"loadFailed": "Nepodarilo sa načítať zoznam uzlov.",
|
||||
"empty": "Zatiaľ žiadne ďalšie uzly. Pridajte jeden, aby ste odtiaľto otvorili jeho Monitor.",
|
||||
"noResponse": "Žiadna odpoveď",
|
||||
"pbsInterface": "rozhranie PBS",
|
||||
"openNewTab": "Otvoriť na novej karte",
|
||||
"manage": "Pridať alebo spravovať uzly…",
|
||||
"dialogTitle": "Prepojené uzly",
|
||||
"dialogDescription": "Uzly klastra a nakonfigurované Proxmox Backup Server sa zisťujú automaticky. Každý Monitor má vlastné prihlásenie.",
|
||||
"addTitle": "Pridať uzol",
|
||||
"name": "Meno",
|
||||
"address": "Adresa monitora",
|
||||
"type": "Typ",
|
||||
"add": "Pridať",
|
||||
"save": "Uložiť",
|
||||
"remove": "Odstrániť",
|
||||
"visible": "Viditeľné",
|
||||
"autoAddress": "Automaticky",
|
||||
"detected": "Zistené",
|
||||
"added": "Pridané",
|
||||
"saveFailed": "Nepodarilo sa uložiť zoznam uzlov.",
|
||||
"backTo": "Späť na {name}",
|
||||
"cameFromTitle": "Pridať uzol monitora?",
|
||||
"cameFromBody": "Pridajte {name} do prepojených uzlov tohto monitora.",
|
||||
"notNow": "Teraz nie"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"unknown": "Okänd",
|
||||
"notAvailable": "N/A",
|
||||
"serverOffline": "Servern är frånkopplad",
|
||||
"supportProject": "Stöd projektet och bidra"
|
||||
"supportProject": "Stöd projektet och bidra",
|
||||
"descriptionPbs": "Proxmox Backup Server Dashboard"
|
||||
},
|
||||
"common": {
|
||||
"notAvailable": "N/A",
|
||||
@@ -165,7 +166,8 @@
|
||||
},
|
||||
"runningCountOne": "{count} körs",
|
||||
"runningCountFew": "{count} körs",
|
||||
"runningCountMany": "{count} körs"
|
||||
"runningCountMany": "{count} körs",
|
||||
"pbsVersion": "PBS-version:"
|
||||
},
|
||||
"storage": {
|
||||
"loadingTitle": "Laddar lagringsdata...",
|
||||
@@ -1110,7 +1112,21 @@
|
||||
"available": "Tillgängliga säkerhetskopior",
|
||||
"loading": "Laddar säkerhetskopior...",
|
||||
"empty": "Inga säkerhetskopior hittades",
|
||||
"emptyHint": "Skapa din första säkerhetskopia med knappen ovan"
|
||||
"emptyHint": "Skapa din första säkerhetskopia med knappen ovan",
|
||||
"refresh": "Uppdatera",
|
||||
"refreshFailed": "Det gick inte att uppdatera säkerhetskopiorna. Uppdatera igen för att bekräfta deras aktuella tillstånd.",
|
||||
"deleteSelected": "Ta bort markerade ({count})",
|
||||
"deletedCount": "{count} säkerhetskopior har tagits bort.",
|
||||
"unconfirmedCount": "Borttagning av {count} säkerhetskopior har inte bekräftats. Uppdatera listan och kontrollera Proxmox uppgifter innan du försöker igen.",
|
||||
"deleteFailed": "Borttagningen kunde inte bekräftas. Uppdatera listan och kontrollera Proxmox uppgifter innan du försöker igen.",
|
||||
"selectHint": "Välj oskyddade säkerhetskopior (upp till 20 per omgång)",
|
||||
"selectBackup": "Välj säkerhetskopia",
|
||||
"protected": "Skyddad",
|
||||
"confirmTitle": "Ta bort {count} säkerhetskopior?",
|
||||
"confirmDescription": "Dessa säkerhetskopieringsarkiv för VM/CT {vmid} kommer att tas bort permanent. Skyddade säkerhetskopior är undantagna. Detta tar inte bort gästen.",
|
||||
"deleting": "Väntar på att Proxmox ska slutföra borttagningen av de valda arkiven…",
|
||||
"cancel": "Avbryt",
|
||||
"confirmDelete": "Ta bort säkerhetskopior"
|
||||
},
|
||||
"updatesPanel": {
|
||||
"pendingTitle": "Väntande paketuppdateringar",
|
||||
@@ -1302,7 +1318,10 @@
|
||||
"torsdag",
|
||||
"fredag",
|
||||
"lördag"
|
||||
]
|
||||
],
|
||||
"runSkippedStatus": "körning överhoppad",
|
||||
"reasonStoppedContainer": "Behållaren är stoppad; den schemalagda uppdateringen hoppades över.",
|
||||
"reasonStoppedContainerHistorical": "Behållaren stoppades vid den schemalagda uppdateringstiden."
|
||||
},
|
||||
"cronChip": {
|
||||
"detected": "host cron upptäckt",
|
||||
@@ -1718,7 +1737,17 @@
|
||||
"portCategoryCustomPlaceholder": "Skriv en kategori och tryck på Enter (Esc för att avbryta)",
|
||||
"portCustomUrlPlaceholder": "Anpassad URL (t.ex. https://vault.example.com) - åsidosätter IP:port",
|
||||
"stackMemberTitle": "En del av en multicontainerapplikation",
|
||||
"stackMemberBody": "Den här behållaren stöder en applikation vars huvudbehållare är {primary}. Applikationen, dess webblänkar och dess version visas där."
|
||||
"stackMemberBody": "Den här behållaren stöder en applikation vars huvudbehållare är {primary}. Applikationen, dess webblänkar och dess version visas där.",
|
||||
"nativeEvidencePort": "Lyssnande TCP-port",
|
||||
"nativeEvidenceFile": "Installerad fil",
|
||||
"nativeEvidenceProcess": "Körande programfil",
|
||||
"nativeEvidenceDirectory": "Arbetskatalog",
|
||||
"nativeEvidenceService": "Tjänstdefinition",
|
||||
"nativeScanFailed": "Programsökningen kunde inte slutföras. Tidigare resultat kan vara inaktuella; försök igen.",
|
||||
"nativeScanPartial": "Programsökningen är ofullständig. Granska det som hittades och försök igen.",
|
||||
"nativeVersionUnknown": "Installerad version okänd",
|
||||
"nativeEvidenceStale": "Det som hittades kan vara inaktuellt eller ofullständigt.",
|
||||
"nativeRegisterOnly": "Bekräfta registreringen i redigeraren. En känd version sparas som ett manuellt värde; ingen uppdatering konfigureras."
|
||||
},
|
||||
"statusFilter": {
|
||||
"ariaLabel": "Filtrera virtuella maskiner och containrar",
|
||||
@@ -1814,7 +1843,8 @@
|
||||
"logs": "Systemloggar",
|
||||
"updates": "Systemuppdateringar",
|
||||
"security": "Säkerhet och certifikat",
|
||||
"temperature": "Temperatur"
|
||||
"temperature": "Temperatur",
|
||||
"pbsServices": "PBS Services"
|
||||
}
|
||||
},
|
||||
"remoteStorage": {
|
||||
@@ -2075,7 +2105,7 @@
|
||||
"webhookUrl": "Webhook URL",
|
||||
"enableEmail": "Aktivera e-post",
|
||||
"smtpHost": "SMTP-värd",
|
||||
"port": "Hamn",
|
||||
"port": "Port",
|
||||
"tlsMode": "TLS-läge",
|
||||
"noTls": "Ingen (port 25)",
|
||||
"username": "Användarnamn",
|
||||
@@ -2108,7 +2138,13 @@
|
||||
"inactiveFooter": "Aktivera aviseringar för att ta emot system-, hälso- och säkerhetsvarningar via de konfigurerade kanalerna.",
|
||||
"notificationLanguage": "Aviseringsspråk",
|
||||
"selectNotificationLanguage": "Välj språk för meddelanden",
|
||||
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat."
|
||||
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat.",
|
||||
"appriseServiceHelp": "Konfiguration av Slack och Matrix",
|
||||
"appriseSlackSteps": "Skapa en inkommande webhook i din Slack-app för målkanalen och klistra sedan in dess fullständiga HTTPS-URL i fältet Apprise-URL. Ingen omvandling behövs.",
|
||||
"appriseSlackDocs": "URL-format och konfiguration för Slack",
|
||||
"appriseMatrixSteps": "Använd ett aviseringskonto som får skicka meddelanden till rummet. Ersätt exemplets användare, lösenord, hemserver och fullständiga rums-ID; URL-koda specialtecken i inloggningsuppgifterna. Schemat matrixs använder HTTPS. Se dokumentationen för stöd för token, SSO och krypterade rum i din installerade Apprise-version.",
|
||||
"appriseMatrixDocs": "URL-format och konfiguration för Matrix",
|
||||
"appriseServiceFinish": "Aktivera Apprise, ange din URL, välj aviseringskategorier eller enskilda händelser, spara och använd sedan Skicka test. Dessa exempel ändrar inte din sparade URL. Denna Apprise-kanal har ett mål; använd Apprise-API för flera tjänster samtidigt."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI-förbättring",
|
||||
@@ -2687,7 +2723,7 @@
|
||||
"interfaceOptional": "Gränssnitt (valfritt)",
|
||||
"applyTo": "Ansök till",
|
||||
"commentOptional": "Kommentar (valfritt)",
|
||||
"port": "Hamn",
|
||||
"port": "Port",
|
||||
"source": "Källa",
|
||||
"level": "Nivå",
|
||||
"interface": "Gränssnitt",
|
||||
@@ -4753,6 +4789,24 @@
|
||||
"thursday": "torsdag",
|
||||
"tuesday": "tisdag",
|
||||
"wednesday": "onsdag"
|
||||
},
|
||||
"storageRetention": {
|
||||
"title": "Lagringens bevarandepolicy",
|
||||
"keepAll": "Behåll alla säkerhetskopior",
|
||||
"description": "Standardpolicy för bevarande av säkerhetskopior, sparad i Proxmox. Säkerhetskopieringsjobb med en uttrycklig policy åsidosätter denna standard.",
|
||||
"refresh": "Uppdatera",
|
||||
"saved": "Lagringens bevarandepolicy har sparats. Ingen gallring har körts.",
|
||||
"choosePolicy": "Välj Behåll alla eller minst ett bevarandeantal som är större än noll.",
|
||||
"saveFailed": "Det gick inte att spara lagringens bevarandepolicy. Uppdatera listan om konfigurationen har ändrats.",
|
||||
"loadFailed": "Det gick inte att läsa in lagringens bevarandepolicy.",
|
||||
"empty": "Ingen lagring för säkerhetskopior hittades.",
|
||||
"default": "Proxmox standard",
|
||||
"edit": "Redigera",
|
||||
"editTitle": "Bevarande för {storage}",
|
||||
"effect": "Gäller framtida säkerhetskopieringar som ärver denna lagringspolicy. Att spara tar inte bort befintliga säkerhetskopior. Skyddade säkerhetskopior förblir skyddade.",
|
||||
"cancel": "Avbryt",
|
||||
"save": "Spara bevarandepolicy",
|
||||
"useDefault": "Använd Proxmox-standard (ingen policy för denna lagring)"
|
||||
}
|
||||
},
|
||||
"healthStatus": {
|
||||
@@ -4783,7 +4837,8 @@
|
||||
"services": "PVE-tjänster",
|
||||
"logs": "Systemloggar",
|
||||
"updates": "Systemuppdateringar",
|
||||
"security": "Säkerhet & certifikat"
|
||||
"security": "Säkerhet & certifikat",
|
||||
"pbsServices": "PBS-tjänster"
|
||||
},
|
||||
"checks": {
|
||||
"cpu_usage": "CPU-användning",
|
||||
@@ -7227,5 +7282,99 @@
|
||||
"diagnosticsOmitted": "Utelämnade diagnosrader eller text: {count}. Originalrapporten bevaras."
|
||||
}
|
||||
}
|
||||
},
|
||||
"pbs": {
|
||||
"title": "Proxmox Backup Server",
|
||||
"server": "PBS-server",
|
||||
"chooseServer": "Välj en PBS-server",
|
||||
"local": "Lokal PBS",
|
||||
"refresh": "Uppdatera",
|
||||
"manage": "Hantera PBS-anslutningar",
|
||||
"loadFailed": "Det gick inte att läsa in PBS-status.",
|
||||
"loading": "Läser in PBS-data…",
|
||||
"noServer": "Lägg till en PBS-anslutning för att visa datalager, uppgifter och scheman.",
|
||||
"permissionsHelp": "Aktivera Monitor-autentisering innan du sparar inloggningsuppgifter. Använd en särskild PBS-API-token med rollen Audit på / i både användarens och tokenens ACL, med nedärvning. Denna panel läser endast PBS-data.",
|
||||
"editConnection": "Anslutning att redigera",
|
||||
"add": "Lägg till en ny anslutning",
|
||||
"name": "Namn",
|
||||
"url": "HTTPS-URL (ta med :8007 för PBS ordinarie port)",
|
||||
"token_id": "API-token-ID (user@realm!token)",
|
||||
"token_secret": "API-tokenhemlighet (lämna tomt för att behålla den sparade hemligheten)",
|
||||
"fingerprint": "Certifikatets SHA256-fingeravtryck (valfritt)",
|
||||
"tlsHelp": "Lämna fingeravtrycket tomt för vanlig kontroll av certifikatutfärdare och värdnamn. För ett självsignerat certifikat kopierar du dess SHA256-fingeravtryck från PBS-konsolen eller en annan betrodd kanal. Fingeravtrycket kontrolleras innan inloggningsuppgifter skickas.",
|
||||
"saved": "PBS-anslutningen har verifierats och sparats.",
|
||||
"saveFailed": "Det gick inte att spara PBS-anslutningen.",
|
||||
"testing": "Verifierar anslutningen…",
|
||||
"save": "Verifiera och spara",
|
||||
"remove": "Ta bort anslutningen",
|
||||
"removeConfirm": "Ta bort denna sparade PBS-anslutning och dess token från Monitor? PBS-data och jobb påverkas inte.",
|
||||
"sampled": "Avläst",
|
||||
"datastores": "Datalager",
|
||||
"total": "Filsystemets kapacitet",
|
||||
"used": "Använt",
|
||||
"free": "Tillgängligt",
|
||||
"noDatastores": "Inga tillgängliga datalager. Kontrollera datalagrets konfiguration samt användarens och tokenens behörigheter.",
|
||||
"tasks": "Senaste uppgifter",
|
||||
"noTasks": "Inga senaste uppgifter returnerades.",
|
||||
"task": "Uppgift",
|
||||
"started": "Startad",
|
||||
"status": "Status",
|
||||
"running": "Körs",
|
||||
"jobs": "Underhållsscheman",
|
||||
"job": "Jobb",
|
||||
"schedule": "Schema",
|
||||
"sync": "Synkronisering",
|
||||
"verify": "Verifiering",
|
||||
"prune": "Gallring",
|
||||
"overview": "Översikt",
|
||||
"logs": "Systemloggar",
|
||||
"terminal": "Terminal",
|
||||
"security": "Säkerhet",
|
||||
"logout": "Logga ut",
|
||||
"navigation": "PBS-navigering",
|
||||
"cpu": "CPU",
|
||||
"memory": "Minne",
|
||||
"uptime": "Drifttid",
|
||||
"capacityUnavailable": "Kapacitet inte tillgänglig.",
|
||||
"datastoresUnavailable": "{count} inte tillgänglig",
|
||||
"datastoresOnline": "Alla tillgängliga",
|
||||
"moreDatastores": "+{count} fler databutiker",
|
||||
"estimatedFull": "Beräknad full",
|
||||
"estimatedFullUnknown": "Inte tillräckligt med data ännu",
|
||||
"duration": "Varaktighet",
|
||||
"noJobs": "Inga synkroniserings-, verifierings- eller beskärningsscheman är konfigurerade."
|
||||
},
|
||||
"monitorLinks": {
|
||||
"groups": {
|
||||
"cluster": "Kluster",
|
||||
"standalone": "Fristående noder",
|
||||
"pbs": "Proxmox Backup Server"
|
||||
},
|
||||
"thisNode": "Denna nod",
|
||||
"checking": "Kontrollerar noder...",
|
||||
"loadFailed": "Kunde inte ladda nodlistan.",
|
||||
"empty": "Inga andra noder än. Lägg till en för att öppna dess Monitor härifrån.",
|
||||
"noResponse": "Inget svar",
|
||||
"pbsInterface": "PBS-gränssnitt",
|
||||
"openNewTab": "Öppna i en ny flik",
|
||||
"manage": "Lägg till eller hantera noder...",
|
||||
"dialogTitle": "Länkade noder",
|
||||
"dialogDescription": "Klusternoder och konfigurerade Proxmox Backup Server identifieras automatiskt. Varje Monitor behåller sin egen inloggning.",
|
||||
"addTitle": "Lägg till en nod",
|
||||
"name": "Namn",
|
||||
"address": "Övervaka adress",
|
||||
"type": "Typ",
|
||||
"add": "Lägg till",
|
||||
"save": "Spara",
|
||||
"remove": "Ta bort",
|
||||
"visible": "Synlig",
|
||||
"autoAddress": "Automatisk",
|
||||
"detected": "Upptäckt",
|
||||
"added": "Tillagd",
|
||||
"saveFailed": "Kunde inte spara nodlistan.",
|
||||
"backTo": "Tillbaka till {name}",
|
||||
"cameFromTitle": "Lägg till monitornod?",
|
||||
"cameFromBody": "Lägg till {name} till de länkade noderna på denna monitor.",
|
||||
"notNow": "Inte nu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1115,6 +1115,9 @@ def _detect_proxmox_certificate_paths():
|
||||
return PROXMOX_CUSTOM_CERT_PATH, PROXMOX_CUSTOM_KEY_PATH
|
||||
if os.path.isfile(PROXMOX_CERT_PATH) and os.path.isfile(PROXMOX_KEY_PATH):
|
||||
return PROXMOX_CERT_PATH, PROXMOX_KEY_PATH
|
||||
pbs_cert, pbs_key = "/etc/proxmox-backup/proxy.pem", "/etc/proxmox-backup/proxy.key"
|
||||
if os.path.isfile(pbs_cert) and os.path.isfile(pbs_key):
|
||||
return pbs_cert, pbs_key
|
||||
return "", ""
|
||||
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ cp "$SCRIPT_DIR/oci_console_logs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
|
||||
cp "$SCRIPT_DIR/oci_instance_info.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_instance_info.py not found"
|
||||
cp "$SCRIPT_DIR/oci_operations.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_operations.py not found"
|
||||
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
|
||||
cp "$SCRIPT_DIR/lvm_device_scope.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
|
||||
cp "$SCRIPT_DIR/disk_identity.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_identity.py not found"
|
||||
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
|
||||
@@ -148,6 +149,7 @@ cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
|
||||
cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
|
||||
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
|
||||
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
|
||||
@@ -160,6 +162,11 @@ cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || ech
|
||||
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
|
||||
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
|
||||
cp "$SCRIPT_DIR/pbs_manager.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_pbs_routes.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/monitor_links.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_monitor_links_routes.py" "$APP_DIR/usr/bin/"
|
||||
cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
|
||||
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
|
||||
cp "$SCRIPT_DIR/flask_security_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_security_routes.py not found"
|
||||
@@ -181,6 +188,7 @@ done
|
||||
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
|
||||
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
|
||||
cp "$SCRIPT_DIR/flask_vm_backup_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_vm_backup_routes.py not found"
|
||||
cp "$SCRIPT_DIR/guest_backup_archives.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found"
|
||||
cp "$SCRIPT_DIR/audit_store.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_store.py not found"
|
||||
cp "$SCRIPT_DIR/audit_checks.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ audit_checks.py not found"
|
||||
|
||||
@@ -526,6 +526,16 @@ def disk_read_policy(disk_name: str, excluded: Optional[set] = None) -> str:
|
||||
return IDLE
|
||||
|
||||
|
||||
def settle_own_reads(disk_names) -> None:
|
||||
"""A reader that has just read these disks itself takes their counters as
|
||||
the new baseline, so its own I/O never counts as activity."""
|
||||
stats = _read_diskstats()
|
||||
with _cache_lock:
|
||||
for name in disk_names:
|
||||
if name in _last_io and name in stats:
|
||||
_last_io[name] = stats[name]
|
||||
|
||||
|
||||
def is_disk_idle(disk_name: str) -> bool:
|
||||
"""True while the disk is being left alone for having no I/O."""
|
||||
ts = _idle_state.get(disk_name)
|
||||
|
||||
@@ -560,10 +560,12 @@ def totp_setup():
|
||||
"""Initialize TOTP setup for a user"""
|
||||
try:
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
username = auth_manager.verify_token(token)
|
||||
username, scope = auth_manager.verify_token_full(token)
|
||||
|
||||
if not username:
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
if scope != 'full_admin':
|
||||
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
|
||||
|
||||
success, secret, qr_code, backup_codes, message = auth_manager.setup_totp(username)
|
||||
|
||||
@@ -586,10 +588,12 @@ def totp_enable():
|
||||
"""Enable TOTP after verification"""
|
||||
try:
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
username = auth_manager.verify_token(token)
|
||||
username, scope = auth_manager.verify_token_full(token)
|
||||
|
||||
if not username:
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
if scope != 'full_admin':
|
||||
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
|
||||
|
||||
data = request.json
|
||||
verification_token = data.get('token')
|
||||
@@ -612,10 +616,12 @@ def totp_disable():
|
||||
"""Disable TOTP (requires password confirmation)"""
|
||||
try:
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
username = auth_manager.verify_token(token)
|
||||
username, scope = auth_manager.verify_token_full(token)
|
||||
|
||||
if not username:
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
if scope != 'full_admin':
|
||||
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
|
||||
|
||||
data = request.json or {}
|
||||
password = data.get('password')
|
||||
@@ -653,10 +659,12 @@ def generate_api_token():
|
||||
if not token:
|
||||
return jsonify({"success": False, "message": "Unauthorized. Please log in first."}), 401
|
||||
|
||||
username = auth_manager.verify_token(token)
|
||||
username, scope = auth_manager.verify_token_full(token)
|
||||
|
||||
if not username:
|
||||
return jsonify({"success": False, "message": "Invalid or expired session. Please log in again."}), 401
|
||||
if scope != 'full_admin':
|
||||
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
|
||||
|
||||
data = request.json
|
||||
password = data.get('password')
|
||||
@@ -749,8 +757,11 @@ def revoke_api_token_route(token_id):
|
||||
return jsonify({"success": False, "message": "Authentication is not configured"}), 400
|
||||
|
||||
token = request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
if not token or not auth_manager.verify_token(token):
|
||||
username, scope = auth_manager.verify_token_full(token) if token else (None, None)
|
||||
if not username:
|
||||
return jsonify({"success": False, "message": "Unauthorized"}), 401
|
||||
if scope != 'full_admin':
|
||||
return jsonify({"success": False, "message": "This action requires a full_admin token"}), 403
|
||||
|
||||
success, message = auth_manager.revoke_api_token(token_id)
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Links between ProxMenux Monitors for the node selector in the header."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
import monitor_links
|
||||
from jwt_middleware import require_auth, require_admin_scope
|
||||
|
||||
monitor_links_bp = Blueprint("monitor_links", __name__)
|
||||
|
||||
|
||||
@monitor_links_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links")
|
||||
@require_auth
|
||||
def list_links():
|
||||
return jsonify(monitor_links.cached_links(include_hidden=request.args.get("all") == "1"))
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def add_link():
|
||||
try:
|
||||
key = monitor_links.add_link(request.get_json(silent=True))
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True, key=key)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["PATCH"])
|
||||
@require_admin_scope
|
||||
def update_link(key):
|
||||
try:
|
||||
monitor_links.update_link(key, request.get_json(silent=True))
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
|
||||
|
||||
@monitor_links_bp.route("/api/monitor-links/<path:key>", methods=["DELETE"])
|
||||
@require_admin_scope
|
||||
def delete_link(key):
|
||||
try:
|
||||
monitor_links.delete_link(key)
|
||||
monitor_links.refresh()
|
||||
return jsonify(success=True)
|
||||
except monitor_links.LinkError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
except OSError:
|
||||
return jsonify(error="Could not save the node list."), 500
|
||||
@@ -1156,6 +1156,14 @@ def setup_pve_webhook_core() -> dict:
|
||||
'fallback_commands': [],
|
||||
'error': None,
|
||||
}
|
||||
try: # Proxmox Backup Server has no /etc/pve notification configuration
|
||||
from pbs_manager import is_pbs_host
|
||||
on_pbs = is_pbs_host()
|
||||
except Exception:
|
||||
on_pbs = False
|
||||
if on_pbs:
|
||||
result['not_applicable'] = True
|
||||
return result
|
||||
|
||||
try:
|
||||
if not _pve_cluster_fs_mounted():
|
||||
@@ -1339,6 +1347,13 @@ def cleanup_pve_webhook_core() -> dict:
|
||||
Only removes blocks named 'proxmenux-webhook' / 'proxmenux-default'.
|
||||
"""
|
||||
result = {'cleaned': False, 'error': None}
|
||||
try: # Proxmox Backup Server has no /etc/pve notification configuration
|
||||
from pbs_manager import is_pbs_host
|
||||
on_pbs = is_pbs_host()
|
||||
except Exception:
|
||||
on_pbs = False
|
||||
if on_pbs:
|
||||
return {'cleaned': True, 'error': None, 'not_applicable': True}
|
||||
|
||||
try:
|
||||
if not _pve_cluster_fs_mounted():
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Read-only PBS status and administrative connection management."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
import auth_manager
|
||||
import pbs_manager as pbs
|
||||
from jwt_middleware import require_auth, require_admin_scope
|
||||
|
||||
pbs_bp = Blueprint("pbs", __name__)
|
||||
|
||||
|
||||
@pbs_bp.after_request
|
||||
def no_cache(response):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
@pbs_bp.route("/api/platform")
|
||||
@require_auth
|
||||
def platform():
|
||||
return jsonify(pbs.get_platform())
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/servers")
|
||||
@require_auth
|
||||
def servers():
|
||||
try:
|
||||
return jsonify(servers=pbs.public_servers(), local_available=pbs.is_pbs_host())
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 500
|
||||
|
||||
|
||||
def _protected_configuration():
|
||||
local = auth_manager.load_auth_config()
|
||||
return local.get("enabled") and not local.get("declined") and local.get("password_hash")
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/servers", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def save_server():
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before saving remote PBS credentials."), 409
|
||||
try:
|
||||
identifier = pbs.save_server(request.get_json(silent=True))
|
||||
return jsonify(success=True, id=identifier, servers=pbs.public_servers())
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
except OSError:
|
||||
return jsonify(error="Could not save PBS connection."), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/servers/<identifier>", methods=["DELETE"])
|
||||
@require_admin_scope
|
||||
def delete_server(identifier):
|
||||
if not _protected_configuration():
|
||||
return jsonify(error="Enable local authentication before changing PBS connections."), 409
|
||||
try:
|
||||
pbs.delete_server(identifier)
|
||||
return jsonify(success=True)
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
except OSError:
|
||||
return jsonify(error="Could not remove PBS connection."), 500
|
||||
|
||||
|
||||
@pbs_bp.route("/api/pbs/status")
|
||||
@require_auth
|
||||
def status():
|
||||
try:
|
||||
return jsonify(pbs.get_status(request.args.get("server", "local")))
|
||||
except pbs.PBSError as exc:
|
||||
return jsonify(error=str(exc)), 502
|
||||
@@ -35,7 +35,7 @@ TOOL_METADATA = {
|
||||
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
|
||||
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
|
||||
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
|
||||
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.2'},
|
||||
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
|
||||
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
|
||||
'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'},
|
||||
|
||||
@@ -90,11 +90,16 @@ from flask_terminal_routes import ( # noqa: E402
|
||||
from flask_health_routes import health_bp # noqa: E402
|
||||
from flask_auth_routes import auth_bp # noqa: E402
|
||||
from flask_proxmenux_routes import proxmenux_bp # noqa: E402
|
||||
from flask_pbs_routes import pbs_bp # noqa: E402
|
||||
from flask_monitor_links_routes import monitor_links_bp # noqa: E402
|
||||
from pbs_manager import get_platform, is_pbs_host # noqa: E402
|
||||
from flask_security_routes import security_bp # noqa: E402
|
||||
from flask_notification_routes import notification_bp # noqa: E402
|
||||
from flask_oci_routes import oci_bp # noqa: E402
|
||||
from flask_audit_routes import audit_bp # noqa: E402
|
||||
from flask_vm_backup_routes import vm_backup_bp # noqa: E402
|
||||
from guest_backup_archives import (BackupArchiveError, delete_guest_backups,
|
||||
guest_backup_deletion_status) # noqa: E402
|
||||
from notification_manager import notification_manager, resolve_notification_hostname # noqa: E402
|
||||
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
|
||||
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
|
||||
@@ -193,6 +198,8 @@ post_install_versions.scan_at_startup()
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(proxmenux_bp)
|
||||
app.register_blueprint(pbs_bp)
|
||||
app.register_blueprint(monitor_links_bp)
|
||||
app.register_blueprint(security_bp)
|
||||
app.register_blueprint(notification_bp)
|
||||
app.register_blueprint(oci_bp)
|
||||
@@ -2271,6 +2278,8 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None:
|
||||
# or stops; drop the cached IP so the next /api/vms poll re-reads
|
||||
# it via `lxc-info`. QEMU guests do not touch this cache.
|
||||
if guest_type == 'lxc':
|
||||
import native_app_discovery
|
||||
native_app_discovery.mark_stale(guest_id)
|
||||
_invalidate_lxc_ip(guest_id)
|
||||
if action in ('start', 'reboot'):
|
||||
if guest_type == 'lxc':
|
||||
@@ -3474,6 +3483,8 @@ def get_coral_info():
|
||||
def get_proxmox_version():
|
||||
"""Get Proxmox version if available. Cached for 6 hours."""
|
||||
global _system_info_cache
|
||||
if is_pbs_host():
|
||||
return get_platform().get('version')
|
||||
|
||||
now = time.time()
|
||||
if _system_info_cache['proxmox_version'] is not None and \
|
||||
@@ -4186,13 +4197,17 @@ def get_system_disks():
|
||||
pass
|
||||
|
||||
try:
|
||||
# 4. Check LVM physical volumes. `--devices` (added when a disk
|
||||
# is parked) keeps pvs from scanning — and waking — standby HDDs.
|
||||
# 4. Check LVM physical volumes. `--devices` keeps pvs off the disks
|
||||
# that are excluded, idle or asleep.
|
||||
dev_args = _lvm_device_args()
|
||||
result = subprocess.run(
|
||||
['pvs', *_lvm_device_args(), '--noheadings', '-o', 'pv_name,vg_name'],
|
||||
['pvs', *dev_args, '--noheadings', '-o', 'pv_name,vg_name'],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
) if dev_args is not None else None
|
||||
if result is not None:
|
||||
from lvm_device_scope import settle_scan_reads
|
||||
settle_scan_reads()
|
||||
if result is not None and result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
@@ -4534,6 +4549,8 @@ def get_storage_info():
|
||||
disk_info['used'] = round(partition_usage.used / (1024**3), 1)
|
||||
disk_info['available'] = round(partition_usage.free / (1024**3), 1)
|
||||
disk_info['usage_percent'] = round(partition_usage.percent, 1)
|
||||
disk_info['total_bytes'] = partition_usage.total
|
||||
disk_info['used_bytes'] = partition_usage.used
|
||||
|
||||
except PermissionError:
|
||||
continue
|
||||
@@ -5002,42 +5019,10 @@ def _hdd_in_standby(disk_name: str) -> bool:
|
||||
return parked
|
||||
|
||||
|
||||
def _lvm_device_args() -> list:
|
||||
"""Build the `--devices` argument that keeps an LVM tool (pvs/lvs/
|
||||
vgs) off parked disks.
|
||||
|
||||
LVM tools scan every block device by default, which spins up a
|
||||
standby HDD (issue #232). When any rotational disk is asleep, hand
|
||||
LVM an explicit list of every block node EXCEPT those belonging to a
|
||||
parked disk, so it never opens the sleeping drives. Each partition
|
||||
must be listed too — `--devices /dev/sda` alone does not cover a PV
|
||||
on /dev/sda3. Returns [] when nothing is parked, so hosts without
|
||||
standby disks keep the plain pvs/lvs/vgs call unchanged.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['lsblk', '-rno', 'NAME,TYPE,PKNAME'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
nodes = []
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
parts = line.split()
|
||||
if len(parts) < 2 or parts[1] not in ('disk', 'part'):
|
||||
continue
|
||||
name = parts[0]
|
||||
base = name if parts[1] == 'disk' else (parts[2] if len(parts) > 2 else name)
|
||||
nodes.append((name, base))
|
||||
standby_bases = {
|
||||
name for name, base in nodes
|
||||
if name == base and _hdd_in_standby(name)
|
||||
}
|
||||
if not standby_bases:
|
||||
return []
|
||||
devices = [f'/dev/{name}' for name, base in nodes if base not in standby_bases]
|
||||
return ['--devices', ','.join(devices)] if devices else []
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return []
|
||||
def _lvm_device_args():
|
||||
"""None means no automatic scan; [] permits LVM's normal scan."""
|
||||
from lvm_device_scope import lvm_device_args
|
||||
return lvm_device_args(_hdd_in_standby)
|
||||
|
||||
|
||||
def get_smart_data(disk_name):
|
||||
@@ -9863,6 +9848,7 @@ def api_system():
|
||||
'cpu_cores': cpu_cores,
|
||||
'cpu_threads': cpu_threads,
|
||||
'proxmox_version': proxmox_version,
|
||||
'platform': get_platform(),
|
||||
'kernel_version': kernel_version,
|
||||
'available_updates': available_updates
|
||||
})
|
||||
@@ -12234,6 +12220,17 @@ def _get_node_metrics_payload(timeframe, max_age=_NODE_METRICS_TTL):
|
||||
lock.release()
|
||||
|
||||
|
||||
def _node_rrd_command(local_node, timeframe, cf=None):
|
||||
"""Node RRD query; Proxmox Backup Server keeps the same fields under its own API."""
|
||||
if is_pbs_host():
|
||||
return ['proxmox-backup-debug', 'api', 'get', '/nodes/localhost/rrd',
|
||||
'--timeframe', timeframe, '--cf', cf or 'AVERAGE', '--output-format', 'json']
|
||||
command = ['pvesh', 'get', f'/nodes/{local_node}/rrddata', '--timeframe', timeframe]
|
||||
if cf:
|
||||
command += ['--cf', cf]
|
||||
return command + ['--output-format', 'json']
|
||||
|
||||
|
||||
def _compute_node_metrics_payload(timeframe: str) -> dict:
|
||||
"""Fetch and shape one node RRD snapshot within a bounded query budget."""
|
||||
valid_timeframes = ('hour', 'day', 'week', 'month', 'year')
|
||||
@@ -12257,8 +12254,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict:
|
||||
|
||||
try:
|
||||
rrd_result = subprocess.run(
|
||||
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
|
||||
'--timeframe', timeframe, '--output-format', 'json'],
|
||||
_node_rrd_command(local_node, timeframe),
|
||||
capture_output=True, text=True, timeout=_NODE_METRICS_TIMEOUT,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -12352,9 +12348,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict:
|
||||
return None
|
||||
try:
|
||||
extra = subprocess.run(
|
||||
['pvesh', 'get', f'/nodes/{local_node}/rrddata',
|
||||
'--timeframe', timeframe, '--cf', cf,
|
||||
'--output-format', 'json'],
|
||||
_node_rrd_command(local_node, timeframe, cf),
|
||||
capture_output=True, text=True, timeout=remaining,
|
||||
)
|
||||
if extra.returncode == 0 and extra.stdout:
|
||||
@@ -13103,7 +13097,6 @@ def api_create_backup(vmid):
|
||||
mode = data.get('mode', 'snapshot') # snapshot, suspend, stop
|
||||
compress = data.get('compress', 'zstd') # none, lzo, gzip, zstd
|
||||
protected = data.get('protected', False) # True/False
|
||||
notification = data.get('notification', 'auto') # always, failure, never, auto
|
||||
notes = data.get('notes', '') # Backup notes/description
|
||||
pbs_change_detection = data.get('pbs_change_detection', None) # default, legacy, data (for PBS + LXC)
|
||||
|
||||
@@ -13303,6 +13296,32 @@ def api_create_backup(vmid):
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/backups/delete', methods=['POST'])
|
||||
@require_admin_scope
|
||||
def api_delete_vm_backups(vmid):
|
||||
"""Delete an explicitly confirmed selection, revalidated by Proxmox."""
|
||||
try:
|
||||
return jsonify(delete_guest_backups(vmid, request.get_json(silent=True)))
|
||||
except BackupArchiveError as error:
|
||||
return jsonify({'error': str(error)}), error.code
|
||||
finally:
|
||||
# Native deletion may have started even if the pvesh response was lost.
|
||||
_vm_cache_invalidate(vmid, _vm_backups_cache)
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/backups/delete-status', methods=['GET'])
|
||||
@require_admin_scope
|
||||
def api_vm_backup_deletion_status(vmid):
|
||||
"""Only poll deletion tasks started here for this guest."""
|
||||
try:
|
||||
result = guest_backup_deletion_status(vmid, request.args.get('task'))
|
||||
if result['status'] != 'pending':
|
||||
_vm_cache_invalidate(vmid, _vm_backups_cache)
|
||||
return jsonify(result)
|
||||
except BackupArchiveError as error:
|
||||
return jsonify({'error': str(error)}), error.code
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/backups', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_backups(vmid):
|
||||
@@ -13383,6 +13402,7 @@ def api_vm_backups(vmid):
|
||||
'timestamp': ctime,
|
||||
'date': datetime.fromtimestamp(ctime).strftime('%Y-%m-%d %H:%M') if ctime else '',
|
||||
'notes': item.get('notes', ''),
|
||||
'protected': item.get('protected', False) not in (False, 0, '0', '', None),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
@@ -13595,6 +13615,19 @@ def get_task_log(upid):
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
def _installed_proxmenux_version():
|
||||
"""Installed ProxMenux version; a beta install keeps beta_version.txt."""
|
||||
for name in ('beta_version.txt', 'version.txt'):
|
||||
try:
|
||||
with open(f'/usr/local/share/proxmenux/{name}') as fh:
|
||||
version = fh.read().strip()
|
||||
if version:
|
||||
return version
|
||||
except OSError:
|
||||
continue
|
||||
return ''
|
||||
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
@require_auth
|
||||
def api_health():
|
||||
@@ -13602,7 +13635,7 @@ def api_health():
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'version': '1.2.2'
|
||||
'version': _installed_proxmenux_version()
|
||||
})
|
||||
|
||||
# ─── User-configurable health thresholds ─────────────────────────────────────
|
||||
@@ -14083,6 +14116,20 @@ def api_vm_apps_suggestions(vmid):
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/apps/native-discovery', methods=['GET', 'POST'])
|
||||
@require_auth
|
||||
def api_vm_apps_native_discovery(vmid):
|
||||
"""Only the Find applications button requests POST. GET never probes."""
|
||||
import native_app_discovery
|
||||
import lxc_apps
|
||||
result = (native_app_discovery.discover(vmid) if request.method == 'POST'
|
||||
else native_app_discovery.cached(vmid))
|
||||
suggestions = _vm_cache_get(_vm_app_suggestions_cache, vmid, _VM_APP_SUGGESTIONS_TTL) or {}
|
||||
sidecar = lxc_apps.load_sidecar(vmid) or {}
|
||||
return jsonify(native_app_discovery.filter_candidates(
|
||||
result, suggestions, sidecar.get('apps') or []))
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/apps/adguard-setup', methods=['GET'])
|
||||
@require_auth
|
||||
def api_vm_apps_adguard_setup(vmid):
|
||||
@@ -15014,11 +15061,11 @@ def api_prometheus():
|
||||
disk_name = disk.get('name', 'unknown')
|
||||
metrics.append(f'# HELP proxmox_disk_total_bytes Total disk space in bytes')
|
||||
metrics.append(f'# TYPE proxmox_disk_total_bytes gauge')
|
||||
metrics.append(f'proxmox_disk_total_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("total", 0)} {timestamp}')
|
||||
metrics.append(f'proxmox_disk_total_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("total_bytes", 0)} {timestamp}')
|
||||
|
||||
metrics.append(f'# HELP proxmox_disk_used_bytes Used disk space in bytes')
|
||||
metrics.append(f'# TYPE proxmox_disk_used_bytes gauge')
|
||||
metrics.append(f'proxmox_disk_used_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("used", 0)} {timestamp}')
|
||||
metrics.append(f'proxmox_disk_used_bytes{{node="{node}",disk="{disk_name}"}} {disk.get("used_bytes", 0)} {timestamp}')
|
||||
|
||||
metrics.append(f'# HELP proxmox_disk_usage_percent Disk usage percentage')
|
||||
metrics.append(f'# TYPE proxmox_disk_usage_percent gauge')
|
||||
@@ -22245,6 +22292,7 @@ def api_proxmenux_self_update_status():
|
||||
# so scheduled and manual applies cannot overlap.
|
||||
|
||||
_APPLY_UPDATES_SCRIPT = _LXC_APPLY_UPDATES_SCRIPT
|
||||
_SCHEDULED_STOPPED_EXIT = 9
|
||||
_DOCKER_ENGINE_INTEGRATED_COMMAND = (
|
||||
'python3 /usr/local/share/proxmenux/monitor-app/usr/bin/'
|
||||
'update_docker_engine.py --vmid "$VMID"'
|
||||
@@ -22286,6 +22334,23 @@ def _append_lxc_update_log(path: str | None, text: str) -> None:
|
||||
print(f'[ProxMenux] scheduler: could not append update log: {exc}', flush=True)
|
||||
|
||||
|
||||
def _scheduled_runner_reported_stopped(result, log_path: str | None, marker: str) -> bool:
|
||||
"""Return true only for the scheduler's own pre-start stopped marker."""
|
||||
if result.returncode != _SCHEDULED_STOPPED_EXIT or not marker:
|
||||
return False
|
||||
if log_path:
|
||||
try:
|
||||
with open(log_path, 'rb') as stream:
|
||||
stream.seek(0, os.SEEK_END)
|
||||
stream.seek(max(0, stream.tell() - 4096))
|
||||
output = stream.read().decode('utf-8', errors='replace')
|
||||
except OSError:
|
||||
return False
|
||||
else:
|
||||
output = (result.stdout or '') + (result.stderr or '')
|
||||
return marker in output
|
||||
|
||||
|
||||
def _prune_lxc_update_logs(vmid: int) -> None:
|
||||
try:
|
||||
candidates = sorted(
|
||||
@@ -22679,8 +22744,10 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
requested_targets = _normalise_schedule_targets(sched)
|
||||
targets = list(requested_targets)
|
||||
before = _lxc_update_snapshot(vmid)
|
||||
requested_target = sched.get("target") or "both"
|
||||
deferred_targets: list[str] = []
|
||||
reasons: list[str] = []
|
||||
reason_code: str | None = None
|
||||
reboot_required: bool | None = None
|
||||
reboot_packages: list[str] = []
|
||||
reboot_check_error: str | None = None
|
||||
@@ -22732,6 +22799,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
'status': status,
|
||||
'target': actual_target,
|
||||
'reason': reason,
|
||||
'reason_code': reason_code,
|
||||
'run_id': run_id,
|
||||
'requested_targets': requested_targets,
|
||||
'executed_targets': list(executed),
|
||||
@@ -22743,10 +22811,23 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
'finalization': finalization,
|
||||
}
|
||||
|
||||
requested_target = sched.get("target") or "both"
|
||||
if not originally_running:
|
||||
reason_code = 'stopped_container'
|
||||
reasons.append('container is stopped; scheduled update was skipped')
|
||||
return finish('skipped', requested_target, [])
|
||||
|
||||
if requested_targets == ['oci_image']:
|
||||
# A container installed by OCI manager Apps is updated by replacing
|
||||
# its image, through the same flow as the OCI menu, unattended.
|
||||
# OCI updates have their own runner rather than apply_updates.sh.
|
||||
# Check once more immediately before handing control to it: a user
|
||||
# may have intentionally stopped the CT after the scheduler's first
|
||||
# observation, and the OCI transaction can start a replacement CT
|
||||
# while validating its new image.
|
||||
if _fast_guest_status(vmid, 'lxc') != 'running':
|
||||
reason_code = 'stopped_container'
|
||||
reasons.append('container was stopped before scheduled update could begin')
|
||||
return finish('skipped', 'oci_image', [])
|
||||
command = ['bash', '/usr/local/share/proxmenux/scripts/oci/oci_manager_apps.sh',
|
||||
'manage', str(vmid), '--action', 'update', '--unattended']
|
||||
storage = (sched.get('backup_storage') or '').strip()
|
||||
@@ -22887,6 +22968,9 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
target = "both" if has_os_target and has_app_targets else ("os" if has_os_target else "app")
|
||||
env = dict(os.environ)
|
||||
env["VMID"] = str(vmid)
|
||||
env["EXECUTION_MODE"] = "scheduled"
|
||||
scheduled_stopped_marker = f"PROXMENUX_SCHEDULED_STOPPED:{run_id}"
|
||||
env["SCHEDULED_STOPPED_MARKER"] = scheduled_stopped_marker
|
||||
env["TARGET"] = target
|
||||
env["BACKUP"] = "1" if sched.get("backup") else "0"
|
||||
env["BACKUP_STORAGE"] = sched.get("backup_storage") or ""
|
||||
@@ -22952,6 +23036,10 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
|
||||
text=True,
|
||||
timeout=60 * 60,
|
||||
)
|
||||
if _scheduled_runner_reported_stopped(r, log_path, scheduled_stopped_marker):
|
||||
reason_code = 'stopped_container'
|
||||
reasons.append('container was stopped before scheduled update could begin')
|
||||
return finish('skipped', target, [])
|
||||
reboot_required, reboot_packages, reboot_check_error = (
|
||||
_inspect_lxc_reboot_requirement(
|
||||
vmid,
|
||||
@@ -23027,12 +23115,14 @@ def _scheduler_loop():
|
||||
status = outcome.get('status') or 'failure'
|
||||
actual_target = outcome.get('target') or 'both'
|
||||
reason = outcome.get('reason')
|
||||
reason_code = outcome.get('reason_code')
|
||||
try:
|
||||
lxc_apps.record_schedule_run(
|
||||
_vmid,
|
||||
status,
|
||||
actual_target,
|
||||
reason,
|
||||
reason_code=reason_code,
|
||||
log_name=outcome.get('log_name'),
|
||||
reboot_required=outcome.get('reboot_required'),
|
||||
reboot_packages=outcome.get('reboot_packages'),
|
||||
@@ -23143,9 +23233,11 @@ if __name__ == '__main__':
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] Disk temperature history init failed: {e}")
|
||||
try:
|
||||
import managed_installs
|
||||
managed_installs.detect_and_register()
|
||||
print("[ProxMenux] Managed-installs registry initialised")
|
||||
# A PBS host has no guests whose applications could be tracked.
|
||||
if not is_pbs_host():
|
||||
import managed_installs
|
||||
managed_installs.detect_and_register()
|
||||
print("[ProxMenux] Managed-installs registry initialised")
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] managed_installs init failed: {e}")
|
||||
# Docker inventories are derived runtime data and intentionally
|
||||
@@ -23287,6 +23379,9 @@ if __name__ == '__main__':
|
||||
# the 24 h PollingCollector cycle. Runs in a daemon thread with
|
||||
# a delay so it doesn't block startup — notification_manager needs
|
||||
# a moment to bind channels before we emit.
|
||||
# The guest services below read VMs and LXCs, which do not exist on a
|
||||
# Proxmox Backup Server.
|
||||
pve_host = not is_pbs_host()
|
||||
try:
|
||||
def _startup_emit_pending_app_updates():
|
||||
time.sleep(15) # let channels bind + first metrics collect
|
||||
@@ -23296,8 +23391,9 @@ if __name__ == '__main__':
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] startup emit_all_pending_updates failed: {e}",
|
||||
file=sys.stderr, flush=True)
|
||||
threading.Thread(target=_startup_emit_pending_app_updates, daemon=True,
|
||||
name='app-updates-startup-emit').start()
|
||||
if pve_host:
|
||||
threading.Thread(target=_startup_emit_pending_app_updates, daemon=True,
|
||||
name='app-updates-startup-emit').start()
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] app-updates startup emitter failed to arm: {e}")
|
||||
|
||||
@@ -23309,6 +23405,13 @@ if __name__ == '__main__':
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}")
|
||||
|
||||
# Linked Monitors for the header node selector, renewed every 5 minutes.
|
||||
try:
|
||||
import monitor_links
|
||||
monitor_links.start_prewarmer()
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] Monitor links prewarmer failed to start: {e}")
|
||||
|
||||
# ── VM/CT modal-cache prewarmer ──
|
||||
# Keeps _vm_details_cache / _vm_backups_cache / app snapshots /
|
||||
# _vm_schedule_cache warm from the backend so the "Loading
|
||||
@@ -23316,9 +23419,10 @@ if __name__ == '__main__':
|
||||
# after the tab has been closed for minutes. The React-side
|
||||
# prefetcher only ran while the page was open.
|
||||
try:
|
||||
vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
|
||||
vm_modal_thread.start()
|
||||
print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
|
||||
if pve_host:
|
||||
vm_modal_thread = threading.Thread(target=_vm_modal_prewarmer_loop, daemon=True, name='vm-modal-prewarmer')
|
||||
vm_modal_thread.start()
|
||||
print("[ProxMenux] VM-modal prewarmer started (one-shot warm-up; caches refreshed by event invalidation only)")
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] VM-modal prewarmer failed to start: {e}")
|
||||
|
||||
@@ -23342,8 +23446,9 @@ if __name__ == '__main__':
|
||||
# identical (backup, restart, /usr/bin/update + custom command
|
||||
# chain) between manual and scheduled invocations.
|
||||
try:
|
||||
scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
|
||||
scheduler_thread.start()
|
||||
if pve_host:
|
||||
scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
|
||||
scheduler_thread.start()
|
||||
except Exception as e:
|
||||
print(f"[ProxMenux] LXC update scheduler failed to start: {e}")
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ Endpoints, all under /api/vm-backup-jobs:
|
||||
DELETE /<id> delete a job (its backups are kept)
|
||||
POST /<id>/toggle enable / disable
|
||||
POST /<id>/run run it now, as a Proxmox task on each node
|
||||
|
||||
Storage retention is read/edited under /api/vm-backup-storages. Saving a
|
||||
policy only changes the native storage configuration; it never runs pruning.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -229,6 +232,58 @@ def options():
|
||||
})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-storages", methods=["GET"])
|
||||
@require_auth
|
||||
def backup_storage_retention():
|
||||
ok, storages = _pvesh_json(["get", "/storage"])
|
||||
if not ok or not isinstance(storages, list):
|
||||
return _err(storages if not ok else "Invalid storage inventory", 502)
|
||||
return jsonify({"storages": [
|
||||
{"id": storage["storage"], "type": storage.get("type", ""),
|
||||
"prune_backups": _flat(storage.get("prune-backups") or ""),
|
||||
"digest": storage.get("digest", "")}
|
||||
for storage in storages
|
||||
if isinstance(storage, dict) and storage.get("storage")
|
||||
and "backup" in (storage.get("content") or "").split(",")
|
||||
]})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-storages/<storage_id>", methods=["PUT"])
|
||||
@require_admin_scope
|
||||
def update_backup_storage_retention(storage_id):
|
||||
if not STORAGE_RE.fullmatch(storage_id):
|
||||
return _err("Invalid storage")
|
||||
data = request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return _err("A retention policy and current configuration digest are required")
|
||||
digest, prune = data.get("digest"), data.get("prune_backups")
|
||||
if not isinstance(digest, str) or not re.fullmatch(r"[a-fA-F0-9]{40}", digest):
|
||||
return _err("Refresh the storage list before editing its retention")
|
||||
# An empty policy removes the storage setting, so the Proxmox default applies.
|
||||
if not isinstance(prune, str) or (prune and not PRUNE_RE.fullmatch(prune)):
|
||||
return _err("Invalid retention policy")
|
||||
pairs = [part.split("=", 1) for part in prune.split(",")] if prune else []
|
||||
keys = [key for key, _ in pairs]
|
||||
if (len(keys) != len(set(keys)) or
|
||||
("keep-all" in keys and (len(keys) != 1 or pairs[0][1] != "1")) or
|
||||
any(int(value) < 1 for _, value in pairs)):
|
||||
return _err("Choose keep-all=1 or one or more positive retention counts")
|
||||
# Check that this is still a backup storage, then let Proxmox check the
|
||||
# digest under its storage lock. Other storage settings are never passed.
|
||||
ok, storage = _pvesh_json(["get", f"/storage/{storage_id}"])
|
||||
if not ok:
|
||||
return _err(storage, 502)
|
||||
if not isinstance(storage, dict) or "backup" not in (storage.get("content") or "").split(","):
|
||||
return _err("This storage does not accept backups", 409)
|
||||
if storage.get("digest") != digest:
|
||||
return _err("Storage configuration changed; refresh before saving", 409)
|
||||
change = ["--prune-backups", prune] if prune else ["--delete", "prune-backups"]
|
||||
ok, out = _pvesh(["set", f"/storage/{storage_id}", "--digest", digest, *change])
|
||||
if not ok:
|
||||
return _err(out, 409)
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@vm_backup_bp.route("/api/vm-backup-jobs", methods=["POST"])
|
||||
@require_admin_scope
|
||||
def create_job():
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# ==========================================================
|
||||
# ProxMenux Monitor - Guest backup archives
|
||||
# Author : Jonatan
|
||||
# GitHub : github.com/jcastro
|
||||
# Maintainer : MacRimi
|
||||
# Copyright : (c) 2026 MacRimi & contributors
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 16/10/2026
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# Deletes explicitly selected VM/CT backups through the native Proxmox
|
||||
# storage API after a fresh inventory/protection check. Tracks native
|
||||
# deletion tasks so an accepted operation is not mistaken for completion.
|
||||
# No filesystem paths or cached inventory authorize deletion.
|
||||
# ==========================================================
|
||||
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
|
||||
MAX_SELECTION = 20
|
||||
REMOVED_RE = re.compile(r"Removed volume '[^'\n]+'")
|
||||
STORAGE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
||||
NODE_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}")
|
||||
_tasks = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class BackupArchiveError(Exception):
|
||||
def __init__(self, message, code=400):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _pvesh(args, timeout=20):
|
||||
try:
|
||||
result = subprocess.run(["pvesh", *args, "--output-format", "json"],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
raise BackupArchiveError("Proxmox command unavailable or timed out; outcome unconfirmed", 502) from error
|
||||
if result.returncode:
|
||||
raise BackupArchiveError((result.stderr or "Proxmox command failed").strip()[:1000], 502)
|
||||
output = result.stdout.strip()
|
||||
try:
|
||||
# pvesh can print no bytes for the API's successful null result.
|
||||
return json.loads(output or "null")
|
||||
except (ValueError, TypeError) as error:
|
||||
# With --delay, a deletion that ends in time prints the line of its
|
||||
# task log instead of JSON. Only that exact line is accepted.
|
||||
if args[0] == "delete" and REMOVED_RE.fullmatch(output):
|
||||
return output
|
||||
raise BackupArchiveError("Invalid Proxmox response; outcome unconfirmed", 502) from error
|
||||
|
||||
|
||||
def _node():
|
||||
# Same local storage/node scope as the guest's existing backup list.
|
||||
node = socket.gethostname().split(".")[0]
|
||||
if not NODE_RE.fullmatch(node):
|
||||
raise BackupArchiveError("Invalid local node name", 502)
|
||||
return node
|
||||
|
||||
|
||||
def _selection(vmid, data):
|
||||
if not 100 <= vmid <= 999999999:
|
||||
raise BackupArchiveError("Invalid VM/CT ID")
|
||||
if not isinstance(data, dict) or data.get("confirm") is not True:
|
||||
raise BackupArchiveError("Explicit confirmation is required")
|
||||
selected = data.get("backups")
|
||||
if not isinstance(selected, list) or not 1 <= len(selected) <= MAX_SELECTION:
|
||||
raise BackupArchiveError(f"Select between 1 and {MAX_SELECTION} backups")
|
||||
seen = set()
|
||||
for item in selected:
|
||||
if not isinstance(item, dict):
|
||||
raise BackupArchiveError("Invalid backup selection")
|
||||
storage, volid = item.get("storage"), item.get("volid")
|
||||
if not isinstance(storage, str) or not STORAGE_RE.fullmatch(storage):
|
||||
raise BackupArchiveError("Invalid backup storage")
|
||||
if (not isinstance(volid, str) or not volid.startswith(storage + ":")
|
||||
or len(volid) > 512 or not re.fullmatch(r"[A-Za-z0-9_:./+@-]+", volid)
|
||||
or any(part in ("", ".", "..") for part in volid.split(":", 1)[1].split("/"))):
|
||||
raise BackupArchiveError("Invalid backup volume ID")
|
||||
if volid in seen:
|
||||
raise BackupArchiveError("Duplicate backup selection")
|
||||
seen.add(volid)
|
||||
return [{"storage": item["storage"], "volid": item["volid"]} for item in selected]
|
||||
|
||||
|
||||
def _protected(value):
|
||||
# Fail closed for an unexpected protection value, including the text 'true'.
|
||||
return value not in (None, False, 0, "0", "")
|
||||
|
||||
|
||||
def _remember_task(task, vmid, node, volid):
|
||||
# These are server-returned task IDs, never arbitrary client paths.
|
||||
if (not isinstance(task, str) or not task.startswith(f"UPID:{node}:")
|
||||
or ":imgdel:" not in task or "/" in task or len(task) > 1024):
|
||||
raise BackupArchiveError("Unexpected deletion task; outcome unconfirmed", 502)
|
||||
with _lock:
|
||||
now = time.monotonic()
|
||||
expired = [key for key, entry in _tasks.items() if now - entry["created"] > 3600]
|
||||
for key in expired:
|
||||
del _tasks[key]
|
||||
# Bound even failed/unpolled jobs; expiry does not cancel a PVE task.
|
||||
if len(_tasks) >= 512:
|
||||
del _tasks[min(_tasks, key=lambda key: _tasks[key]["created"])]
|
||||
_tasks[task] = {"vmid": vmid, "node": node, "volid": volid, "created": now}
|
||||
|
||||
|
||||
def delete_guest_backups(vmid, data):
|
||||
selected = _selection(vmid, data)
|
||||
node = _node()
|
||||
inventory = {}
|
||||
# Preflight the entire batch before deleting anything. An unavailable
|
||||
# storage or a protected/stale/cross-guest selection fails the whole batch.
|
||||
for storage in sorted({item["storage"] for item in selected}):
|
||||
entries = _pvesh(["get", f"/nodes/{node}/storage/{storage}/content",
|
||||
"--content", "backup", "--vmid", str(vmid)])
|
||||
if not isinstance(entries, list):
|
||||
raise BackupArchiveError("Invalid backup inventory", 502)
|
||||
for entry in entries:
|
||||
if (isinstance(entry, dict) and entry.get("content") == "backup"
|
||||
and isinstance(entry.get("volid"), str)):
|
||||
inventory[(storage, entry.get("volid"))] = entry
|
||||
for item in selected:
|
||||
entry = inventory.get((item["storage"], item["volid"]))
|
||||
if entry is None or str(entry.get("vmid")) != str(vmid):
|
||||
raise BackupArchiveError("Backup no longer exists or does not belong to this VM/CT", 409)
|
||||
if _protected(entry.get("protected")):
|
||||
raise BackupArchiveError("Protected backups cannot be deleted", 409)
|
||||
|
||||
def remove(item):
|
||||
try:
|
||||
# Proxmox also enforces protection at deletion time. It removes
|
||||
# associated notes/log files and returns null only after success.
|
||||
task = _pvesh(["delete", f'/nodes/{node}/storage/{item["storage"]}/content/{item["volid"]}',
|
||||
"--delay", "1"])
|
||||
if isinstance(task, str) and task.startswith("UPID:"):
|
||||
_remember_task(task, vmid, node, item["volid"])
|
||||
return {**item, "status": "pending", "task": task}
|
||||
if task is None or task == f"Removed volume '{item['volid']}'":
|
||||
return {**item, "status": "deleted"}
|
||||
return {**item, "status": "unconfirmed", "error": "Invalid Proxmox response; outcome unconfirmed"}
|
||||
except BackupArchiveError as error:
|
||||
return {**item, "status": "unconfirmed", "error": str(error)}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(selected)), thread_name_prefix="backup-delete") as pool:
|
||||
results = list(pool.map(remove, selected))
|
||||
return {"results": results}
|
||||
|
||||
|
||||
def guest_backup_deletion_status(vmid, task):
|
||||
with _lock:
|
||||
entry = _tasks.get(task) if isinstance(task, str) else None
|
||||
if not entry or entry["vmid"] != vmid or time.monotonic() - entry["created"] > 3600:
|
||||
raise BackupArchiveError("Deletion task unknown or expired; refresh the backup list", 409)
|
||||
entry = entry.copy()
|
||||
status = _pvesh(["get", f'/nodes/{entry["node"]}/tasks/{task}/status'])
|
||||
if not isinstance(status, dict) or status.get("status") not in ("running", "stopped"):
|
||||
raise BackupArchiveError("Invalid deletion task status", 502)
|
||||
result = {"volid": entry["volid"], "status": "pending"}
|
||||
if status["status"] == "stopped":
|
||||
if status.get("exitstatus") == "OK":
|
||||
result["status"] = "deleted"
|
||||
else:
|
||||
result.update(status="unconfirmed", error=status.get("exitstatus") or "Deletion outcome unconfirmed")
|
||||
return result
|
||||
@@ -51,6 +51,7 @@ DEBUG_PERF = False
|
||||
# ─── Startup Grace Period ────────────────────────────────────────────────────
|
||||
# Import centralized startup grace management for consistent behavior
|
||||
import startup_grace
|
||||
from pbs_manager import get_platform, PBS_SERVICES
|
||||
|
||||
def _is_startup_health_grace() -> bool:
|
||||
"""Check if we're within the startup health grace period (5 min).
|
||||
@@ -153,36 +154,10 @@ def _hdd_in_standby(disk_name: str) -> bool:
|
||||
return parked
|
||||
|
||||
|
||||
def _lvm_device_args() -> list:
|
||||
"""`--devices` list scoping pvs/lvs/vgs off parked disks so they
|
||||
aren't spun up by the LVM scan (issue #232). Empty when nothing is
|
||||
parked, so the plain command runs unchanged. Each partition is
|
||||
listed too — a whole-disk path alone doesn't cover a PV on a
|
||||
partition."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['lsblk', '-rno', 'NAME,TYPE,PKNAME'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
nodes = []
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
parts = line.split()
|
||||
if len(parts) < 2 or parts[1] not in ('disk', 'part'):
|
||||
continue
|
||||
name = parts[0]
|
||||
base = name if parts[1] == 'disk' else (parts[2] if len(parts) > 2 else name)
|
||||
nodes.append((name, base))
|
||||
standby_bases = {
|
||||
name for name, base in nodes
|
||||
if name == base and _hdd_in_standby(name)
|
||||
}
|
||||
if not standby_bases:
|
||||
return []
|
||||
devices = [f'/dev/{name}' for name, base in nodes if base not in standby_bases]
|
||||
return ['--devices', ','.join(devices)] if devices else []
|
||||
except Exception:
|
||||
return []
|
||||
def _lvm_device_args():
|
||||
"""None means no automatic scan; [] permits LVM's normal scan."""
|
||||
from lvm_device_scope import lvm_device_args
|
||||
return lvm_device_args(_hdd_in_standby)
|
||||
|
||||
|
||||
def _is_disk_removable(disk_name: str) -> bool:
|
||||
@@ -466,6 +441,7 @@ class HealthMonitor:
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize health monitor with state tracking"""
|
||||
self.platform_type = get_platform().get("product")
|
||||
self.state_history = defaultdict(list)
|
||||
self.last_check_times = {}
|
||||
self.cached_results = {}
|
||||
@@ -919,9 +895,9 @@ class HealthMonitor:
|
||||
_perf_log("services", (time.time() - _t) * 1000)
|
||||
details['services'] = services_status
|
||||
if services_status['status'] == 'CRITICAL':
|
||||
critical_issues.append(f"PVE Services: {services_status.get('reason', 'Service failure')}")
|
||||
critical_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service failure')}")
|
||||
elif services_status['status'] == 'WARNING':
|
||||
warning_issues.append(f"PVE Services: {services_status.get('reason', 'Service issue')}")
|
||||
warning_issues.append(f"Proxmox Services: {services_status.get('reason', 'Service issue')}")
|
||||
|
||||
# Priority 1.5: Proxmox Storage Check (External Module)
|
||||
_t = time.time()
|
||||
@@ -1661,7 +1637,7 @@ class HealthMonitor:
|
||||
|
||||
mem_critical_samples = [
|
||||
entry for entry in self.state_history[state_key]
|
||||
if entry['mem_percent'] >= 90 and
|
||||
if entry['mem_percent'] >= self.MEMORY_CRITICAL and
|
||||
current_time - entry['time'] <= self.MEMORY_DURATION
|
||||
]
|
||||
|
||||
@@ -1695,7 +1671,7 @@ class HealthMonitor:
|
||||
oldest = min(s['time'] for s in mem_critical_samples)
|
||||
actual_duration = int(current_time - oldest)
|
||||
status = 'CRITICAL'
|
||||
reason = f'RAM >90% sustained for {actual_duration}s'
|
||||
reason = f'RAM >{self.MEMORY_CRITICAL}% sustained for {actual_duration}s'
|
||||
elif swap_critical >= 2:
|
||||
status = 'CRITICAL'
|
||||
reason = (f'Memory pressure: swap {swap_percent:.0f}% used '
|
||||
@@ -1715,7 +1691,7 @@ class HealthMonitor:
|
||||
swap_total_gb = round(swap.total / (1024**3), 2)
|
||||
|
||||
# Determine per-sub-check status
|
||||
ram_status = 'CRITICAL' if mem_percent >= 90 and mem_critical_count >= MEM_CRITICAL_MIN_SAMPLES else ('WARNING' if mem_percent >= self.MEMORY_WARNING and mem_warning_count >= MEM_WARNING_MIN_SAMPLES else 'OK')
|
||||
ram_status = 'CRITICAL' if mem_percent >= self.MEMORY_CRITICAL and mem_critical_count >= MEM_CRITICAL_MIN_SAMPLES else ('WARNING' if mem_percent >= self.MEMORY_WARNING and mem_warning_count >= MEM_WARNING_MIN_SAMPLES else 'OK')
|
||||
swap_status = 'CRITICAL' if swap_critical >= 2 else 'OK'
|
||||
|
||||
result = {
|
||||
@@ -2303,12 +2279,17 @@ class HealthMonitor:
|
||||
|
||||
# `--devices` keeps lvs from scanning parked HDDs (issue #232).
|
||||
dev_args = _lvm_device_args()
|
||||
if dev_args is None:
|
||||
return {'status': 'INFO', 'skipped': True,
|
||||
'reason': 'Automatic LVM scan skipped by disk read policy or unavailable inventory'}
|
||||
result = subprocess.run(
|
||||
['lvs', *dev_args, '--noheadings', '--options', 'lv_name,vg_name,lv_attr'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3
|
||||
)
|
||||
from lvm_device_scope import settle_scan_reads
|
||||
settle_scan_reads()
|
||||
|
||||
if result.returncode != 0:
|
||||
return {'status': 'WARNING', 'reason': 'lvs command failed'}
|
||||
@@ -2333,6 +2314,7 @@ class HealthMonitor:
|
||||
text=True,
|
||||
timeout=3
|
||||
)
|
||||
settle_scan_reads()
|
||||
if vg_result.returncode == 0 and vg_result.stdout.strip():
|
||||
return {'status': 'WARNING', 'reason': 'No active LVM volumes detected'}
|
||||
else:
|
||||
@@ -2347,6 +2329,8 @@ class HealthMonitor:
|
||||
# The new _check_proxmox_storage function handles this logic better.
|
||||
def _check_proxmox_storages(self) -> Dict[str, Any]:
|
||||
"""Check Proxmox-specific storages (only report problems)"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
|
||||
storages = {}
|
||||
|
||||
try:
|
||||
@@ -3515,6 +3499,8 @@ class HealthMonitor:
|
||||
Optimized VM/CT check - detects qmp failures and startup errors from logs.
|
||||
Improved detection of container and VM errors from journalctl.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
|
||||
try:
|
||||
# First: auto-resolve any persisted VM/CT errors where the guest
|
||||
# is now running. This clears stale "Failed to start" / QMP
|
||||
@@ -3678,6 +3664,8 @@ class HealthMonitor:
|
||||
Check VMs/CTs with persistent error tracking.
|
||||
Errors persist until VM starts or 48h elapsed.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return {'status': 'OK', 'reason': 'Not applicable on PBS', 'checks': {}, 'not_applicable': True}
|
||||
try:
|
||||
issues = []
|
||||
vm_details = {}
|
||||
@@ -3919,8 +3907,18 @@ class HealthMonitor:
|
||||
"""
|
||||
try:
|
||||
# Build service list: base PVE services + corosync if clustered
|
||||
services_to_check = list(self.PVE_SERVICES)
|
||||
is_cluster = os.path.exists('/etc/corosync/corosync.conf')
|
||||
pbs_host = getattr(self, "platform_type", "pve") == "pbs"
|
||||
service_prefix = "pbs" if pbs_host else "pve"
|
||||
product_name = "PBS" if pbs_host else "PVE"
|
||||
services_to_check = list(PBS_SERVICES) if pbs_host else list(self.PVE_SERVICES)
|
||||
if pbs_host:
|
||||
# Old Monitor versions incorrectly treated absent PVE services
|
||||
# as PBS failures; remove only those known inapplicable errors.
|
||||
for service in self.PVE_SERVICES:
|
||||
old_key = f'pve_service_{service}'
|
||||
if health_persistence.is_error_active(old_key):
|
||||
health_persistence.clear_error(old_key)
|
||||
is_cluster = not pbs_host and os.path.exists('/etc/corosync/corosync.conf')
|
||||
if is_cluster and 'corosync' not in services_to_check:
|
||||
services_to_check.append('corosync')
|
||||
|
||||
@@ -3952,14 +3950,14 @@ class HealthMonitor:
|
||||
# cleanup loop). Audit Tier 5 — Health stack.
|
||||
for svc in services_to_check:
|
||||
if svc not in failed_services:
|
||||
error_key = f'pve_service_{svc}'
|
||||
error_key = f'{service_prefix}_service_{svc}'
|
||||
if health_persistence.is_error_active(error_key):
|
||||
health_persistence.clear_error(error_key)
|
||||
|
||||
# Build checks dict with status per service
|
||||
checks = {}
|
||||
for svc in services_to_check:
|
||||
error_key = f'pve_service_{svc}'
|
||||
error_key = f'{service_prefix}_service_{svc}'
|
||||
if svc in failed_services:
|
||||
state = service_details.get(svc, 'inactive')
|
||||
checks[svc] = {
|
||||
@@ -3987,12 +3985,12 @@ class HealthMonitor:
|
||||
# Record each failed service in persistence, respecting dismiss
|
||||
active_failed = []
|
||||
for svc in failed_services:
|
||||
error_key = f'pve_service_{svc}'
|
||||
error_key = f'{service_prefix}_service_{svc}'
|
||||
rec_result = health_persistence.record_error(
|
||||
error_key=error_key,
|
||||
category='pve_services',
|
||||
severity='CRITICAL',
|
||||
reason=f'PVE service {svc} is {service_details.get(svc, "inactive")}',
|
||||
reason=f'{product_name} service {svc} is {service_details.get(svc, "inactive")}',
|
||||
details={'service': svc, 'state': service_details.get(svc, 'inactive')}
|
||||
)
|
||||
if rec_result and rec_result.get('type') == 'skipped_acknowledged':
|
||||
@@ -5816,6 +5814,8 @@ class HealthMonitor:
|
||||
- No persistent errors are recorded
|
||||
This prevents false positives when NFS/PBS/remote storage is still mounting.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
if not PROXMOX_STORAGE_AVAILABLE:
|
||||
return None
|
||||
|
||||
@@ -6076,6 +6076,8 @@ class HealthMonitor:
|
||||
if status == 'stale':
|
||||
checks[check_label] = {
|
||||
'status': 'CRITICAL',
|
||||
'error_key': error_key,
|
||||
'dismissable': True,
|
||||
'detail': f'{fstype} mount stale: {error or "unreachable"}',
|
||||
'fstype': fstype,
|
||||
'source': m.get('source', ''),
|
||||
@@ -6105,6 +6107,8 @@ class HealthMonitor:
|
||||
elif status == 'readonly':
|
||||
checks[check_label] = {
|
||||
'status': 'WARNING',
|
||||
'error_key': error_key,
|
||||
'dismissable': True,
|
||||
'detail': f'{fstype} mount unexpectedly read-only',
|
||||
'fstype': fstype,
|
||||
'source': m.get('source', ''),
|
||||
@@ -6205,6 +6209,8 @@ class HealthMonitor:
|
||||
Notification cooldown is the standard 1h via the storage
|
||||
category, so a CT teetering at 96% won't spam the channel.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
# Cheap pre-check: bail out when there are no running LXCs. On
|
||||
# nodes without CTs, the previous version still paid ~860ms per
|
||||
# cycle for a `pvesh /cluster/resources` call that produced no
|
||||
@@ -6365,6 +6371,8 @@ class HealthMonitor:
|
||||
path, so a slow / dead guest agent can't stretch the health
|
||||
cycle.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
try:
|
||||
import flask_server # deferred — avoids circular import
|
||||
resources = flask_server.get_cached_pvesh_cluster_resources_vm() or []
|
||||
@@ -6524,6 +6532,8 @@ class HealthMonitor:
|
||||
check (already covered) because those are usually large data
|
||||
mounts whose fill rate is driven by user content, not log spam.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
if not MOUNT_MONITOR_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
@@ -6627,6 +6637,8 @@ class HealthMonitor:
|
||||
check via _check_filesystem. Re-alerting on the same condition
|
||||
through two channels just clutters the inbox.
|
||||
"""
|
||||
if getattr(self, "platform_type", "pve") == "pbs":
|
||||
return None
|
||||
if not PROXMOX_STORAGE_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
|
||||
@@ -8,6 +8,8 @@ from flask import request, jsonify, g
|
||||
from functools import wraps
|
||||
from auth_manager import load_auth_config, verify_token, verify_token_full
|
||||
|
||||
_READ_METHODS = ('GET', 'HEAD', 'OPTIONS')
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""
|
||||
@@ -64,12 +66,18 @@ def require_auth(f):
|
||||
token = parts[1]
|
||||
|
||||
# Verify token
|
||||
username = verify_token(token)
|
||||
username, scope = verify_token_full(token)
|
||||
if not username:
|
||||
return jsonify({
|
||||
"error": "Invalid or expired token",
|
||||
"message": "Please log in again"
|
||||
}), 401
|
||||
|
||||
# read_only tokens may only read; any method that can change
|
||||
# state needs full_admin.
|
||||
if request.method not in _READ_METHODS and scope != 'full_admin':
|
||||
return jsonify({"error": "Insufficient scope",
|
||||
"message": f"This action requires a full_admin token (your token: {scope})"}), 403
|
||||
|
||||
# Token is valid, allow access
|
||||
return f(*args, **kwargs)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# ==========================================================
|
||||
# ProxMenux Monitor - LVM disk read scope
|
||||
# Author : Jonatan
|
||||
# GitHub : github.com/jcastro
|
||||
# Maintainer : MacRimi
|
||||
# Copyright : (c) 2026 MacRimi & contributors
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 16/10/2026
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# Applies the automatic disk-read policy to LVM metadata scans in both
|
||||
# Monitor readers. Excluded and idle disks receive no power-mode probe;
|
||||
# every device built on them is excluded too, and the scan's own reads
|
||||
# are not taken as activity. No eligible devices means no scan.
|
||||
# ==========================================================
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
_NAME = re.compile(r"[A-Za-z0-9._+-]+")
|
||||
_scan_lock = threading.Lock()
|
||||
_scanned_disks = set()
|
||||
|
||||
|
||||
def _topology():
|
||||
"""Every block device by kernel name: its type, its parents and whether
|
||||
it is an LVM physical volume. lsblk reads this from udev, not the disks.
|
||||
A RAID or multipath device appears once per member."""
|
||||
result = subprocess.run(["lsblk", "-J", "-l", "-o", "KNAME,TYPE,PKNAME,FSTYPE"],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode:
|
||||
return None
|
||||
types, parents, volumes = {}, {}, set()
|
||||
for device in json.loads(result.stdout).get("blockdevices") or []:
|
||||
name, parent = device.get("kname"), device.get("pkname")
|
||||
if not isinstance(name, str) or not _NAME.fullmatch(name):
|
||||
return None
|
||||
types[name] = device.get("type")
|
||||
parents.setdefault(name, set())
|
||||
if parent is not None:
|
||||
if not isinstance(parent, str) or not _NAME.fullmatch(parent):
|
||||
return None
|
||||
parents[name].add(parent)
|
||||
if device.get("fstype") == "LVM2_member" and device.get("type") != "lvm":
|
||||
volumes.add(name)
|
||||
return types, parents, volumes
|
||||
|
||||
|
||||
def _disks_under(name, parents, seen=None):
|
||||
"""The disks a device is built on: itself, or its parents' disks."""
|
||||
seen = seen if seen is not None else set()
|
||||
if name in seen:
|
||||
return set()
|
||||
seen.add(name)
|
||||
if not parents.get(name):
|
||||
return {name}
|
||||
found = set()
|
||||
for parent in parents[name]:
|
||||
found |= _disks_under(parent, parents, seen)
|
||||
return found
|
||||
|
||||
|
||||
def lvm_device_args(standby_check):
|
||||
"""Return [] for an unrestricted scan, --devices args, or None to skip.
|
||||
|
||||
A failed inventory must never fall back to an unrestricted scan. None
|
||||
is distinct from []: if every disk is excluded/idle/asleep, callers
|
||||
must not start pvs/lvs/vgs at all.
|
||||
"""
|
||||
try:
|
||||
from disk_temperature_history import disk_read_policy, excluded_disk_names
|
||||
topology = _topology()
|
||||
if topology is None:
|
||||
return None
|
||||
types, parents, volumes = topology
|
||||
disks = {name for name, kind in types.items() if kind == "disk"}
|
||||
if not disks:
|
||||
return None
|
||||
excluded = excluded_disk_names()
|
||||
blocked = set()
|
||||
for disk in sorted(disks):
|
||||
# Test policy first: even CHECK POWER MODE is forbidden for an
|
||||
# excluded/idle disk. Some USB bridges do not honour -n standby.
|
||||
if disk_read_policy(disk, excluded) != "read" or standby_check(disk):
|
||||
blocked.add(disk)
|
||||
allowed = sorted(f"/dev/{name}" for name in volumes if not (_disks_under(name, parents) & blocked))
|
||||
with _scan_lock:
|
||||
_scanned_disks.clear()
|
||||
_scanned_disks.update(disks - blocked)
|
||||
if not blocked:
|
||||
return []
|
||||
# An explicit list makes LVM open every device in it, so only the
|
||||
# physical volumes are named: a RAID, multipath or encrypted volume
|
||||
# built on a blocked disk would read that disk too and stays out.
|
||||
if not allowed:
|
||||
return None
|
||||
return ["--devices", ",".join(allowed)]
|
||||
except Exception:
|
||||
# Unavailable exclusions, topology or policy are not permission to
|
||||
# open devices that may have been deliberately excluded.
|
||||
return None
|
||||
|
||||
|
||||
def settle_scan_reads():
|
||||
"""The LVM scan reads the disks it is allowed to. Those reads are its
|
||||
own, so they must not make an unused disk look busy at the next check."""
|
||||
try:
|
||||
from disk_temperature_history import settle_own_reads
|
||||
with _scan_lock:
|
||||
disks = set(_scanned_disks)
|
||||
settle_own_reads(disks)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -94,6 +94,7 @@ _VALID_UPSTREAM_TYPES = ("github", "http_json", "docker_hub")
|
||||
# `*/N` step, and comma lists — that covers every preset the UI
|
||||
# exposes and the freeform "custom" text field.
|
||||
_VALID_SCHEDULE_TARGETS = ("os", "app", "both")
|
||||
_SCHEDULE_REASON_CODES = frozenset({"stopped_container"})
|
||||
_SCHEDULE_TARGET_ID_RE = re.compile(
|
||||
r"^(?:os|apps|oci_image|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-(?:compose|container):[A-Za-z0-9][A-Za-z0-9_.-]{0,127}|docker-unit:[a-f0-9]{20})$"
|
||||
)
|
||||
@@ -1178,6 +1179,13 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
|
||||
return _err("logo_url must be an http(s) URL (max 512 chars)")
|
||||
conf["logo_url"] = logo
|
||||
|
||||
# Native discovery identity is separate from a curated helper/updater.
|
||||
discovery_id = payload.get("discovery_id")
|
||||
if discovery_id is not None:
|
||||
if not isinstance(discovery_id, str) or not re.fullmatch(r"native-[0-9a-f]{24}", discovery_id):
|
||||
return _err("invalid discovery_id")
|
||||
conf["discovery_id"] = discovery_id
|
||||
|
||||
# Optional helper_slug — set by the frontend when the user picks
|
||||
# an auto-detected app (primary or extra). Persisted so we can
|
||||
# filter the "also detected" chip list against apps already
|
||||
@@ -2081,8 +2089,28 @@ class _DockerNoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
_docker_no_redirect_opener = urllib.request.build_opener(_DockerNoRedirect)
|
||||
|
||||
|
||||
def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Exchange a parsed ``WWW-Authenticate`` challenge for a pull token."""
|
||||
_REGISTRY_AUTH_FILE = "/etc/pve/priv/proxmenux/registry-auth.json"
|
||||
_DOCKER_HUB_API_HOSTS = {"docker.io", "index.docker.io", "registry-1.docker.io", "registry.hub.docker.com"}
|
||||
|
||||
|
||||
def _registry_saved_login(api_host: str) -> Optional[str]:
|
||||
"""The login saved in ProxMenux for a registry, as a Basic credential."""
|
||||
try:
|
||||
with open(_REGISTRY_AUTH_FILE, encoding="utf-8") as handle:
|
||||
auths = (json.load(handle) or {}).get("auths") or {}
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return None
|
||||
host = (api_host or "").lower()
|
||||
for key in ([host, "docker.io"] if host in _DOCKER_HUB_API_HOSTS else [host]):
|
||||
entry = auths.get(key) if isinstance(auths, dict) else None
|
||||
if isinstance(entry, dict) and isinstance(entry.get("auth"), str) and entry["auth"]:
|
||||
return f"Basic {entry['auth']}"
|
||||
return None
|
||||
|
||||
|
||||
def _registry_bearer_token(challenge: dict, saved_login: Optional[str] = None) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Exchange a parsed ``WWW-Authenticate`` challenge for a pull token,
|
||||
with the login saved for a private registry when there is one."""
|
||||
query = {
|
||||
key: challenge[key]
|
||||
for key in ("service", "scope") if challenge.get(key)
|
||||
@@ -2090,10 +2118,10 @@ def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str
|
||||
token_url = challenge["realm"]
|
||||
if query:
|
||||
token_url += ("&" if "?" in token_url else "?") + urllib.parse.urlencode(query)
|
||||
token_req = urllib.request.Request(
|
||||
token_url,
|
||||
headers={"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"},
|
||||
)
|
||||
token_headers = {"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"}
|
||||
if saved_login:
|
||||
token_headers["Authorization"] = saved_login
|
||||
token_req = urllib.request.Request(token_url, headers=token_headers)
|
||||
try:
|
||||
with urllib.request.urlopen(token_req, timeout=_docker_registry_timeout()) as response:
|
||||
body = response.read(65537)
|
||||
@@ -2160,13 +2188,18 @@ def _registry_request(url: str, headers: dict, method: str = "HEAD",
|
||||
if error:
|
||||
return None, None, token, error
|
||||
if status == 401:
|
||||
challenge = _parse_bearer_challenge((response_headers or {}).get("WWW-Authenticate", ""))
|
||||
if not challenge:
|
||||
authenticate = (response_headers or {}).get("WWW-Authenticate", "")
|
||||
saved_login = _registry_saved_login(urllib.parse.urlsplit(url).netloc)
|
||||
challenge = _parse_bearer_challenge(authenticate)
|
||||
if challenge:
|
||||
token, token_error = _registry_bearer_token(challenge, saved_login)
|
||||
if token_error:
|
||||
return None, None, None, token_error
|
||||
attempt_headers["Authorization"] = f"Bearer {token}"
|
||||
elif saved_login and authenticate.lower().startswith("basic") and url.startswith("https://"):
|
||||
attempt_headers["Authorization"] = saved_login
|
||||
else:
|
||||
return None, None, token, "registry authentication required"
|
||||
token, token_error = _registry_bearer_token(challenge)
|
||||
if token_error:
|
||||
return None, None, None, token_error
|
||||
attempt_headers["Authorization"] = f"Bearer {token}"
|
||||
response_headers, body, status, location, error = _registry_open(
|
||||
url, attempt_headers, method, max_bytes,
|
||||
)
|
||||
@@ -3918,6 +3951,7 @@ def record_schedule_run(
|
||||
target: str,
|
||||
reason: Optional[str] = None,
|
||||
*,
|
||||
reason_code: Optional[str] = None,
|
||||
log_name: Optional[str] = None,
|
||||
reboot_required: Optional[bool] = None,
|
||||
reboot_packages: Optional[list[str]] = None,
|
||||
@@ -3937,6 +3971,10 @@ def record_schedule_run(
|
||||
sidecar["schedule"]["last_run_reason"] = str(reason)[:300]
|
||||
else:
|
||||
sidecar["schedule"].pop("last_run_reason", None)
|
||||
if reason_code in _SCHEDULE_REASON_CODES:
|
||||
sidecar["schedule"]["last_run_reason_code"] = reason_code
|
||||
else:
|
||||
sidecar["schedule"].pop("last_run_reason_code", None)
|
||||
if log_name:
|
||||
sidecar["schedule"]["last_run_log"] = os.path.basename(str(log_name))[:220]
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Other ProxMenux Monitors reachable from this one: cluster nodes, PBS servers
|
||||
and entries added by hand.
|
||||
|
||||
Only links are kept here. No credential is stored or sent: the browser opens
|
||||
the other Monitor, which keeps its own sign-in.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pbs_manager
|
||||
|
||||
LINKS_FILE = Path(os.environ.get("PROXMENUX_MONITOR_LINKS", "/usr/local/share/proxmenux/monitor_links.json"))
|
||||
SSL_CONFIG_FILE = Path(os.environ.get("PROXMENUX_SSL_CONFIG", "/etc/proxmenux/ssl_config.json"))
|
||||
STORAGE_CFG = Path("/etc/pve/storage.cfg")
|
||||
MONITOR_PORT = 8008
|
||||
PBS_UI_PORT = 8007
|
||||
PROBE_TIMEOUT = 3
|
||||
PROBE_TTL = 300
|
||||
MAX_MANUAL = 64
|
||||
PRODUCTS = ("pve", "pbs")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_probe_cache = {}
|
||||
_snapshot = {"at": 0.0, "data": None}
|
||||
_refresh_lock = threading.Lock()
|
||||
|
||||
|
||||
class LinkError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _load():
|
||||
try:
|
||||
data = json.loads(LINKS_FILE.read_text())
|
||||
except (OSError, ValueError):
|
||||
return {"manual": [], "overrides": {}}
|
||||
manual = data.get("manual") if isinstance(data.get("manual"), list) else []
|
||||
overrides = data.get("overrides") if isinstance(data.get("overrides"), dict) else {}
|
||||
return {"manual": [row for row in manual if isinstance(row, dict)], "overrides": overrides}
|
||||
|
||||
|
||||
def _save(data):
|
||||
LINKS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = LINKS_FILE.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(data, indent=2))
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, LINKS_FILE)
|
||||
|
||||
|
||||
def normalize_url(value):
|
||||
"""Return the origin of an http(s) address, or raise LinkError."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise LinkError("Enter the address of the Monitor.")
|
||||
text = value.strip()
|
||||
if "://" not in text:
|
||||
text = "https://" + text
|
||||
parsed = urllib.parse.urlsplit(text)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
raise LinkError("The address must start with http:// or https:// and name a host.")
|
||||
if parsed.username or parsed.password:
|
||||
raise LinkError("The address must not contain a user or password.")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise LinkError("The port of the address is not valid.") from exc
|
||||
host = parsed.hostname
|
||||
if ":" in host:
|
||||
host = f"[{host}]"
|
||||
return f"{parsed.scheme}://{host}" + (f":{port}" if port else "")
|
||||
|
||||
|
||||
def _local_scheme():
|
||||
try:
|
||||
return "https" if json.loads(SSL_CONFIG_FILE.read_text()).get("enabled") else "http"
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return "http"
|
||||
|
||||
|
||||
def _host_url(host, port, scheme=None):
|
||||
if ":" in host and not host.startswith("["):
|
||||
host = f"[{host}]"
|
||||
return f"{scheme or _local_scheme()}://{host}:{port}"
|
||||
|
||||
|
||||
def _is_private(host):
|
||||
"""Only addresses in private ranges are probed without being asked to."""
|
||||
try:
|
||||
addresses = {info[4][0] for info in socket.getaddrinfo(host, None)}
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
return bool(addresses) and all(ipaddress.ip_address(a.split("%")[0]).is_private for a in addresses)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _cluster_nodes():
|
||||
try:
|
||||
result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"],
|
||||
capture_output=True, text=True, timeout=8)
|
||||
rows = json.loads(result.stdout) if result.returncode == 0 else []
|
||||
except (OSError, subprocess.SubprocessError, ValueError):
|
||||
return []
|
||||
nodes = []
|
||||
for row in rows if isinstance(rows, list) else []:
|
||||
if isinstance(row, dict) and row.get("type") == "node" and not row.get("local") and row.get("name") and row.get("ip"):
|
||||
nodes.append({"key": f"cluster:{row['name']}", "name": str(row["name"]), "product": "pve",
|
||||
"group": "cluster", "host": str(row["ip"])})
|
||||
return nodes
|
||||
|
||||
|
||||
def _storage_pbs():
|
||||
try:
|
||||
text = STORAGE_CFG.read_text()
|
||||
except OSError:
|
||||
return []
|
||||
servers, current = [], None
|
||||
for line in text.splitlines():
|
||||
header = re.match(r"^(\w+):\s*(\S+)", line)
|
||||
if header:
|
||||
current = {"name": header.group(2), "port": PBS_UI_PORT} if header.group(1) == "pbs" else None
|
||||
if current is not None:
|
||||
servers.append(current)
|
||||
continue
|
||||
option = re.match(r"^\s+(server|port)\s+(\S+)", line)
|
||||
if current is not None and option:
|
||||
current[option.group(1)] = option.group(2)
|
||||
return [row for row in servers if row.get("server")]
|
||||
|
||||
|
||||
def _pbs_servers():
|
||||
found = {}
|
||||
for row in _storage_pbs():
|
||||
host = row["server"]
|
||||
port = row.get("port") if str(row.get("port", "")).isdigit() else PBS_UI_PORT
|
||||
found.setdefault(host, {"name": row["name"], "native_url": _host_url(host, port, "https")})
|
||||
try:
|
||||
for row in pbs_manager.public_servers():
|
||||
host = urllib.parse.urlsplit(row.get("url", "")).hostname
|
||||
if host:
|
||||
found.setdefault(host, {"name": row.get("name") or host, "native_url": normalize_url(row["url"])})
|
||||
except Exception:
|
||||
pass
|
||||
return [{"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs",
|
||||
"host": host, "native_url": info["native_url"]} for host, info in found.items()]
|
||||
|
||||
|
||||
def _probe(url):
|
||||
"""True when a ProxMenux Monitor answers at url; nothing is sent but the request."""
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
cached = _probe_cache.get(url)
|
||||
if cached and cached[0] > now:
|
||||
return cached[1]
|
||||
context = ssl.create_default_context()
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE # liveness only: no credential or token is ever sent
|
||||
online = False
|
||||
try:
|
||||
request = urllib.request.Request(url + "/api/auth/status", headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(request, timeout=PROBE_TIMEOUT, context=context) as response:
|
||||
online = response.status == 200 and "auth_enabled" in json.loads(response.read(4096))
|
||||
except Exception:
|
||||
online = False
|
||||
with _lock:
|
||||
if len(_probe_cache) > 256:
|
||||
_probe_cache.clear()
|
||||
_probe_cache[url] = (now + PROBE_TTL, online)
|
||||
return online
|
||||
|
||||
|
||||
def _resolve(entry, override):
|
||||
"""Pick the Monitor address and its state for one discovered or manual entry."""
|
||||
if override.get("url"):
|
||||
return override["url"], "online" if _probe(override["url"]) else "offline"
|
||||
if entry.get("url"):
|
||||
return entry["url"], "online" if _probe(entry["url"]) else "offline"
|
||||
host = entry["host"]
|
||||
if not _is_private(host):
|
||||
return None, "unknown"
|
||||
for scheme in (_local_scheme(), "https" if _local_scheme() == "http" else "http"):
|
||||
url = _host_url(host, MONITOR_PORT, scheme)
|
||||
if _probe(url):
|
||||
return url, "online"
|
||||
return _host_url(host, MONITOR_PORT), "offline"
|
||||
|
||||
|
||||
def list_links(include_hidden=False):
|
||||
platform = pbs_manager.get_platform()
|
||||
data = _load()
|
||||
entries = []
|
||||
if platform.get("product") != "pbs":
|
||||
entries += _cluster_nodes() + _pbs_servers()
|
||||
for row in data["manual"]:
|
||||
entries.append({"key": f"manual:{row.get('id')}", "name": row.get("name") or row.get("url"),
|
||||
"product": row.get("product") if row.get("product") in PRODUCTS else "pve",
|
||||
"group": "pbs" if row.get("product") == "pbs" else "standalone",
|
||||
"url": row.get("url"), "manual": True})
|
||||
visible = [e for e in entries if include_hidden or not data["overrides"].get(e["key"], {}).get("hidden")]
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
resolved = list(executor.map(lambda e: _resolve(e, data["overrides"].get(e["key"], {})), visible))
|
||||
links = []
|
||||
for entry, (url, state) in zip(visible, resolved):
|
||||
override = data["overrides"].get(entry["key"], {})
|
||||
links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"],
|
||||
"group": entry["group"], "url": url, "status": state,
|
||||
"native_url": entry.get("native_url"), "manual": bool(entry.get("manual")),
|
||||
"hidden": bool(override.get("hidden")), "custom_url": bool(override.get("url"))})
|
||||
return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")},
|
||||
"links": links}
|
||||
|
||||
|
||||
def refresh():
|
||||
"""Recompute the list with fresh probes and keep it for the header."""
|
||||
with _refresh_lock:
|
||||
with _lock:
|
||||
_probe_cache.clear()
|
||||
data = list_links(include_hidden=True)
|
||||
_snapshot.update(at=time.monotonic(), data=data)
|
||||
return data
|
||||
|
||||
|
||||
def _refresh_quietly():
|
||||
try:
|
||||
refresh()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def cached_links(include_hidden=False):
|
||||
"""The last list at once; a stale one is renewed in the background."""
|
||||
data = _snapshot["data"]
|
||||
if data is None:
|
||||
data = refresh()
|
||||
elif time.monotonic() - _snapshot["at"] > PROBE_TTL and not _refresh_lock.locked():
|
||||
threading.Thread(target=_refresh_quietly, daemon=True, name="monitor-links-refresh").start()
|
||||
if include_hidden:
|
||||
return data
|
||||
return {**data, "links": [row for row in data["links"] if not row["hidden"]]}
|
||||
|
||||
|
||||
def start_prewarmer():
|
||||
def loop():
|
||||
time.sleep(10)
|
||||
while True:
|
||||
_refresh_quietly()
|
||||
time.sleep(PROBE_TTL)
|
||||
threading.Thread(target=loop, daemon=True, name="monitor-links-prewarmer").start()
|
||||
|
||||
|
||||
def add_link(payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
name = str(payload.get("name") or "").strip()[:64]
|
||||
url = normalize_url(payload.get("url"))
|
||||
product = payload.get("product") if payload.get("product") in PRODUCTS else "pve"
|
||||
with _lock:
|
||||
data = _load()
|
||||
if len(data["manual"]) >= MAX_MANUAL:
|
||||
raise LinkError(f"At most {MAX_MANUAL} nodes can be added.")
|
||||
if any(row.get("url") == url for row in data["manual"]):
|
||||
raise LinkError("This address is already in the list.")
|
||||
identifier = secrets.token_hex(6)
|
||||
data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname,
|
||||
"url": url, "product": product})
|
||||
_save(data)
|
||||
return f"manual:{identifier}"
|
||||
|
||||
|
||||
def update_link(key, payload):
|
||||
if not isinstance(payload, dict):
|
||||
raise LinkError("Invalid request.")
|
||||
with _lock:
|
||||
data = _load()
|
||||
if key.startswith("manual:"):
|
||||
row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None)
|
||||
if row is None:
|
||||
raise LinkError("Node not found.")
|
||||
if "name" in payload:
|
||||
row["name"] = str(payload["name"] or "").strip()[:64] or row["name"]
|
||||
if payload.get("url"):
|
||||
row["url"] = normalize_url(payload["url"])
|
||||
if payload.get("product") in PRODUCTS:
|
||||
row["product"] = payload["product"]
|
||||
elif re.fullmatch(r"(cluster|pbs):[\w.:\-\[\]]{1,253}", key):
|
||||
override = data["overrides"].setdefault(key, {})
|
||||
if "url" in payload:
|
||||
if payload["url"]:
|
||||
override["url"] = normalize_url(payload["url"])
|
||||
else:
|
||||
override.pop("url", None)
|
||||
if "hidden" in payload:
|
||||
override["hidden"] = bool(payload["hidden"])
|
||||
if not override:
|
||||
data["overrides"].pop(key, None)
|
||||
else:
|
||||
raise LinkError("Node not found.")
|
||||
_save(data)
|
||||
|
||||
|
||||
def delete_link(key):
|
||||
with _lock:
|
||||
data = _load()
|
||||
remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key]
|
||||
if len(remaining) == len(data["manual"]):
|
||||
raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.")
|
||||
data["manual"] = remaining
|
||||
_save(data)
|
||||
@@ -0,0 +1,750 @@
|
||||
#!/usr/bin/env python3
|
||||
# ==========================================================
|
||||
# ProxMenux - A menu-driven script for Proxmox VE management
|
||||
# Author : VAIO73
|
||||
# GitHub : github.com/Vaso73
|
||||
# Maintainer : MacRimi
|
||||
# Copyright : (c) 2026 MacRimi & contributors
|
||||
# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE)
|
||||
# Version : 1.0
|
||||
# Last Updated: 14/10/2026
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# On-demand native LXC discovery from service and installation metadata.
|
||||
# Reads bounded data only; never executes discovered programs, starts guests,
|
||||
# registers applications or infers their update mechanism.
|
||||
# ==========================================================
|
||||
"""Separate from catalog detection: callers must explicitly request a scan."""
|
||||
|
||||
import copy
|
||||
from contextlib import contextmanager
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import selectors
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
TOTAL_SECONDS = 20
|
||||
PROBE_SECONDS = 3
|
||||
OUTPUT_BYTES = 64 * 1024
|
||||
MAX_RECORDS = 256
|
||||
MAX_CANDIDATES = 50
|
||||
CACHE_SECONDS = 300
|
||||
LOCAL_CONFIG = Path('/etc/pve/local/lxc')
|
||||
OCI_ROOT = Path('/usr/local/share/proxmenux/oci/instances')
|
||||
UPDATE_LOCK_ROOT = Path('/run/lock')
|
||||
_cache = {}
|
||||
_locks = {}
|
||||
_generations = {}
|
||||
_mutex = threading.Lock()
|
||||
_PATH = re.compile(r'/[A-Za-z0-9_.+/@-]{1,510}\Z')
|
||||
_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.+@-]{0,63}\Z')
|
||||
_VERSION = re.compile(r'v?\d+(?:\.\d+){1,4}(?:[-+._][A-Za-z0-9.-]+)?\Z')
|
||||
_SYSTEM = {
|
||||
'ssh', 'sshd', 'cron', 'crond', 'postfix', 'rsyslog', 'syslog', 'syslog-ng',
|
||||
# Alpine/OpenRC system logging and service supervision are not apps.
|
||||
'klogd', 'syslogd', 'openrc-user', 'runit', 'runsvdir', 'runsv', 'supervise-daemon',
|
||||
'dbus', 'dbus-broker', 'systemd', 'udev', 'networking', 'network', 'networkmanager',
|
||||
'dhcpcd', 'dhclient', 'chrony', 'chronyd', 'ntp', 'ntpd', 'openntpd', 'agetty',
|
||||
'getty', 'login', 'polkit', 'polkitd', 'accounts-daemon', 'acpid', 'anacron',
|
||||
'docker', 'containerd', 'containerd-shim', 'podman', 'qemu-guest-agent', 'rngd',
|
||||
'auditd', 'logrotate', 'local', 'bootmisc', 'hostname', 'hwclock', 'modules', 'master',
|
||||
'proxmox-boot-sync', 'pve-container', 'pve-firewall', 'pvefw-logger',
|
||||
'pvedaemon', 'pvestatd', 'pveproxy', 'pvescheduler', 'spiceproxy', 'lxc',
|
||||
'mount-ro', 'mdev', 'sysctl', 'sysfs', 'devfs', 'cgroups', 'rc', 'rcs', 'swap',
|
||||
'sleep', 'true', 'false', 'echo', 'cat', 'head', 'tail', 'tee', 'logger',
|
||||
'apt', 'apt-get', 'dpkg', 'dpkg-query', 'apk', 'unattended-upgrades',
|
||||
'fstrim', 'fsck', 'fsckd', 'e2scrub', 'e2scrub-all', 'e2scrub-reap',
|
||||
'irqbalance', 'smartd', 'smartmontools', 'rpcbind', 'nfs-common', 'nfs-server',
|
||||
'rpc-statd', 'rpc-statd-notify', 'rpc-gssd', 'rpc-svcgssd', 'nftables', 'iptables',
|
||||
'ip6tables', 'firewalld', 'ufw', 'resolvconf', 'wpa-supplicant', 'modemmanager',
|
||||
'console-setup', 'keyboard-setup', 'plymouth', 'halt', 'reboot', 'shutdown',
|
||||
'rc-local', 'ntpsec', 'networkd-dispatcher', 'busybox', 'atd', 'uuidd',
|
||||
# Common packaged daemons are infrastructure, not a user-managed app.
|
||||
'nginx', 'apache', 'apache2', 'httpd', 'lighttpd', 'haproxy', 'traefik',
|
||||
'redis', 'redis-server', 'memcached', 'mysql', 'mysqld', 'mariadb', 'mariadbd',
|
||||
'postgres', 'postgresql', 'mongod', 'mongodb', 'rabbitmq-server',
|
||||
'elasticsearch', 'opensearch', 'fail2ban', 'named', 'bind9', 'dnsmasq',
|
||||
'avahi-daemon', 'smbd', 'nmbd', 'samba', 'vsftpd', 'proftpd', 'cups', 'tor',
|
||||
}
|
||||
_RUNTIMES = re.compile(r'(?:python[0-9.]*|node(?:js)?|npm|pnpm|yarn|bun|sh|bash|dash|env|java|ruby|perl)\Z')
|
||||
_PACKAGE_ROOT_RUNTIMES = {'node', 'nodejs', 'npm', 'pnpm', 'yarn', 'bun'}
|
||||
_HELPER_ROLES = {
|
||||
'backup', 'bootstrap', 'bridge', 'cleanup', 'housekeeping', 'maintenance',
|
||||
'migrate', 'migration', 'restore', 'rotate', 'update', 'updater', 'upgrade',
|
||||
'watch', 'watchdog', 'worker',
|
||||
}
|
||||
_HESTIA_NGINX = '/usr/local/hestia/nginx/sbin/hestia-nginx'
|
||||
_HESTIA_ROOT = '/usr/local/hestia'
|
||||
_HESTIA_PANEL_PORT = 8083
|
||||
|
||||
|
||||
class ProbeError(Exception):
|
||||
"""Safe error code only; guest output must never become an API error."""
|
||||
|
||||
|
||||
def bounded_run(argv, deadline):
|
||||
"""Cap combined stdout/stderr while reading, and kill/reap the process group."""
|
||||
remaining = min(PROBE_SECONDS, deadline - time.monotonic())
|
||||
if remaining <= 0:
|
||||
raise ProbeError('deadline')
|
||||
end = time.monotonic() + remaining
|
||||
try:
|
||||
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
start_new_session=True)
|
||||
except OSError:
|
||||
raise ProbeError('unavailable') from None
|
||||
outputs = [bytearray(), bytearray()]
|
||||
try:
|
||||
with selectors.DefaultSelector() as poll:
|
||||
for index, stream in enumerate((proc.stdout, proc.stderr)):
|
||||
os.set_blocking(stream.fileno(), False)
|
||||
poll.register(stream, selectors.EVENT_READ, index)
|
||||
while poll.get_map():
|
||||
wait = end - time.monotonic()
|
||||
if wait <= 0:
|
||||
raise ProbeError('timeout')
|
||||
for key, _ in poll.select(wait):
|
||||
block = os.read(key.fileobj.fileno(), 4096)
|
||||
if not block:
|
||||
poll.unregister(key.fileobj)
|
||||
continue
|
||||
if sum(map(len, outputs)) + len(block) > OUTPUT_BYTES:
|
||||
raise ProbeError('output_limit')
|
||||
outputs[key.data].extend(block)
|
||||
try:
|
||||
proc.wait(timeout=max(0.001, end - time.monotonic()))
|
||||
except subprocess.TimeoutExpired:
|
||||
raise ProbeError('timeout') from None
|
||||
if proc.returncode:
|
||||
raise ProbeError('read_failed')
|
||||
return outputs[0].decode('utf-8', errors='strict')
|
||||
except UnicodeError:
|
||||
raise ProbeError('invalid_data') from None
|
||||
finally:
|
||||
# Also clean up descendants that outlive a successfully exited wrapper.
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
proc.wait()
|
||||
proc.stdout.close()
|
||||
proc.stderr.close()
|
||||
|
||||
|
||||
# These are fixed read-only scripts, not guest-supplied shell or init scripts.
|
||||
# awk emits only executable/script paths and WorkingDirectory, never arguments
|
||||
# or environment values. OpenRC files are parsed as data, never sourced.
|
||||
SERVICE_SCRIPT = r'''
|
||||
command -v awk >/dev/null && command -v head >/dev/null || exit 1
|
||||
n=0
|
||||
for dir in /etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d; do
|
||||
# With a merged /usr, /lib is /usr/lib: the same units must not count twice.
|
||||
[ "$dir" = /lib/systemd/system ] && [ /lib/systemd/system -ef /usr/lib/systemd/system ] && continue
|
||||
[ ! -d "$dir" ] || [ -r "$dir" ] || exit 1
|
||||
for f in "$dir"/*; do
|
||||
[ -f "$f" ] || continue
|
||||
case "$dir:$f" in /etc/init.d:*) kind=openrc;; *:*.service) kind=systemd;; *) continue;; esac
|
||||
n=$((n+1)); [ "$n" -le 256 ] || { printf 'LIMIT\n'; exit 0; }
|
||||
[ -r "$f" ] || exit 1
|
||||
content=$(timeout -s KILL 1 head -c 16385 "$f") || exit 1
|
||||
[ "${#content}" -le 16384 ] || { printf 'LIMIT\n'; exit 0; }
|
||||
printf '%s\n' "$content" | awk -v kind="$kind" -v file="$f" '
|
||||
function clean(v) {gsub(/^[ \t]+|[ \t]+$/, "", v); gsub(/^["\047]|["\047]$/, "", v); return v}
|
||||
/^ExecStart=/ {v=substr($0,11); sub(/^[-+!:@]+/, "", v); split(v,a,/[ \t]+/); exe=clean(a[1]); script=clean(a[2]); if(script !~ /^\//) script=""}
|
||||
/^WorkingDirectory=/ {dir=clean(substr($0,18))}
|
||||
/^command=/ {exe=clean(substr($0,9))}
|
||||
/^directory=/ {dir=clean(substr($0,11))}
|
||||
/^command_args=/ {v=clean(substr($0,14)); split(v,a,/[ \t]+/); script=clean(a[1]); if(script !~ /^\//) script=""}
|
||||
END {printf "S\t%s\t%s\t%s\t%s\t%s\n", file, kind, exe, script, dir}'
|
||||
done
|
||||
done
|
||||
printf 'END\n'
|
||||
'''
|
||||
PROCESS_SCRIPT = r'''
|
||||
command -v readlink >/dev/null && command -v head >/dev/null || exit 1
|
||||
n=0
|
||||
for dir in /proc/[0-9]*; do
|
||||
[ -d "$dir" ] || continue
|
||||
n=$((n+1)); [ "$n" -le "$1" ] || { printf 'LIMIT\n'; exit 0; }
|
||||
# Never inspect Docker/OCI child workloads or read cmdline/environ.
|
||||
[ -d "$dir" ] || continue
|
||||
[ -r "$dir/cgroup" ] || exit 1
|
||||
group=$(timeout -s KILL 1 head -c 8193 "$dir/cgroup") || exit 1
|
||||
[ "${#group}" -le 8192 ] || { printf 'LIMIT\n'; exit 0; }
|
||||
case "$group" in *docker*|*libpod*|*kubepods*|*containerd*) continue;; esac
|
||||
# Kernel threads and vanished processes have no application executable.
|
||||
[ -L "$dir/exe" ] || continue
|
||||
exe=$(timeout -s KILL 1 readlink "$dir/exe") || continue
|
||||
cwd=$(timeout -s KILL 1 readlink "$dir/cwd") || continue
|
||||
printf 'P\t%s\t%s\t%s\n' "${dir##*/}" "$exe" "$cwd"
|
||||
done
|
||||
printf 'END\n'
|
||||
'''
|
||||
PORT_SCRIPT = r'''
|
||||
command -v awk >/dev/null || exit 1
|
||||
if command -v ss >/dev/null; then
|
||||
(timeout -s KILL 1 ss -H -lntp || printf 'FAILED\n') | awk -v cap="$1" '
|
||||
/^FAILED$/ {failed=1; exit}
|
||||
NR > cap {limited=1; exit}
|
||||
{port=$4; sub(/^.*:/,"",port); rest=$0
|
||||
# More than one owner is ambiguous; never choose the first arbitrarily.
|
||||
if (gsub(/pid=/,"pid=",rest)!=1) next
|
||||
if (match(rest,/pid=[0-9]+/)) {pid=substr(rest,RSTART+4,RLENGTH-4); printf "L\t%s\t%s\n",pid,port}}
|
||||
END {print failed ? "FAILED" : (limited ? "LIMIT" : "END")}'
|
||||
elif command -v netstat >/dev/null; then
|
||||
(timeout -s KILL 1 netstat -lntp || printf 'FAILED\n') | awk -v cap="$1" '
|
||||
/^FAILED$/ {failed=1; exit}
|
||||
/^tcp/ {n++; if(n>cap) {limited=1; exit}; port=$4; sub(/^.*:/,"",port)
|
||||
pid=$7; sub(/\/.*$/,"",pid); if(pid ~ /^[0-9]+$/) printf "L\t%s\t%s\n",pid,port}
|
||||
END {print failed ? "FAILED" : (limited ? "LIMIT" : "END")}'
|
||||
else
|
||||
# Without ss or netstat there is no listener evidence; a retry would not change it.
|
||||
printf 'END\n'
|
||||
fi
|
||||
'''
|
||||
HESTIA_PANEL_SCRIPT = r'''
|
||||
# The generic process collector intentionally stops after a bounded number of
|
||||
# records and ignores shared listener ownership. Hestia's master/worker pair
|
||||
# can therefore be outside that generic result. This narrow fallback never
|
||||
# accepts a name alone: it requires Hestia's exact executable and its panel
|
||||
# listener on the fixed local port.
|
||||
[ -x /usr/local/hestia/nginx/sbin/hestia-nginx ] || { printf 'END\n'; exit 0; }
|
||||
command -v pgrep >/dev/null && command -v readlink >/dev/null && command -v ss >/dev/null && command -v head >/dev/null || exit 1
|
||||
n=0
|
||||
for pid in $(pgrep -x hestia-nginx 2>/dev/null | head -n 9); do
|
||||
case "$pid" in *[!0-9]*|'') continue;; esac
|
||||
n=$((n+1)); [ "$n" -le 8 ] || { printf 'LIMIT\n'; exit 0; }
|
||||
exe=$(timeout -s KILL 1 readlink "/proc/$pid/exe") || continue
|
||||
[ "$exe" = /usr/local/hestia/nginx/sbin/hestia-nginx ] || continue
|
||||
cwd=$(timeout -s KILL 1 readlink "/proc/$pid/cwd") || continue
|
||||
if timeout -s KILL 1 ss -H -lntp | awk -v pid="$pid" '
|
||||
{port=$4; sub(/^.*:/,"",port)
|
||||
if (port == 8083 && index($0, "pid=" pid ",")) found=1}
|
||||
END {exit(found ? 0 : 1)}'; then
|
||||
printf 'H\t%s\t%s\t%s\t8083\n' "$pid" "$exe" "$cwd"
|
||||
fi
|
||||
done
|
||||
printf 'END\n'
|
||||
'''
|
||||
FOOTPRINT_SCRIPT = r'''
|
||||
[ -f "$1" ] && [ -r "$1" ] || exit 1
|
||||
printf 'FILE\n'
|
||||
if [ -f "$2/package.json" ]; then
|
||||
[ -r "$2/package.json" ] || exit 1
|
||||
timeout -s KILL 1 head -c 8192 "$2/package.json"
|
||||
fi
|
||||
'''
|
||||
MANIFEST_FOOTPRINT_SCRIPT = r'''
|
||||
command -v head >/dev/null && command -v readlink >/dev/null || exit 1
|
||||
# Resolve only the operator-owned working directory and its declared parent.
|
||||
# A release symlink may change the concrete version directory, but it must not
|
||||
# escape the bounded custom application root supplied by the host.
|
||||
workdir=$(timeout -s KILL 1 readlink -f "$1") || exit 1
|
||||
anchor=$(timeout -s KILL 1 readlink -f "$2") || exit 1
|
||||
parent=$(timeout -s KILL 1 readlink -f "$3") || exit 1
|
||||
case "$anchor" in "$parent"/*) ;; *) printf 'OUTSIDE\n'; exit 0;; esac
|
||||
case "$workdir" in "$anchor"|"$anchor"/*) ;; *) printf 'OUTSIDE\n'; exit 0;; esac
|
||||
for relative in package.json server/package.json; do
|
||||
candidate="$workdir/$relative"
|
||||
[ -f "$candidate" ] && [ -r "$candidate" ] || continue
|
||||
resolved=$(timeout -s KILL 1 readlink -f "$candidate") || exit 1
|
||||
case "$resolved" in "$workdir"/*) ;; *) printf 'OUTSIDE\n'; exit 0;; esac
|
||||
printf 'MANIFEST\t%s\n' "$relative"
|
||||
timeout -s KILL 1 head -c 8192 "$resolved"
|
||||
exit 0
|
||||
done
|
||||
printf 'MISSING\n'
|
||||
'''
|
||||
|
||||
|
||||
def _path(value):
|
||||
return bool(isinstance(value, str) and _PATH.fullmatch(value)
|
||||
and '..' not in value.split('/'))
|
||||
|
||||
|
||||
def _system(name):
|
||||
raw = name.lower().removesuffix('.service')
|
||||
stem = raw.split('@')[0]
|
||||
return (raw.startswith('user@') or stem in _SYSTEM
|
||||
or (raw.startswith('php') and raw.endswith('-fpm'))
|
||||
or stem.startswith((
|
||||
'systemd-', 'dbus-', 'getty', 'containerd-', 'apt-', 'dpkg-', 'e2scrub',
|
||||
'unattended-', 'ifup', 'ifdown', 'user@', 'btrfs-', 'lvm2-', 'lxc-')))
|
||||
|
||||
|
||||
def _helper(name):
|
||||
"""Reject service roles that support an application but are not one themselves."""
|
||||
raw = name.lower().removesuffix('.service')
|
||||
if raw.endswith('@'):
|
||||
return True # A systemd template is not a concrete running application.
|
||||
return any(part in _HELPER_ROLES for part in re.split(r'[._-]+', raw) if part)
|
||||
|
||||
|
||||
def _hestia_panel_process(executable, ports):
|
||||
"""Recognize only Hestia's own bound control-panel process.
|
||||
|
||||
Hestia's generated init unit does not expose an ExecStart that the generic
|
||||
service parser may treat as an application. Its panel process and port are
|
||||
stable, local runtime metadata, so this stays narrower than accepting
|
||||
arbitrary vendor services or a generic web server.
|
||||
"""
|
||||
return executable == _HESTIA_NGINX and _HESTIA_PANEL_PORT in ports
|
||||
|
||||
|
||||
def _custom(path):
|
||||
if not _path(path):
|
||||
return False
|
||||
if path.startswith(('/opt/', '/srv/', '/usr/local/')):
|
||||
return True
|
||||
# Native apps commonly keep their runtime below /home/<service-user>/<app>.
|
||||
# Do not accept a home directory itself: a second path component is required.
|
||||
parts = path.split('/')
|
||||
return len(parts) >= 4 and parts[1] == 'home' and bool(parts[2]) and bool(parts[3])
|
||||
|
||||
|
||||
def _manifest_bounds(executable, directory):
|
||||
"""Return a narrow, operator-owned boundary for a runtime manifest probe."""
|
||||
try:
|
||||
shared = os.path.commonpath((executable, directory))
|
||||
except ValueError:
|
||||
shared = ''
|
||||
anchor = shared if _custom(shared) else directory
|
||||
parts = anchor.split('/')
|
||||
if anchor.startswith(('/opt/', '/srv/')):
|
||||
parent = '/' + parts[1]
|
||||
elif anchor.startswith('/usr/local/'):
|
||||
parent = '/usr/local'
|
||||
elif len(parts) >= 4 and parts[1] == 'home' and parts[2]:
|
||||
parent = f'/home/{parts[2]}'
|
||||
else:
|
||||
return None
|
||||
return anchor, parent
|
||||
|
||||
|
||||
def _manifest_footprint(record, run, deadline):
|
||||
"""Read one of two fixed Node manifest locations without following an escape."""
|
||||
output = run(['pct', 'exec', str(record['vmid']), '--', 'timeout', '-s', 'KILL', '2',
|
||||
'sh', '-c', MANIFEST_FOOTPRINT_SCRIPT, 'discovery',
|
||||
record['root'], record['manifest_anchor'], record['manifest_parent']], deadline)
|
||||
header, separator, metadata = output.partition('\n')
|
||||
if header == 'MISSING':
|
||||
if metadata:
|
||||
raise ProbeError('invalid_data')
|
||||
return None, None
|
||||
if header == 'OUTSIDE':
|
||||
if metadata:
|
||||
raise ProbeError('invalid_data')
|
||||
raise ProbeError('unsafe_manifest_root')
|
||||
prefix = 'MANIFEST\t'
|
||||
relative = header.removeprefix(prefix)
|
||||
if not header.startswith(prefix) or relative not in ('package.json', 'server/package.json'):
|
||||
raise ProbeError('invalid_data')
|
||||
if not separator:
|
||||
raise ProbeError('invalid_data')
|
||||
candidate_record = dict(record)
|
||||
candidate_record['path'] = str(Path(record['root']) / relative)
|
||||
return candidate_record, 'FILE\n' + metadata
|
||||
|
||||
|
||||
def parse_records(text, kind):
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[-1] not in ('END', 'LIMIT'):
|
||||
raise ProbeError('incomplete')
|
||||
if len(lines) - 1 > MAX_RECORDS:
|
||||
raise ProbeError('metadata_limit')
|
||||
records = []
|
||||
seen_services = set()
|
||||
for line in lines[:-1]:
|
||||
parts = line.split('\t')
|
||||
if kind == 'S' and len(parts) == 6 and parts[0] == 'S':
|
||||
_, source, manager, exe, script, directory = parts
|
||||
if not _path(source) or manager not in ('systemd', 'openrc'):
|
||||
raise ProbeError('invalid_data')
|
||||
service = Path(source).name.removesuffix('.service')
|
||||
if service in seen_services:
|
||||
continue # /etc overrides vendor units; no duplicate installations.
|
||||
seen_services.add(service)
|
||||
if (not _NAME.fullmatch(service) or _system(service) or _helper(service)
|
||||
or not _path(exe)):
|
||||
continue
|
||||
runtime_name = Path(exe).name
|
||||
runtime = bool(_RUNTIMES.fullmatch(runtime_name))
|
||||
manifest_bounds = None
|
||||
if runtime and not _path(script):
|
||||
# A Node/npm wrapper may keep its entry point relative to an
|
||||
# operator-owned working directory. Only two fixed manifest
|
||||
# locations can prove that root; no argv, environment, recursive
|
||||
# search or guest-provided path is ever used.
|
||||
if runtime_name not in _PACKAGE_ROOT_RUNTIMES or not _custom(directory):
|
||||
continue
|
||||
install = str(Path(directory) / 'package.json')
|
||||
manifest_bounds = _manifest_bounds(exe, directory)
|
||||
if manifest_bounds is None:
|
||||
continue
|
||||
else:
|
||||
install = script if runtime else exe
|
||||
if (not _path(install) or _system(Path(install).name)
|
||||
or install.startswith(('/usr/lib/systemd/', '/lib/systemd/',
|
||||
'/usr/lib/udev/', '/lib/udev/', '/usr/lib/apt/'))):
|
||||
continue
|
||||
# /etc units are operator-owned. Do not infer an application from an
|
||||
# arbitrary packaged systemd unit: without a package-db probe it is
|
||||
# impossible to distinguish vendor infrastructure from a user app.
|
||||
if (manager == 'systemd'
|
||||
and source.startswith(('/usr/lib/systemd/system/', '/lib/systemd/system/'))
|
||||
and not _custom(install)):
|
||||
continue
|
||||
root = directory if _custom(directory) else str(Path(install).parent)
|
||||
# Generic runtime without an absolute application script is ambiguous.
|
||||
if runtime and not _custom(install):
|
||||
continue
|
||||
record = {'name': service, 'path': install, 'root': root,
|
||||
'service': source, 'manager': manager, 'executable': exe}
|
||||
if manifest_bounds:
|
||||
record['manifest_anchor'], record['manifest_parent'] = manifest_bounds
|
||||
records.append(record)
|
||||
elif kind == 'P' and len(parts) == 4 and parts[0] == 'P':
|
||||
_, pid, exe, directory = parts
|
||||
# A process rooted at / is normally too ambiguous for generic
|
||||
# discovery, but it is valid process metadata for the exact Hestia
|
||||
# panel signature below. It never becomes a generic candidate.
|
||||
if not pid.isdigit() or not _path(exe) or (directory != '/' and not _path(directory)):
|
||||
continue
|
||||
records.append({'pid': pid, 'executable': exe, 'root': directory})
|
||||
elif kind == 'L' and len(parts) == 3 and parts[0] == 'L':
|
||||
_, pid, port = parts
|
||||
if pid.isdigit() and port.isdigit() and 0 < int(port) < 65536:
|
||||
records.append({'pid': pid, 'port': int(port)})
|
||||
else:
|
||||
raise ProbeError('invalid_data')
|
||||
elif kind == 'H' and len(parts) == 5 and parts[0] == 'H':
|
||||
_, pid, exe, directory, port = parts
|
||||
if (pid.isdigit() and exe == _HESTIA_NGINX
|
||||
and (directory == '/' or _path(directory))
|
||||
and port == str(_HESTIA_PANEL_PORT)):
|
||||
records.append({'pid': pid, 'executable': exe, 'root': directory,
|
||||
'port': _HESTIA_PANEL_PORT})
|
||||
else:
|
||||
raise ProbeError('invalid_data')
|
||||
else:
|
||||
raise ProbeError('invalid_data')
|
||||
return records, lines[-1] == 'LIMIT', len(lines) - 1
|
||||
|
||||
|
||||
def candidate(vmid, record, footprint):
|
||||
if not footprint.startswith('FILE\n'):
|
||||
raise ProbeError('invalid_data')
|
||||
version, metadata_issue = None, None
|
||||
metadata = footprint[5:].strip()
|
||||
if metadata:
|
||||
try:
|
||||
manifest = json.loads(metadata)
|
||||
except (ValueError, TypeError):
|
||||
manifest = None
|
||||
# Only the whitelisted version field leaves this function. Never retain
|
||||
# manifest scripts, repository guesses, config or arbitrary extra fields.
|
||||
if not isinstance(manifest, dict):
|
||||
metadata_issue = 'invalid_metadata'
|
||||
manifest = {}
|
||||
value = manifest.get('version')
|
||||
if (isinstance(value, str) and len(value) <= 64 and _VERSION.fullmatch(value)
|
||||
and record['path'].startswith(record['root'].rstrip('/') + '/')):
|
||||
version = value
|
||||
identity = 'native-' + hashlib.sha256(
|
||||
f'{int(vmid)}:{record["path"]}:{record["root"]}'.encode()).hexdigest()[:24]
|
||||
evidence = [{'kind': 'installation_file', 'value': record['path']}]
|
||||
if record.get('service'):
|
||||
evidence.append({'kind': record['manager'], 'value': record['service']})
|
||||
else:
|
||||
evidence.extend([{'kind': 'process_executable', 'value': record['process']},
|
||||
{'kind': 'working_directory',
|
||||
'value': record.get('process_root', record['root'])}])
|
||||
ports = sorted(set(record.get('listening_ports') or []))
|
||||
# Hestia's control panel is a narrow, verified exception to generic port
|
||||
# inference: its own executable serves the panel on fixed HTTPS port 8083.
|
||||
# Keep this declaration on the exact runtime signature so an unrelated
|
||||
# application merely listening on 8083 never receives an HTTPS guess.
|
||||
hestia_panel = (record['name'] == 'Hestia'
|
||||
and record['path'] == _HESTIA_NGINX
|
||||
and record['root'] == _HESTIA_ROOT
|
||||
and _HESTIA_PANEL_PORT in ports)
|
||||
evidence.extend({'kind': 'tcp_listener', 'value': str(port)} for port in ports)
|
||||
return {'slug': identity, 'discovery_id': identity, 'source': 'native_metadata',
|
||||
'name': record['name'], 'installed_version': version, 'evidence': evidence,
|
||||
'installation_path': record['path'],
|
||||
'default_ports': [_HESTIA_PANEL_PORT] if hestia_panel else [],
|
||||
'listening_ports': ports,
|
||||
**({'scheme': 'https'} if hestia_panel else {}),
|
||||
'metadata_issue': metadata_issue,
|
||||
'tracking_suggestion': ({'installed_via': 'manual', 'installed_version': version,
|
||||
'detected_version': version, 'detector_verified': True}
|
||||
if version else None)}
|
||||
|
||||
|
||||
def _guard(vmid, deadline, run):
|
||||
# The local node path rejects QEMU, missing and remote CTs before pct exec.
|
||||
path = LOCAL_CONFIG / f'{vmid}.conf'
|
||||
if not path.is_file():
|
||||
raise ProbeError('not_local_lxc')
|
||||
# Presence, including a damaged/in-progress record, excludes generic probes.
|
||||
try:
|
||||
os.lstat(OCI_ROOT / str(vmid))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
raise ProbeError('oci_unknown') from None
|
||||
else:
|
||||
raise ProbeError('managed_oci')
|
||||
try:
|
||||
with path.open('rb') as handle:
|
||||
raw = handle.read(OUTPUT_BYTES + 1)
|
||||
if len(raw) > OUTPUT_BYTES:
|
||||
raise ProbeError('config_limit')
|
||||
config = raw.decode('utf-8')
|
||||
except (OSError, UnicodeError):
|
||||
raise ProbeError('config_unavailable') from None
|
||||
if re.search(r'^lock:', config, re.M):
|
||||
raise ProbeError('guest_busy')
|
||||
update_lock = UPDATE_LOCK_ROOT / f'proxmenux-lxc-update-{vmid}.lock'
|
||||
try:
|
||||
fd = os.open(update_lock, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, 'rb') as handle:
|
||||
info = os.fstat(handle.fileno())
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ProbeError('lock_unavailable')
|
||||
if info.st_size:
|
||||
raise ProbeError('guest_busy')
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise ProbeError('guest_busy') from None
|
||||
finally:
|
||||
fcntl.flock(handle, fcntl.LOCK_UN)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
raise ProbeError('lock_unavailable') from None
|
||||
if re.search(r'^ostype:\s*unmanaged\s*$', config, re.M):
|
||||
raise ProbeError('unsupported_guest')
|
||||
if not re.search(r'^ostype:\s*(?:debian|ubuntu|alpine|archlinux|centos|fedora|opensuse|gentoo)\s*$', config, re.M):
|
||||
raise ProbeError('unsupported_guest')
|
||||
status = run(['pct', 'status', str(vmid)], deadline).strip()
|
||||
if status != 'status: running':
|
||||
raise ProbeError('guest_stopped' if status == 'status: stopped' else 'status_unknown')
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _scan_reservation(vmid):
|
||||
"""Exclude cooperating updates/control tasks for this bounded scan only.
|
||||
|
||||
Keep an empty lock inode after first use, as the update runner does.
|
||||
Never truncate a pending control-task reservation or unlink its inode.
|
||||
"""
|
||||
path = UPDATE_LOCK_ROOT / f'proxmenux-lxc-update-{vmid}.lock'
|
||||
fd = None
|
||||
try:
|
||||
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
||||
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
||||
raise ProbeError('lock_unavailable')
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise ProbeError('guest_busy') from None
|
||||
if os.fstat(fd).st_size:
|
||||
raise ProbeError('guest_busy')
|
||||
yield
|
||||
except OSError:
|
||||
raise ProbeError('lock_unavailable') from None
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def scan(vmid, run=bounded_run):
|
||||
deadline = time.monotonic() + TOTAL_SECONDS
|
||||
_guard(vmid, deadline, run)
|
||||
with _scan_reservation(vmid):
|
||||
result = _scan_metadata(vmid, deadline, run)
|
||||
# External PVE lifecycle operations do not share our flock. Fail closed
|
||||
# if the guest stopped, changed ownership or became locked meanwhile.
|
||||
_guard(vmid, deadline, run)
|
||||
return result
|
||||
|
||||
|
||||
def _scan_metadata(vmid, deadline, run):
|
||||
records, issues, consumed, processes, listeners, hestia_panels = [], [], 0, [], [], []
|
||||
for kind, script in (('S', SERVICE_SCRIPT), ('P', PROCESS_SCRIPT), ('L', PORT_SCRIPT)):
|
||||
try:
|
||||
if consumed >= MAX_RECORDS:
|
||||
issues.append('metadata_limit')
|
||||
break
|
||||
parsed, limited, count = parse_records(run(
|
||||
['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2',
|
||||
'sh', '-c', script, 'discovery', str(MAX_RECORDS - consumed)], deadline), kind)
|
||||
if consumed + count > MAX_RECORDS:
|
||||
issues.append('metadata_limit')
|
||||
parsed = parsed[:MAX_RECORDS - consumed]
|
||||
consumed = min(MAX_RECORDS, consumed + count)
|
||||
# The entire collector, not each metadata source, has 256 records.
|
||||
capacity = MAX_RECORDS - len(records)
|
||||
if kind == 'S':
|
||||
records.extend(parsed[:capacity])
|
||||
elif kind == 'P':
|
||||
processes = parsed[:capacity]
|
||||
else:
|
||||
listeners = parsed[:capacity]
|
||||
if limited or len(parsed) > capacity:
|
||||
issues.append('metadata_limit')
|
||||
except ProbeError as exc:
|
||||
issues.append(str(exc))
|
||||
try:
|
||||
# This is outside the generic record budget: it returns only an exact,
|
||||
# fixed Hestia signature, not additional arbitrary process metadata.
|
||||
hestia_panels, limited, _ = parse_records(run(
|
||||
['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2',
|
||||
'sh', '-c', HESTIA_PANEL_SCRIPT, 'discovery'], deadline), 'H')
|
||||
if limited:
|
||||
issues.append('metadata_limit')
|
||||
except ProbeError as exc:
|
||||
issues.append(str(exc))
|
||||
for process in processes:
|
||||
exe, directory = process['executable'], process['root']
|
||||
related = [record for record in records if record['executable'] == exe and (
|
||||
not _RUNTIMES.fullmatch(Path(exe).name) or record['root'] == directory)]
|
||||
ports = [entry['port'] for entry in listeners if entry['pid'] == process['pid']]
|
||||
if len(related) == 1:
|
||||
related[0].setdefault('listening_ports', []).extend(ports)
|
||||
elif not related and _hestia_panel_process(exe, ports):
|
||||
records.append({'name': 'Hestia', 'path': exe, 'root': _HESTIA_ROOT,
|
||||
'process': exe, 'process_root': directory, 'executable': exe,
|
||||
'listening_ports': ports})
|
||||
elif not related and (_custom(exe) and _custom(directory)
|
||||
and not _system(Path(exe).name)
|
||||
and not _helper(Path(exe).name)
|
||||
and not _RUNTIMES.fullmatch(Path(exe).name)
|
||||
and exe.startswith(directory.rstrip('/') + '/')
|
||||
and _NAME.fullmatch(Path(exe).name)):
|
||||
records.append({'name': Path(exe).name, 'path': exe, 'root': directory,
|
||||
'process': exe, 'executable': exe, 'listening_ports': ports})
|
||||
for panel in hestia_panels:
|
||||
records.append({'name': 'Hestia', 'path': panel['executable'], 'root': _HESTIA_ROOT,
|
||||
'process': panel['executable'], 'process_root': panel['root'],
|
||||
'executable': panel['executable'],
|
||||
'listening_ports': [panel['port']]})
|
||||
items, seen = [], set()
|
||||
for record in records:
|
||||
key = (record['path'], record['root'])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
if len(items) >= MAX_CANDIDATES:
|
||||
issues.append('candidate_limit')
|
||||
break
|
||||
try:
|
||||
probe_record = dict(record)
|
||||
probe_record['vmid'] = vmid
|
||||
if 'manifest_anchor' in record:
|
||||
probe_record, footprint = _manifest_footprint(probe_record, run, deadline)
|
||||
if probe_record is None:
|
||||
continue
|
||||
else:
|
||||
footprint = run(['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2',
|
||||
'sh', '-c', FOOTPRINT_SCRIPT,
|
||||
'discovery', record['path'], record['root']], deadline)
|
||||
item = candidate(vmid, probe_record, footprint)
|
||||
items.append(item)
|
||||
if item['metadata_issue']:
|
||||
issues.append(item['metadata_issue'])
|
||||
except ProbeError as exc:
|
||||
issues.append(str(exc))
|
||||
if str(exc) == 'deadline':
|
||||
break
|
||||
return {'vmid': vmid, 'status': 'partial' if issues else 'complete',
|
||||
'candidates': items, 'issues': sorted(set(issues)), 'stale': False,
|
||||
'scanned_at': time.time()}
|
||||
|
||||
|
||||
def cached(vmid):
|
||||
with _mutex:
|
||||
value = copy.deepcopy(_cache.get(int(vmid)))
|
||||
if value is None:
|
||||
return {'vmid': int(vmid), 'status': 'not_scanned', 'candidates': [], 'stale': False}
|
||||
if time.time() - value['scanned_at'] >= CACHE_SECONDS:
|
||||
value['stale'] = True
|
||||
return value
|
||||
|
||||
|
||||
def mark_stale(vmid):
|
||||
"""Lifecycle events retire freshness, never collect new guest metadata."""
|
||||
with _mutex:
|
||||
_generations[int(vmid)] = _generations.get(int(vmid), 0) + 1
|
||||
if int(vmid) in _cache:
|
||||
_cache[int(vmid)]['stale'] = True
|
||||
|
||||
|
||||
def discover(vmid):
|
||||
vmid = int(vmid)
|
||||
with _mutex:
|
||||
lock = _locks.setdefault(vmid, threading.Lock())
|
||||
if not lock.acquire(blocking=False):
|
||||
return {**cached(vmid), 'status': 'busy', 'stale': True}
|
||||
try:
|
||||
with _mutex:
|
||||
generation = _generations.get(vmid, 0)
|
||||
try:
|
||||
result = scan(vmid)
|
||||
except ProbeError as exc:
|
||||
result = {**cached(vmid), 'status': 'error', 'issues': [str(exc)], 'stale': True}
|
||||
result.setdefault('scanned_at', time.time())
|
||||
# Comparing the generation and publishing must be atomic with lifecycle
|
||||
# invalidation, including the first scan when no cache exists yet.
|
||||
with _mutex:
|
||||
previous = copy.deepcopy(_cache.get(vmid))
|
||||
if _generations.get(vmid, 0) != generation:
|
||||
result = {**(previous or {'vmid': vmid, 'candidates': [],
|
||||
'scanned_at': time.time()}),
|
||||
'status': 'error', 'issues': ['guest_changed'], 'stale': True}
|
||||
elif result['status'] != 'complete' and previous and previous['candidates']:
|
||||
# Partial output cannot prove that previously seen apps disappeared.
|
||||
ids = {item['discovery_id'] for item in result['candidates']}
|
||||
result['candidates'].extend(item for item in previous['candidates']
|
||||
if item['discovery_id'] not in ids)
|
||||
result['candidates'] = result['candidates'][:MAX_CANDIDATES]
|
||||
result['stale'] = True
|
||||
_cache[vmid] = copy.deepcopy(result)
|
||||
return result
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
def filter_candidates(result, suggestions, apps):
|
||||
"""Pure composition: never run catalog probes, load or write registries."""
|
||||
known = [*apps, *(suggestions.get('extras') or []),
|
||||
*(suggestions.get('docker_workloads') or [])]
|
||||
if suggestions.get('helper_slug'):
|
||||
known.append({'name': suggestions.get('name_suggestion'),
|
||||
'helper_slug': suggestions['helper_slug'],
|
||||
'tracking_suggestion': suggestions.get('tracking_suggestion')})
|
||||
identities = {a.get('discovery_id') for a in known if a.get('discovery_id')}
|
||||
names, paths = set(), set()
|
||||
for app in known:
|
||||
for field in ('name', 'slug', 'helper_slug', 'package'):
|
||||
if app.get(field):
|
||||
names.add(str(app[field]).casefold())
|
||||
detector = app.get('tracking_suggestion') or app
|
||||
for field in ('binary_path', 'file_path'):
|
||||
if detector.get(field):
|
||||
paths.add(detector[field])
|
||||
items = [item for item in result['candidates']
|
||||
if item['discovery_id'] not in identities
|
||||
and item['name'].casefold() not in names
|
||||
and item['installation_path'] not in paths]
|
||||
return {**result, 'candidates': items}
|
||||
@@ -1564,7 +1564,7 @@ class AppriseChannel(NotificationChannel):
|
||||
return apprise_mod.NotifyType.FAILURE
|
||||
if sev == 'WARNING':
|
||||
return apprise_mod.NotifyType.WARNING
|
||||
if sev == 'SUCCESS':
|
||||
if sev in ('SUCCESS', 'OK'):
|
||||
return apprise_mod.NotifyType.SUCCESS
|
||||
return apprise_mod.NotifyType.INFO
|
||||
|
||||
|
||||
@@ -3850,6 +3850,13 @@ class PollingCollector:
|
||||
return
|
||||
self._last_managed_check = now
|
||||
|
||||
try:
|
||||
from pbs_manager import is_pbs_host
|
||||
if is_pbs_host():
|
||||
return # a Proxmox Backup Server has no guests or integrations to track
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
import managed_installs
|
||||
except Exception:
|
||||
|
||||
@@ -3335,6 +3335,7 @@ def _ai_cache_key(title, body, ai_config, detail_level, use_emojis):
|
||||
str(ai_config.get('ai_provider', '')), '\x1f',
|
||||
str(ai_config.get('ai_model', '')), '\x1f',
|
||||
str(ai_config.get('ai_language', '')), '\x1f',
|
||||
str(ai_config.get('channel_type', '')).lower(), '\x1f',
|
||||
detail_level, '\x1f', '1' if use_emojis else '0',
|
||||
]
|
||||
return _hash_ai_cache.sha256(''.join(parts).encode('utf-8', 'replace')).hexdigest()
|
||||
@@ -3428,7 +3429,8 @@ def format_with_ai_full(title: str, body: str, severity: str,
|
||||
channel_type = ai_config.get('channel_type', '').lower()
|
||||
is_email = channel_type == 'email'
|
||||
|
||||
if is_email and detail_level == 'detailed' and body and len(body) > 50:
|
||||
if (is_email and detail_level == 'detailed' and body and len(body) > 50
|
||||
and result_body != body):
|
||||
# Only append if original has substantial content
|
||||
result_body += "\n\n" + "-" * 40 + "\n"
|
||||
result_body += "Original message:\n"
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Local PBS maintenance information and remote, read-only PBS monitoring."""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.client
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import auth_manager
|
||||
|
||||
try:
|
||||
from proxmox_platform import detect_platform
|
||||
except ModuleNotFoundError:
|
||||
# Development checkout; AppImage bundles the canonical shared detector.
|
||||
_path = Path(__file__).resolve().parents[2] / "scripts/global/proxmox_platform.py"
|
||||
_spec = importlib.util.spec_from_file_location("proxmox_platform", _path)
|
||||
_platform_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_platform_module)
|
||||
detect_platform = _platform_module.detect_platform
|
||||
|
||||
_lock = threading.RLock()
|
||||
_platform_cache = (0, {})
|
||||
_status_cache = {}
|
||||
PBS_SERVICES = ("proxmox-backup", "proxmox-backup-proxy")
|
||||
MAX_RESPONSE = 4 * 1024 * 1024
|
||||
|
||||
|
||||
class PBSError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def get_platform():
|
||||
global _platform_cache
|
||||
with _lock:
|
||||
if _platform_cache[0] <= time.monotonic():
|
||||
_platform_cache = (time.monotonic() + 60, detect_platform())
|
||||
return dict(_platform_cache[1])
|
||||
|
||||
|
||||
def is_pbs_host():
|
||||
return get_platform().get("product") == "pbs"
|
||||
|
||||
|
||||
def _servers_path():
|
||||
return auth_manager.CONFIG_DIR / "pbs_servers.json"
|
||||
|
||||
|
||||
def _load_servers():
|
||||
try:
|
||||
data = json.loads(_servers_path().read_text())
|
||||
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
|
||||
raise ValueError()
|
||||
return data
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except (OSError, ValueError) as exc:
|
||||
raise PBSError("Could not read the PBS server configuration.") from exc
|
||||
|
||||
|
||||
def _save_servers(servers):
|
||||
auth_manager.ensure_config_dir()
|
||||
descriptor, temporary = tempfile.mkstemp(prefix=".pbs-servers-", dir=auth_manager.CONFIG_DIR)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w") as handle:
|
||||
os.fchmod(handle.fileno(), 0o600)
|
||||
json.dump(servers, handle, indent=2)
|
||||
os.replace(temporary, _servers_path())
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
_status_cache.clear()
|
||||
|
||||
|
||||
def public_servers():
|
||||
fields = ("id", "name", "url", "token_id", "fingerprint")
|
||||
with _lock:
|
||||
return [{**{key: row.get(key, "") for key in fields}, "has_token": bool(row.get("token_secret"))}
|
||||
for row in _load_servers()]
|
||||
|
||||
|
||||
def _validate_server(data, previous=None):
|
||||
if not isinstance(data, dict):
|
||||
raise PBSError("Invalid PBS server configuration.")
|
||||
previous = previous or {}
|
||||
row = {field: data.get(field, previous.get(field, ""))
|
||||
for field in ("name", "url", "token_id", "token_secret", "fingerprint")}
|
||||
if any(not isinstance(value, str) or len(value) > 4096 for value in row.values()):
|
||||
raise PBSError("Invalid PBS connection field.")
|
||||
row["name"] = row["name"].strip()
|
||||
row["url"] = row["url"].strip().rstrip("/")
|
||||
try:
|
||||
parts = urlsplit(row["url"])
|
||||
parts.port
|
||||
if (parts.scheme != "https" or not parts.hostname or parts.username or parts.password
|
||||
or parts.query or parts.fragment):
|
||||
raise ValueError()
|
||||
except ValueError as exc:
|
||||
raise PBSError("Use an HTTPS PBS URL without embedded credentials, query or fragment.") from exc
|
||||
if not row["name"] or len(row["name"]) > 128:
|
||||
raise PBSError("A server name of at most 128 characters is required.")
|
||||
row["token_id"] = row["token_id"].strip()
|
||||
if not re.fullmatch(r"[^\s:!]+@[^\s:!]+![^\s:!]+", row["token_id"]):
|
||||
raise PBSError("Enter a PBS API token ID in user@realm!token format.")
|
||||
if (not row["token_secret"] or not row["token_secret"].isascii()
|
||||
or any(char.isspace() or ord(char) < 32 for char in row["token_secret"])):
|
||||
raise PBSError("A valid API token secret is required.")
|
||||
if not row["token_id"].isascii():
|
||||
raise PBSError("Invalid API token ID.")
|
||||
fingerprint = row["fingerprint"].strip().lower().replace(":", "")
|
||||
if fingerprint and not re.fullmatch("[a-f0-9]{64}", fingerprint):
|
||||
raise PBSError("The certificate fingerprint must be SHA256 (64 hex characters).")
|
||||
row["fingerprint"] = fingerprint
|
||||
return row
|
||||
|
||||
|
||||
def _remote_get(server, endpoint):
|
||||
"""Validate the TLS peer BEFORE transmitting the PBS token (no redirects)."""
|
||||
parts = urlsplit(server["url"])
|
||||
fingerprint = server.get("fingerprint")
|
||||
if fingerprint:
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
context.check_hostname = False
|
||||
context.verify_mode = ssl.CERT_NONE # Authenticated by the explicit pin below.
|
||||
else:
|
||||
context = ssl.create_default_context()
|
||||
connection = http.client.HTTPSConnection(parts.hostname, parts.port or 443,
|
||||
timeout=5, context=context)
|
||||
try:
|
||||
connection.connect()
|
||||
if fingerprint:
|
||||
actual = hashlib.sha256(connection.sock.getpeercert(binary_form=True)).hexdigest()
|
||||
if not hmac.compare_digest(actual, fingerprint):
|
||||
raise PBSError("PBS certificate fingerprint does not match. No credentials were sent.")
|
||||
path = parts.path.rstrip("/") + "/api2/json" + endpoint
|
||||
connection.request("GET", path, headers={
|
||||
"Accept": "application/json",
|
||||
"Authorization": "PBSAPIToken=" + server["token_id"] + ":" + server["token_secret"],
|
||||
})
|
||||
response = connection.getresponse()
|
||||
content = response.read(MAX_RESPONSE + 1)
|
||||
if response.status in (401, 403):
|
||||
raise PBSError("PBS rejected the API token or its permissions. Check both the user and token ACLs.")
|
||||
if response.status != 200 or len(content) > MAX_RESPONSE:
|
||||
raise PBSError("PBS returned an invalid response. Redirects are not followed.")
|
||||
document = json.loads(content)
|
||||
if not isinstance(document, dict) or "data" not in document:
|
||||
raise PBSError("PBS returned an invalid JSON document.")
|
||||
return document["data"]
|
||||
except PBSError:
|
||||
raise
|
||||
except (OSError, http.client.HTTPException, ValueError) as exc:
|
||||
raise PBSError("Could not connect to PBS over verified HTTPS. Check the address, certificate and service.") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def save_server(data):
|
||||
identifier = data.get("id") if isinstance(data, dict) else None
|
||||
if identifier is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", str(identifier)):
|
||||
raise PBSError("Invalid PBS server ID.")
|
||||
with _lock:
|
||||
previous = next((row for row in _load_servers() if row["id"] == identifier), None)
|
||||
row = _validate_server(data, previous)
|
||||
# /version may be public; validate credentials on the protected status API.
|
||||
usage = _remote_get(row, "/status/datastore-usage")
|
||||
if not isinstance(usage, list):
|
||||
raise PBSError("PBS returned an invalid datastore list.")
|
||||
row.update(id=identifier or "pbs-" + secrets.token_hex(6), revision=secrets.token_hex(16))
|
||||
with _lock:
|
||||
servers = _load_servers()
|
||||
if previous is None and len(servers) >= 32:
|
||||
raise PBSError("At most 32 PBS connections may be configured.")
|
||||
servers = [existing for existing in servers if existing["id"] != row["id"]]
|
||||
_save_servers([*servers, row])
|
||||
return row["id"]
|
||||
|
||||
|
||||
def delete_server(identifier):
|
||||
with _lock:
|
||||
servers = _load_servers()
|
||||
remaining = [row for row in servers if row["id"] != identifier]
|
||||
if len(remaining) == len(servers):
|
||||
raise PBSError("PBS server not found.")
|
||||
_save_servers(remaining)
|
||||
|
||||
|
||||
def _cli(*arguments):
|
||||
try:
|
||||
result = subprocess.run(["proxmox-backup-manager", *arguments, "--output-format", "json"],
|
||||
check=True, text=True, capture_output=True, timeout=8)
|
||||
data = json.loads(result.stdout)
|
||||
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
|
||||
raise ValueError()
|
||||
return data
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as exc:
|
||||
raise PBSError("Could not read PBS " + " ".join(arguments[:2]) + ". Check its service and permissions.") from exc
|
||||
|
||||
|
||||
def _local_datastores():
|
||||
stores = _cli("datastore", "list")
|
||||
result = []
|
||||
for store in stores:
|
||||
row = {"name": store.get("name"), "path": store.get("path"),
|
||||
"maintenance_mode": store.get("maintenance-mode"), "total": None, "used": None, "avail": None}
|
||||
path = store.get("path")
|
||||
# A removable datastore directory may survive unmounting. Do not report
|
||||
# its underlying root filesystem as the missing datastore's capacity.
|
||||
inactive = store.get("backing-device") and not (path and os.path.ismount(path))
|
||||
if not path or inactive or store.get("maintenance-mode"):
|
||||
row["error"] = "Datastore is offline, in maintenance or has no mounted backing device."
|
||||
else:
|
||||
try:
|
||||
capacity = shutil.disk_usage(path)
|
||||
row.update(total=capacity.total, used=capacity.used, avail=capacity.free)
|
||||
except OSError:
|
||||
row["error"] = "Datastore filesystem is not available."
|
||||
result.append(row)
|
||||
return result
|
||||
|
||||
|
||||
def _local_services():
|
||||
result = {}
|
||||
for service in PBS_SERVICES:
|
||||
try:
|
||||
check = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=3)
|
||||
result[service] = check.stdout.strip() if check.returncode == 0 else "inactive"
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
result[service] = "unknown"
|
||||
return result
|
||||
|
||||
|
||||
def get_status(identifier):
|
||||
with _lock:
|
||||
cached = _status_cache.get(identifier)
|
||||
if cached and cached[0] > time.monotonic():
|
||||
return cached[1]
|
||||
errors = {}
|
||||
if identifier == "local":
|
||||
if not is_pbs_host():
|
||||
raise PBSError("The local host is not a PBS server.")
|
||||
name, version = "Local PBS", get_platform().get("version")
|
||||
services = _local_services()
|
||||
operations = {"datastores": _local_datastores,
|
||||
"tasks": lambda: _cli("task", "list", "--all", "true", "--limit", "20"),
|
||||
"sync": lambda: _cli("sync-job", "list"),
|
||||
"verify": lambda: _cli("verify-job", "list"),
|
||||
"prune": lambda: _cli("prune-job", "list")}
|
||||
else:
|
||||
with _lock:
|
||||
server = next((row for row in _load_servers() if row["id"] == identifier), None)
|
||||
if not server:
|
||||
raise PBSError("PBS server not found.")
|
||||
metadata = _remote_get(server, "/version")
|
||||
name, version = server["name"], metadata.get("version") if isinstance(metadata, dict) else None
|
||||
services = {}
|
||||
endpoints = {"datastores": "/status/datastore-usage", "tasks": "/nodes/localhost/tasks?limit=20",
|
||||
"sync": "/admin/sync", "verify": "/admin/verify", "prune": "/admin/prune"}
|
||||
operations = {key: (lambda endpoint=path: _remote_get(server, endpoint)) for key, path in endpoints.items()}
|
||||
result = {"server": identifier, "name": name, "version": version, "services": services,
|
||||
"fetched_at": int(time.time()), "errors": errors}
|
||||
with ThreadPoolExecutor(max_workers=5) as executor:
|
||||
futures = {key: executor.submit(operation) for key, operation in operations.items()}
|
||||
for key, future in futures.items():
|
||||
try:
|
||||
data = future.result()
|
||||
if not isinstance(data, list) or not all(isinstance(row, dict) for row in data):
|
||||
raise PBSError("PBS returned an invalid list.")
|
||||
result[key] = [{**row, "name": row.get("store", row.get("name"))} for row in data] if key == "datastores" else data
|
||||
except PBSError as exc:
|
||||
result[key] = None # Unknown/forbidden is never presented as zero.
|
||||
errors[key] = str(exc)
|
||||
with _lock:
|
||||
if len(_status_cache) >= 33:
|
||||
_status_cache.clear()
|
||||
_status_cache[identifier] = (time.monotonic() + 15, result)
|
||||
return result
|
||||
@@ -26,18 +26,18 @@ class FwbrUdevRuleOrderTests(unittest.TestCase):
|
||||
def test_auto_post_install_uses_late_rule_and_removes_legacy_rule(self):
|
||||
source = network_optimization_block(AUTO_POST_INSTALL)
|
||||
self.assertIn('local FUNC_VERSION="1.2"', source)
|
||||
self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
|
||||
def test_customizable_post_install_uses_late_rule_and_removes_legacy_rule(self):
|
||||
source = network_optimization_block(CUSTOM_POST_INSTALL)
|
||||
self.assertIn('local FUNC_VERSION="1.2"', source)
|
||||
self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source)
|
||||
self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source)
|
||||
|
||||
def test_uninstall_removes_new_and_legacy_rule_names(self):
|
||||
source = UNINSTALL_TOOLS.read_text()
|
||||
|
||||
@@ -33,6 +33,8 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
|
||||
self.vmid,
|
||||
'success',
|
||||
'both',
|
||||
reason='container is stopped; scheduled update was skipped',
|
||||
reason_code='stopped_container',
|
||||
log_name='../9911-scheduled-' + ('a' * 32) + '.log',
|
||||
reboot_required=True,
|
||||
reboot_packages=['linux-image-amd64', 'libc6'],
|
||||
@@ -43,11 +45,31 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
|
||||
'9911-scheduled-' + ('a' * 32) + '.log',
|
||||
)
|
||||
self.assertTrue(schedule['last_run_reboot_required'])
|
||||
self.assertEqual(schedule['last_run_reason_code'], 'stopped_container')
|
||||
self.assertEqual(
|
||||
schedule['last_run_reboot_packages'],
|
||||
['linux-image-amd64', 'libc6'],
|
||||
)
|
||||
|
||||
def test_record_rejects_unknown_reason_code_and_clears_the_previous_one(self):
|
||||
self.assertTrue(lxc_apps.record_schedule_run(
|
||||
self.vmid,
|
||||
'skipped',
|
||||
'os',
|
||||
reason='container is stopped; scheduled update was skipped',
|
||||
reason_code='stopped_container',
|
||||
))
|
||||
self.assertTrue(lxc_apps.record_schedule_run(
|
||||
self.vmid,
|
||||
'failure',
|
||||
'os',
|
||||
reason='update runner exited with code 9',
|
||||
reason_code='untrusted_value',
|
||||
))
|
||||
schedule = lxc_apps._read_sidecar(self.vmid)['schedule']
|
||||
self.assertEqual(schedule['last_run_reason'], 'update runner exited with code 9')
|
||||
self.assertNotIn('last_run_reason_code', schedule)
|
||||
|
||||
def test_lifecycle_clear_preserves_run_and_log(self):
|
||||
lxc_apps.record_schedule_run(
|
||||
self.vmid,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
|
||||
import monitor_links as links
|
||||
|
||||
|
||||
class MonitorLinksTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
storage = self.root / "storage.cfg"
|
||||
storage.write_text("dir: local\n\tpath /var/lib/vz\n\npbs: Lab\n\tdatastore store1\n\tserver 10.0.0.49\n\n"
|
||||
"pbs: Cloud\n\tdatastore x\n\tserver pbs.example.com\n\tport 8443\n")
|
||||
patches = [
|
||||
mock.patch.object(links, "LINKS_FILE", self.root / "links.json"),
|
||||
mock.patch.object(links, "SSL_CONFIG_FILE", self.root / "ssl.json"),
|
||||
mock.patch.object(links, "STORAGE_CFG", storage),
|
||||
mock.patch.object(links.pbs_manager, "get_platform", return_value={"product": "pve", "hostname": "amd"}),
|
||||
mock.patch.object(links.pbs_manager, "public_servers", return_value=[]),
|
||||
]
|
||||
for patch in patches:
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
links._probe_cache.clear()
|
||||
cluster = [{"type": "cluster", "name": "Casa"},
|
||||
{"type": "node", "name": "amd", "ip": "10.0.0.55", "local": 1},
|
||||
{"type": "node", "name": "constructor", "ip": "10.0.0.50", "local": 0}]
|
||||
self.run_patch = mock.patch.object(links.subprocess, "run",
|
||||
return_value=SimpleNamespace(returncode=0, stdout=json.dumps(cluster)))
|
||||
self.run_patch.start()
|
||||
self.addCleanup(self.run_patch.stop)
|
||||
|
||||
def test_normalize_url_keeps_only_the_origin_and_rejects_credentials(self):
|
||||
self.assertEqual(links.normalize_url("192.168.0.49:8008/dashboard?x=1"), "https://192.168.0.49:8008")
|
||||
self.assertEqual(links.normalize_url("http://pve.lan"), "http://pve.lan")
|
||||
for bad in ("", "ftp://host", "https://user:pass@host", "https://host:99999", "javascript:alert(1)"):
|
||||
with self.subTest(bad=bad), self.assertRaises(links.LinkError):
|
||||
links.normalize_url(bad)
|
||||
|
||||
def test_discovers_other_cluster_nodes_and_pbs_without_the_local_node(self):
|
||||
probed = []
|
||||
with mock.patch.object(links, "_probe", side_effect=lambda url: probed.append(url) or url.endswith("10.0.0.50:8008")), \
|
||||
mock.patch.object(links, "_is_private", side_effect=lambda host: host.startswith("10.")):
|
||||
result = links.list_links()
|
||||
by_key = {row["key"]: row for row in result["links"]}
|
||||
self.assertEqual(result["self"]["name"], "amd")
|
||||
self.assertNotIn("cluster:amd", by_key)
|
||||
self.assertEqual(by_key["cluster:constructor"]["status"], "online")
|
||||
self.assertEqual(by_key["pbs:10.0.0.49"]["native_url"], "https://10.0.0.49:8007")
|
||||
self.assertEqual(by_key["pbs:10.0.0.49"]["status"], "offline")
|
||||
# A public PBS is listed with its own interface but never probed unasked.
|
||||
self.assertEqual(by_key["pbs:pbs.example.com"]["status"], "unknown")
|
||||
self.assertEqual(by_key["pbs:pbs.example.com"]["native_url"], "https://pbs.example.com:8443")
|
||||
self.assertFalse(any("example.com" in url for url in probed))
|
||||
|
||||
def test_pbs_host_lists_only_manual_entries(self):
|
||||
links.pbs_manager.get_platform.return_value = {"product": "pbs", "hostname": "pbs-lab"}
|
||||
links.add_link({"name": "amd", "url": "https://10.0.0.55:8008", "product": "pve"})
|
||||
with mock.patch.object(links, "_probe", return_value=True):
|
||||
result = links.list_links()
|
||||
self.assertEqual([row["name"] for row in result["links"]], ["amd"])
|
||||
self.links_run_not_called()
|
||||
|
||||
def links_run_not_called(self):
|
||||
self.assertFalse(any(call.args and call.args[0][:1] == ["pvesh"] for call in links.subprocess.run.call_args_list))
|
||||
|
||||
def test_manual_entries_overrides_and_hidden_detected_nodes(self):
|
||||
key = links.add_link({"name": "lab", "url": "10.0.0.46:8008", "product": "pve"})
|
||||
with self.assertRaises(links.LinkError):
|
||||
links.add_link({"url": "https://10.0.0.46:8008"})
|
||||
links.update_link("cluster:constructor", {"url": "https://constructor.lan:8008"})
|
||||
links.update_link("pbs:10.0.0.49", {"hidden": True})
|
||||
with mock.patch.object(links, "_probe", return_value=False), \
|
||||
mock.patch.object(links, "_is_private", return_value=True):
|
||||
visible = {row["key"]: row for row in links.list_links()["links"]}
|
||||
everything = {row["key"]: row for row in links.list_links(include_hidden=True)["links"]}
|
||||
self.assertEqual(visible[key]["url"], "https://10.0.0.46:8008")
|
||||
self.assertEqual(visible["cluster:constructor"]["url"], "https://constructor.lan:8008")
|
||||
self.assertNotIn("pbs:10.0.0.49", visible)
|
||||
self.assertTrue(everything["pbs:10.0.0.49"]["hidden"])
|
||||
with self.assertRaises(links.LinkError):
|
||||
links.delete_link("cluster:constructor")
|
||||
links.delete_link(key)
|
||||
self.assertEqual(json.loads((self.root / "links.json").read_text())["manual"], [])
|
||||
self.assertEqual((self.root / "links.json").stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_cached_list_is_served_without_new_probes_and_renewed_when_stale(self):
|
||||
links._snapshot.update(at=0.0, data=None)
|
||||
self.addCleanup(links._snapshot.update, at=0.0, data=None)
|
||||
links.update_link("pbs:10.0.0.49", {"hidden": True})
|
||||
with mock.patch.object(links, "_probe", return_value=True) as probe, \
|
||||
mock.patch.object(links, "_is_private", return_value=True):
|
||||
first = links.cached_links()
|
||||
calls = probe.call_count
|
||||
again = links.cached_links()
|
||||
self.assertEqual(probe.call_count, calls)
|
||||
self.assertEqual(first, again)
|
||||
self.assertNotIn("pbs:10.0.0.49", [row["key"] for row in first["links"]])
|
||||
self.assertIn("pbs:10.0.0.49", [row["key"] for row in links.cached_links(include_hidden=True)["links"]])
|
||||
links._snapshot["at"] -= links.PROBE_TTL + 1
|
||||
with mock.patch.object(links.threading, "Thread") as thread:
|
||||
stale = links.cached_links()
|
||||
self.assertEqual(stale, first)
|
||||
thread.assert_called_once()
|
||||
|
||||
def test_unknown_keys_are_rejected(self):
|
||||
for key in ("other:x", "cluster:../../etc", "manual:nope"):
|
||||
with self.subTest(key=key), self.assertRaises(links.LinkError):
|
||||
links.update_link(key, {"hidden": True})
|
||||
|
||||
def test_probe_sends_no_credentials_and_needs_a_monitor_answer(self):
|
||||
seen = []
|
||||
|
||||
class Response:
|
||||
status = 200
|
||||
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
def read(self, _size):
|
||||
return self.body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def urlopen(request, timeout, context):
|
||||
seen.append(request)
|
||||
return Response(b'{"auth_enabled": true}' if "monitor" in request.full_url else b"<html>")
|
||||
|
||||
with mock.patch.object(links.urllib.request, "urlopen", side_effect=urlopen):
|
||||
self.assertTrue(links._probe("https://monitor:8008"))
|
||||
self.assertFalse(links._probe("https://other:8008"))
|
||||
self.assertTrue(all(request.full_url.endswith("/api/auth/status") for request in seen))
|
||||
self.assertTrue(all("Authorization" not in request.headers for request in seen))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,785 @@
|
||||
"""Host-independent discovery fixtures; no real pct, guest, auth or registry."""
|
||||
import ast
|
||||
import copy
|
||||
import fcntl
|
||||
from functools import wraps
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
import native_app_discovery as native
|
||||
import lxc_apps
|
||||
|
||||
|
||||
def service(name='handmade', exe='/opt/handmade/server', directory='/opt/handmade', manager='systemd', script='', source=None):
|
||||
path = source or ('/etc/systemd/system/' + name + '.service' if manager == 'systemd'
|
||||
else '/etc/init.d/' + name)
|
||||
return '\t'.join(['S', path, manager, exe, script, directory]) + '\nEND\n'
|
||||
|
||||
|
||||
class DiscoveryFixtures(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.config = self.root / 'lxc'
|
||||
self.config.mkdir()
|
||||
(self.config / '101.conf').write_text('ostype: debian\n')
|
||||
self.oci = self.root / 'oci'
|
||||
self.oci.mkdir()
|
||||
for context in (patch.object(native, 'LOCAL_CONFIG', self.config),
|
||||
patch.object(native, 'OCI_ROOT', self.oci),
|
||||
patch.object(native, 'UPDATE_LOCK_ROOT', self.root)):
|
||||
context.start()
|
||||
self.addCleanup(context.stop)
|
||||
native._cache.clear()
|
||||
native._locks.clear()
|
||||
native._generations.clear()
|
||||
self.calls = []
|
||||
|
||||
def runner(self, services=None, processes='END\n', footprint='FILE\n', manifest=None,
|
||||
status='status: running', ports='END\n', hestia_panels='END\n'):
|
||||
def run(argv, deadline):
|
||||
self.calls.append(argv)
|
||||
self.assertLessEqual(deadline - time.monotonic(), native.TOTAL_SECONDS)
|
||||
if argv[1] == 'status':
|
||||
return status
|
||||
self.assertEqual(argv[:6], ['pct', 'exec', '101', '--', 'timeout', '-s'])
|
||||
self.assertEqual(argv[6:9], ['KILL', '2', 'sh'])
|
||||
script = argv[10]
|
||||
if script == native.SERVICE_SCRIPT:
|
||||
return services if services is not None else service()
|
||||
if script == native.PROCESS_SCRIPT:
|
||||
return processes
|
||||
if script == native.PORT_SCRIPT:
|
||||
return ports
|
||||
if script == native.HESTIA_PANEL_SCRIPT:
|
||||
return hestia_panels
|
||||
if script == native.MANIFEST_FOOTPRINT_SCRIPT:
|
||||
if manifest is not None:
|
||||
return manifest
|
||||
return 'MANIFEST\tpackage.json\n' + footprint.removeprefix('FILE\n')
|
||||
if script == native.FOOTPRINT_SCRIPT:
|
||||
return footprint
|
||||
self.fail('Unexpected probe')
|
||||
return run
|
||||
|
||||
def test_unknown_version_is_present_with_evidence_and_no_capabilities(self):
|
||||
result = native.scan(101, self.runner())
|
||||
self.assertEqual(result['status'], 'complete')
|
||||
item, = result['candidates']
|
||||
self.assertIsNone(item['installed_version'])
|
||||
self.assertIsNone(item['tracking_suggestion'])
|
||||
self.assertEqual(item['default_ports'], [])
|
||||
self.assertEqual({e['kind'] for e in item['evidence']}, {'installation_file', 'systemd'})
|
||||
for field in ('helper_slug', 'repo', 'update_command', 'binary_path', 'upstream_type'):
|
||||
self.assertNotIn(field, item)
|
||||
|
||||
def test_openrc_node_script_known_version_is_manual_and_secret_free(self):
|
||||
result = native.scan(101, self.runner(
|
||||
service(exe='/usr/bin/node', script='/opt/handmade/server.js', manager='openrc'),
|
||||
footprint='FILE\n' + json.dumps({'version': '1.2.3', 'scripts': {'start': 'SECRET'}, 'env': 'PASSWORD'})))
|
||||
item, = result['candidates']
|
||||
self.assertEqual(item['installed_version'], '1.2.3')
|
||||
self.assertEqual(item['tracking_suggestion']['installed_via'], 'manual')
|
||||
self.assertNotIn('SECRET', json.dumps(result))
|
||||
self.assertNotIn('PASSWORD', json.dumps(result))
|
||||
self.assertFalse(any('systemctl' in arg for call in self.calls for arg in call))
|
||||
|
||||
def test_inactive_service_and_packaged_application_are_not_hidden(self):
|
||||
result = native.scan(101, self.runner(service(name='legit-app', exe='/usr/bin/legit-app', directory='')))
|
||||
self.assertEqual(result['candidates'][0]['name'], 'legit-app')
|
||||
|
||||
def test_system_services_and_ambiguous_runtimes_have_zero_footprint_probes(self):
|
||||
for name, exe in [('ssh', '/usr/sbin/sshd'), ('cron', '/usr/sbin/cron'),
|
||||
('postfix', '/usr/lib/postfix/master'), ('rsyslog', '/usr/sbin/rsyslogd'),
|
||||
('systemd-networkd', '/usr/lib/systemd/systemd-networkd'),
|
||||
('ambiguous', '/usr/bin/python3')]:
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(service(name=name, exe=exe)))
|
||||
self.assertEqual(result['candidates'], [])
|
||||
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
|
||||
|
||||
def test_common_system_daemons_and_vendor_units_have_zero_footprint_probes(self):
|
||||
cases = [
|
||||
('nginx', '/usr/sbin/nginx', 'openrc', None),
|
||||
('apache2', '/usr/sbin/apache2', 'systemd', None),
|
||||
('redis-server', '/usr/bin/redis-server', 'systemd', None),
|
||||
('mariadb', '/usr/sbin/mariadbd', 'systemd', None),
|
||||
('postgresql', '/usr/lib/postgresql/16/bin/postgres', 'systemd', None),
|
||||
('php8.3-fpm', '/usr/sbin/php-fpm8.3', 'systemd', None),
|
||||
('vendor-daemon', '/usr/bin/vendor-daemon', 'systemd',
|
||||
'/usr/lib/systemd/system/vendor-daemon.service'),
|
||||
]
|
||||
for name, exe, manager, source in cases:
|
||||
with self.subTest(name=name):
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(
|
||||
service(name=name, exe=exe, manager=manager, source=source)))
|
||||
self.assertEqual(result['candidates'], [])
|
||||
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
|
||||
|
||||
def test_alpine_openrc_infrastructure_has_zero_footprint_probes(self):
|
||||
# Actual Alpine 3.24.1 CT217 init metadata: klogd, user and runsvdir.
|
||||
# The runsvdir definition can exist without /usr/bin/runsvdir installed;
|
||||
# rejecting it must precede probing, not turn the scan into partial.
|
||||
for name, executable in (
|
||||
('klogd', '/sbin/klogd'),
|
||||
('user', '/usr/libexec/rc/bin/openrc-user'),
|
||||
('runsvdir', '/usr/bin/runsvdir'),
|
||||
('renamed-system-service', '/usr/libexec/rc/bin/openrc-user'),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(
|
||||
service(name=name, exe=executable, directory='', manager='openrc')))
|
||||
self.assertEqual(result['status'], 'complete')
|
||||
self.assertEqual(result['candidates'], [])
|
||||
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
|
||||
|
||||
def test_helper_services_and_templates_have_zero_footprint_probes(self):
|
||||
cases = [
|
||||
('netpulse-stable-update', '/bin/bash', '/opt/netpulse', '/opt/netpulse/stable-apply.sh'),
|
||||
('rent-tool-backup', '/bin/bash', '/home/app/rent-tool', '/home/app/rent-tool/scripts/backup-db.sh'),
|
||||
('keynest-update', '/bin/bash', '/opt/keynest', '/opt/keynest/keynest-update.sh'),
|
||||
('cortendesk-bootstrap-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/bootstrap-worker.sh'),
|
||||
('cortendesk-source-worker', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/source-worker.sh'),
|
||||
('cortendesk-managed-password@', '/bin/bash', '/opt/cortendesk', '/opt/cortendesk/password-bridge.sh'),
|
||||
('miminails-watch', '/usr/local/bin/miminails-watch', '/usr/local/bin', ''),
|
||||
]
|
||||
for name, exe, directory, script in cases:
|
||||
with self.subTest(name=name):
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(service(
|
||||
name=name, exe=exe, directory=directory, script=script)))
|
||||
self.assertEqual(result['candidates'], [])
|
||||
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
|
||||
|
||||
def test_hestia_panel_is_recognized_only_from_its_exact_runtime_and_port(self):
|
||||
hestia = '/usr/local/hestia/nginx/sbin/hestia-nginx'
|
||||
panel = f'H\t391\t{hestia}\t/\t8083\nEND\n'
|
||||
result = native.scan(101, self.runner(
|
||||
services='END\n', processes='LIMIT\n', ports='END\n', hestia_panels=panel))
|
||||
item, = result['candidates']
|
||||
self.assertEqual(item['name'], 'Hestia')
|
||||
self.assertEqual(item['installation_path'], hestia)
|
||||
self.assertEqual(item['listening_ports'], [8083])
|
||||
self.assertEqual(item['default_ports'], [8083])
|
||||
self.assertEqual(item['scheme'], 'https')
|
||||
self.assertIn({'kind': 'working_directory', 'value': '/'}, item['evidence'])
|
||||
self.assertIn('metadata_limit', result['issues'])
|
||||
|
||||
unrelated = native.candidate(101, {
|
||||
'name': 'other-panel', 'path': '/opt/other-panel/server', 'root': '/opt/other-panel',
|
||||
'process': '/opt/other-panel/server', 'listening_ports': [8083],
|
||||
}, 'FILE\n')
|
||||
self.assertEqual(unrelated['default_ports'], [])
|
||||
self.assertNotIn('scheme', unrelated)
|
||||
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(
|
||||
services='END\n', processes='LIMIT\n', ports='END\n'))
|
||||
self.assertEqual(result['candidates'], [])
|
||||
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
|
||||
|
||||
with self.assertRaises(native.ProbeError):
|
||||
native.parse_records(f'H\t391\t{hestia}\t/\t8443\nEND\n', 'H')
|
||||
|
||||
def test_node_and_npm_wrappers_use_only_bounded_manifest_roots(self):
|
||||
cases = [
|
||||
('keynest', '/usr/bin/node', '/opt/keynest'),
|
||||
('rent-tool', '/usr/local/bin/npm', '/home/app/rent-tool'),
|
||||
]
|
||||
for name, exe, directory in cases:
|
||||
with self.subTest(name=name):
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(
|
||||
service(name=name, exe=exe, directory=directory),
|
||||
footprint='FILE\n{"version":"1.2.3"}'))
|
||||
item, = result['candidates']
|
||||
self.assertEqual(item['name'], name)
|
||||
self.assertEqual(item['installation_path'], directory + '/package.json')
|
||||
self.assertEqual(item['installed_version'], '1.2.3')
|
||||
footprint_calls = [call for call in self.calls if native.MANIFEST_FOOTPRINT_SCRIPT in call]
|
||||
self.assertEqual(len(footprint_calls), 1)
|
||||
if directory.startswith('/home/'):
|
||||
parent = '/home/app'
|
||||
else:
|
||||
parent = '/opt'
|
||||
self.assertEqual(footprint_calls[0][-3:], [directory, directory, parent])
|
||||
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(service(
|
||||
name='ambiguous-home-runtime', exe='/usr/bin/node', directory='/home/app')))
|
||||
self.assertEqual(result['candidates'], [])
|
||||
self.assertFalse(any(native.MANIFEST_FOOTPRINT_SCRIPT in call for call in self.calls))
|
||||
|
||||
def test_node_release_layout_uses_only_server_manifest_under_shared_root(self):
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(
|
||||
service(name='keynest', exe='/opt/keynest/node/bin/node', directory='/opt/keynest/current'),
|
||||
manifest='MANIFEST\tserver/package.json\n{"version":"1.5.63"}'))
|
||||
item, = result['candidates']
|
||||
self.assertEqual(item['name'], 'keynest')
|
||||
self.assertEqual(item['installation_path'], '/opt/keynest/current/server/package.json')
|
||||
self.assertEqual(item['installed_version'], '1.5.63')
|
||||
manifest_calls = [call for call in self.calls if native.MANIFEST_FOOTPRINT_SCRIPT in call]
|
||||
self.assertEqual(len(manifest_calls), 1)
|
||||
self.assertEqual(manifest_calls[0][-3:],
|
||||
['/opt/keynest/current', '/opt/keynest', '/opt'])
|
||||
|
||||
def test_node_release_layout_rejects_manifest_outside_operator_root(self):
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(
|
||||
service(name='outside-root', exe='/opt/outside/node/bin/node', directory='/opt/outside/current'),
|
||||
manifest='OUTSIDE\n'))
|
||||
self.assertEqual(result['candidates'], [])
|
||||
self.assertIn('unsafe_manifest_root', result['issues'])
|
||||
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
|
||||
|
||||
def test_actual_manifest_probe_accepts_release_and_rejects_symlink_escape(self):
|
||||
parent = self.root / 'opt'
|
||||
anchor = parent / 'keynest'
|
||||
release = anchor / 'releases' / 'v1.5.63'
|
||||
manifest = release / 'server' / 'package.json'
|
||||
manifest.parent.mkdir(parents=True)
|
||||
manifest.write_text('{"version":"1.5.63"}')
|
||||
current = anchor / 'current'
|
||||
current.symlink_to(release)
|
||||
output = native.bounded_run(
|
||||
['sh', '-c', native.MANIFEST_FOOTPRINT_SCRIPT, 'fixture',
|
||||
str(current), str(anchor), str(parent)], time.monotonic() + 3)
|
||||
self.assertEqual(output, 'MANIFEST\tserver/package.json\n{"version":"1.5.63"}')
|
||||
|
||||
outside = self.root / 'outside' / 'package.json'
|
||||
outside.parent.mkdir()
|
||||
outside.write_text('{"version":"not-readable"}')
|
||||
manifest.unlink()
|
||||
manifest.symlink_to(outside)
|
||||
output = native.bounded_run(
|
||||
['sh', '-c', native.MANIFEST_FOOTPRINT_SCRIPT, 'fixture',
|
||||
str(current), str(anchor), str(parent)], time.monotonic() + 3)
|
||||
self.assertEqual(output, 'OUTSIDE\n')
|
||||
self.assertNotIn('not-readable', output)
|
||||
|
||||
def test_node_wrapper_with_no_fixed_manifest_is_not_an_error_or_candidate(self):
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(
|
||||
service(name='missing-manifest', exe='/opt/missing/node/bin/node', directory='/opt/missing/current'),
|
||||
manifest='MISSING\n'))
|
||||
self.assertEqual(result['status'], 'complete')
|
||||
self.assertEqual(result['candidates'], [])
|
||||
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
|
||||
|
||||
def test_helper_process_fallback_has_zero_footprint_probes(self):
|
||||
self.calls.clear()
|
||||
result = native.scan(101, self.runner(
|
||||
services='END\n', processes='P\t123\t/opt/cortendesk/run-worker\t/opt/cortendesk\nEND\n'))
|
||||
self.assertEqual(result['candidates'], [])
|
||||
self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls))
|
||||
|
||||
def test_missing_remote_qemu_stopped_busy_oci_guards_precede_exec(self):
|
||||
scenarios = ['missing', 'stopped', 'busy', 'oci', 'invalid-oci', 'unmanaged']
|
||||
for scenario in scenarios:
|
||||
with self.subTest(scenario=scenario):
|
||||
(self.config / '101.conf').write_text('ostype: debian\n')
|
||||
if scenario == 'missing':
|
||||
(self.config / '101.conf').unlink()
|
||||
if scenario == 'busy':
|
||||
(self.config / '101.conf').write_text('lock: backup\n')
|
||||
if scenario == 'unmanaged':
|
||||
(self.config / '101.conf').write_text('ostype: unmanaged\n')
|
||||
if 'oci' in scenario:
|
||||
(self.oci / '101').mkdir(exist_ok=True)
|
||||
(self.oci / '101' / 'oci-compose.json').write_text('broken')
|
||||
self.calls.clear()
|
||||
with self.assertRaises(native.ProbeError):
|
||||
native.scan(101, self.runner(status='status: stopped' if scenario == 'stopped' else 'status: running'))
|
||||
self.assertFalse(any(call[1] == 'exec' for call in self.calls))
|
||||
if (self.oci / '101').exists():
|
||||
(self.oci / '101' / 'oci-compose.json').unlink()
|
||||
(self.oci / '101').rmdir()
|
||||
|
||||
def test_failure_and_partial_output_are_not_successful_empty_results(self):
|
||||
for text in ('', 'malformed\nEND\n', 'S\tpartial', 'LIMIT\n'):
|
||||
result = native.scan(101, self.runner(services=text))
|
||||
self.assertEqual(result['status'], 'partial')
|
||||
run = self.runner()
|
||||
def failed(argv, deadline):
|
||||
if argv[1] == 'exec':
|
||||
raise native.ProbeError('read_failed')
|
||||
return run(argv, deadline)
|
||||
self.assertEqual(native.scan(101, failed)['status'], 'partial')
|
||||
|
||||
def test_optional_invalid_version_metadata_keeps_proven_candidate(self):
|
||||
result = native.scan(101, self.runner(footprint='FILE\n{broken'))
|
||||
self.assertEqual(result['status'], 'partial')
|
||||
item, = result['candidates']
|
||||
self.assertIsNone(item['installed_version'])
|
||||
self.assertIsNone(item['tracking_suggestion'])
|
||||
|
||||
def test_working_directory_manifest_does_not_version_an_unrelated_executable(self):
|
||||
result = native.scan(101, self.runner(service(exe='/opt/other/server'),
|
||||
footprint='FILE\n{"version":"1.2.3"}'))
|
||||
self.assertIsNone(result['candidates'][0]['installed_version'])
|
||||
|
||||
def test_path_injection_and_port_only_data_do_not_become_candidates(self):
|
||||
for text in (service(exe='/opt/app/$(id)'), service(exe='/opt/../server'),
|
||||
service(name='app;id'), service(exe='/bin/sh')):
|
||||
self.assertEqual(native.scan(101, self.runner(text))['candidates'], [])
|
||||
with self.assertRaises(native.ProbeError):
|
||||
native.parse_records('PORT\t8080\nEND\n', 'P')
|
||||
|
||||
def test_same_process_deduplicates_but_two_apps_using_node_do_not_merge(self):
|
||||
services = service(exe='/usr/bin/node', script='/opt/handmade/server.js').removesuffix('END\n')
|
||||
services += service(name='second', exe='/usr/bin/node', script='/opt/second/server.js', directory='/opt/second')
|
||||
result = native.scan(101, self.runner(services))
|
||||
self.assertEqual(len(result['candidates']), 2)
|
||||
result = native.scan(101, self.runner(processes='P\t123\t/opt/handmade/server\t/opt/handmade\nEND\n'))
|
||||
self.assertEqual(len(result['candidates']), 1)
|
||||
other = native.candidate(102, native.parse_records(service(), 'S')[0][0], 'FILE\n')
|
||||
self.assertNotEqual(other['discovery_id'], result['candidates'][0]['discovery_id'])
|
||||
|
||||
def test_record_and_candidate_caps(self):
|
||||
too_many = 'P\t123\t/opt/a/app\t/opt/a\n' * 257 + 'END\n'
|
||||
with self.assertRaises(native.ProbeError):
|
||||
native.parse_records(too_many, 'P')
|
||||
services = ''.join(service(name=f'app{i}', exe=f'/opt/app{i}/server', directory=f'/opt/app{i}').removesuffix('END\n') for i in range(51)) + 'END\n'
|
||||
result = native.scan(101, self.runner(services))
|
||||
self.assertEqual(len(result['candidates']), 50)
|
||||
self.assertIn('candidate_limit', result['issues'])
|
||||
|
||||
def test_cached_ttl_never_runs_scan_and_error_preserves_last_known(self):
|
||||
complete = native.scan(101, self.runner())
|
||||
with patch.object(native, 'scan', return_value=complete) as scan:
|
||||
native.discover(101)
|
||||
for age, stale in ((299.9, False), (300, True), (300.1, True)):
|
||||
with patch.object(native.time, 'time', return_value=complete['scanned_at'] + age):
|
||||
self.assertEqual(native.cached(101)['stale'], stale)
|
||||
self.assertEqual(scan.call_count, 1)
|
||||
with patch.object(native, 'scan', side_effect=native.ProbeError('timeout')):
|
||||
result = native.discover(101)
|
||||
self.assertEqual(result['candidates'], complete['candidates'])
|
||||
self.assertEqual(native.cached(101)['status'], 'error')
|
||||
self.assertTrue(native.cached(101)['stale'])
|
||||
partial = {**complete, 'status': 'partial', 'candidates': [], 'issues': ['read_failed']}
|
||||
with patch.object(native, 'scan', return_value=partial):
|
||||
self.assertEqual(native.discover(101)['candidates'], complete['candidates'])
|
||||
with patch.object(native, 'scan', return_value={**complete, 'candidates': []}):
|
||||
self.assertEqual(native.discover(101)['candidates'], [])
|
||||
|
||||
def test_lifecycle_during_first_or_cached_scan_cannot_publish_fresh_result(self):
|
||||
complete = native.scan(101, self.runner())
|
||||
for prior in (None, complete):
|
||||
with self.subTest(cached=prior is not None):
|
||||
native._cache.clear()
|
||||
if prior:
|
||||
native._cache[101] = copy.deepcopy(prior)
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
results = []
|
||||
def delayed(vmid):
|
||||
entered.set()
|
||||
if not release.wait(3):
|
||||
raise AssertionError('scan release timed out')
|
||||
return copy.deepcopy(complete)
|
||||
with patch.object(native, 'scan', side_effect=delayed):
|
||||
worker = threading.Thread(target=lambda: results.append(native.discover(101)))
|
||||
worker.start()
|
||||
try:
|
||||
self.assertTrue(entered.wait(3))
|
||||
native.mark_stale(101)
|
||||
finally:
|
||||
release.set()
|
||||
worker.join(3)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertTrue(results[0]['stale'])
|
||||
self.assertIn('guest_changed', results[0]['issues'])
|
||||
self.assertEqual(results[0]['candidates'], prior['candidates'] if prior else [])
|
||||
self.assertTrue(native.cached(101)['stale'])
|
||||
with patch.object(native, 'scan', return_value=copy.deepcopy(complete)):
|
||||
self.assertFalse(native.discover(101)['stale'])
|
||||
with patch.object(native, 'scan', side_effect=lambda vmid: (
|
||||
native.mark_stale(102), copy.deepcopy(complete))[1]):
|
||||
self.assertFalse(native.discover(101)['stale'])
|
||||
|
||||
def test_scan_excludes_later_updater_and_releases_lock_after_success_or_failure(self):
|
||||
path = self.root / 'proxmenux-lxc-update-101.lock'
|
||||
for fail in (False, True):
|
||||
with self.subTest(fail=fail):
|
||||
runner = self.runner()
|
||||
probes = []
|
||||
def checked(argv, deadline):
|
||||
if argv[1] == 'exec':
|
||||
attempt = subprocess.run(['flock', '-n', str(path), 'true'])
|
||||
self.assertEqual(attempt.returncode, 1)
|
||||
probes.append(argv)
|
||||
if fail:
|
||||
raise native.ProbeError('timeout')
|
||||
return runner(argv, deadline)
|
||||
native.scan(101, checked)
|
||||
self.assertTrue(probes)
|
||||
self.assertEqual(subprocess.run(['flock', '-n', str(path), 'true']).returncode, 0)
|
||||
self.assertEqual(path.read_bytes(), b'')
|
||||
|
||||
def test_pending_control_reservation_blocks_scan_without_touching_it(self):
|
||||
path = self.root / 'proxmenux-lxc-update-101.lock'
|
||||
original = b'{"task":"pending-control"}'
|
||||
path.write_bytes(original)
|
||||
with self.assertRaisesRegex(native.ProbeError, 'guest_busy'):
|
||||
native.scan(101, self.runner())
|
||||
self.assertFalse(any(call[1] == 'exec' for call in self.calls))
|
||||
self.assertEqual(path.read_bytes(), original)
|
||||
|
||||
def test_guest_stop_during_scan_never_returns_complete(self):
|
||||
runner = self.runner()
|
||||
statuses = iter(['status: running', 'status: stopped'])
|
||||
def stopped(argv, deadline):
|
||||
return next(statuses) if argv[1] == 'status' else runner(argv, deadline)
|
||||
with self.assertRaisesRegex(native.ProbeError, 'guest_stopped'):
|
||||
native.scan(101, stopped)
|
||||
self.assertTrue(all(call[1] == 'exec' for call in self.calls))
|
||||
|
||||
def test_scan_reservation_closes_race_after_guard_and_releases_after_exception(self):
|
||||
import lxc_check_job
|
||||
path = self.root / 'proxmenux-lxc-update-101.lock'
|
||||
runner = self.runner()
|
||||
handles = []
|
||||
def starts_update(argv, deadline):
|
||||
if argv[1] == 'status':
|
||||
handles.append(lxc_check_job.acquire(101))
|
||||
return runner(argv, deadline)
|
||||
with patch.object(lxc_check_job, 'LOCK_DIR', self.root):
|
||||
try:
|
||||
with self.assertRaisesRegex(native.ProbeError, 'guest_busy'):
|
||||
native.scan(101, starts_update)
|
||||
self.assertFalse(any(call[1] == 'exec' for call in self.calls))
|
||||
finally:
|
||||
for handle in handles:
|
||||
handle.close()
|
||||
def crash(argv, deadline):
|
||||
if argv[1] == 'exec':
|
||||
with self.assertRaises(BlockingIOError):
|
||||
lxc_check_job.acquire(101)
|
||||
# Another CT must remain available.
|
||||
lxc_check_job.acquire(102).close()
|
||||
raise RuntimeError('unexpected probe failure')
|
||||
return runner(argv, deadline)
|
||||
with self.assertRaisesRegex(RuntimeError, 'unexpected'):
|
||||
native.scan(101, crash)
|
||||
lxc_check_job.acquire(101).close()
|
||||
self.assertEqual(path.read_bytes(), b'')
|
||||
|
||||
def test_failed_scan_releases_discovery_lock_and_keeps_prior_evidence(self):
|
||||
complete = native.scan(101, self.runner())
|
||||
native._cache[101] = copy.deepcopy(complete)
|
||||
with patch.object(native, 'scan', side_effect=RuntimeError('unexpected')):
|
||||
with self.assertRaises(RuntimeError):
|
||||
native.discover(101)
|
||||
self.assertFalse(native._locks[101].locked())
|
||||
self.assertEqual(native.cached(101)['candidates'], complete['candidates'])
|
||||
|
||||
def test_invalid_lock_inode_and_unreadable_config_fail_before_any_probe(self):
|
||||
import os
|
||||
path = self.root / 'proxmenux-lxc-update-101.lock'
|
||||
target = self.root / 'unrelated'
|
||||
target.write_bytes(b'keep')
|
||||
for kind in ('symlink', 'fifo', 'directory'):
|
||||
with self.subTest(kind=kind):
|
||||
if kind == 'symlink':
|
||||
path.symlink_to(target)
|
||||
elif kind == 'fifo':
|
||||
os.mkfifo(path)
|
||||
else:
|
||||
path.mkdir()
|
||||
try:
|
||||
with self.assertRaisesRegex(native.ProbeError, 'lock_unavailable'):
|
||||
native.scan(101, self.runner())
|
||||
self.assertEqual(self.calls, [])
|
||||
self.assertEqual(target.read_bytes(), b'keep')
|
||||
finally:
|
||||
path.rmdir() if kind == 'directory' else path.unlink()
|
||||
with patch.object(Path, 'open', side_effect=PermissionError):
|
||||
with self.assertRaisesRegex(native.ProbeError, 'config_unavailable'):
|
||||
native.scan(101, self.runner())
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_same_ct_busy_and_different_ct_cache_isolation(self):
|
||||
lock = threading.Lock()
|
||||
lock.acquire()
|
||||
native._locks[101] = lock
|
||||
with patch.object(native, 'scan') as scanner:
|
||||
self.assertEqual(native.discover(101)['status'], 'busy')
|
||||
scanner.assert_not_called()
|
||||
self.assertEqual(native.cached(102)['status'], 'not_scanned')
|
||||
|
||||
def test_filter_registered_curated_and_docker_children_without_mutation(self):
|
||||
result = native.scan(101, self.runner())
|
||||
original = copy.deepcopy(result)
|
||||
for suggestions, apps in (({}, [{'discovery_id': result['candidates'][0]['discovery_id']}]),
|
||||
({'extras': [{'name': 'Handmade'}]}, []),
|
||||
({'docker_workloads': [{'name': 'Handmade'}]}, []),
|
||||
({'helper_slug': 'handmade', 'name_suggestion': 'Handmade'}, [])):
|
||||
self.assertEqual(native.filter_candidates(result, suggestions, apps)['candidates'], [])
|
||||
self.assertEqual(result, original)
|
||||
|
||||
def test_actual_save_first_check_and_recheck_never_probe_or_configure_updater(self):
|
||||
with patch.object(lxc_apps, '_APPS_DIR', str(self.root / 'apps')), \
|
||||
patch.object(lxc_apps, '_pct_exec', side_effect=AssertionError('forbidden guest execution')) as execute, \
|
||||
patch.object(lxc_apps, 'fetch_latest_upstream_details', side_effect=AssertionError('forbidden upstream')) as upstream:
|
||||
for version in (None, '1.2.3'):
|
||||
item = native.candidate(101, native.parse_records(service(), 'S')[0][0],
|
||||
'FILE\n' + (json.dumps({'version': version}) if version else ''))
|
||||
payload = {'name': item['name'], 'discovery_id': item['discovery_id'],
|
||||
'update_method': 'none', **(item['tracking_suggestion'] or {})}
|
||||
ok, sidecar = lxc_apps.add_app(101, payload)
|
||||
self.assertTrue(ok, sidecar)
|
||||
app = sidecar['apps'][-1]
|
||||
self.assertEqual(app['state']['installed_version'], version)
|
||||
self.assertIsNone(app['state']['update_available'])
|
||||
self.assertEqual(app['update_method'], 'none')
|
||||
for field in ('helper_slug', 'update_command', 'repo', 'upstream_type'):
|
||||
self.assertNotIn(field, app)
|
||||
lxc_apps.check_app(101, app['id'], force=True)
|
||||
self.assertIsNone(lxc_apps.get_schedule(101))
|
||||
self.assertIsNone(lxc_apps.get_bulk_update(101))
|
||||
execute.assert_not_called()
|
||||
upstream.assert_not_called()
|
||||
|
||||
def test_failed_save_has_no_first_check(self):
|
||||
with patch.object(lxc_apps, '_write_sidecar', return_value=False), \
|
||||
patch.object(lxc_apps, '_read_sidecar', return_value=None), \
|
||||
patch.object(lxc_apps, 'check_app') as check:
|
||||
self.assertFalse(lxc_apps.add_app(101, {'name': 'handmade'})[0])
|
||||
check.assert_not_called()
|
||||
|
||||
def test_active_update_lock_blocks_all_guest_probes_without_writing(self):
|
||||
path = self.root / 'proxmenux-lxc-update-101.lock'
|
||||
path.write_bytes(b'unchanged')
|
||||
with path.open('rb') as handle:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX)
|
||||
with self.assertRaisesRegex(native.ProbeError, 'guest_busy'):
|
||||
native.scan(101, self.runner())
|
||||
self.assertEqual(path.read_bytes(), b'unchanged')
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_actual_service_parser_reads_data_never_executes_or_sources_it(self):
|
||||
for manager in ('systemd', 'openrc'):
|
||||
directory = self.root / manager
|
||||
directory.mkdir()
|
||||
marker = self.root / ('forbidden-' + manager)
|
||||
unit = directory / ('handmade.service' if manager == 'systemd' else 'handmade')
|
||||
content = ('[Service]\nExecStart=/usr/bin/node /opt/handmade/server.js --password SECRET\nWorkingDirectory=/opt/handmade\n'
|
||||
if manager == 'systemd' else 'command="/usr/bin/node"\ncommand_args="/opt/handmade/server.js --password SECRET"\ndirectory="/opt/handmade"\n')
|
||||
unit.write_text(content + '\ntouch ' + str(marker) + '\n')
|
||||
script = native.SERVICE_SCRIPT.replace('/etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d', str(directory))
|
||||
# Substitute only fixture paths, not the collector/parser behavior.
|
||||
if manager == 'openrc':
|
||||
script = script.replace('/etc/init.d:*)', str(directory) + ':*)')
|
||||
output = native.bounded_run(['sh', '-c', script], time.monotonic() + 3)
|
||||
record, = native.parse_records(output, 'S')[0]
|
||||
self.assertEqual(record['path'], '/opt/handmade/server.js')
|
||||
self.assertNotIn('SECRET', output)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_merged_usr_units_are_counted_once(self):
|
||||
usr = self.root / 'usr-units'
|
||||
usr.mkdir()
|
||||
(usr / 'handmade.service').write_text('[Service]\nExecStart=/opt/handmade/server\n')
|
||||
lib = self.root / 'lib-units'
|
||||
lib.symlink_to(usr)
|
||||
check = '[ "$dir" = /lib/systemd/system ] && [ /lib/systemd/system -ef /usr/lib/systemd/system ]'
|
||||
self.assertIn(check, native.SERVICE_SCRIPT)
|
||||
script = (native.SERVICE_SCRIPT
|
||||
.replace('/etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d', f'{usr} {lib}')
|
||||
.replace(check, f'[ "$dir" = {lib} ] && [ {lib} -ef {usr} ]'))
|
||||
output = native.bounded_run(['sh', '-c', script], time.monotonic() + 3)
|
||||
self.assertEqual(output.count('handmade.service'), 1)
|
||||
|
||||
def test_a_guest_without_ss_or_netstat_has_no_listener_evidence(self):
|
||||
bin_dir = self.root / 'no-tools'
|
||||
bin_dir.mkdir()
|
||||
(bin_dir / 'awk').symlink_to('/usr/bin/awk')
|
||||
output = native.bounded_run(['sh', '-c', 'PATH=' + str(bin_dir) + '\n' + native.PORT_SCRIPT, 'fixture', '256'],
|
||||
time.monotonic() + 3)
|
||||
self.assertEqual(native.parse_records(output, 'L'), ([], False, 0))
|
||||
|
||||
def test_actual_process_parser_skips_docker_and_never_reads_environ_cmdline(self):
|
||||
proc = self.root / 'proc'
|
||||
proc.mkdir()
|
||||
for pid, group in ((1, '0::/\n'), (2, '0::/docker/child\n')):
|
||||
directory = proc / str(pid)
|
||||
directory.mkdir()
|
||||
(directory / 'cgroup').write_text(group)
|
||||
(directory / 'exe').symlink_to('/opt/handmade/server')
|
||||
(directory / 'cwd').symlink_to('/opt/handmade')
|
||||
(directory / 'cmdline').write_text('SECRET')
|
||||
(directory / 'environ').write_text('PASSWORD')
|
||||
script = native.PROCESS_SCRIPT.replace('/proc/[0-9]*', str(proc) + '/[0-9]*')
|
||||
output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3)
|
||||
self.assertEqual(output, 'P\t1\t/opt/handmade/server\t/opt/handmade\nEND\n')
|
||||
self.assertNotIn('"$dir/cmdline"', native.PROCESS_SCRIPT)
|
||||
self.assertNotIn('"$dir/environ"', native.PROCESS_SCRIPT)
|
||||
self.assertNotIn('SECRET', output)
|
||||
|
||||
def test_ports_require_exact_process_ownership_and_do_not_infer_http(self):
|
||||
process = 'P\t123\t/opt/handmade/server\t/opt/handmade\nEND\n'
|
||||
result = native.scan(101, self.runner(processes=process, ports='L\t123\t8080\nL\t999\t9000\nEND\n'))
|
||||
item, = result['candidates']
|
||||
self.assertEqual(item['listening_ports'], [8080])
|
||||
self.assertEqual(item['default_ports'], [], 'TCP listener does not prove HTTP')
|
||||
result = native.scan(101, self.runner(ports='L\t123\t8080\nEND\n'))
|
||||
self.assertEqual(result['candidates'][0]['listening_ports'], [])
|
||||
|
||||
def test_shared_runtime_ports_are_assigned_only_by_working_directory(self):
|
||||
services = service(exe='/usr/bin/node', script='/opt/handmade/server.js').removesuffix('END\n')
|
||||
services += service(name='second', exe='/usr/bin/node', script='/opt/second/server.js', directory='/opt/second')
|
||||
process = 'P\t123\t/usr/bin/node\t/opt/second\nEND\n'
|
||||
result = native.scan(101, self.runner(services, processes=process, ports='L\t123\t8080\nEND\n'))
|
||||
self.assertEqual(result['candidates'][0]['listening_ports'], [])
|
||||
self.assertEqual(result['candidates'][1]['listening_ports'], [8080])
|
||||
|
||||
def test_ss_and_alpine_netstat_parser_are_bounded_and_secret_free(self):
|
||||
for tool, line in (('ss', 'LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("SECRET",pid=123,fd=7))'),
|
||||
('netstat', 'tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 123/SECRET')):
|
||||
bin_dir = self.root / tool
|
||||
bin_dir.mkdir()
|
||||
executable = bin_dir / tool
|
||||
executable.write_text('#!/bin/sh\nprintf \'%s\\n\' \'' + line + '\'\n')
|
||||
executable.chmod(0o755)
|
||||
(bin_dir / 'awk').symlink_to('/usr/bin/awk')
|
||||
(bin_dir / 'timeout').symlink_to('/usr/bin/timeout')
|
||||
script = 'PATH=' + str(bin_dir) + '\n' + native.PORT_SCRIPT
|
||||
output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3)
|
||||
self.assertEqual(output, 'L\t123\t8080\nEND\n')
|
||||
self.assertNotIn('SECRET', output)
|
||||
executable.write_text('#!/bin/sh\nexit 1\n')
|
||||
output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3)
|
||||
with self.assertRaises(native.ProbeError):
|
||||
native.parse_records(output, 'L')
|
||||
|
||||
|
||||
class BoundedRunner(unittest.TestCase):
|
||||
def test_output_limit_is_enforced_during_read(self):
|
||||
with self.assertRaisesRegex(native.ProbeError, 'output_limit'):
|
||||
native.bounded_run([sys.executable, '-c', 'import os; os.write(2,b"x"*1000000)'], time.monotonic() + 3)
|
||||
|
||||
def test_pipe_holding_child_does_not_bypass_timeout(self):
|
||||
start = time.monotonic()
|
||||
with self.assertRaisesRegex(native.ProbeError, 'timeout'):
|
||||
native.bounded_run([sys.executable, '-c', 'import os,time; os.fork(); time.sleep(10)'], start + .15)
|
||||
self.assertLess(time.monotonic() - start, .8)
|
||||
|
||||
def test_failures_never_reveal_stderr_and_deadline_blocks_spawn(self):
|
||||
with self.assertRaisesRegex(native.ProbeError, '^read_failed$'):
|
||||
native.bounded_run([sys.executable, '-c', 'import sys; sys.stderr.write("SECRET"); sys.exit(1)'], time.monotonic() + 3)
|
||||
with patch.object(native.subprocess, 'Popen') as launch:
|
||||
with self.assertRaisesRegex(native.ProbeError, 'deadline'):
|
||||
native.bounded_run(['forbidden'], time.monotonic() - 1)
|
||||
launch.assert_not_called()
|
||||
|
||||
|
||||
class Routes(unittest.TestCase):
|
||||
"""Execute actual Flask route bodies; fixture auth, no server startup jobs."""
|
||||
def setUp(self):
|
||||
from flask import Flask, request, jsonify, g
|
||||
self.app = Flask('native-fixture')
|
||||
self.authorized = True
|
||||
def require_auth(fn):
|
||||
@wraps(fn)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not self.authorized:
|
||||
return jsonify({'error': 'auth_required'}), 401
|
||||
return fn(*args, **kwargs)
|
||||
return wrapped
|
||||
self.globals = {'app': self.app, 'request': request, 'jsonify': jsonify,
|
||||
'require_auth': require_auth, '_vm_app_suggestions_cache': {},
|
||||
'_VM_APP_SUGGESTIONS_TTL': 300,
|
||||
'_vm_cache_get': lambda cache, vmid, ttl: cache.get(vmid),
|
||||
'_vm_cache_put': lambda cache, vmid, value: cache.update({vmid: value}),
|
||||
'_pvesh_cache': {}, '_vm_disk_cache': {}, '_vm_schedule_cache': {}, '_vm_cache_invalidate': Mock(),
|
||||
'_invalidate_lxc_ip': Mock(), '_schedule_started_guest_refresh': Mock(),
|
||||
'_publish_guest_modal_cache_revision': Mock()}
|
||||
tree = ast.parse((SCRIPTS / 'flask_server.py').read_text())
|
||||
routes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in (
|
||||
'api_vm_apps_suggestions', 'api_vm_apps_native_discovery', 'api_vm_apps_add',
|
||||
'_handle_guest_lifecycle')]
|
||||
self.assertEqual(len(routes), 4)
|
||||
exec(compile(ast.Module(body=routes, type_ignores=[]), str(SCRIPTS / 'flask_server.py'), 'exec'), self.globals)
|
||||
self.client = self.app.test_client()
|
||||
|
||||
def test_explicit_post_only_get_and_existing_post_never_native_scan(self):
|
||||
result = {'vmid': 101, 'status': 'complete', 'candidates': [], 'stale': False}
|
||||
with patch.object(native, 'discover', return_value=result) as scan, \
|
||||
patch.object(native, 'cached', return_value=result), \
|
||||
patch.object(lxc_apps, 'get_suggestions', return_value={'extras': []}) as suggestions, \
|
||||
patch.object(lxc_apps, 'load_sidecar', return_value={'apps': []}), \
|
||||
patch.object(lxc_apps, '_write_sidecar') as write:
|
||||
self.assertEqual(self.client.get('/api/vms/101/apps/native-discovery').status_code, 200)
|
||||
self.client.get('/api/vms/101/apps/suggestions')
|
||||
# OCI refresh and Find's existing catalog path retain their purpose.
|
||||
self.client.post('/api/vms/101/apps/suggestions')
|
||||
with self.app.test_request_context('/api/vms/101/apps/suggestions'):
|
||||
from flask import g
|
||||
g._internal_call = True
|
||||
self.globals['_vm_app_suggestions_cache'].clear()
|
||||
self.globals['api_vm_apps_suggestions'](101)
|
||||
scan.assert_not_called()
|
||||
self.client.post('/api/vms/101/apps/native-discovery')
|
||||
scan.assert_called_once_with(101)
|
||||
self.assertEqual(suggestions.call_count, 2)
|
||||
write.assert_not_called()
|
||||
|
||||
def test_unauthorized_and_wrong_method_have_zero_scans(self):
|
||||
with patch.object(native, 'discover') as scan, patch.object(lxc_apps, 'load_sidecar') as read:
|
||||
self.authorized = False
|
||||
self.assertEqual(self.client.post('/api/vms/101/apps/native-discovery').status_code, 401)
|
||||
self.assertEqual(self.client.put('/api/vms/101/apps/native-discovery').status_code, 405)
|
||||
scan.assert_not_called()
|
||||
read.assert_not_called()
|
||||
|
||||
def test_actual_lifecycle_marks_last_known_stale_without_scanning(self):
|
||||
native._cache[101] = {'vmid': 101, 'status': 'complete', 'stale': False,
|
||||
'candidates': [], 'scanned_at': time.time()}
|
||||
with patch.object(native, 'discover') as scan, \
|
||||
patch.object(lxc_apps, 'clear_schedule_reboot_required') as clear:
|
||||
for action in ('start', 'reboot', 'stop'):
|
||||
self.globals['_handle_guest_lifecycle']('101', 'lxc', action)
|
||||
self.assertTrue(native.cached(101)['stale'])
|
||||
scan.assert_not_called()
|
||||
self.assertEqual(clear.call_count, 2)
|
||||
|
||||
def test_actual_repeated_prewarmer_has_zero_native_scans(self):
|
||||
tree = ast.parse((SCRIPTS / 'flask_server.py').read_text())
|
||||
warmer = next(node for node in tree.body if isinstance(node, ast.FunctionDef)
|
||||
and node.name == '_vm_modal_prewarmer_pass')
|
||||
environment = dict(self.globals)
|
||||
for name in ('_vm_details_cache', '_vm_backups_cache', '_vm_schedule_cache', '_vm_mounts_cache'):
|
||||
environment[name] = {}
|
||||
for name in ('_VM_DETAILS_TTL', '_VM_BACKUPS_TTL', '_VM_SCHEDULE_TTL', '_VM_MOUNTS_TTL'):
|
||||
environment[name] = 300
|
||||
for name in ('get_vm_config', 'api_vm_backups', 'api_vm_apps_schedule', 'api_lxc_mount_points'):
|
||||
environment[name] = lambda vmid: None
|
||||
environment.update({'get_cached_pvesh_cluster_resources_vm': lambda: [
|
||||
{'vmid': 101, 'type': 'lxc', 'status': 'running'}], 'time': Mock(), 'sys': sys})
|
||||
exec(compile(ast.Module(body=[warmer], type_ignores=[]), 'flask_server.py', 'exec'), environment)
|
||||
with patch.object(native, 'discover') as scan, \
|
||||
patch.object(lxc_apps, 'ensure_oci_registration'), \
|
||||
patch.object(lxc_apps, 'load_sidecar', return_value={'apps': []}), \
|
||||
patch.object(lxc_apps, 'get_suggestions', return_value={'extras': []}):
|
||||
for _ in range(2):
|
||||
environment['_vm_app_suggestions_cache'].clear()
|
||||
self.assertEqual(environment['_vm_modal_prewarmer_pass'](), 1)
|
||||
scan.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,289 @@
|
||||
"""PBS platform, HTTPS protocol, authorization and isolated AppImage regressions."""
|
||||
|
||||
import ast
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
from flask import Flask
|
||||
import jwt
|
||||
import auth_manager
|
||||
import pbs_manager as pbs
|
||||
from flask_pbs_routes import pbs_bp
|
||||
|
||||
|
||||
class PlatformTests(unittest.TestCase):
|
||||
def test_supported_platforms_and_partial_dpkg_failure(self):
|
||||
module = pbs.detect_platform.__globals__
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
release = Path(directory) / "os-release"
|
||||
for package, version, suite, product in (
|
||||
("pve-manager", "8.4.15", "bookworm", "pve"),
|
||||
("pve-manager", "9.2.1", "trixie", "pve"),
|
||||
("proxmox-backup-server", "3.4.0-1", "bookworm", "pbs"),
|
||||
("proxmox-backup-server", "4.2.5-1", "trixie", "pbs"),
|
||||
):
|
||||
release.write_text(f'VERSION_CODENAME="{suite}"\n')
|
||||
output = f"{package}\tinstall ok installed\t{version}\n"
|
||||
with self.subTest(product=product, version=version), \
|
||||
mock.patch.dict(module, {"OS_RELEASE": release}), \
|
||||
mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output, returncode=1)):
|
||||
detected = pbs.detect_platform()
|
||||
self.assertEqual(detected["product"], product)
|
||||
self.assertEqual(detected["version"], version)
|
||||
self.assertTrue(detected["supported"])
|
||||
|
||||
def test_client_only_and_removed_server_are_not_pbs(self):
|
||||
for output in ("proxmox-backup-client\tinstall ok installed\t4.2.5\n",
|
||||
"proxmox-backup-server\tdeinstall ok config-files\t4.2.5\n", ""):
|
||||
with self.subTest(output=output), mock.patch.object(subprocess, "run", return_value=SimpleNamespace(stdout=output)):
|
||||
self.assertEqual(pbs.detect_platform()["product"], "unknown")
|
||||
|
||||
def test_pbs_utilities_preserve_sources_and_never_call_pve_policy(self):
|
||||
helper = ROOT / "scripts/global/repository-functions.sh"
|
||||
for supported in (True, False):
|
||||
harness = f'''
|
||||
source {shlex.quote(str(helper))}
|
||||
pmx_platform_field() {{ if [[ "$1" == product ]]; then echo pbs; else echo {str(supported).lower()}; fi; }}
|
||||
msg_info() {{ :; }}
|
||||
msg_error() {{ :; }}
|
||||
translate() {{ printf '%s' "$1"; }}
|
||||
pveversion() {{ echo 'FORBIDDEN: pveversion'; return 90; }}
|
||||
repository_policy() {{ echo 'FORBIDDEN: repository mutation'; return 90; }}
|
||||
apt-get() {{ echo 'FORBIDDEN: apt'; return 90; }}
|
||||
ensure_repositories
|
||||
'''
|
||||
result = subprocess.run(["bash"], input=harness, text=True, capture_output=True)
|
||||
self.assertNotIn("FORBIDDEN", result.stdout + result.stderr)
|
||||
self.assertEqual(result.returncode, 0 if supported else 1)
|
||||
|
||||
|
||||
class PBSManagerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name)
|
||||
patch = mock.patch.multiple(auth_manager, CONFIG_DIR=self.root, AUTH_CONFIG_FILE=self.root / "auth.json")
|
||||
patch.start(); self.addCleanup(patch.stop)
|
||||
auth_manager.save_auth_config({"enabled": True, "username": "admin", "password_hash": "configured",
|
||||
"jwt_secret": "s" * 48, "api_tokens": [], "revoked_tokens": []})
|
||||
auth_manager._invalidate_revoked_cache()
|
||||
pbs._status_cache.clear()
|
||||
self.server = {"name": "Backup", "url": "https://pbs.test:8007", "token_id": "audit@pbs!monitor",
|
||||
"token_secret": "fixture-token-secret", "fingerprint": "a" * 64}
|
||||
app = Flask(__name__); app.register_blueprint(pbs_bp)
|
||||
self.client = app.test_client()
|
||||
self.headers = {"Authorization": "Bearer " + auth_manager.generate_token("admin")}
|
||||
|
||||
def test_save_redacts_secret_and_probes_authenticated_endpoint(self):
|
||||
with mock.patch.object(pbs, "_remote_get", return_value=[]) as request:
|
||||
identifier = pbs.save_server(self.server)
|
||||
request.assert_called_once()
|
||||
self.assertEqual(request.call_args.args[1], "/status/datastore-usage")
|
||||
self.assertEqual((self.root / "pbs_servers.json").stat().st_mode & 0o777, 0o600)
|
||||
public = pbs.public_servers()
|
||||
self.assertEqual(public[0]["id"], identifier)
|
||||
self.assertNotIn("fixture-token-secret", json.dumps(public))
|
||||
self.assertTrue(public[0]["has_token"])
|
||||
with mock.patch.object(pbs, "_remote_get", return_value=[]):
|
||||
pbs.save_server({"id": identifier, "name": "Renamed"})
|
||||
self.assertEqual(pbs._load_servers()[0]["token_secret"], "fixture-token-secret")
|
||||
pbs.delete_server(identifier)
|
||||
self.assertEqual(pbs.public_servers(), [])
|
||||
|
||||
def test_failed_credentials_or_invalid_config_do_not_replace_saved_connection(self):
|
||||
with mock.patch.object(pbs, "_remote_get", return_value=[]):
|
||||
identifier = pbs.save_server(self.server)
|
||||
original = (self.root / "pbs_servers.json").read_bytes()
|
||||
with mock.patch.object(pbs, "_remote_get", side_effect=pbs.PBSError("Rejected token")):
|
||||
with self.assertRaises(pbs.PBSError):
|
||||
pbs.save_server({**self.server, "id": identifier, "token_secret": "wrong"})
|
||||
for changes in ({"url": "http://pbs.test"}, {"url": "https://user:pass@pbs.test"},
|
||||
{"fingerprint": "bad-pin"}, {"token_id": "not-a-token"},
|
||||
{"token_secret": "line\nbreak"}):
|
||||
with self.subTest(changes=changes), self.assertRaises(pbs.PBSError):
|
||||
pbs.save_server({**self.server, **changes, "id": identifier})
|
||||
self.assertEqual((self.root / "pbs_servers.json").read_bytes(), original)
|
||||
|
||||
def test_remote_partial_failure_is_unknown_instead_of_zero(self):
|
||||
def remote(server, endpoint):
|
||||
if endpoint == "/version": return {"version": "4.2.5"}
|
||||
if endpoint == "/status/datastore-usage": return [{"store": "backup", "total": 100, "used": 30, "avail": 70}]
|
||||
if endpoint == "/admin/verify": raise pbs.PBSError("Permission denied")
|
||||
return []
|
||||
with mock.patch.object(pbs, "_remote_get", side_effect=remote) as request:
|
||||
identifier = pbs.save_server(self.server)
|
||||
result = pbs.get_status(identifier)
|
||||
count = request.call_count
|
||||
self.assertEqual(pbs.get_status(identifier), result)
|
||||
self.assertEqual(request.call_count, count)
|
||||
self.assertEqual(result["datastores"][0]["name"], "backup")
|
||||
self.assertIsNone(result["verify"])
|
||||
self.assertIn("verify", result["errors"])
|
||||
|
||||
def test_unmounted_removable_datastore_has_no_fabricated_capacity(self):
|
||||
with mock.patch.object(pbs, "_cli", return_value=[{"name": "removable", "path": "/not-mounted", "backing-device": "uuid"}]), \
|
||||
mock.patch.object(pbs.os.path, "ismount", return_value=False), \
|
||||
mock.patch.object(pbs.shutil, "disk_usage") as usage:
|
||||
result = pbs._local_datastores()
|
||||
usage.assert_not_called()
|
||||
self.assertIsNone(result[0]["total"])
|
||||
self.assertIn("error", result[0])
|
||||
|
||||
def test_local_status_reads_pbs_commands_and_filesystem_capacity(self):
|
||||
def run(command, **kwargs):
|
||||
self.assertNotIn("shell", kwargs)
|
||||
self.assertEqual(command[0], "proxmox-backup-manager")
|
||||
self.assertEqual(command[-2:], ["--output-format", "json"])
|
||||
data = [{"name": "backup", "path": str(self.root)}] if command[1:3] == ["datastore", "list"] else []
|
||||
return SimpleNamespace(stdout=json.dumps(data), returncode=0)
|
||||
with mock.patch.object(pbs, "get_platform", return_value={"product": "pbs", "version": "4.2.5"}), \
|
||||
mock.patch.object(pbs, "_local_services", return_value={key: "active" for key in pbs.PBS_SERVICES}), \
|
||||
mock.patch.object(pbs.subprocess, "run", side_effect=run), \
|
||||
mock.patch.object(pbs.shutil, "disk_usage", return_value=SimpleNamespace(total=100, used=30, free=70)):
|
||||
result = pbs.get_status("local")
|
||||
self.assertEqual(result["version"], "4.2.5")
|
||||
self.assertEqual(result["errors"], {})
|
||||
self.assertEqual(result["datastores"][0]["used"], 30)
|
||||
self.assertEqual(set(result["services"]), set(pbs.PBS_SERVICES))
|
||||
|
||||
def test_connection_api_requires_full_admin_and_does_not_return_secret(self):
|
||||
self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401)
|
||||
with mock.patch.object(pbs, "_remote_get", return_value=[]):
|
||||
response = self.client.post("/api/pbs/servers", headers=self.headers, json=self.server)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn("fixture-token-secret", response.text)
|
||||
payload = jwt.decode(self.headers["Authorization"].split()[1], "s" * 48,
|
||||
algorithms=["HS256"], audience=auth_manager.JWT_AUDIENCE)
|
||||
payload["scope"] = "read_only"
|
||||
read_only = {"Authorization": "Bearer " + jwt.encode(payload, "s" * 48, algorithm="HS256")}
|
||||
self.assertEqual(self.client.post("/api/pbs/servers", headers=read_only, json=self.server).status_code, 403)
|
||||
self.assertEqual(self.client.get("/api/pbs/servers", headers=read_only).status_code, 200)
|
||||
auth_manager.save_auth_config({"enabled": False})
|
||||
self.assertEqual(self.client.post("/api/pbs/servers", json=self.server).status_code, 409)
|
||||
|
||||
def test_health_uses_pbs_services_and_skips_pve_only_checks(self):
|
||||
source = ast.parse((ROOT / "AppImage/scripts/health_monitor.py").read_text())
|
||||
names = ("_check_pve_services", "_check_vms_cts_optimized", "_check_vms_cts_with_persistence",
|
||||
"_check_proxmox_storages", "_check_proxmox_storage", "_check_lxc_disk_usage",
|
||||
"_check_vm_disk_usage", "_check_lxc_mount_capacity", "_check_pve_storage_capacity")
|
||||
functions = [node for node in ast.walk(source) if isinstance(node, ast.FunctionDef) and node.name in names]
|
||||
persistence = mock.Mock(); persistence.is_error_active.return_value = False
|
||||
run = mock.Mock(return_value=SimpleNamespace(returncode=0, stdout="active\n"))
|
||||
namespace = {"Dict": dict, "Any": object, "Optional": __import__('typing').Optional,
|
||||
"PBS_SERVICES": pbs.PBS_SERVICES, "health_persistence": persistence,
|
||||
"subprocess": SimpleNamespace(run=run), "os": __import__('os')}
|
||||
exec(compile(ast.Module(body=functions, type_ignores=[]), '<health-pbs-test>', 'exec'), namespace)
|
||||
instance = SimpleNamespace(platform_type="pbs", PVE_SERVICES=["pveproxy", "pvedaemon", "pvestatd", "pve-cluster"])
|
||||
services = namespace["_check_pve_services"](instance)
|
||||
self.assertEqual(services["status"], "OK")
|
||||
self.assertEqual([call.args[0][2] for call in run.call_args_list], list(pbs.PBS_SERVICES))
|
||||
for name in names[1:]:
|
||||
result = namespace[name](instance)
|
||||
self.assertTrue(result is None or result.get("not_applicable"))
|
||||
|
||||
def test_appimage_copies_and_imports_pbs_dependencies_outside_source_tree(self):
|
||||
builder = (ROOT / "AppImage/scripts/build_appimage.sh").read_text()
|
||||
modules = ("pbs_manager", "flask_pbs_routes", "auth_manager", "jwt_middleware")
|
||||
copies = []
|
||||
for name in modules:
|
||||
matches = [line for line in builder.splitlines() if line.startswith(f'cp "$SCRIPT_DIR/{name}.py"')]
|
||||
self.assertEqual(len(matches), 1, name)
|
||||
copies.extend(matches)
|
||||
copies.extend(line for line in builder.splitlines() if line.startswith('cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py"'))
|
||||
self.assertEqual(len(copies), len(modules) + 1)
|
||||
app = self.root / 'AppDir'; (app / 'usr/bin').mkdir(parents=True)
|
||||
q = shlex.quote
|
||||
shell = f'APP_DIR={q(str(app))}\nSCRIPT_DIR={q(str(ROOT / "AppImage/scripts"))}\nAPPIMAGE_ROOT={q(str(ROOT / "AppImage"))}\n' + '\n'.join(copies)
|
||||
subprocess.run(['bash', '-e', '-c', shell], check=True)
|
||||
code = f'import sys; sys.path.insert(0, {str(app / "usr/bin")!r}); import pbs_manager, flask_pbs_routes, proxmox_platform'
|
||||
subprocess.run([sys.executable, '-I', '-B', '-c', code], cwd=self.root, check=True, capture_output=True)
|
||||
|
||||
|
||||
class TLSTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temporary = tempfile.TemporaryDirectory()
|
||||
root = Path(cls.temporary.name)
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
|
||||
certificate = (x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number()).not_valid_before(datetime.now(timezone.utc) - timedelta(minutes=1))
|
||||
.not_valid_after(datetime.now(timezone.utc) + timedelta(hours=1))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]), critical=False)
|
||||
.sign(key, hashes.SHA256()))
|
||||
cert_path, key_path = root / 'server.pem', root / 'server.key'
|
||||
cert_path.write_bytes(certificate.public_bytes(serialization.Encoding.PEM))
|
||||
key_path.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
|
||||
cls.fingerprint = hashlib.sha256(certificate.public_bytes(serialization.Encoding.DER)).hexdigest()
|
||||
cls.requests = []
|
||||
cls.response_status = 200
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
super().handle()
|
||||
except ConnectionResetError:
|
||||
pass # A rejected TLS pin closes before any HTTP request.
|
||||
|
||||
def do_GET(self):
|
||||
cls.requests.append((self.path, self.headers.get("Authorization")))
|
||||
body = json.dumps({"data": []}).encode()
|
||||
self.send_response(cls.response_status)
|
||||
self.send_header('Content-Length', str(len(body)))
|
||||
self.end_headers(); self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args): pass
|
||||
|
||||
cls.server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); context.load_cert_chain(cert_path, key_path)
|
||||
cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True); cls.thread.start()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.server.shutdown(); cls.server.server_close(); cls.thread.join(timeout=5); cls.temporary.cleanup()
|
||||
|
||||
def setUp(self):
|
||||
self.requests.clear(); type(self).response_status = 200
|
||||
self.connection = {"url": f'https://127.0.0.1:{self.server.server_port}',
|
||||
"fingerprint": self.fingerprint, "token_id": "audit@pbs!monitor", "token_secret": "fake-secret"}
|
||||
|
||||
def test_real_tls_matching_pin_sends_correct_pbs_authorization(self):
|
||||
self.assertEqual(pbs._remote_get(self.connection, '/status/datastore-usage'), [])
|
||||
self.assertEqual(self.requests, [('/api2/json/status/datastore-usage', 'PBSAPIToken=audit@pbs!monitor:fake-secret')])
|
||||
|
||||
def test_bad_pin_and_untrusted_ca_send_no_http_credentials(self):
|
||||
for fingerprint in ('0' * 64, ''):
|
||||
with self.subTest(fingerprint=fingerprint), self.assertRaises(pbs.PBSError):
|
||||
pbs._remote_get({**self.connection, "fingerprint": fingerprint}, '/status/datastore-usage')
|
||||
self.assertEqual(self.requests, [])
|
||||
|
||||
def test_bad_credentials_and_redirects_are_rejected(self):
|
||||
for status in (401, 403, 302):
|
||||
type(self).response_status = status
|
||||
with self.subTest(status=status), self.assertRaises(pbs.PBSError):
|
||||
pbs._remote_get(self.connection, '/status/datastore-usage')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""The update check of a private image uses the login saved in ProxMenux:
|
||||
it is sent to the token service the registry names, or to the registry
|
||||
itself when it asks for Basic authentication, and never anywhere else."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import lxc_apps
|
||||
|
||||
CREDENTIAL = base64.b64encode(b"macrimi:ghp_secret").decode()
|
||||
MANIFEST = "https://ghcr.io/v2/macrimi/app/manifests/latest"
|
||||
|
||||
|
||||
class SavedLoginTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.auth_file = Path(self.folder.name) / "registry-auth.json"
|
||||
self.auth_file.write_text(json.dumps({"auths": {"ghcr.io": {"auth": CREDENTIAL},
|
||||
"docker.io": {"auth": CREDENTIAL},
|
||||
"registry.example.com:5000": {"auth": CREDENTIAL}}}))
|
||||
patcher = mock.patch.object(lxc_apps, "_REGISTRY_AUTH_FILE", str(self.auth_file))
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
|
||||
def test_the_login_is_found_by_registry_host(self):
|
||||
self.assertEqual(lxc_apps._registry_saved_login("ghcr.io"), f"Basic {CREDENTIAL}")
|
||||
self.assertEqual(lxc_apps._registry_saved_login("registry-1.docker.io"), f"Basic {CREDENTIAL}")
|
||||
self.assertEqual(lxc_apps._registry_saved_login("registry.example.com:5000"), f"Basic {CREDENTIAL}")
|
||||
self.assertIsNone(lxc_apps._registry_saved_login("quay.io"))
|
||||
|
||||
def test_without_the_file_nothing_is_sent(self):
|
||||
self.auth_file.unlink()
|
||||
self.assertIsNone(lxc_apps._registry_saved_login("ghcr.io"))
|
||||
|
||||
def test_a_bearer_challenge_asks_the_token_with_the_saved_login(self):
|
||||
challenge = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:macrimi/app:pull"'
|
||||
answers = [({"WWW-Authenticate": challenge}, b"", 401, None, None),
|
||||
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
|
||||
sent = []
|
||||
|
||||
def token_service(request, timeout):
|
||||
sent.append(dict(request.header_items()))
|
||||
response = mock.MagicMock()
|
||||
response.__enter__.return_value.read.return_value = b'{"token": "pull-token"}'
|
||||
return response
|
||||
|
||||
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry, \
|
||||
mock.patch.object(lxc_apps.urllib.request, "urlopen", side_effect=token_service):
|
||||
headers, _body, token, error = lxc_apps._registry_request(MANIFEST, {"Accept": "*/*"})
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(token, "pull-token")
|
||||
self.assertEqual(sent[0]["Authorization"], f"Basic {CREDENTIAL}")
|
||||
self.assertEqual(registry.call_args_list[1].args[1]["Authorization"], "Bearer pull-token")
|
||||
|
||||
def test_a_basic_challenge_is_answered_with_the_saved_login(self):
|
||||
answers = [({"WWW-Authenticate": 'Basic realm="registry"'}, b"", 401, None, None),
|
||||
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
|
||||
url = "https://registry.example.com:5000/v2/team/app/manifests/latest"
|
||||
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry:
|
||||
_headers, _body, _token, error = lxc_apps._registry_request(url, {"Accept": "*/*"})
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(registry.call_args_list[1].args[1]["Authorization"], f"Basic {CREDENTIAL}")
|
||||
|
||||
def test_a_basic_challenge_over_http_gets_no_login(self):
|
||||
answers = [({"WWW-Authenticate": 'Basic realm="registry"'}, b"", 401, None, None)]
|
||||
url = "http://registry.example.com:5000/v2/team/app/manifests/latest"
|
||||
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry:
|
||||
_headers, _body, _token, error = lxc_apps._registry_request(url, {"Accept": "*/*"})
|
||||
self.assertEqual(error, "registry authentication required")
|
||||
self.assertEqual(registry.call_count, 1)
|
||||
|
||||
def test_a_public_image_asks_its_token_without_a_login(self):
|
||||
challenge = 'Bearer realm="https://auth.example.org/token",service="example",scope="repository:x:pull"'
|
||||
answers = [({"WWW-Authenticate": challenge}, b"", 401, None, None),
|
||||
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
|
||||
sent = []
|
||||
|
||||
def token_service(request, timeout):
|
||||
sent.append(dict(request.header_items()))
|
||||
response = mock.MagicMock()
|
||||
response.__enter__.return_value.read.return_value = b'{"token": "t"}'
|
||||
return response
|
||||
|
||||
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers), \
|
||||
mock.patch.object(lxc_apps.urllib.request, "urlopen", side_effect=token_service):
|
||||
lxc_apps._registry_request("https://quay.io/v2/x/manifests/latest", {})
|
||||
self.assertNotIn("Authorization", sent[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Remote mount checks carry the key their dismissal is saved under."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
_health_persistence_module = ModuleType("health_persistence")
|
||||
_health_persistence_module.health_persistence = SimpleNamespace(cleanup_old_errors=lambda: None)
|
||||
_health_persistence_module.disk_base_name = lambda name: str(name).replace("/dev/", "")
|
||||
sys.modules.setdefault("health_persistence", _health_persistence_module)
|
||||
sys.modules.setdefault("psutil", ModuleType("psutil"))
|
||||
sys.modules.setdefault("flask_server", SimpleNamespace(
|
||||
get_proxmox_node_name=lambda: "fixture",
|
||||
get_cached_pvesh_cluster_resources_vm=lambda: [],
|
||||
get_cached_vm_disk=lambda _vmid: None,
|
||||
))
|
||||
|
||||
import health_monitor # noqa: E402
|
||||
|
||||
|
||||
class _Persistence:
|
||||
def __init__(self):
|
||||
self.recorded = []
|
||||
|
||||
def record_error(self, **kwargs):
|
||||
self.recorded.append(kwargs)
|
||||
|
||||
def get_active_errors(self, *args, **kwargs):
|
||||
return []
|
||||
|
||||
def clear_error(self, key):
|
||||
pass
|
||||
|
||||
|
||||
class RemoteMountDismissal(unittest.TestCase):
|
||||
def check(self, host, lxc=()):
|
||||
persistence = _Persistence()
|
||||
mounts = SimpleNamespace(scan_remote_mounts=lambda force: list(host),
|
||||
scan_lxc_mounts=lambda force: list(lxc))
|
||||
with patch.object(health_monitor, "MOUNT_MONITOR_AVAILABLE", True), \
|
||||
patch.object(health_monitor, "mount_monitor", mounts, create=True), \
|
||||
patch.object(health_monitor, "health_persistence", persistence):
|
||||
result = health_monitor.HealthMonitor._check_remote_mounts(SimpleNamespace())
|
||||
return result, persistence
|
||||
|
||||
def test_stale_and_read_only_mounts_carry_their_saved_key(self):
|
||||
result, persistence = self.check(
|
||||
[{"target": "/mnt/pve/nas", "status": "stale", "fstype": "nfs4"},
|
||||
{"target": "/mnt/share", "status": "ok", "fstype": "cifs"}],
|
||||
[{"target": "/media", "status": "readonly", "fstype": "nfs4", "lxc_id": "120", "lxc_name": "app"}])
|
||||
checks = result["checks"]
|
||||
self.assertEqual(checks["/mnt/pve/nas"]["error_key"], "mount_stale_/mnt/pve/nas")
|
||||
self.assertEqual(checks["CT 120 (app) /media"]["error_key"], "mount_readonly_ct120:/media")
|
||||
self.assertTrue(checks["/mnt/pve/nas"]["dismissable"])
|
||||
self.assertTrue(checks["CT 120 (app) /media"]["dismissable"])
|
||||
self.assertNotIn("error_key", checks["/mnt/share"])
|
||||
self.assertEqual({entry["error_key"] for entry in persistence.recorded},
|
||||
{"mount_stale_/mnt/pve/nas", "mount_readonly_ct120:/media"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,69 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../components/proxmox-dashboard.tsx'), 'utf8')
|
||||
const tree = ts.createSourceFile('dashboard.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const wrapper = tree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'ProxmoxDashboard')
|
||||
assert.ok(wrapper, 'platform gate must remain separate from PVE hooks/prewarmers')
|
||||
const code = ts.transpileModule(ts.createPrinter().printNode(ts.EmitHint.Unspecified, wrapper, tree), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText
|
||||
const PBS = () => {}, PVE = () => {}
|
||||
for (const [data, error, expected] of [
|
||||
[{ product: 'pbs' }, null, PBS], [{ product: 'pve' }, null, PVE],
|
||||
[undefined, new Error('older API'), PVE], [undefined, null, 'p'],
|
||||
]) {
|
||||
const sandbox = {
|
||||
exports: {}, PBSDashboard: PBS, PVEDashboard: PVE,
|
||||
useT: () => key => key, useSWR: () => ({ data, error }),
|
||||
require: () => ({ jsx: (type, props) => ({ type, props }) }),
|
||||
}
|
||||
vm.runInNewContext(code, sandbox)
|
||||
assert.equal(sandbox.exports.ProxmoxDashboard().type, expected)
|
||||
}
|
||||
console.log('PASS: PBS dashboard routing and loading gate do not mount PVE prewarmers')
|
||||
|
||||
// The PVE Backup tab keeps native guest retention alongside jobs when adding
|
||||
// its PBS connection view. Exercise the shipped JSX for all three selections.
|
||||
const backupSource = fs.readFileSync(path.join(__dirname, '../components/backup-tab.tsx'), 'utf8')
|
||||
const backupTree = ts.createSourceFile('backup-tab.tsx', backupSource, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const backupTab = backupTree.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'BackupTab')
|
||||
assert.ok(backupTab, 'Backup tab must keep a platform-specific view switch')
|
||||
const backupCode = ts.transpileModule(ts.createPrinter().printNode(ts.EmitHint.Unspecified, backupTab, backupTree), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
|
||||
}).outputText
|
||||
const HostBackup = () => {}, VmBackupJobs = () => {}, BackupStorageRetention = () => {}, PBSPanel = () => {}
|
||||
for (const [view, expected] of [
|
||||
['host', [HostBackup]], ['guests', [VmBackupJobs, BackupStorageRetention]], ['pbs', [PBSPanel]],
|
||||
]) {
|
||||
const selected = []
|
||||
const sandbox = {
|
||||
exports: {}, HostBackup, VmBackupJobs, BackupStorageRetention, PBSPanel, DatabaseBackup: () => {},
|
||||
useT: () => key => key, useState: () => [view, value => selected.push(value)],
|
||||
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }), Fragment: Symbol('Fragment') }),
|
||||
}
|
||||
vm.runInNewContext(backupCode, sandbox)
|
||||
const visible = [], mountedHosts = [], buttons = []
|
||||
const components = new Set([HostBackup, VmBackupJobs, BackupStorageRetention, PBSPanel])
|
||||
function walk(node, hidden = false) {
|
||||
if (Array.isArray(node)) { node.forEach(item => walk(item, hidden)); return }
|
||||
if (!node || typeof node !== 'object') return
|
||||
hidden ||= node.props?.className === 'hidden'
|
||||
if (node.type === HostBackup) mountedHosts.push(node)
|
||||
if (components.has(node.type) && !hidden) visible.push(node.type)
|
||||
if (node.type === 'button') buttons.push(node)
|
||||
walk(node.props?.children, hidden)
|
||||
}
|
||||
walk(sandbox.exports.BackupTab())
|
||||
assert.deepEqual(visible, expected, `visible backup panels for ${view}`)
|
||||
assert.equal(mountedHosts.length, 1, 'host backup progress must stay mounted across view changes')
|
||||
assert.equal(buttons.length, 3)
|
||||
assert.equal(buttons.filter(button => button.props['aria-pressed']).length, 1)
|
||||
const pbsButton = buttons.find(button => button.props.children === 'pbs.title')
|
||||
assert.ok(pbsButton, 'PBS navigation button must exist')
|
||||
pbsButton.props.onClick()
|
||||
assert.deepEqual(selected, ['pbs'])
|
||||
}
|
||||
console.log('PASS: host progress stays mounted, guest jobs and retention stay together, and PBS navigation remains separate')
|
||||
+17
-4
@@ -818,7 +818,7 @@ Note that the dev server has no Flask backend on the same host by default: the a
|
||||
|
||||
### Testing
|
||||
|
||||
#### Offline i18n script tests
|
||||
#### Offline script regressions (including i18n)
|
||||
|
||||
From the repository root, run the complete script suite:
|
||||
|
||||
@@ -836,6 +836,14 @@ It does not launch React or require `npm install`, `pip install`, a Proxmox host
|
||||
API keys, or a translation provider. Generator tests use isolated temporary
|
||||
catalogs and fake/forbidden providers; repository catalogs are not regenerated.
|
||||
|
||||
The command-description recovery regression first completes disposable copies
|
||||
of the shipped catalogs with the real generator and a deterministic offline
|
||||
provider, then requires a second run to make zero provider calls and zero
|
||||
writes. It checks that all existing nonempty translations and the frozen
|
||||
command-description arrays survive unchanged. A synthetic EN-only feature tests
|
||||
new nested strings, arrays, placeholders and non-text values. New message keys
|
||||
do not need to be added to a test-specific translation seed list.
|
||||
|
||||
Scheduler fixtures discover every shipped `lang/*.json` cache and run the actual
|
||||
`translate` lookup at both empty-job consumers for PBS and local archive backends.
|
||||
They accept English fallback for missing/empty entries; complete translations are
|
||||
@@ -859,12 +867,17 @@ than silently skipping the consumer assertions. `.github/workflows/test-i18n.yml
|
||||
provisions Python and Node and runs this same command on relevant pull requests
|
||||
and main/develop pushes (or manually). It is separate from the translation
|
||||
publication workflow and has read-only repository permissions.
|
||||
Its Actions name is **Offline script regressions** because it also covers menus,
|
||||
downloads, scheduler behavior and other scripts. The `offline-i18n` check ID is
|
||||
retained for compatibility with existing branch protection rules; a failure of
|
||||
that check is not necessarily a translation failure. Read its failing test name
|
||||
and traceback before changing catalogs or translation-provider settings.
|
||||
|
||||
#### Qualified offline `tests/` CI lanes
|
||||
|
||||
`.github/workflows/test-offline-qualified.yml` runs **only** the explicitly listed
|
||||
nine Python files (including the inert active-notification payload fixture and
|
||||
the focused health recurrence tests) and eleven
|
||||
ten Python files (including the native discovery fixtures, the inert active-notification payload fixture and
|
||||
the focused health recurrence tests) and twelve
|
||||
Node files in `.github/scripts/run_offline_qualified.py`.
|
||||
The Python runner gives each test file a private temporary directory through
|
||||
`TMPDIR`. It removes that directory after the test file finishes.
|
||||
@@ -887,7 +900,7 @@ dangling symlink) rather than overwrite or reuse it. In Bash:
|
||||
)
|
||||
```
|
||||
|
||||
Requires Python 3.11, Node 22.14, npm and Bash/coreutils for the bounded shell
|
||||
Requires Python 3.11, Flask 3.x for the native route fixtures, Node 22.14, npm and Bash/coreutils for the bounded shell
|
||||
fixtures. The separate lockfile installs only TypeScript, React, React DOM,
|
||||
Lucide icons and the locked scheduler dependency; it does not build the Monitor
|
||||
or run npm lifecycle scripts. `--legacy-peer-deps` admits the existing Lucide
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
# Proxmox Backup Server support
|
||||
|
||||
ProxMenux detects PBS from the installed **proxmox-backup-server** package.
|
||||
Installing proxmox-backup-client on PVE or another Debian system does not turn
|
||||
that host into PBS. Supported maintenance platforms are PBS 3/Debian 12 and
|
||||
PBS 4/Debian 13; PVE 8/9 detection remains separate. If both server products
|
||||
are co-installed, PVE takes precedence.
|
||||
|
||||
## On a PBS host
|
||||
|
||||
The main CLI opens a PBS menu with package/version information, datastores,
|
||||
recent tasks, the existing system-utility installer, settings and a PBS package
|
||||
update. Utility installation preserves the operator's APT sources instead of
|
||||
trying to find a PVE version or enabling PVE/Ceph repositories. Configure a
|
||||
working subscription or no-subscription PBS repository in PBS itself if APT
|
||||
reports an error.
|
||||
|
||||
The update refreshes configured indexes, displays the simulated full-upgrade
|
||||
plan and requires confirmation before applying it. It does not run PVE repository
|
||||
cleanup, major-version migration, kernel cleanup or guest scripts. The automatic
|
||||
and customizable PVE post-install entry points reject PBS before adjustments.
|
||||
|
||||
Monitor opens a PBS dashboard with CPU, RAM, uptime, local PBS status,
|
||||
datastores, recent tasks and maintenance schedules, plus logs, terminal and
|
||||
Monitor authentication/HTTPS settings. PVE guest prewarmers, firewall controls
|
||||
and guest-storage checks are not part of this dashboard. Health checks monitor
|
||||
proxmox-backup and proxmox-backup-proxy rather than reporting absent PVE daemons.
|
||||
Monitor can reuse the native PBS proxy.pem/proxy.key pair, including renewal.
|
||||
|
||||
Local data is read with proxmox-backup-manager JSON commands. Datastore capacity
|
||||
is filesystem capacity, not the logical sum of backups; datastores sharing one
|
||||
filesystem can show the same capacity. An unmounted removable datastore or a
|
||||
failed check has unknown capacity, not zero/free space.
|
||||
|
||||
## Monitor a remote PBS from PVE
|
||||
|
||||
Open **Backup → Proxmox Backup Server → Manage PBS connections**. Enable local
|
||||
Monitor authentication first, then add a named HTTPS endpoint (for example
|
||||
https://pbs.example:8007), a dedicated API token ID (`user@pbs!monitor`) and its
|
||||
secret. Give both the user and token the PBS **Audit** role on `/`, with
|
||||
propagation, to view system tasks and datastore status. Token ACLs cannot exceed
|
||||
the user's effective permissions. A restricted token may show only its permitted
|
||||
data; denied sections are displayed as unavailable.
|
||||
|
||||
Use a CA-trusted certificate or copy the certificate's SHA256 fingerprint from
|
||||
the PBS console/a trusted channel. With a pin, Monitor checks the peer certificate
|
||||
before sending the token. With no pin, the normal system CA and hostname checks
|
||||
apply. Connections go directly to the configured HTTPS endpoint and do not follow
|
||||
redirects. A reverse-proxy prefix is supported; omit the port for HTTPS 443 or
|
||||
include the native PBS port explicitly.
|
||||
|
||||
Saving tests an authenticated datastore-status endpoint. Secrets are stored
|
||||
atomically with mode 0600 in `~/.config/proxmenux-monitor/pbs_servers.json`, never
|
||||
returned by the API, and retained when the edit form's secret is left empty.
|
||||
Connection changes require full administrator access; read-only Monitor API tokens
|
||||
can read monitoring data but cannot change connections. Removing a connection
|
||||
removes its saved credentials only. Monitoring does not start prune, garbage
|
||||
collection, verification or sync jobs and does not modify PBS backups.
|
||||
|
||||
Status is cached for 15 seconds and the panel refreshes every 30 seconds. Task or
|
||||
schedule permission failures are shown independently, without hiding successful
|
||||
datastore data or presenting failed queries as empty healthy results.
|
||||
|
||||
References: [PBS command syntax](https://pbs.proxmox.com/docs/command-syntax.html),
|
||||
[PBS API](https://pbs.proxmox.com/docs/api-viewer/index.html),
|
||||
[PBS users, tokens and ACLs](https://pbs.proxmox.com/docs/user-management.html).
|
||||
@@ -21,7 +21,7 @@
|
||||
# • whiptail (interactive terminal menus)
|
||||
# • curl (downloads and connectivity checks)
|
||||
# • jq (JSON parsing)
|
||||
# • Python 3 + venv (for translation support)
|
||||
# - Installs pre-generated JSON language catalogs (no translation runtime).
|
||||
# - Creates the ProxMenux base directories and configuration files:
|
||||
# • $BASE_DIR/config.json
|
||||
# • $BASE_DIR/cache.json
|
||||
@@ -396,7 +396,8 @@ select_language() {
|
||||
"de" "German" \
|
||||
"it" "Italian" \
|
||||
"pt" "Portuguese" \
|
||||
"sk" "Slovenčina" 3>&1 1>&2 2>&3)
|
||||
"sk" "Slovenčina" \
|
||||
"sv" "Svenska" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$LANGUAGE" ]; then
|
||||
msg_error "No language selected. Exiting."
|
||||
|
||||
@@ -594,7 +594,8 @@ select_language() {
|
||||
"de" "German" \
|
||||
"it" "Italian" \
|
||||
"pt" "Portuguese" \
|
||||
"sk" "Slovenčina" 3>&1 1>&2 2>&3)
|
||||
"sk" "Slovenčina" \
|
||||
"sv" "Svenska" 3>&1 1>&2 2>&3)
|
||||
|
||||
if [ -z "$LANGUAGE" ]; then
|
||||
msg_error "No language selected. Exiting."
|
||||
|
||||
+192
-26
File diff suppressed because it is too large
Load Diff
+258
-92
File diff suppressed because it is too large
Load Diff
+198
-32
File diff suppressed because it is too large
Load Diff
+173
-7
@@ -37,6 +37,7 @@
|
||||
"/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (percorso vzdump predefinito di Proxmox)",
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (predefinito di Proxmox)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx per tutti. Non è necessario alcun gruppo condiviso.",
|
||||
"====== PBS UPDATE COMPLETED ======": "====== AGGIORNAMENTO PBS COMPLETATO ======",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== AGGIORNAMENTO PVE COMPLETATO ======",
|
||||
">> Create a new backup job <<": ">> Crea un nuovo job di backup <<",
|
||||
">> Save and return <<": ">> Salva e torna indietro <<",
|
||||
@@ -87,6 +88,7 @@
|
||||
"A multi-container application is not recreated: its containers are updated together.": "Un’applicazione con più container non viene ricreata: i suoi container vengono aggiornati insieme.",
|
||||
"A new ProxMenux version is available:": "È disponibile una nuova versione di ProxMenux:",
|
||||
"A new kernel is staged for the next boot:": "Un nuovo kernel è predisposto per il prossimo avvio:",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "è installato un kernel più recente. Riavviare prima della finestra di backup successiva per utilizzarlo.",
|
||||
"A newer version is available:": "È disponibile una versione più recente:",
|
||||
"A pending operation exists for": "Operazione in sospeso per il",
|
||||
"A pending stack assembly already exists; it is not overwritten": "Esiste già un assemblaggio dello stack in sospeso; non verrà sovrascritto",
|
||||
@@ -169,6 +171,8 @@
|
||||
"APT language configuration updated": "Configurazione lingue APT aggiornata",
|
||||
"APT language downloads restored": "Download dei file lingua APT ripristinati",
|
||||
"APT package lists updated": "Elenchi pacchetti APT aggiornati",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "aggiornamento APT non riuscito. Controllare l'abbonamento e i repository PBS in Amministrazione > Repository. Tronco d'albero:",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Aggiornamento degli indici APT non riuscito. Controlla l'abbonamento e i repository PBS nell'interfaccia nativa di PBS; nessuna sorgente è stata modificata.",
|
||||
"APT update failed. Check repository access; NVIDIA installation stopped.": "Aggiornamento APT non riuscito. Controlla l’accesso al repository; installazione NVIDIA interrotta.",
|
||||
"Aborted": "Interrotto",
|
||||
"About to restore": "Ripristino imminente di",
|
||||
@@ -192,6 +196,7 @@
|
||||
"Access bridge for Tandoor": "Bridge di accesso per Tandoor",
|
||||
"Access could not be granted completely; the application may be unable to write to:": "Non è stato possibile concedere completamente l'accesso; l'applicazione potrebbe non riuscire a scrivere a:",
|
||||
"Access could not be granted; the application cannot write to:": "Impossibile concedere l'accesso; l'applicazione non può scrivere a:",
|
||||
"Access denied by {registry}. Check the image name and sign in from Registry accounts.": "Accesso negato da {registry}. Controlla il nome dell'immagine e accedi dagli account del Registro.",
|
||||
"Access granted to the application on:": "Accesso concesso all'applicazione a:",
|
||||
"Access password of the web interface (optional)": "Password di accesso all'interfaccia web (facoltativa)",
|
||||
"Access profile:": "Profilo di accesso:",
|
||||
@@ -310,6 +315,7 @@
|
||||
"Advanced": "Avanzato",
|
||||
"Advanced Diagnostics": "Diagnostica avanzata",
|
||||
"Advanced Network Diagnostics": "Diagnostica di rete avanzata",
|
||||
"Advanced settings": "Impostazioni avanzate",
|
||||
"Advanced: every setting of the container": "Avanzata: tutte le impostazioni del container",
|
||||
"Affected LXC containers": "Container LXC interessati",
|
||||
"After completing GPU setup, start the VM manually when the host is ready.": "Dopo aver completato la configurazione della GPU, avvia manualmente la VM quando l'host è pronto.",
|
||||
@@ -403,6 +409,7 @@
|
||||
"Another OCI operation is using the instance registry. Wait for it to finish and open this menu again; no container is modified.": "Un'altra operazione OCI sta utilizzando il registro delle istanze. Attendere che termini e riaprire questo menu; nessun container è stato modificato.",
|
||||
"Another OCI operation is using the instance registry. Wait for it to finish.": "Un’altra operazione OCI sta utilizzando il registro delle istanze. Attendi che termini.",
|
||||
"Another OCI operation is using the registry. This operation was not started.": "Un’altra operazione OCI sta utilizzando il registro. Questa operazione non è stata avviata.",
|
||||
"Another ProxMenux OCI operation is running. Try again when it finishes.": "È in esecuzione un'altra operazione ProxMenux OCI. Riprovare al termine.",
|
||||
"Another ZRAM configuration exists or could not be inspected; systemd-zram-generator was retained": "Esiste un'altra configurazione ZRAM o non è possibile controllarla; systemd-zram-generator è stato mantenuto",
|
||||
"Another ZRAM generator configuration exists; refusing to overwrite it:": "Esiste un'altra configurazione del generatore ZRAM; rifiutandosi di sovrascriverlo:",
|
||||
"Another ZRAM swap setup exists; refusing to replace it:": "Esiste un'altra configurazione di scambio ZRAM; rifiutandosi di sostituirlo:",
|
||||
@@ -410,6 +417,7 @@
|
||||
"Another instance uses": "Un'altra istanza usa",
|
||||
"Another job that writes to the same storage still starts them.": "Un altro job che scrive sullo stesso storage continua ad avviarli.",
|
||||
"Another node, typed by hand": "Altro nodo di dispositivo (manuale)",
|
||||
"Another registry account operation is running. Try again when it finishes.": "È in esecuzione un'altra operazione sull'account del registro. Riprovare al termine.",
|
||||
"Another stack operation is pending": "È in sospeso un'altra operazione sullo stack",
|
||||
"Application": "Applicazione",
|
||||
"Application registered on this host:": "Applicazione registrata su questo host:",
|
||||
@@ -421,6 +429,7 @@
|
||||
"Apply": "Applica",
|
||||
"Apply AMD CPU fixes": "Applica correzioni CPU AMD",
|
||||
"Apply Available Updates": "Applica aggiornamenti disponibili",
|
||||
"Apply PBS package upgrade": "Applica l'aggiornamento dei pacchetti PBS",
|
||||
"Apply and restart services:": "Applicare e riavviare i servizi:",
|
||||
"Apply available updates": "Applica aggiornamenti disponibili",
|
||||
"Apply boot/initramfs changes": "Applica modifiche boot/initramfs",
|
||||
@@ -434,12 +443,15 @@
|
||||
"Apply options from the current catalog template? New required paths and settings may be requested. Review the resulting configuration before recreating the CT.": "Applicare le opzioni dal modello di catalogo corrente? Potrebbero essere richiesti nuovi percorsi e impostazioni obbligatori. Controlla la configurazione risultante prima di ricreare il CT.",
|
||||
"Apply read+write access for 'others' on the host directory?": "Applicare l'accesso in lettura+scrittura per \"altri\" nella directory host?",
|
||||
"Apply the options from the current catalog template? Your data and configuration are kept.": "Applicare le opzioni dal modello di catalogo corrente? I dati e la configurazione sono conservati.",
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Applicare ora l'aggiornamento dei pacchetti esaminato? I servizi potrebbero riavviarsi e i pacchetti indicati per la rimozione verranno rimossi.",
|
||||
"Apply these changes?": "Applicare queste modifiche?",
|
||||
"Applying AMD-specific fixes...": "Applicazione delle correzioni specifiche AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Applicazione delle modifiche a CPU e RAM senza ricreare il container...",
|
||||
"Applying Changes": "Applicazione delle modifiche",
|
||||
"Applying Controller/NVMe passthrough to VM": "Applicazione del passthrough Controller/NVMe alla VM",
|
||||
"Applying balanced memory optimization settings...": "Applicazione delle impostazioni bilanciate di ottimizzazione della memoria...",
|
||||
"Applying changes safely...": "Applicazione sicura delle modifiche...",
|
||||
"Applying container settings without rebuilding the container...": "Applicazione delle impostazioni senza ricreare il container...",
|
||||
"Applying custom restore": "Applicazione del ripristino personalizzato",
|
||||
"Applying default VM configuration": "Applicazione della configurazione predefinita della VM",
|
||||
"Applying host permissions for unprivileged LXC bind-mounts...": "Applicazione delle autorizzazioni sull’host per i bind mount dei container LXC non privilegiati...",
|
||||
@@ -502,6 +514,7 @@
|
||||
"Authentication": "Autenticazione",
|
||||
"Authentication & Security": "Autenticazione e sicurezza",
|
||||
"Authentication Error": "Errore di autenticazione",
|
||||
"Authentication failed for {registry}. Check the account and token permissions.": "Messaggio tecnico per Proxmox e IT. Traduzione: autenticazione non riuscita per {registry}. Controlla le autorizzazioni dell'account e del token.",
|
||||
"Authentication failed.": "Autenticazione non riuscita.",
|
||||
"Authentication required:": "Autenticazione richiesta:",
|
||||
"Authentication:": "Autenticazione:",
|
||||
@@ -530,6 +543,7 @@
|
||||
"Available Borg targets:": "Destinazioni Borg disponibili:",
|
||||
"Available Disks on Host": "Dischi disponibili sull'host",
|
||||
"Available ISO Images": "Immagini ISO disponibili",
|
||||
"Available PBS Version": "versione PBS disponibile",
|
||||
"Available PBS repositories:": "Repository PBS disponibili:",
|
||||
"Available PVE Version": "Versione PVE disponibile",
|
||||
"Available Shares:": "Condivisioni disponibili:",
|
||||
@@ -739,7 +753,9 @@
|
||||
"Cannot resolve the container home directory:": "Impossibile determinare la directory home del container:",
|
||||
"Cannot validate credentials - no shares available for testing.": "Impossibile convalidare le credenziali: nessuna condivisione disponibile per il test.",
|
||||
"Cannot verify the reused disk:": "Impossibile verificare il disco riutilizzato:",
|
||||
"Cannot write the image archive to {storage}. Check storage access and mount status.": "Impossibile scrivere l'archivio immagini su {storage}. Controlla l'accesso allo spazio di archiviazione e lo stato di montaggio.",
|
||||
"Capabilities cannot be kept and all dropped at the same time": "Non è possibile conservare alcune capability e rimuoverle tutte contemporaneamente",
|
||||
"Catalog options": "Opzioni del catalogo",
|
||||
"Category": "Categoria",
|
||||
"Caution: Maximum mode generates more heat.": "Attenzione: la modalità massima genera più calore.",
|
||||
"Ceph check skipped by user flag (--ignore-ceph-check)": "Controllo Ceph saltato dal flag utente (--ignore-ceph-check)",
|
||||
@@ -799,6 +815,7 @@
|
||||
"Check Samba Status": "Controlla stato Samba",
|
||||
"Check VM disk/PCI slots": "Controlla gli slot disco/PCI della VM",
|
||||
"Check VM passthrough settings": "Controlla impostazioni passthrough VM",
|
||||
"Check access to an image": "Controlla l'accesso a un'immagine",
|
||||
"Check connectivity with another host": "Controlla la connettività con un altro host",
|
||||
"Check container mount points": "Controlla i punti di montaggio del container",
|
||||
"Check if container is unprivileged:": "Controlla se il container non è privilegiato:",
|
||||
@@ -811,6 +828,7 @@
|
||||
"Check that:": "Controlla che:",
|
||||
"Check the error messages above for details.": "Controlla i messaggi di errore qui sopra per i dettagli.",
|
||||
"Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.": "Controllare il riferimento dell'immagine e l'accesso al registro. Se il registro non è disponibile, riprovare più tardi. Un registro privato richiede credenziali, che non sono ancora supportate.",
|
||||
"Check the image reference and registry access. If the registry is unavailable, try again later. For a private image, sign in from Registry accounts in the OCI menu.": "Controlla il riferimento all'immagine e l'accesso al registro. Se il registro non è disponibile, riprova più tardi. Per un'immagine privata, accedi dagli account del Registro nel menu OCI.",
|
||||
"Check the service status manually if needed.": "Controlla manualmente lo stato del servizio, se necessario.",
|
||||
"Checking MOTD configuration...": "Controllo della configurazione MOTD...",
|
||||
"Checking NVIDIA driver status with nvidia-smi": "Controllo dello stato del driver NVIDIA con nvidia-smi",
|
||||
@@ -824,6 +842,7 @@
|
||||
"Checking free space in /var/cache/apt/archives...": "Controllo dello spazio libero in /var/cache/apt/archives...",
|
||||
"Checking if system disk is SSD or M.2...": "Verifica se il disco di sistema è SSD o M.2...",
|
||||
"Checking if the server belongs to OVH...": "Verifica se il server appartiene a OVH...",
|
||||
"Checking image access and metadata. Please wait...": "Controllo dell'accesso alle immagini e dei metadati. Attendere prego...",
|
||||
"Checking kernel headers and build tools...": "Controllo delle intestazioni kernel e degli strumenti di compilazione...",
|
||||
"Checking remaining interfaces": "Controllo delle interfacce rimanenti",
|
||||
"Checking that the container keeps running...": "Controllo che il container rimanga in esecuzione...",
|
||||
@@ -871,6 +890,7 @@
|
||||
"Choose a schedule (systemd calendar-event syntax):": "Scegli una pianificazione (sintassi calendar-event di systemd):",
|
||||
"Choose a script to execute:": "Scegli uno script da eseguire:",
|
||||
"Choose a server:": "Scegli un server:",
|
||||
"Choose a setting. Changes are applied only after the final review.": "Scegli cosa configurare. Le modifiche vengono applicate solo dopo la verifica finale.",
|
||||
"Choose a share to mount:": "Scegli una condivisione da montare:",
|
||||
"Choose a specific supported GPU or USB node": "Scegli uno specifico nodo GPU o USB supportato",
|
||||
"Choose action for this controller/NVMe:": "Scegli l'azione per questo controller/NVMe:",
|
||||
@@ -897,6 +917,7 @@
|
||||
"Choose iperf3 mode:": "Scegli la modalità iperf3:",
|
||||
"Choose options to configure:": "Scegli le opzioni da configurare:",
|
||||
"Choose specific VMs/CTs": "Scegli VM/CT specifici",
|
||||
"Choose storage for the downloaded image.": "Scegli lo spazio di archiviazione per l'immagine scaricata.",
|
||||
"Choose the driver version for Coral M.2:": "Scegli la versione del driver per Coral M.2:",
|
||||
"Choose the filesystem for the new partition:": "Scegli il filesystem per la nuova partizione:",
|
||||
"Choose the release channel to use:": "Scegli il canale di rilascio da usare:",
|
||||
@@ -950,6 +971,7 @@
|
||||
"Code-server is VS Code running on a remote server, accessible through the browser.": "Code-server è VS Code in esecuzione su un server remoto, accessibile tramite il browser.",
|
||||
"CodeProject.AI Server": "CodeProject.AI Server",
|
||||
"Command": "Comando",
|
||||
"Command failed": "Comando non riuscito",
|
||||
"Command override for an unknown service:": "Override del comando per un servizio sconosciuto:",
|
||||
"Commenting any residual Bookworm lines in *.list...": "Disattivazione tramite commento delle eventuali righe residue di Bookworm in *.list…",
|
||||
"Commenting legacy PVE 8 repository .list files (if any)...": "Commento dei file .list del repository PVE 8 legacy (se presenti)...",
|
||||
@@ -1364,17 +1386,20 @@
|
||||
"Could not publish pending restore. Previous pending restore was kept.": "Impossibile pubblicare il ripristino in sospeso. Il precedente ripristino in sospeso è stato mantenuto.",
|
||||
"Could not push the key. Check the password and that": "Impossibile trasferire la chiave. Controlla la password e verifica che",
|
||||
"Could not query the image registry": "Impossibile interrogare il registro delle immagini",
|
||||
"Could not reach {registry}. Check its address, DNS and network access.": "Impossibile raggiungere {registry}. Controlla il suo indirizzo, DNS e accesso alla rete.",
|
||||
"Could not read SMART data from": "Impossibile leggere i dati SMART da",
|
||||
"Could not read VM configuration.": "Impossibile leggere la configurazione della VM.",
|
||||
"Could not read the CUDA compute capability": "Impossibile leggere la compute capability CUDA",
|
||||
"Could not read the NVIDIA driver version": "Impossibile leggere la versione del driver NVIDIA",
|
||||
"Could not read the host firewall rules": "Impossibile leggere le regole del firewall dell’host",
|
||||
"Could not read the image from {registry}. Check the reference and sign in from Registry accounts.": "Impossibile leggere l'immagine da {registry}. Controlla il riferimento e accedi dagli account del Registro.",
|
||||
"Could not read this job.": "Impossibile leggere questo job.",
|
||||
"Could not release ZRAM pages; existing profile remains active": "Impossibile rilasciare le pagine ZRAM; il profilo esistente rimane attivo",
|
||||
"Could not remount automatically. Try manually or check credentials.": "Impossibile rimontare automaticamente. Prova manualmente o controlla le credenziali.",
|
||||
"Could not remove VM automatically. Run manually:": "Impossibile rimuovere la VM automaticamente. Esegui manualmente:",
|
||||
"Could not remove ZRAM profile; configuration was restored": "Impossibile rimuovere il profilo ZRAM; la configurazione è stata ripristinata",
|
||||
"Could not remove ZRAM profile; configuration was restored but swap could not be restarted": "Impossibile rimuovere il profilo ZRAM; la configurazione è stata ripristinata ma non è stato possibile riavviare lo scambio",
|
||||
"Could not remove credentials for {registry}; saved file credentials were not changed.": "Messaggio tecnico per Proxmox e IT. Traduzione: impossibile rimuovere le credenziali per {registry}; le credenziali del file salvato non sono state modificate.",
|
||||
"Could not remove previous DKMS tree at": "Impossibile rimuovere l'albero DKMS precedente in",
|
||||
"Could not reserve a private network for the stack": "Impossibile riservare una rete privata per lo stack",
|
||||
"Could not resolve the Compose user:": "Impossibile risolvere l’utente Compose:",
|
||||
@@ -1389,6 +1414,7 @@
|
||||
"Could not set VM virtual display to vga: std": "Impossibile impostare il display virtuale della VM su vga: std",
|
||||
"Could not set boot order for": "Impossibile impostare l'ordine di avvio per",
|
||||
"Could not set the container entrypoint": "Impossibile impostare l'entrypoint del container",
|
||||
"Could not sign in to {registry}. Check registry access and token permissions.": "Impossibile accedere a {registry}. Controlla l'accesso al registro e le autorizzazioni del token.",
|
||||
"Could not stage pending restore path:": "Impossibile preparare il percorso di ripristino in sospeso:",
|
||||
"Could not stage pending restore. Nothing new was scheduled.": "Impossibile preparare il ripristino in sospeso. Non è stato pianificato nulla di nuovo.",
|
||||
"Could not stop LXC": "Impossibile fermare LXC",
|
||||
@@ -1475,11 +1501,15 @@
|
||||
"Creating the private network...": "Creazione rete privata...",
|
||||
"Creating the temporary data container": "Creazione del container temporaneo per i dati",
|
||||
"Creative & Design": "Creatività e design",
|
||||
"Credential helper": "Assistente credenziali",
|
||||
"Credential storage and access": "Archiviazione e accesso alle credenziali",
|
||||
"Credentials are correct": "Le credenziali sono corrette",
|
||||
"Credentials cleared. jwt_secret and API tokens preserved.": "Credenziali cancellate. jwt_secret e token API conservati.",
|
||||
"Credentials file created securely.": "File delle credenziali creato in sicurezza.",
|
||||
"Credentials file removed.": "File delle credenziali rimosso.",
|
||||
"Credentials file:": "File delle credenziali:",
|
||||
"Credentials file:\n{path}\n\nOwned by root; directory mode 700 and file mode 600.\nBasic credentials are base64 encoded, not encrypted.\nCredential helpers use their own storage.\n\nUse an access token with permission to pull images.\nConfigure credentials on each node that downloads images.": "File delle credenziali:\n{path}\n\nDi proprietà di root; modalità directory 700 e modalità file 600.\nLe credenziali di base sono codificate base64, non crittografate.\nGli helper credenziali utilizzano il proprio spazio di archiviazione.\n\nUtilizza un token di accesso con l'autorizzazione per estrarre le immagini.\nConfigura le credenziali su ciascun nodo che scarica le immagini.",
|
||||
"Credentials file:\n{path}\n\nOwned by root; directory mode 700 and file mode 600.\nBasic credentials are base64 encoded, not encrypted.\nCredential helpers use their own storage.\n\nUse an access token with permission to pull images.\nEvery node of the cluster uses the accounts saved here.": "File delle credenziali:\n{path}\n\nDi proprietà di root; modalità directory 700 e modalità file 600.\nLe credenziali di base sono codificate base64, non crittografate.\nGli helper credenziali utilizzano il proprio spazio di archiviazione.\n\nUtilizza un token di accesso con l'autorizzazione per estrarre le immagini.\nOgni nodo del cluster utilizza gli account salvati qui.",
|
||||
"Credentials validated successfully": "Credenziali convalidate correttamente",
|
||||
"Cross-host restore: guest IDs in backup overlap live IDs on target:": "Ripristino tra host: gli ID guest nel backup si sovrappongono agli ID presenti sulla destinazione:",
|
||||
"Cross-kernel restore — kernel-tied paths merged, not copied": "Ripristino tra kernel diversi: percorsi legati al kernel uniti, non copiati",
|
||||
@@ -1498,6 +1528,7 @@
|
||||
"Current NVIDIA inventory resolved: a refresh is required": "Inventario NVIDIA attuale acquisito: è necessario un aggiornamento",
|
||||
"Current NVIDIA inventory resolved: no refresh is required": "Inventario NVIDIA corrente acquisito: non serve aggiornarlo",
|
||||
"Current Network Configuration": "Configurazione di rete corrente",
|
||||
"Current PBS Version": "versione attuale di PBS",
|
||||
"Current PVE Version": "Versione PVE attuale",
|
||||
"Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Gli attuali script host ProxMenux registrano le condivisioni remote come storage Proxmox con pvesm.",
|
||||
"Current ProxMenux version:": "Versione attuale di ProxMenux:",
|
||||
@@ -1617,6 +1648,7 @@
|
||||
"Default login": "Accesso predefinito",
|
||||
"Default options": "Opzioni predefinite",
|
||||
"Default options read/write": "Opzioni predefinite di lettura/scrittura",
|
||||
"Default settings": "Impostazioni predefinite",
|
||||
"Default will be used:": "Sarà usato il valore predefinito:",
|
||||
"Default:": "Predefinito:",
|
||||
"Default: only what the application needs": "Predefinita: solo ciò che serve all’applicazione",
|
||||
@@ -1662,6 +1694,7 @@
|
||||
"Detected Coral hardware:": "Hardware Coral rilevato:",
|
||||
"Detected GPU(s):": "GPU rilevate:",
|
||||
"Detected Intel CPU": "Rilevata CPU Intel",
|
||||
"Detected Proxmox Backup Server": "Proxmox Backup Server rilevato",
|
||||
"Detected Proxmox VE": "Rilevato Proxmox VE",
|
||||
"Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Rilevato Proxmox VE 8.x - Installazione di Ceph Squid per Debian",
|
||||
"Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Rilevato Proxmox VE 9.x - Installazione di Ceph Squid per Debian Trixie",
|
||||
@@ -1743,6 +1776,9 @@
|
||||
"Disabling and stopping rpcbind service and socket...": "Disabilitazione e arresto del servizio e del socket rpcbind...",
|
||||
"Disabling non-free firmware warnings...": "Disabilitazione degli avvisi sul firmware non libero...",
|
||||
"Disabling portmapper/rpcbind for security...": "Disabilitazione di portmapper/rpcbind per sicurezza...",
|
||||
"Discard changes": "Scarta le modifiche",
|
||||
"Discard unapplied changes?": "Scartare le modifiche non applicate?",
|
||||
"Discover NFS exports on a server": "Individuare le esportazioni NFS di un server",
|
||||
"Discover targets on portal:": "Scopri target sul portale:",
|
||||
"Discovering iSCSI targets...": "Individuazione dei target iSCSI...",
|
||||
"Discovery successful": "Scoperta riuscita",
|
||||
@@ -1822,6 +1858,7 @@
|
||||
"Do you want to proceed with the system update?": "Vuoi procedere con l'aggiornamento del sistema?",
|
||||
"Do you want to proceed?": "Vuoi procedere?",
|
||||
"Do you want to reboot now?": "Vuoi riavviare ora?",
|
||||
"Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Vuoi rimuovere il banner dell'abbonamento Proxmox dall'interfaccia web di Proxmox Backup Server?",
|
||||
"Do you want to remove the stale entry from vfio.conf and continue?": "Vuoi rimuovere la voce obsoleta da vfio.conf e continuare?",
|
||||
"Do you want to remove the stale entry from vfio.conf?": "Vuoi rimuovere la voce obsoleta da vfio.conf?",
|
||||
"Do you want to restart the network service now to apply changes?": "Riavviare ora il servizio di rete per applicare le modifiche?",
|
||||
@@ -1851,6 +1888,7 @@
|
||||
"Doplarr_rs is a Discord bot for requesting media through *arr backends, written in Rust.": "Doplarr_rs è un bot Discord scritto in Rust per richiedere contenuti multimediali tramite i backend *arr.",
|
||||
"Double Commander is a free cross platform open source file manager with two panels side by side. It is inspired by Total Commander and features some new ideas.": "Double Commander è un file manager gratuito, open source e multipiattaforma con due pannelli affiancati. Si ispira a Total Commander e introduce alcune nuove idee.",
|
||||
"Download Spotify music with album art and metadata": "Scarica musica da Spotify con copertine album e metadati",
|
||||
"Download an image to template storage": "Scarica un'immagine nella memoria del modello",
|
||||
"Download an oci-compose from an address": "Scarica un oci-compose da un indirizzo",
|
||||
"Download failed for all attempted URLs": "Download non riuscito per tutti gli URL tentati",
|
||||
"Download its Compose file from an address": "Scarica il file Compose da un indirizzo",
|
||||
@@ -1866,6 +1904,7 @@
|
||||
"Downloading Proxmology logo...": "Download del logo Proxmology...",
|
||||
"Downloading UUP converter...": "Download del convertitore UUP...",
|
||||
"Downloading amdgpu_top": "Download di amdgpu_top",
|
||||
"Downloading and verifying the image.\nThis can take a few minutes. Please wait...": "Download e verifica dell'immagine.\nL'operazione può richiedere alcuni minuti. Attendere prego...",
|
||||
"Downloading official installer...": "Download del programma di installazione ufficiale...",
|
||||
"Downloading pre-existing encrypted backups from this host will fail unless you kept a copy of the current key.": "Il download di backup crittografati preesistenti da questo host fallirà a meno che tu non abbia conservato una copia della chiave corrente.",
|
||||
"Downloading the image:": "Download dell'immagine:",
|
||||
@@ -1999,9 +2038,11 @@
|
||||
"Enter ZFS pool name for the selected disk:": "Inserisci il nome del pool ZFS per il disco selezionato:",
|
||||
"Enter a name for the mount point (used as /mnt/<name>):": "Inserisci un nome per il punto di montaggio (usato come /mnt/<name>):",
|
||||
"Enter a name for the new virtual machine:": "Inserisci un nome per la nuova VM:",
|
||||
"Enter a non-empty password or access token on a single line.": "Inserisci una password non vuota o un token di accesso su una singola riga.",
|
||||
"Enter a number, or write or paste a command:": "Inserisci un numero o scrivi o incolla un comando:",
|
||||
"Enter a systemd calendar event, e.g.:": "Inserisci un evento di calendario systemd, ad esempio:",
|
||||
"Enter a usable IPv4 address with its prefix, for example": "Inserisci un indirizzo IPv4 utilizzabile con il suo prefisso, per esempio",
|
||||
"Enter a valid registry username.": "Inserisci un nome utente di registro valido.",
|
||||
"Enter backup file (.zst):": "Inserisci file backup (.zst):",
|
||||
"Enter backup path (.tar.zst):": "Inserisci percorso backup (.tar.zst):",
|
||||
"Enter backup path (.vma.zst):": "Inserisci percorso backup (.vma.zst):",
|
||||
@@ -2243,6 +2284,7 @@
|
||||
"Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Impossibile clonare il repository log2ram. Controlla /tmp/log2ram_install.log",
|
||||
"Failed to configure EFI disk": "Impossibile configurare il disco EFI",
|
||||
"Failed to configure IOMMU automatically.": "Impossibile configurare IOMMU automaticamente.",
|
||||
"Failed to configure Proxmox Backup Server repositories": "Impossibile configurare i repository di Proxmox Backup Server",
|
||||
"Failed to configure Proxmox repositories": "Impossibile configurare i repository Proxmox",
|
||||
"Failed to configure TPM device in VM": "Impossibile configurare il dispositivo TPM nella VM",
|
||||
"Failed to configure repositories.": "Impossibile configurare i repository.",
|
||||
@@ -2287,12 +2329,12 @@
|
||||
"Failed to download Proxmology logo": "Impossibile scaricare il logo Proxmology",
|
||||
"Failed to download VirtIO driver ISO.": "Impossibile scaricare l'ISO del driver VirtIO.",
|
||||
"Failed to download amdgpu_top": "Impossibile scaricare amdgpu_top",
|
||||
"Failed to download loader image.": "impossibile scaricare l'immagine del caricatore.",
|
||||
"Failed to download loader image.": "Impossibile scaricare l'immagine del loader.",
|
||||
"Failed to edit /etc/fstab — remove the line manually.": "Impossibile modificare /etc/fstab: rimuovi la riga manualmente.",
|
||||
"Failed to enable ZFS autotrim for pool:": "Impossibile abilitare ZFS autotrim per il pool:",
|
||||
"Failed to enable automatic time synchronization": "Impossibile abilitare la sincronizzazione automatica dell'ora",
|
||||
"Failed to extract OVA file:": "Impossibile estrarre il file OVA:",
|
||||
"Failed to extract a nonempty loader image.": "impossibile estrarre un'immagine del caricatore non vuota.",
|
||||
"Failed to extract a nonempty loader image.": "Impossibile estrarre un'immagine del loader non vuota.",
|
||||
"Failed to extract package": "Impossibile estrarre il pacchetto",
|
||||
"Failed to fetch the Google Coral GPG key. Check": "Impossibile recuperare la chiave GPG di Google Coral. Controlla",
|
||||
"Failed to format disk as": "Impossibile formattare il disco come",
|
||||
@@ -2329,7 +2371,7 @@
|
||||
"Failed to mount container filesystem.": "Impossibile montare il filesystem del container.",
|
||||
"Failed to mount disk": "Impossibile montare il disco",
|
||||
"Failed to obtain public IP address - keeping current timezone settings": "Impossibile ottenere l'indirizzo IP pubblico: impostazioni attuali del fuso orario mantenute",
|
||||
"Failed to prepare loader download directory.": "impossibile preparare la directory di download del caricatore.",
|
||||
"Failed to prepare loader download directory.": "Impossibile preparare la directory di download del loader.",
|
||||
"Failed to reconcile conflicting ZFS ARC settings.": "Impossibile correggere le impostazioni ZFS ARC in conflitto.",
|
||||
"Failed to refresh boot configuration": "Impossibile aggiornare la configurazione di avvio",
|
||||
"Failed to refresh proxmox-boot-tool": "Impossibile aggiornare proxmox-boot-tool",
|
||||
@@ -2347,9 +2389,9 @@
|
||||
"Failed to restart — restart manually to activate mount": "Impossibile riavviare: riavvia manualmente per attivare il montaggio",
|
||||
"Failed to restore subscription banner completely": "Impossibile ripristinare completamente il banner di abbonamento",
|
||||
"Failed to retrieve Fastfetch download URL.": "Impossibile recuperare l'URL di download di Fastfetch.",
|
||||
"Failed to retrieve loader release metadata.": "impossibile recuperare i metadati della versione del caricatore.",
|
||||
"Failed to retrieve loader release metadata.": "Impossibile recuperare i metadati della versione del loader.",
|
||||
"Failed to run log2ram installer. Check /tmp/log2ram_install.log": "Impossibile eseguire l’installer log2ram. Controlla /tmp/log2ram_install.log",
|
||||
"Failed to save loader image.": "impossibile salvare l'immagine del caricatore.",
|
||||
"Failed to save loader image.": "Impossibile salvare l'immagine del loader.",
|
||||
"Failed to set VM description": "Impossibile impostare la descrizione della VM",
|
||||
"Failed to set timezone - keeping current settings": "Impossibile impostare il fuso orario: impostazioni attuali mantenute",
|
||||
"Failed to start CT": "Impossibile avviare il CT",
|
||||
@@ -2676,6 +2718,7 @@
|
||||
"Help and Info": "Aiuto e informazioni",
|
||||
"Help and Info Commands": "Comandi di aiuto e informazioni",
|
||||
"Helper-Scripts logo applied": "Logo Helper-Scripts applicato",
|
||||
"Helper:": "Aiutante:",
|
||||
"Hermes WebUI": "Hermes WebUI",
|
||||
"Hidden for safety": "Dispositivi nascosti per sicurezza",
|
||||
"Hidden:": "Archivi .tar non ProxMenux nascosti:",
|
||||
@@ -2890,16 +2933,20 @@
|
||||
"If your DHCP has a static reservation for the old MAC, update it.": "Se il tuo DHCP ha una prenotazione statica per il vecchio MAC, aggiornala.",
|
||||
"Image": "Immagine",
|
||||
"Image Source Directory": "Directory origine immagini",
|
||||
"Image access confirmed.\n\n{reference}\n\nArchitecture: {architecture}\nVersion: {version}\nDownload size: {size}\nDigest: {digest}": "Accesso alle immagini confermato.\n\n{reference}\n\nArchitettura: {architecture}\nVersione: {version}\nDimensioni del download: {size}\nDigestione: {digest}",
|
||||
"Image cache": "Cache immagini",
|
||||
"Image compatibility restored:": "Compatibilità dell'immagine ripristinata:",
|
||||
"Image compatibility verified:": "Compatibilità dell'immagine verificata:",
|
||||
"Image directory:": "Directory immagini:",
|
||||
"Image download failed:": "Download dell'immagine non riuscito:",
|
||||
"Image downloaded": "Immagine scaricata",
|
||||
"Image downloaded and verified.\n\n{reference}\n\nStorage: {storage}\nArchive size: {size}\nArchive: {archive}\n\nNo container was created.\nProxMenux removes unused cached images after successful updates.": "Immagine scaricata e verificata.\n\n{reference}\n\nConservazione: {storage}\nDimensione archivio: {size}\nArchivio: {archive}\n\nNon è stato creato alcun contenitore.\nProxMenux rimuove le immagini memorizzate nella cache inutilizzate dopo gli aggiornamenti riusciti.",
|
||||
"Image file not found:": "File immagine non trovato:",
|
||||
"Image imported:": "Immagine importata:",
|
||||
"Image integrity verification failed. The partial download was discarded; try again.": "Verifica dell'integrità dell'immagine non riuscita. Il download parziale è stato scartato; riprova.",
|
||||
"Image integrity verified": "Integrità dell’immagine verificata",
|
||||
"Image not allowed for the host monitor profile": "Immagine non consentita per il profilo di monitoraggio dell'host",
|
||||
"Image reference": "Riferimento all'immagine",
|
||||
"Image reference (for example ghcr.io/user/application:latest)": "Riferimento immagine (ad esempio ghcr.io/user/application:latest)",
|
||||
"Image selection was cancelled": "Selezione immagine annullata",
|
||||
"Image that is not in the catalog": "Immagine non presente nel catalogo",
|
||||
@@ -2945,6 +2992,7 @@
|
||||
"Inaccessible device during VM startup": "Dispositivo inaccessibile durante l'avvio della VM",
|
||||
"Inactive": "Inattivo",
|
||||
"Inactive (entry in fstab, not currently mounted)": "Inattivo (presente in fstab, attualmente non montato)",
|
||||
"Include these changes in oci-compose?": "Includere queste modifiche in oci-compose?",
|
||||
"Include these resources in oci-compose?": "Includere queste risorse in oci-compose?",
|
||||
"Includes /etc/network (may drop SSH immediately)": "Include /etc/network (potrebbe interrompere immediatamente la connessione SSH)",
|
||||
"Includes /etc/zfs": "Include /etc/zfs",
|
||||
@@ -3027,6 +3075,7 @@
|
||||
"Install Samba inside CT:": "Installa Samba nel CT:",
|
||||
"Install ZFS auto-snapshot": "Installa ZFS auto-snapshot",
|
||||
"Install a version from the branch the kernel names.": "Installa una versione del ramo indicato dal kernel.",
|
||||
"Install an image as a native LXC": "Installa un'immagine come LXC nativa",
|
||||
"Install an image that is not in the catalog": "Installa un’immagine non presente nel catalogo",
|
||||
"Install analysis tools": "Installa strumenti di analisi",
|
||||
"Install and configure": "Installa e configura",
|
||||
@@ -3066,6 +3115,7 @@
|
||||
"Installation finished but drivers are not loaded. A reboot may be required.": "Installazione terminata, ma i driver non sono caricati. Potrebbe servire un riavvio.",
|
||||
"Installation incomplete. These containers and their data are kept:": "Installazione incompleta. Questi container e i relativi dati vengono conservati:",
|
||||
"Installation log:": "Registro installazione:",
|
||||
"Installation settings": "Impostazioni di installazione",
|
||||
"Installation summary": "Riepilogo installazione",
|
||||
"Installed": "Installato",
|
||||
"Installed at:": "Installazione:",
|
||||
@@ -3179,6 +3229,8 @@
|
||||
"Invalid ALLOWED_HOSTS value": "Valore ALLOWED_HOSTS non valido",
|
||||
"Invalid Home Assistant OS check timeout": "Timeout controllo Home Assistant OS non valido",
|
||||
"Invalid ID": "ID non valido",
|
||||
"Invalid IPv4 address": "Indirizzo IPv4 non valido",
|
||||
"Invalid IPv4 gateway": "Gateway IPv4 non valido",
|
||||
"Invalid Intel render path": "Percorso di rendering Intel non valido",
|
||||
"Invalid Jellyfin path:": "Percorso Jellyfin non valido:",
|
||||
"Invalid MAC address:": "Indirizzo MAC non valido:",
|
||||
@@ -3211,6 +3263,7 @@
|
||||
"Invalid administrator email": "Email amministratore non valida",
|
||||
"Invalid administrator user name": "Nome utente amministratore non valido",
|
||||
"Invalid base VMID": "VMID di base non valido",
|
||||
"Invalid bridge": "Bridge non valido",
|
||||
"Invalid check package:": "Pacchetto controllo non valido:",
|
||||
"Invalid configuration path:": "Percorso configurazione non valido:",
|
||||
"Invalid consume/export volumes": "Volumi di acquisizione/esportazione non validi",
|
||||
@@ -3239,6 +3292,7 @@
|
||||
"Invalid host path:": "Percorso host non valido:",
|
||||
"Invalid host-monitor firewall plan": "Piano firewall monitoraggio host non valido",
|
||||
"Invalid image probe descriptor": "Descrittore verifica immagine non valido",
|
||||
"Invalid image tag.": "Tag immagine non valido.",
|
||||
"Invalid input": "Immissione non valida",
|
||||
"Invalid internal volume": "Volume interno non valido",
|
||||
"Invalid list:": "Elenco non valido:",
|
||||
@@ -3273,6 +3327,7 @@
|
||||
"Invalid remote name": "Nome del remote non valido",
|
||||
"Invalid remote path": "Percorso remoto non valido",
|
||||
"Invalid repair version:": "Versione di riparazione non valida:",
|
||||
"Invalid resource value": "Valore della risorsa non valido",
|
||||
"Invalid resources": "Risorse non valide",
|
||||
"Invalid restored volume path": "Percorso volume ripristinato non valido",
|
||||
"Invalid running state": "Stato di esecuzione non valido",
|
||||
@@ -3291,11 +3346,13 @@
|
||||
"Invalid stack members": "Membri stack non validi",
|
||||
"Invalid stack name": "Nome stack non valido",
|
||||
"Invalid stack operation": "Operazione stack non valida",
|
||||
"Invalid storage": "Archiviazione non valida",
|
||||
"Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "ID storage non valido. Usa solo lettere, numeri, trattini e trattini bassi.",
|
||||
"Invalid storage name": "Nome storage non valido",
|
||||
"Invalid suite application": "Applicazione suite non valida",
|
||||
"Invalid sysctl value:": "Valore sysctl non valido:",
|
||||
"Invalid template storage": "Storage dei template non valido",
|
||||
"Invalid template storage name.": "Nome di archiviazione del modello non valido.",
|
||||
"Invalid tmpfs options:": "Opzioni tmpfs non valide:",
|
||||
"Invalid tmpfs path:": "Percorso tmpfs non valido:",
|
||||
"Invalid tmpfs size:": "Dimensione tmpfs non valida:",
|
||||
@@ -3535,6 +3592,8 @@
|
||||
"Log2RAM write scheduled every": "Scrittura Log2RAM programmata ogni",
|
||||
"Log:": "Registro:",
|
||||
"Logged-in users": "Utenti connessi",
|
||||
"Login to {registry} timed out. Saved credentials were not changed.": "Accesso a {registry} scaduto. Le credenziali salvate non sono state modificate.",
|
||||
"Logout from {registry} timed out. Saved file credentials were not changed.": "Disconnessione da {registry} scaduta. Le credenziali del file salvato non sono state modificate.",
|
||||
"Logrotate optimization completed": "Ottimizzazione Logrotate completata",
|
||||
"Logrotate service restarted successfully": "Servizio Logrotate riavviato correttamente",
|
||||
"Lollypop is a lightweight modern music player designed to work excellently on the GNOME desktop environment.": "Lollypop è un lettore musicale moderno e leggero, progettato per integrarsi al meglio con l'ambiente desktop GNOME.",
|
||||
@@ -3597,6 +3656,8 @@
|
||||
"Manage custom paths (add / remove your folders)": "Gestisci percorsi personalizzati (aggiungi/rimuovi le tue cartelle)",
|
||||
"Manage installed OCI applications": "Gestisci le applicazioni OCI installate",
|
||||
"Manage local backup target": "Gestisci la destinazione di backup locale",
|
||||
"Manage private image registries.\nSaved credentials let ProxMenux access your private images.": "Gestisci i registri di immagini private.\nLe credenziali salvate consentono a ProxMenux di accedere alle tue immagini private.",
|
||||
"Manage registry accounts as root on the Proxmox node.": "Gestisci gli account del registro come root sul nodo Proxmox.",
|
||||
"Managed disks must have backup enabled and a valid size": "I dischi gestiti devono avere il backup abilitato e una dimensione valida",
|
||||
"Managing Nginx proxy hosts with a simple, powerful interface.": "Gestione degli host proxy Nginx tramite un'interfaccia semplice e potente.",
|
||||
"Manual CLI Guide (Disk and Storage Manager)": "Guida CLI manuale (Gestione dischi e storage)",
|
||||
@@ -4114,8 +4175,10 @@
|
||||
"No accessible Samba servers found.": "Nessun server Samba accessibile trovato.",
|
||||
"No accessible exports found.": "Nessuna esportazione accessibile trovata.",
|
||||
"No accessible shares found.": "Nessuna condivisione accessibile trovata.",
|
||||
"No active Proxmox storage accepts OCI template archives.": "Nessuno spazio di archiviazione Proxmox attivo accetta archivi modello OCI.",
|
||||
"No active exports or showmount not available": "Nessuna esportazione attiva o showmount non disponibile",
|
||||
"No active session": "Nessuna sessione attiva",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "nessun abbonamento attivo e nessun repository PBS utilizzabile. La modalità non interattiva non può modificare le origini APT;configurarli in Amministrazione > Repository.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Nessun abbonamento attivo e nessun repository PVE utilizzabile. La modalità non interattiva non può modificare le origini APT; configurarle in Nodo > Aggiornamenti > Repository.",
|
||||
"No additional GPU can be added.": "Non è possibile aggiungere alcuna GPU aggiuntiva.",
|
||||
"No additional device needs to be added.": "Non è necessario aggiungere alcun dispositivo aggiuntivo.",
|
||||
@@ -4220,6 +4283,7 @@
|
||||
"No password provided.": "Nessuna password fornita.",
|
||||
"No paths available": "Nessun percorso disponibile",
|
||||
"No paths selected. Select at least one path.": "Nessun percorso selezionato. Seleziona almeno un percorso.",
|
||||
"No pending changes": "Nessuna modifica in sospeso",
|
||||
"No pending paths to schedule for reboot.": "Nessun percorso in sospeso da pianificare per il riavvio.",
|
||||
"No permanent NFS mounts configured.": "Nessun montaggio NFS permanente configurato.",
|
||||
"No permanent Samba mounts configured.": "Nessun montaggio Samba permanente configurato.",
|
||||
@@ -4233,6 +4297,7 @@
|
||||
"No recent": "Nessun server recente di tipo",
|
||||
"No recent Samba servers found.": "Nessun server Samba recente trovato.",
|
||||
"No registered OCI containers are available for selection on this host.": "Nessun container OCI registrato disponibile per la selezione su questo host.",
|
||||
"No registry API was found at {registry}. Check the hostname and port.": "Nessuna API di registro trovata in {registry}. Controlla il nome host e la porta.",
|
||||
"No restored OCI application is waiting to be recovered.": "Nessuna applicazione OCI ripristinata è in attesa di recupero.",
|
||||
"No results": "Nessun risultato",
|
||||
"No routing information found.": "Nessuna informazione di routing trovata.",
|
||||
@@ -4263,7 +4328,7 @@
|
||||
"No sysfs ROM entry — trying ACPI VFCT table...": "Nessuna voce ROM in sysfs: provo la tabella ACPI VFCT...",
|
||||
"No target configured.": "Nessuna destinazione configurata.",
|
||||
"No test currently running": "Nessun test attualmente in corso",
|
||||
"No unambiguous loader download found in release metadata.": "nessun download di caricatore inequivocabile trovato nei metadati della versione.",
|
||||
"No unambiguous loader download found in release metadata.": "Nessun download univoco del loader trovato nei metadati della versione.",
|
||||
"No uninstaller found for:": "Nessun programma di disinstallazione trovato per:",
|
||||
"No unprivileged containers available in Proxmox.": "Nessun container non privilegiato disponibile in Proxmox.",
|
||||
"No updates available — run a scan first or wait for the Monitor to refresh.": "Nessun aggiornamento disponibile: esegui prima una scansione o attendi l'aggiornamento del monitor.",
|
||||
@@ -4297,7 +4362,9 @@
|
||||
"Not an OVH server, skipping RTM installation": "Non è un server OVH, installazione RTM saltata",
|
||||
"Not currently mounted": "Attualmente non montato",
|
||||
"Not currently mounted — skipping umount.": "Attualmente non montato: umount saltato.",
|
||||
"Not declared": "Non dichiarato",
|
||||
"Not enough free space for the backup": "Spazio libero insufficiente per il backup",
|
||||
"Not enough free space on {storage}. Free some space or select another storage.": "Spazio libero insufficiente su {storage}. Libera spazio o seleziona un altro spazio di archiviazione.",
|
||||
"Not found": "Non trovato",
|
||||
"Not found in the OCI archive:": "Non trovato nell'archivio OCI:",
|
||||
"Not imported:": "Non importato:",
|
||||
@@ -4358,6 +4425,7 @@
|
||||
"OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM rimosso (gli artefatti di Puppet potrebbero richiedere una pulizia manuale)",
|
||||
"OVH server detected": "Rilevato server OVH",
|
||||
"OVH server detection and RTM installation process completed": "Processo di rilevamento dei server OVH e installazione RTM completato",
|
||||
"Object detector": "Rilevatore di oggetti",
|
||||
"Object detector for Frigate": "Rilevatore di oggetti per Frigate",
|
||||
"Observer is not responding on port 4357": "Observer non risponde sulla porta 4357",
|
||||
"Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian è un’app per creare, collegare e organizzare le note sul tuo dispositivo, con centinaia di plugin e temi per personalizzare il flusso di lavoro. Puoi pubblicare le note online, consultarle offline e sincronizzarle in modo sicuro con crittografia end-to-end.",
|
||||
@@ -4388,6 +4456,7 @@
|
||||
"Only Intel/AMD DRM, Coral and USB nodes can be adopted automatically": "Solo i nodi DRM Intel/AMD, Coral e USB possono essere acquisiti automaticamente",
|
||||
"Only Intel/AMD DRM, Coral, NPU and USB nodes can be adopted automatically": "Solo i nodi Intel/AMD DRM, Coral, NPU e USB possono essere adottati automaticamente",
|
||||
"Only a Docker Swarm uses these settings, so they are not applied:": "Queste impostazioni si usano solo in Docker Swarm, quindi non vengono applicate:",
|
||||
"Only a full sha256 image digest is supported.": "Messaggio tecnico per Proxmox e IT. Traduzione: è supportato solo un digest di immagini sha256 completo.",
|
||||
"Only an installed standalone OCI instance can adopt external changes": "Solo un’istanza OCI autonoma installata può acquisire modifiche esterne",
|
||||
"Only backed-up Proxmox volumes can be adopted": "L'adozione dei volumi Proxmox richiede l'opzione backup=1",
|
||||
"Only convert to privileged if absolutely necessary for your use case.": "Converti in privilegiato solo se assolutamente necessario per il tuo caso d'uso.",
|
||||
@@ -4482,11 +4551,17 @@
|
||||
"PBS backup error log": "Registro errori backup PBS",
|
||||
"PBS backup failed.": "Backup PBS non riuscito.",
|
||||
"PBS certificate": "Certificato PBS",
|
||||
"PBS datastores": "Datastore PBS",
|
||||
"PBS encryption keyfile (show / replace / remove)": "File chiave di crittografia PBS (mostra/sostituisci/rimuovi)",
|
||||
"PBS encryption keyfile — detailed info": "File chiave di crittografia PBS: dettagli",
|
||||
"PBS extraction failed": "Estrazione PBS non riuscita",
|
||||
"PBS host or IP address:": "Host PBS o indirizzo IP:",
|
||||
"PBS package update": "Aggiornamento dei pacchetti PBS",
|
||||
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Aggiornamento dei pacchetti PBS completato. Verifica se è necessario un riavvio prima della prossima finestra di backup.",
|
||||
"PBS package upgrade failed. Review the APT output before retrying.": "Aggiornamento dei pacchetti PBS non riuscito. Esamina l'output di APT prima di riprovare.",
|
||||
"PBS upload": "Caricamento PBS",
|
||||
"PBS version and packages": "Versione e pacchetti PBS",
|
||||
"PBS: using the configured APT repositories without changing sources.": "PBS: vengono usati i repository APT configurati senza modificare le sorgenti.",
|
||||
"PCI Address": "Indirizzo PCI",
|
||||
"PCI passthrough, TPM state, cloud-init configuration, Proxmox hooks": "Passthrough PCI, stato TPM, configurazione cloud-init, hook Proxmox",
|
||||
"PCI passthrough, TPM state, cloud-init, snapshots, Proxmox-specific hooks": "Passthrough PCI, stato TPM, cloud-init, snapshot, hook specifici di Proxmox",
|
||||
@@ -4541,6 +4616,7 @@
|
||||
"Passthrough may still work without a ROM file.": "Il passthrough potrebbe funzionare anche senza un file ROM.",
|
||||
"Passthrough may work, but startup/restart reliability is not guaranteed.": "Il passthrough potrebbe funzionare, ma l'affidabilità di avvio/riavvio non è garantita.",
|
||||
"Password": "Password",
|
||||
"Password / access token (image pull permission)": "Password/token di accesso (autorizzazione al pull dell'immagine)",
|
||||
"Password Mismatch": "Password non corrispondenti",
|
||||
"Password cannot be empty.": "La password non può essere vuota.",
|
||||
"Password confirmation cannot be empty.": "La conferma della password non può essere vuota.",
|
||||
@@ -4576,7 +4652,7 @@
|
||||
"Passwords do not match. Please try again.": "Le password non corrispondono. Riprova.",
|
||||
"Paste an oci-compose in the terminal": "Incolla un oci-compose nel terminale",
|
||||
"Paste it here and press Ctrl+D on an empty line.": "Incollalo qui e premi Ctrl+D su una riga vuota.",
|
||||
"Paste it here and press Ctrl+D.": "incollalo qui e premi Ctrl+D.",
|
||||
"Paste it here and press Ctrl+D.": "Incollalo qui e premi Ctrl+D.",
|
||||
"Paste its Compose file in the terminal": "Incolla il file Compose nel terminale",
|
||||
"Paste its docker run command in the terminal": "Incolla il comando docker run nel terminale",
|
||||
"Paste the UUP Dump URL here": "Incolla qui l'URL di UUP Dump",
|
||||
@@ -4806,7 +4882,9 @@
|
||||
"ProxMenux attaches directories, not single files, so this image cannot be installed yet.": "ProxMenux allega directory, non file singoli, quindi questa immagine non può essere ancora installata.",
|
||||
"ProxMenux can apply open permissions on this NFS directory from the host so the container can read and write:": "ProxMenux può applicare permessi aperti su questa directory NFS dall'host in modo che il container possa leggere e scrivere:",
|
||||
"ProxMenux can remount it with open permissions so any LXC can read and write.": "ProxMenux può rimontarlo con permessi aperti in modo che qualsiasi LXC possa leggere e scrivere.",
|
||||
"ProxMenux cannot install this image automatically. Import its Compose file instead.": "ProxMenux non può installare questa immagine automaticamente. Importa invece il relativo file di composizione.",
|
||||
"ProxMenux cannot override NFS server-side permissions from the host.": "ProxMenux non può sovrascrivere le autorizzazioni lato server NFS dall'host.",
|
||||
"ProxMenux could not translate this image. Try importing its Compose file from OCI manager Apps.": "ProxMenux non è riuscito a tradurre questa immagine. Prova a importare il file Compose da OCI manager Apps.",
|
||||
"ProxMenux customizations removed from bashrc": "Personalizzazioni ProxMenux rimosse da bashrc",
|
||||
"ProxMenux does not give a container the system of its host.": "ProxMenux non dà al container accesso al sistema dell’host.",
|
||||
"ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux non li installa. Durante l'installazione ti verranno chiesti i dati di connessione.",
|
||||
@@ -4820,12 +4898,17 @@
|
||||
"Proxmology logo applied": "Logo Proxmology applicato",
|
||||
"Proxmox 9 system update allready": "Il sistema è già aggiornato a Proxmox 9",
|
||||
"Proxmox APT repositories configured": "Repository APT Proxmox configurati",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Destinazioni Proxmox Backup Server (PBS)",
|
||||
"Proxmox Backup Server APT repositories configured": "Repository APT di Proxmox Backup Server configurati",
|
||||
"Proxmox Backup Server Update": "Aggiornamento di Proxmox Backup Server",
|
||||
"Proxmox Backup Server update completed.": "Aggiornamento di Proxmox Backup Server completato.",
|
||||
"Proxmox CIFS Storage Status:": "Stato storage CIFS Proxmox:",
|
||||
"Proxmox NFS Storage Status:": "Stato storage NFS Proxmox:",
|
||||
"Proxmox System Update": "Aggiornamento sistema Proxmox",
|
||||
"Proxmox Update": "Aggiornamento Proxmox",
|
||||
"Proxmox VE": "Proxmox VE",
|
||||
"Proxmox VE 8 package upgrade failed. Review the APT output before retrying.": "L’aggiornamento dei pacchetti di Proxmox VE 8 non è riuscito. Controlla l’output di APT prima di riprovare.",
|
||||
"Proxmox VE 8 system update completed successfully": "Aggiornamento sistema Proxmox VE 8 completato",
|
||||
"Proxmox VE 8 to 9 Manual Upgrade Guide": "Guida aggiornamento manuale Proxmox VE da 8 a 9",
|
||||
"Proxmox VE 9.x no-subscription repository created": "Repository senza abbonamento Proxmox VE 9.x creato",
|
||||
@@ -4833,6 +4916,7 @@
|
||||
"Proxmox VE configuration completed.": "Configurazione Proxmox VE completata.",
|
||||
"Proxmox VE safe update completed": "Aggiornamento sicuro di Proxmox VE completato",
|
||||
"Proxmox auth logger service created and started": "Servizio log autenticazione Proxmox creato e avviato",
|
||||
"Proxmox could not apply the container settings": "Proxmox non è riuscito ad applicare le impostazioni del container",
|
||||
"Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Il repository enterprise Proxmox potrebbe essere mancante o inaccessibile. Tentativo di passare al repository no-subscription...",
|
||||
"Proxmox filter configured": "Filtro Proxmox configurato",
|
||||
"Proxmox iSCSI Storage Status:": "Stato storage iSCSI Proxmox:",
|
||||
@@ -4927,6 +5011,7 @@
|
||||
"Reading SMART data...": "Lettura dei dati SMART...",
|
||||
"Reading SMART self-test log...": "Lettura del registro dell'autotest SMART...",
|
||||
"Reading full SMART report...": "Lettura del rapporto SMART completo...",
|
||||
"Reading image metadata. Please wait...": "Lettura dei metadati dell'immagine. Attendere prego...",
|
||||
"Real-time Performance Monitoring": "Monitoraggio prestazioni in tempo reale",
|
||||
"Real-time bandwidth usage (press q to exit)": "Utilizzo banda in tempo reale (premi q per uscire)",
|
||||
"Real-time collaborative document editor": "Editor collaborativo di documenti in tempo reale",
|
||||
@@ -4948,6 +5033,7 @@
|
||||
"Rebuilding initramfs to apply nouveau blacklist before installation...": "Ricostruzione di initramfs per applicare la blacklist di nouveau prima dell'installazione...",
|
||||
"Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed.": "La ricostruzione del container e il ripristino dal backup sono stati testati con la stessa immagine; il primo aggiornamento a un'immagine più recente resta da verificare.",
|
||||
"Recent": "Recente",
|
||||
"Recent PBS tasks": "Attività PBS recenti",
|
||||
"Recent Samba Servers": "Server Samba recenti",
|
||||
"Recent Samba server": "Server Samba recente",
|
||||
"Recent logs:": "Registri recenti:",
|
||||
@@ -5007,6 +5093,7 @@
|
||||
"Recreation completed. Data kept.": "Ricreazione completata. Dati conservati.",
|
||||
"Recreation prepared": "Ricreazione preparata",
|
||||
"Refresh APT index and verify repositories:": "Aggiorna l'indice APT e verifica i repository:",
|
||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Aggiornare gli indici dei repository APT configurati ed esaminare l'aggiornamento dei pacchetti PBS? I file dei repository verranno conservati.",
|
||||
"Refresh token of the connector, from the Twingate admin console": "Token di refresh del connettore, dalla console di amministrazione Twingate",
|
||||
"Refresh your browser (Ctrl+Shift+R) to see changes": "Aggiorna il browser (Ctrl+Shift+R) per vedere le modifiche",
|
||||
"Refresh your browser to see changes (server restart may be required)": "Aggiorna il browser per vedere le modifiche (potrebbe essere necessario il riavvio del server)",
|
||||
@@ -5019,6 +5106,9 @@
|
||||
"Register mount path in Proxmox:": "Registra il percorso di montaggio in Proxmox:",
|
||||
"Registering disk as Proxmox storage...": "Registrazione disco come storage Proxmox...",
|
||||
"Registering module with DKMS...": "Registrazione del modulo con DKMS...",
|
||||
"Registry accounts": "Conti del Registro di sistema",
|
||||
"Registry hostname": "Nome host del registro",
|
||||
"Registry: {registry}\nAccount: {account}\nAuthentication: {method}\n\nSaved credentials are verified when an image is accessed.": "Registro: {registry}\nConto: {account}\nAutenticazione: {method}\n\nLe credenziali salvate vengono verificate quando si accede a un'immagine.",
|
||||
"Reinstall / update Coral drivers": "Reinstalla/aggiorna i driver Coral",
|
||||
"Reinstall Fail2Ban": "Reinstalla Fail2Ban",
|
||||
"Reinstall Lynis": "Reinstalla Lynis",
|
||||
@@ -5090,8 +5180,12 @@
|
||||
"Remove obsolete systemd-boot meta-package": "Rimuovi il metapacchetto systemd-boot obsoleto",
|
||||
"Remove old repository files:": "Rimuovi i vecchi file del repository:",
|
||||
"Remove passthrough device from VM": "Rimuovi dispositivo passthrough dalla VM",
|
||||
"Remove saved credentials": "Rimuovi le credenziali salvate",
|
||||
"Remove subscription banner": "Rimuovi il banner di abbonamento",
|
||||
"Remove the application? Its container disks are deleted, and only a backup can bring them back.": "Rimuovere l’applicazione? I dischi dei suoi container vengono eliminati e solo un backup può ripristinarli.",
|
||||
"Remove the lock left on a VM by an interrupted task": "Rimuovere il blocco lasciato su una VM da un’attività interrotta",
|
||||
"Remove the lock left on a container by an interrupted task": "Rimuovere il blocco lasciato su un container da un’attività interrotta",
|
||||
"Remove the saved credentials for {registry}?\nRunning apps will keep working.": "Rimuovere le credenziali salvate per {registry}?\nLe app in esecuzione continueranno a funzionare.",
|
||||
"Remove the unprivileged flag from configuration:": "Rimuovi il flag unprivileged dalla configurazione:",
|
||||
"Remove unused packages and their config": "Rimuovi i pacchetti non utilizzati e la loro configurazione",
|
||||
"Remove: delete the application and its containers": "Rimuovi: elimina l'applicazione e i suoi container",
|
||||
@@ -5172,6 +5266,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "Funzione di gestione dei repository non disponibile. L’installazione di NVIDIA è stata interrotta.",
|
||||
"Repository policy returned an unexpected result.": "La policy del repository ha restituito un risultato imprevisto.",
|
||||
"Repository ready.": "Repository pronto.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Le sorgenti dei repository sono cambiate, ma l'aggiornamento dell'elenco dei pacchetti APT non è riuscito. Operazione interrotta; le sorgenti non sono state ripristinate. Controlla Administration > Repositories e riesegui apt-get update prima di continuare.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Le origini del repository sono cambiate, ma l'aggiornamento dell'elenco dei pacchetti APT non è riuscito. Operazione interrotta; le origini non sono state ripristinate. Controlla Nodo > Aggiornamenti > Repository e riprova apt-get update prima di continuare.",
|
||||
"Repository switch declined; no APT source changed.": "Cambio di repository rifiutato; nessuna origine APT è stata modificata.",
|
||||
"Repository:": "Repository:",
|
||||
@@ -5266,7 +5361,9 @@
|
||||
"Reverting ZFS ARC tuning...": "Rimozione delle ottimizzazioni di ZFS ARC…",
|
||||
"Reverting pigz wrapper...": "Rimozione del wrapper pigz e ripristino della configurazione precedente...",
|
||||
"Reverting vzdump speed tuning...": "Rimozione ottimizzazioni velocità vzdump…",
|
||||
"Review and apply": "Verifica e applica",
|
||||
"Review external OCI changes": "Esamina le modifiche OCI esterne",
|
||||
"Review package upgrade": "Esamina l'aggiornamento dei pacchetti",
|
||||
"Review passthrough config files": "Esamina i file di configurazione passthrough",
|
||||
"Review what will be removed": "Controlla cosa verrà rimosso",
|
||||
"Rip DVD and Blu-ray media from a browser": "Estrai contenuti da DVD e Blu-ray tramite browser",
|
||||
@@ -5309,6 +5406,7 @@
|
||||
"Run the following inside the VM:": "Esegui quanto segue nella VM:",
|
||||
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Esegui il cambio del canale di rilascio da una sessione SSH o dalla console dell’host Proxmox con:",
|
||||
"Run the update from an SSH session or the Proxmox host console with:": "Esegui l’aggiornamento da una sessione SSH o dalla console dell’host Proxmox con:",
|
||||
"Run this operation as root.": "Esegui questa operazione come root.",
|
||||
"Run upgrade checklist script:": "Esegui lo script di controllo dell’aggiornamento:",
|
||||
"Running": "In esecuzione",
|
||||
"Running Lynis security audit...": "Esecuzione controllo di sicurezza Lynis...",
|
||||
@@ -5413,6 +5511,7 @@
|
||||
"Save and classify articles. Read them later. Freely.": "Salva e classifica gli articoli. Leggili più tardi. Liberamente.",
|
||||
"Save the passphrase somewhere safe NOW, before continuing.": "Salva la passphrase in un posto sicuro ORA, prima di continuare.",
|
||||
"Save this Borg target so you don't need to enter the details again?": "Salvare questa destinazione Borg per non dover reinserire i dettagli?",
|
||||
"Saved credentials for {registry} were removed.\nOther Skopeo/Docker credential files are managed separately.": "Le credenziali salvate per {registry} sono state rimosse.\nGli altri file di credenziali Skopeo/Docker vengono gestiti separatamente.",
|
||||
"Saved record removed": "Registrazione salvata rimossa",
|
||||
"Saving changes...": "Salvataggio delle modifiche...",
|
||||
"Saving the new configuration": "Salvataggio della nuova configurazione",
|
||||
@@ -5722,6 +5821,7 @@
|
||||
"ShareCoveX panel": "Pannello ShareCoveX",
|
||||
"Shared Directory Ready:": "Directory condivisa pronta:",
|
||||
"Shared Group": "Gruppo condiviso",
|
||||
"Shared directories": "Cartelle condivise",
|
||||
"Shared directory created:": "Directory condivisa creata:",
|
||||
"Shared directory for consume and export": "Directory condivisa per consume ed export",
|
||||
"Shared directory for copy/sync operations": "Directory condivisa per copia e sincronizzazione",
|
||||
@@ -5751,6 +5851,7 @@
|
||||
"Should show pve-manager/9.x.x": "Dovrebbe mostrare pve-manager/9.x.x",
|
||||
"Shoutrrr addresses for the notifications (optional)": "Indirizzi Shoutrrr per le notifiche (facoltativi)",
|
||||
"Show All Scripts": "Mostra tutti gli script",
|
||||
"Show CT users and their UID/GID on the host": "Utenti del CT e loro UID/GID sull’host",
|
||||
"Show CT users in table format": "Mostra utenti CT in tabella",
|
||||
"Show Container Privilege Status": "Mostra stato privilegi container",
|
||||
"Show DNS resolution status": "Mostra stato risoluzione DNS",
|
||||
@@ -5793,9 +5894,11 @@
|
||||
"Shutdown timeout": "Timeout di spegnimento",
|
||||
"SiYuan access code": "Codice di accesso SiYuan",
|
||||
"SickGear provides management of TV shows and/or Anime, it detects new episodes, links downloader apps, and more..": "SickGear gestisce serie TV e/o anime, rileva nuovi episodi, si collega alle applicazioni di download e altro ancora.",
|
||||
"Sign in to a registry": "Accedi a un registro",
|
||||
"Sign in to the panel and create a Samba user in Settings before publishing a directory over SMB.": "Accedi al pannello e crea un utente Samba in Impostazioni prima di pubblicare una directory su SMB.",
|
||||
"Signal is a messaging app with privacy at its core. It is free and easy to use, with strong end-to-end encryption that keeps your communication completely private.": "Signal è un'app di messaggistica che mette la privacy al centro. È gratuita e facile da usare, con una robusta crittografia end-to-end che protegge la riservatezza delle comunicazioni.",
|
||||
"Signatures removed. Partition table preserved.": "Firme rimosse. Tabella delle partizioni conservata.",
|
||||
"Signed in to {registry} as {username}.\n\nCredentials are saved for ProxMenux.\nSelect the account to check, download or install an image.": "Accesso a {registry} come {username}.\n\nLe credenziali vengono salvate per ProxMenux.\nSeleziona l'account per controllare, scaricare o installare un'immagine.",
|
||||
"Simple and easy to use DDNS": "DDNS semplice e facile da usare",
|
||||
"Single GPU Warning": "Avviso GPU singola",
|
||||
"Single target found — selected automatically:": "Unica destinazione trovata - selezionata automaticamente:",
|
||||
@@ -5821,6 +5924,8 @@
|
||||
"Skipping SR-IOV device": "Dispositivo SR-IOV saltato",
|
||||
"Skipping installation.": "Installazione saltata.",
|
||||
"Skipping manual patches — feranick fork already supports this kernel.": "Patch manuali saltate: il fork feranick supporta già questo kernel.",
|
||||
"Skopeo is not available. Restore the ProxMenux OCI dependencies and try again.": "Scopeo non è disponibile. Ripristinare le dipendenze ProxMenux OCI e riprovare.",
|
||||
"Skopeo returned an invalid credential file; saved credentials were not changed.": "Skopeo ha restituito un file di credenziali non valido; le credenziali salvate non sono state modificate.",
|
||||
"Sleek podcast downloader with GPodder sync": "Downloader podcast intuitivo con sincronizzazione GPodder",
|
||||
"Smart restore plan — hardware compatibility check": "Piano di ripristino intelligente: verifica compatibilità hardware",
|
||||
"Smokeping keeps track of your network latency. For a full example of what this application is capable of visit UCDavis.": "Smokeping monitora la latenza della rete. Per un esempio completo delle sue funzionalità, visita UCDavis.",
|
||||
@@ -5908,6 +6013,7 @@
|
||||
"Start the VM to begin Windows installation from the mounted ISO.": "Avvia la VM per iniziare l’installazione di Windows dalla ISO montata.",
|
||||
"Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.": "Avviare le applicazioni dopo la registrazione? Rispondi No se i container originali sono ancora in esecuzione su un altro host: entrambi userebbero gli stessi indirizzi.",
|
||||
"Start the converted container:": "Avvia il container convertito:",
|
||||
"Start the interactive Proxmox package upgrade": "Avviare l’aggiornamento interattivo dei pacchetti Proxmox",
|
||||
"Start the main system upgrade:": "Avvia l’aggiornamento principale del sistema:",
|
||||
"Start the stack with Proxmox": "Avvia lo stack con Proxmox",
|
||||
"Start uploading to PBS — sets a recovery passphrase": "Avvia il caricamento su PBS — viene impostata una passphrase di ripristino",
|
||||
@@ -6078,6 +6184,7 @@
|
||||
"TCP optimizations configuration completed": "Configurazione ottimizzazioni TCP completata",
|
||||
"TCP optimizations reverted (defaults restored on next reboot)": "Ottimizzazioni TCP rimosse (valori predefiniti ripristinati al prossimo riavvio)",
|
||||
"TCP port 5201 is reachable (firewall on server)": "La porta TCP 5201 è raggiungibile (firewall sul server)",
|
||||
"TLS verification failed for {registry}. Install the registry CA in /etc/containers/certs.d/{registry}/ca.crt.": "Verifica TLS non riuscita per {registry}. Installare la CA del registro in /etc/containers/certs.d/{registry}/ca.crt.",
|
||||
"TPM device added to VM": "Dispositivo TPM aggiunto alla VM",
|
||||
"TPM storage selection failed or was cancelled. VM creation aborted.": "Selezione storage TPM non riuscita o annullata. Creazione VM interrotta.",
|
||||
"TROUBLESHOOTING:": "RISOLUZIONE DEI PROBLEMI:",
|
||||
@@ -6186,6 +6293,7 @@
|
||||
"The Paperless persistent volumes need at least 8 GB": "I volumi persistenti di Paperless richiedono almeno 8 GB",
|
||||
"The PostgreSQL volume needs at least 4 GB": "Il volume PostgreSQL richiede almeno 4 GB",
|
||||
"The PostgreSQL volume needs at least 8 GB": "Il volume PostgreSQL richiede almeno 8 GB",
|
||||
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "L’aggiornamento di Proxmox VE 8 non è stato completato. L’aggiornamento a Proxmox VE 9 non è stato avviato.",
|
||||
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Manca il portachiavi dell’archivio Proxmox; l’installazione di Ceph non può continuare in sicurezza",
|
||||
"The Proxmox inventory and the local configurations differ": "L'inventario Proxmox e le configurazioni locali differiscono",
|
||||
"The ROCm image has no support for the AMD GPU of this host:": "L’immagine ROCm non supporta la GPU AMD di questo host:",
|
||||
@@ -6256,6 +6364,7 @@
|
||||
"The build could not be checked beforehand; continuing without that check.": "Non è stato possibile verificare la build in anticipo; si prosegue senza tale verifica.",
|
||||
"The cached image does not match the current digest": "L'immagine nella cache non corrisponde al digest corrente",
|
||||
"The cached image is damaged; it will be downloaded again.": "L'immagine nella cache è danneggiata; verrà scaricata nuovamente.",
|
||||
"The changes need the application installer": "Le modifiche richiedono il programma di installazione dell’applicazione",
|
||||
"The character device does not exist:": "Il dispositivo a caratteri non esiste:",
|
||||
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Il comando richiede una password non visualizzata durante la digitazione. Dopo la creazione degli utenti, l’interfaccia web richiede nome utente e password.",
|
||||
"The command does not name an image": "Il comando non specifica un’immagine",
|
||||
@@ -6271,6 +6380,7 @@
|
||||
"The configuration must run as root on Proxmox VE": "La configurazione deve essere eseguita come root su Proxmox VE",
|
||||
"The configuration of a new member changed after it was created": "La configurazione di un nuovo componente è cambiata dopo la creazione",
|
||||
"The configuration stopped because of an unexpected error": "La configurazione si è interrotta per un errore imprevisto",
|
||||
"The configured credential helper could not be used. Check its installation and credential store.": "Non è stato possibile utilizzare l'assistente credenziali configurato. Controlla la sua installazione e l'archivio credenziali.",
|
||||
"The console of the container could not be configured": "Impossibile configurare la console del container",
|
||||
"The consume/export volumes need at least 1 GB": "I volumi consume/export richiedono almeno 1 GB",
|
||||
"The container could not be removed automatically:": "Impossibile rimuovere automaticamente il container:",
|
||||
@@ -6309,6 +6419,7 @@
|
||||
"The containers do not need them any more; an update downloads the new versions when there are any.": "I container non ne hanno più bisogno; un aggiornamento scarica le nuove versioni, se disponibili.",
|
||||
"The containers were created from downloaded OCI images": "I container sono stati creati da immagini OCI scaricate",
|
||||
"The coordinated backup was modified": "Il backup coordinato è stato modificato",
|
||||
"The credential file changed during this operation. Try again; it was not overwritten.": "Il file delle credenziali è stato modificato durante questa operazione. Riprova;non è stato sovrascritto.",
|
||||
"The current driver will be completely uninstalled before installing the new version. Continue?": "Il driver corrente verrà completamente disinstallato prima di installare la nuova versione. Continuare?",
|
||||
"The current image of the saved channel will be checked and downloaded. Resources, paths and GPU are kept. The CT is stopped during the replacement and a native backup is created first.": "L'immagine corrente del canale salvato verrà controllata e scaricata. Risorse, percorsi e GPU sono tenuti. La CT viene fermata durante la sostituzione e viene creato un backup nativo prima.",
|
||||
"The current record is missing for": "Manca il record attuale per",
|
||||
@@ -6373,8 +6484,10 @@
|
||||
"The host bind source does not exist:": "La sorgente del bind mount sull’host non esiste:",
|
||||
"The host bind source is not a regular file or directory:": "La sorgente del bind mount sull'host non è un file regolare né una directory:",
|
||||
"The host directory changed before it was mounted": "La directory dell'host è cambiata prima del montaggio",
|
||||
"The host directory does not exist": "La directory dell’host non esiste",
|
||||
"The host directory may not be accessible from an unprivileged container.": "La directory dell’host potrebbe non essere accessibile da un container non privilegiato.",
|
||||
"The host has no IPv4 address on the selected bridge": "L'host non ha un indirizzo IPv4 sul bridge selezionato",
|
||||
"The host loses access to them. If the group includes a network or storage controller the host needs, cancel at the summary.": "L’host perde l’accesso a questi dispositivi. Se il gruppo include un controller di rete o di archiviazione necessario all’host, annulla nel riepilogo.",
|
||||
"The host monitor does not see the real host memory": "Il monitoraggio dell'host non rileva la memoria reale dell'host",
|
||||
"The host monitor does not share this host namespace:": "Il monitor dell'host non condivide questo namespace dell'host:",
|
||||
"The host monitor include is unknown or differs from the saved record": "L'include del monitor host è sconosciuto o diverso dal record salvato",
|
||||
@@ -6391,7 +6504,9 @@
|
||||
"The host-monitor firewall subnet changed; no firewall rule was added": "La sottorete del firewall per il monitoraggio dell’host è cambiata; non è stata aggiunta alcuna regola",
|
||||
"The host-monitor web interface uses the host network.": "L’interfaccia web del monitor usa la rete dell’host.",
|
||||
"The identity of a member was replaced": "L'identità di un membro è stata sostituita",
|
||||
"The image cache path is unsafe. Check the template storage before trying again.": "Il percorso della cache delle immagini non è sicuro. Controlla la memorizzazione del modello prima di riprovare.",
|
||||
"The image changes the user expected by the adapter": "L'immagine modifica l'utente previsto dall'adattatore",
|
||||
"The image could not be downloaded or verified. Check registry and storage access, then try again.": "Non è stato possibile scaricare o verificare l'immagine. Controlla l'accesso al registro e allo spazio di archiviazione, quindi riprova.",
|
||||
"The image could not be read from its registry:": "Impossibile leggere l’immagine dal relativo registry:",
|
||||
"The image declares data paths that are still stored in the rootfs": "L’immagine dichiara percorsi dei dati che sono ancora archiviati nel rootfs",
|
||||
"The image did not grant the application user access to the devices; check its native init. Host permissions were not relaxed.": "L’immagine non ha consentito all’utente dell’applicazione di accedere ai dispositivi; verificare l’inizializzazione nativa. I permessi sull’host non sono stati allentati.",
|
||||
@@ -6404,6 +6519,8 @@
|
||||
"The image is in its registry, for one architecture.": "L’immagine è presente nel relativo registry per una sola architettura.",
|
||||
"The image is in its registry.": "L’immagine è presente nel relativo registry.",
|
||||
"The image is in its registry:": "L’immagine è presente nel relativo registry:",
|
||||
"The image manifest failed integrity verification. Try again or contact the image publisher.": "Il manifest dell'immagine non ha superato la verifica dell'integrità. Riprova o contatta l'editore dell'immagine.",
|
||||
"The image or tag was not found on {registry}, or this account cannot access it. Check the reference.": "L'immagine o il tag non è stato trovato su {registry} oppure questo account non può accedervi. Controllare il riferimento.",
|
||||
"The image requests NVIDIA, but the host has no working NVIDIA driver": "L'immagine richiede NVIDIA, ma l'host non ha un driver NVIDIA funzionante",
|
||||
"The image requests disabling part of the AppArmor or seccomp confinement. Continue only if you trust the image and accept this risk.": "L’immagine richiede di disabilitare parte dell’isolamento AppArmor o seccomp. Continuare solo se si ritiene affidabile l’immagine e si accetta il rischio.",
|
||||
"The image requests disabling part of the AppArmor or seccomp confinement. The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "L’immagine richiede di disabilitare parte dell’isolamento AppArmor o seccomp. Compose consente di allentare queste restrizioni; l’opzione resta disattivata salvo selezione dell’utente. Continuare solo se si ritiene affidabile l’immagine e si accetta il rischio.",
|
||||
@@ -6541,7 +6658,15 @@
|
||||
"The record or diagnosis could not be completed; no update was run.": "Non è stato possibile completare la registrazione o la diagnosi; non è stato eseguito alcun aggiornamento.",
|
||||
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "Il recupero non è completo. Rivedere il registro e scegliere \"Recuperare\" di nuovo per questo contenitore nel menu di gestione OCI.",
|
||||
"The recovery did not complete. Review the log and choose \"{choice}\" again for this container in the OCI management menu.": "Il ripristino non è stato completato. Controllare il log e scegliere di nuovo \"{choice}\" per questo container nel menu di gestione OCI.",
|
||||
"The registry authentication file is invalid; it was not changed.": "Il file di autenticazione del registro non è valido;non è stato cambiato.",
|
||||
"The registry authentication file must be owned by root with mode 600.": "Il file di autenticazione del registro deve essere di proprietà di root con modalità 600.",
|
||||
"The registry authentication file must have an absolute path.": "Il file di autenticazione del registro deve avere un percorso assoluto.",
|
||||
"The registry authentication path cannot contain symbolic links.": "Il percorso di autenticazione del registro non può contenere collegamenti simbolici.",
|
||||
"The registry authentication path has an unsafe parent directory.": "Il percorso di autenticazione del registro ha una directory principale non sicura.",
|
||||
"The registry credential directory must be owned by root with mode 700.": "La directory delle credenziali del registro deve essere di proprietà di root con modalità 700.",
|
||||
"The registry did not return the saved image digest": "Il registry non ha restituito il digest salvato dell’immagine",
|
||||
"The registry returned incomplete or unsupported image metadata.": "Il registro ha restituito metadati dell'immagine incompleti o non supportati.",
|
||||
"The registry returned invalid or unsupported image metadata.": "Il registro ha restituito metadati dell'immagine non validi o non supportati.",
|
||||
"The remote does not exist; create and authorize it first in the WebUI:": "Il remote non esiste; crearlo e autorizzarlo prima nella WebUI:",
|
||||
"The remote installer must run as root on Proxmox VE": "L'installer remoto deve essere eseguito come root su Proxmox VE",
|
||||
"The remote must already be created and authorized in the Rclone web UI. This operation restarts the CT and publishes two FUSE views on the host.": "Il remote deve essere già creato e autorizzato nell’interfaccia web Rclone. Questa operazione riavvia il CT e pubblica due viste FUSE sull’host.",
|
||||
@@ -6552,6 +6677,7 @@
|
||||
"The repair must preserve the image dependencies:": "La riparazione deve conservare le dipendenze dell'immagine:",
|
||||
"The requested VMID block is already in use": "Il blocco di VMID richiesto è già in uso",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Il profilo GPU richiesto per il machine learning non funziona; non viene sostituito dalla CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Non è stato possibile annullare completamente la modifica delle risorse. Riapri il menu di gestione per verificare e importare le impostazioni attuali di Proxmox.",
|
||||
"The restored service did not pass its health check": "Il servizio ripristinato non ha superato il controllo dello stato",
|
||||
"The restored service stopped; the recovery is not confirmed": "Il servizio ripristinato si è arrestato; il recupero non è confermato",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Lo stack Tandoor esaminato non richiede un container LXC privilegiato.",
|
||||
@@ -6597,6 +6723,7 @@
|
||||
"The selected VM": "La VM selezionata",
|
||||
"The selected VM is running.": "La VM selezionata è in esecuzione.",
|
||||
"The selected base folder does not exist and could not be created:": "La cartella di base selezionata non esiste e non è stato possibile crearla:",
|
||||
"The selected bridge does not exist": "Il bridge selezionato non esiste",
|
||||
"The selected bridge has no IPv4 subnet for the host-monitor firewall": "Il bridge selezionato non ha una sottorete IPv4 per il firewall del monitoraggio dell’host",
|
||||
"The selected configuration needs to start the LXC during the installation": "La configurazione selezionata richiede l'avvio del container LXC durante l'installazione",
|
||||
"The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Il container selezionato non è privilegiato. È richiesto un container privilegiato per il passthrough diretto del dispositivo.",
|
||||
@@ -6619,6 +6746,7 @@
|
||||
"The service builds its own image; only a published image can be installed": "Il servizio crea la propria immagine; si possono installare solo immagini pubblicate",
|
||||
"The service declares no image:": "Il servizio non dichiara alcuna immagine:",
|
||||
"The setting has no final value:": "L'impostazione non ha un valore finale:",
|
||||
"The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Non è stato possibile ripristinare tutte le impostazioni. Controlla il container e la modifica salvata prima di continuare.",
|
||||
"The share already exists in smb.conf:": "La condivisione esiste già in smb.conf:",
|
||||
"The shared destination is not a directory": "La destinazione condivisa non è una directory",
|
||||
"The shared directory points to a protected host path": "La directory condivisa punta a un percorso protetto dell'host",
|
||||
@@ -6686,20 +6814,26 @@
|
||||
"These Proxmox resources were added outside ProxMenux:": "Queste risorse Proxmox sono state aggiunte fuori da ProxMenux:",
|
||||
"These VMIDs are not free:": "Questi VMID non sono disponibili:",
|
||||
"These are the changes that will be made": "Verranno apportate queste modifiche",
|
||||
"These changes were made in Proxmox outside ProxMenux:": "Queste modifiche sono state apportate in Proxmox al di fuori di ProxMenux:",
|
||||
"These container mount paths have an extension-like suffix:": "Questi percorsi di mount del container hanno un suffisso simile a un'estensione:",
|
||||
"These containers were restored from a backup and this host has no record of their OCI application:": "Questi container sono stati ripristinati da un backup e questo host non ha il record della loro applicazione OCI:",
|
||||
"These devices share the IOMMU group of the selected GPU and will be assigned to the VM with it:": "Questi dispositivi condividono il gruppo IOMMU della GPU selezionata e verranno assegnati alla VM insieme a essa:",
|
||||
"These host backups run with this job and expect its storage:": "Questi backup dell’host vengono eseguiti con questo job e richiedono il suo storage:",
|
||||
"These host backups run with this job and will stop running:": "Questi backup dell'host vengono eseguiti con questo lavoro e interromperanno l'esecuzione:",
|
||||
"These interface configurations will be removed": "Queste configurazioni dell'interfaccia verranno rimosse",
|
||||
"These manual changes cannot be adopted safely:": "Queste modifiche manuali non possono essere acquisite in modo sicuro:",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Questi percorsi non verranno ripristinati sul sistema in esecuzione e verranno estratti per il ripristino manuale.",
|
||||
"These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Queste impostazioni richiedono un adattamento specifico per l’applicazione. L’immagine non è stata scaricata e il container non è stato ricreato.",
|
||||
"These values are asked during the installation:": "Questi valori verranno richiesti durante l’installazione:",
|
||||
"They are described in the image documentation:": "Sono descritti nella documentazione dell'immagine:",
|
||||
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Se confermi e la verifica finale riesce, queste risorse verranno registrate in oci-compose prima di proseguire. Le risorse non supportate o modificate non vengono acquisite.",
|
||||
"They will be saved in oci-compose before continuing.": "Verranno salvate in oci-compose prima di continuare.",
|
||||
"This CIFS share is mounted with restrictive permissions.": "Questa condivisione CIFS è montata con autorizzazioni restrittive.",
|
||||
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Questa GPU è considerata incompatibile con il passthrough GPU a una VM in ProxMenux.",
|
||||
"This NFS share is fully restricted — even the host root cannot write to it.": "Questa condivisione NFS è completamente limitata: nemmeno l'utente root dell'host può scrivervi.",
|
||||
"This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Questo Proxmox Backup Server non ha un abbonamento attivo. Passare al repository senza abbonamento? La sorgente Enterprise PBS non accessibile verrà disattivata.",
|
||||
"This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Questa VM richiede ulteriori passaggi di installazione; vedere la guida:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144",
|
||||
"This account is for {registry}. Enter an image from that registry.": "Questo account è per {registry}. Inserisci un'immagine da quel registro.",
|
||||
"This action will:": "Questa azione:",
|
||||
"This application cannot be recovered yet:": "Questa applicazione non può ancora essere recuperata:",
|
||||
"This application cannot be recovered yet; nothing was changed:": "Questa applicazione non può ancora essere recuperata; non è cambiato nulla:",
|
||||
@@ -6731,8 +6865,10 @@
|
||||
"This explicitly marks the container as privileged": "Ciò contrassegna esplicitamente il container come privilegiato",
|
||||
"This file decides what is mounted and with which privileges. Install it only if it comes from a source you trust.": "Questo file decide cosa viene montato e con quali privilegi. Installarlo solo se proviene da una fonte attendibile.",
|
||||
"This guarantees that device nodes are available before applying LXC GPU config.": "Garantisce la disponibilità dei nodi dispositivo prima di applicare la configurazione GPU LXC.",
|
||||
"This host architecture is not supported by the OCI image importer.": "Questa architettura host non è supportata dall'importatore di immagini OCI.",
|
||||
"This host has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PVE source will be disabled. Enterprise Ceph sources, if present, will be disabled without choosing a replacement Ceph channel; configure Ceph separately if needed.": "Questo host non ha un abbonamento attivo, passare al repository senza abbonamento? L'origine PVE Enterprise, non accessibile, verrà disabilitata. Le origini Ceph Enterprise, se presenti, verranno disabilitate senza scegliere un canale Ceph sostitutivo; configurare Ceph separatamente se necessario.",
|
||||
"This image cannot be installed as it is described:": "L’immagine non può essere installata secondo la descrizione fornita:",
|
||||
"This image has no compatible Linux image for this host ({architecture}).": "Questa immagine non ha un'immagine Linux compatibile per questo host ({architecture}).",
|
||||
"This image requires the host module": "Questa immagine richiede il modulo dell’host",
|
||||
"This import rejects these paths without checking whether they are files or directories.": "Questa importazione rifiuta questi percorsi senza verificare se sono file o directory.",
|
||||
"This installation will:": "Questa installazione:",
|
||||
@@ -6757,10 +6893,13 @@
|
||||
"This may take several minutes...": "L'operazione potrebbe richiedere diversi minuti...",
|
||||
"This means Proxmox handles mount lifecycle natively (no manual /etc/fstab needed for NFS/CIFS host storages).": "Proxmox gestisce il montaggio nativamente (non occorre modificare manualmente /etc/fstab per gli storage host NFS/CIFS).",
|
||||
"This means the credentials are incorrect.": "Ciò significa che le credenziali non sono corrette.",
|
||||
"This menu requires Proxmox Backup Server.": "Questo menu richiede Proxmox Backup Server.",
|
||||
"This might indicate network connectivity issues.": "Ciò potrebbe indicare problemi di connettività di rete.",
|
||||
"This monitor uses a privileged LXC, shares processes and network with Proxmox and disables AppArmor in the CT. It uses the IP address and firewall of the host. A compromised image could affect the host; do not expose its web UI to the Internet.": "Questo monitor usa un LXC privilegiato, condivide processi e rete con Proxmox e disabilita AppArmor nel CT. Usa IP e firewall dell’host. Un’immagine compromessa potrebbe compromettere l’host; non esporre l’interfaccia web a Internet.",
|
||||
"This only removes the schedule. Existing backup archives are NOT deleted.": "Viene rimossa solo la pianificazione. Gli archivi di backup esistenti NON vengono eliminati.",
|
||||
"This operation may take several minutes and requires internet connectivity.": "L’operazione può richiedere diversi minuti e necessita di connettività Internet.",
|
||||
"This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Questa operazione richiede un host Proxmox VE. Usa il menu PBS su Proxmox Backup Server.",
|
||||
"This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Questa operazione supporta PBS 3 su Debian 12 e PBS 4 su Debian 13.",
|
||||
"This package was installed by older versions of the ProxMenux Coral installer that placed the M.2 kernel driver on every system, including USB-only setups. It is not needed for Coral USB devices, which use libedgetpu1-std / libedgetpu1-max only.": "Questo pacchetto è stato installato dalle versioni precedenti del programma di installazione ProxMenux Coral, che installavano il driver del kernel M.2 su ogni sistema, comprese le configurazioni solo USB. Non è necessario per i dispositivi USB Coral, che utilizzano solo libedgetpu1-std / libedgetpu1-max.",
|
||||
"This passphrase is the ONLY way to access encrypted Borg backups.": "Questa passphrase è l'UNICO modo per accedere ai backup Borg crittografati.",
|
||||
"This path is already used as a mount point in this container.": "Questo percorso è già utilizzato come punto di montaggio in questo container.",
|
||||
@@ -6776,6 +6915,7 @@
|
||||
"This script cannot be executed from the Proxmox web terminal.": "Questo script non può essere eseguito dal terminale web Proxmox.",
|
||||
"This script must be run as root.": "Questo script deve essere eseguito come root.",
|
||||
"This script must be run on a Proxmox host.": "Questo script deve essere eseguito su un host Proxmox.",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al tuo Proxmox Backup Server",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Questo script applicherà le seguenti ottimizzazioni e regolazioni avanzate al server Proxmox VE",
|
||||
"This script will update your Proxmox VE system with advanced options:": "Questo script aggiornerà il sistema Proxmox VE con opzioni avanzate:",
|
||||
"This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Questa sessione è in esecuzione nel terminale Monitor. Eseguirlo da qui interromperebbe la connessione a metà installazione e lascerebbe il cambio di canale incompleto.",
|
||||
@@ -6848,6 +6988,7 @@
|
||||
"To use the GPU again in LXC, run Add GPU to LXC from GPUs and Coral-TPU Menu": "Per riutilizzare la GPU in LXC, eseguire Aggiungi GPU a LXC dal Menu GPU e Coral-TPU",
|
||||
"To use the VM without issues, the host must be restarted before starting it.": "Per utilizzare la VM senza problemi, è necessario riavviare l'host prima di avviarla.",
|
||||
"To use this share from an LXC, bind-mount it via:": "Per usare questa condivisione da un LXC, eseguire il bind-mount tramite:",
|
||||
"Token": "Gettone",
|
||||
"Tool exit code:": "Codice di uscita strumento:",
|
||||
"Tool output:": "Output strumento:",
|
||||
"Tools": "Strumenti",
|
||||
@@ -6906,6 +7047,7 @@
|
||||
"Unable to identify the root filesystem. Aborting.": "Impossibile identificare il filesystem root. Interruzione.",
|
||||
"Unable to resolve system ZFS pool disks. Aborting.": "Impossibile individuare i dischi del pool ZFS di sistema. Interruzione.",
|
||||
"Unable to resolve system disk topology. Aborting.": "Impossibile determinare la topologia del disco di sistema. Interruzione.",
|
||||
"Unapplied changes": "Modifiche non applicate",
|
||||
"Understand the security implications of privileged containers": "Comprendere le implicazioni sulla sicurezza dei container privilegiati",
|
||||
"Unexpected Proxmox inventory; recovery blocked": "Inventario Proxmox inatteso; ripristino bloccato",
|
||||
"Unexpected formatting directory in the new Valkey volume": "Directory di formattazione imprevista nel nuovo volume Valkey",
|
||||
@@ -6992,6 +7134,7 @@
|
||||
"Unsafe private configuration path": "Percorso configurazione privata non sicuro",
|
||||
"Unsafe qBittorrent configuration path": "Percorso configurazione qBittorrent non sicuro",
|
||||
"Unsafe record": "Record non sicuro",
|
||||
"Unsafe registry credential lock.": "Blocco delle credenziali del registro non sicuro.",
|
||||
"Unsafe registry directory": "Directory registro non sicura",
|
||||
"Unsafe registry lock": "Lock registro non sicuro",
|
||||
"Unsafe rootfs for the capture": "Rootfs non sicura per l'acquisizione",
|
||||
@@ -7004,6 +7147,7 @@
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Profilo OCI-LXC AppArmor non supportato:",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Profilo seccomp OCI-LXC non supportato:",
|
||||
"Unsupported Terminal": "Terminale non supportato",
|
||||
"Unsupported architecture for Figurine:": "Architettura non supportata da Figurine:",
|
||||
"Unsupported architecture:": "Architettura non supportata:",
|
||||
"Unsupported backup compression": "Compressione backup non supportata",
|
||||
"Unsupported credential pattern:": "Schema credenziali non supportato:",
|
||||
@@ -7019,6 +7163,7 @@
|
||||
"Unsupported mount type": "Tipo mount non supportato",
|
||||
"Unsupported mount type:": "Tipo mount non supportato:",
|
||||
"Unsupported native device type:": "Tipo dispositivo nativo non supportato:",
|
||||
"Unsupported network settings": "Impostazioni di rete non supportate",
|
||||
"Unsupported operation": "Operazione non supportata",
|
||||
"Unsupported or unknown Proxmox version; no repository changed.": "Versione Proxmox non supportata o sconosciuta; nessun repository è stato modificato.",
|
||||
"Unsupported output format:": "Formato output non supportato:",
|
||||
@@ -7046,6 +7191,7 @@
|
||||
"Update NVIDIA in LXC Containers": "Aggiorna NVIDIA nei container LXC",
|
||||
"Update OCI": "Aggiorna OCI",
|
||||
"Update OCI stack": "Aggiorna stack OCI",
|
||||
"Update PBS using configured repositories": "Aggiorna PBS usando i repository configurati",
|
||||
"Update PVE enterprise repository (Only if using enterprise):": "Aggiorna repository enterprise PVE (solo se si utilizza enterprise):",
|
||||
"Update Proxmox VE Appliance Manager": "Aggiorna Proxmox VE Appliance Manager",
|
||||
"Update Proxmox package lists": "Aggiorna elenchi pacchetti Proxmox",
|
||||
@@ -7056,6 +7202,7 @@
|
||||
"Update cancelled by user": "Aggiornamento annullato dall'utente",
|
||||
"Update completed. Data kept.": "Aggiornamento completato. Dati conservati.",
|
||||
"Update completed. Press Enter to continue...": "Aggiornamento completato. Premere Invio per continuare...",
|
||||
"Update credentials": "Aggiorna le credenziali",
|
||||
"Update every container of the application": "Aggiorna tutti i container dell’applicazione",
|
||||
"Update kernel to compatible version": "Aggiorna il kernel alla versione compatibile",
|
||||
"Update now?": "Aggiornare ora?",
|
||||
@@ -7097,6 +7244,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Assistente aggiornamento: Proxmox VE 8 → 9 (Trixie)",
|
||||
"Upgrade canceled by user": "Aggiornamento annullato dall'utente",
|
||||
"Upgrade one node at a time": "Aggiorna un nodo alla volta",
|
||||
"Upgrade the system, offering the": "aggiorna il sistema, offrendo il",
|
||||
"Upgrade to Proxmox VE 9": "Aggiorna a Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "Aggiornamento utilità NVIDIA (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Caricare una copia crittografata della chiave su PBS per recuperarla su un host reinstallato con la sola passphrase?",
|
||||
@@ -7122,8 +7270,10 @@
|
||||
"Use a custom path": "Usa percorso personalizzato",
|
||||
"Use a privileged LXC for NFS server/client workflows.": "Usare un LXC privilegiato per le operazioni server/client NFS.",
|
||||
"Use a privileged LXC for Samba client/server workflows.": "Usare un LXC privilegiato per le operazioni client/server Samba.",
|
||||
"Use a registry hostname, for example ghcr.io or registry.example.com:5000.": "Utilizza un nome host del registro, ad esempio ghcr.io o registry.example.com:5000.",
|
||||
"Use an existing SSH private key file on this host": "Usa un file di chiave privata SSH esistente su questo host",
|
||||
"Use an existing keyfile": "Usa file di chiavi esistente",
|
||||
"Use an image reference, for example ghcr.io/owner/app:latest.": "Utilizza un riferimento a un'immagine, ad esempio ghcr.io/owner/app:latest.",
|
||||
"Use as-is — keep data and filesystem": "Usa così com’è — conserva dati e file system",
|
||||
"Use content types according to your use case.": "Usare i tipi di contenuto adatti al caso d’uso.",
|
||||
"Use default": "Usa impostazione predefinita",
|
||||
@@ -7163,6 +7313,7 @@
|
||||
"User-friendly WebUI for LLMs (Formerly Ollama WebUI)": "Interfaccia web intuitiva per LLM (in precedenza Ollama WebUI)",
|
||||
"Username": "Nome utente",
|
||||
"Username (e.g. root@pam or user@pbs!token):": "Nome utente (ad esempio root@pam o user@pbs!token):",
|
||||
"Username / token": "Nome utente/token",
|
||||
"Username and password": "Nome utente e password",
|
||||
"Username and password will be cleared.": "Nome utente e password verranno cancellati.",
|
||||
"Username cannot be empty.": "Il nome utente non può essere vuoto.",
|
||||
@@ -7262,6 +7413,7 @@
|
||||
"Verifying Ceph packages availability...": "Verifica disponibilità pacchetti Ceph...",
|
||||
"Verifying all utilities status": "Verifica stato di tutte le utilità",
|
||||
"Verifying disk accessibility in CT": "Verifica accessibilità disco nel CT",
|
||||
"Verifying registry access. Please wait...": "Verifica dell'accesso al registro. Attendere prego...",
|
||||
"Verifying the backups...": "Verifica backup...",
|
||||
"Verifying the image integrity...": "Verifica integrità immagine...",
|
||||
"Version": "Versione",
|
||||
@@ -7530,6 +7682,7 @@
|
||||
"active VF(s)": "VF attive",
|
||||
"active VFs": "VF attive",
|
||||
"active Virtual Functions. Changing its driver binding would destroy every VF.": "funzioni virtuali attive. La modifica del collegamento del driver distruggerebbe ogni VF.",
|
||||
"added in Proxmox": "aggiunta in Proxmox",
|
||||
"after copy; source tree is incomplete.": "dopo la copia; l'albero dei sorgenti è incompleto.",
|
||||
"after update.": "dopo l'aggiornamento.",
|
||||
"all guests": "tutti i guest",
|
||||
@@ -7561,6 +7714,7 @@
|
||||
"assembling": "assemblaggio",
|
||||
"automatically. Install it manually inside the container.": "automaticamente. Installalo manualmente all'interno del container.",
|
||||
"automatically. Reboot LXC to fully release.": "automaticamente. Riavviare LXC per il rilascio completo.",
|
||||
"available": "Disponibile",
|
||||
"available for LXC bind-mounts via 'LXC Mount Manager'": "disponibile per bind mount LXC tramite Gestore montaggi LXC",
|
||||
"available in this same GPU and TPU menu.": "disponibile nello stesso menu GPU e TPU.",
|
||||
"backup at /etc/fstab.proxmenux.bak": "backup su /etc/fstab.proxmenux.bak",
|
||||
@@ -7896,6 +8050,7 @@
|
||||
"remapped users": "utenti rimappati",
|
||||
"remove the (now-empty) directory if possible": "rimuovere la directory (ora vuota) se possibile",
|
||||
"removed from Proxmox": "rimosso da Proxmox",
|
||||
"removed in Proxmox": "rimossa in Proxmox",
|
||||
"removed successfully from Proxmox.": "rimosso con successo da Proxmox.",
|
||||
"requires running the official installer, which restarts the Monitor service.": "richiede l'esecuzione del programma di installazione ufficiale, che riavvia il servizio Monitor.",
|
||||
"requires the package": "richiede il pacchetto",
|
||||
@@ -7913,6 +8068,7 @@
|
||||
"runs in": "usa",
|
||||
"safe paths now (configs, packages, /etc, /root, ...)": "percorsi sicuri ora (configurazioni, pacchetti, /etc, /root, ...)",
|
||||
"same MAC": "stesso MAC",
|
||||
"saved": "Salvato",
|
||||
"seconds (default)": "secondi (predefinito)",
|
||||
"see log:": "vedi registro:",
|
||||
"selected by default": "selezionata per impostazione predefinita",
|
||||
@@ -7998,6 +8154,7 @@
|
||||
"using": "utilizzando",
|
||||
"using direct path (not guaranteed to survive reboots).": "utilizzando il percorso diretto (non è garantito che sopravviva ai riavvii).",
|
||||
"vCPUs:": "vCPU:",
|
||||
"value changed in Proxmox": "valore modificato in Proxmox",
|
||||
"vfio-pci IDs configured": "ID vfio-pci configurati",
|
||||
"vfio-pci IDs in /etc/modprobe.d/vfio.conf": "ID vfio-pci in /etc/modprobe.d/vfio.conf",
|
||||
"video": "video",
|
||||
@@ -8010,6 +8167,7 @@
|
||||
"was": "era",
|
||||
"was installed, but the kernel reports:": "è stato installato, ma il kernel segnala:",
|
||||
"when finished": "al termine",
|
||||
"when the server has no subscription (you will be asked)": "quando il server non ha abbonamento (ti verrà chiesto)",
|
||||
"will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "riassocerà la GPU a vfio-pci al prossimo riavvio, impedendo il funzionamento del driver che sta per essere installato.",
|
||||
"wipefs failed on": "wipefs non è riuscito su",
|
||||
"with": "con",
|
||||
@@ -8031,8 +8189,16 @@
|
||||
"zfs-auto-snapshot removed (existing snapshots preserved on the pool)": "zfs-auto-snapshot rimosso (snapshot esistenti conservati nel pool)",
|
||||
"zpool command not found. Install zfsutils-linux and retry.": "Comando zpool non trovato. Installare zfsutils-linux e riprovare.",
|
||||
"zpool not found. Install zfsutils-linux and retry.": "zpool non trovato. Installare zfsutils-linux e riprovare.",
|
||||
"{count} saved account.": "Account salvato {count}.",
|
||||
"{count} saved accounts.": "Account salvati {count}.",
|
||||
"{path} already exists and belongs to another user of the host, so {app} cannot write to it.\n\nProxMenux can add access rules (ACL) to this directory, to everything inside it and to what is created there later, for the users and groups {app} runs as on the host (IDs {ids}). The owner of the files does not change and whatever reads or writes them today keeps doing so; the owner of the directory also gets access to the files {app} creates.\n\nGrant {app} access to this directory?": "{path} esiste già e appartiene a un altro utente dell'host, quindi {app} non può scrivervi.\n\nProxMenux può aggiungere regole di accesso (ACL) a questa directory, a tutto ciò che contiene e a ciò che vi verrà creato in seguito, per gli utenti e i gruppi con cui {app} viene eseguito sull'host (ID {ids}). Il proprietario dei file non cambia e chi oggi li legge o li scrive continua a farlo; anche il proprietario della directory ottiene l'accesso ai file creati da {app}.\n\nConcedere a {app} l'accesso a questa directory?",
|
||||
"{path} already exists and belongs to another user of the host, so {app} cannot write to it.\n\nProxMenux can add an access rule (ACL) for the identity {app} uses on the host (UID {uid}). Nothing that is there changes owner or permissions: whatever reads or writes those files today keeps doing so, and the owner of the directory also gets access to the files {app} creates.\n\nGrant {app} access to this directory?": "{path} esiste già e appartiene a un altro utente dell'host, quindi {app} non può scrivervi.\n\nProxMenux può aggiungere una regola di accesso (ACL) per l'identità utilizzata da {app} sull'host (UID {uid}).Niente di ciò che c'è cambia proprietario o autorizzazioni: qualunque cosa legga o scriva quei file oggi continua a farlo, e anche il proprietario della directory ottiene l'accesso ai file creati da {app}.\n\nConcedere a {app} l'accesso a questa directory?",
|
||||
"{registry} answered with plain HTTP. Use a registry with HTTPS; TLS verification remains enabled.": "{registry} ha risposto con un semplice HTTP. Utilizzare un registro con HTTPS; La verifica TLS rimane abilitata.",
|
||||
"{registry} did not respond within {timeout} seconds. Try again later.": "{registry} non ha risposto entro {timeout} secondi. Riprova più tardi.",
|
||||
"{registry} is rate limiting requests. Wait a while and try again.": "{registry} è una richiesta di limitazione della velocità. Aspetta un po' e riprova.",
|
||||
"{registry} refused access to this image:\n{reference}\n\nIf the image is private, sign in with an account that can pull it.\n\nSign in now?": "{registry} ha rifiutato l'accesso a questa immagine:\n{reference}\n\nSe l'immagine è privata, accedi con un account che possa recuperarla.\n\nAccedi adesso?",
|
||||
"{registry} reported a server error. Try again later.": "{registry} ha segnalato un errore del server. Riprova più tardi.",
|
||||
"{registry} returned invalid or unsupported image metadata.": "{registry} ha restituito metadati dell'immagine non validi o non supportati.",
|
||||
"— Full/Custom restore will skip kernel-tied paths": "— il ripristino completo/personalizzato salterà i percorsi legati al kernel",
|
||||
"— disk may be busy. Skipping fstab removal.": "— il disco potrebbe essere occupato. Rimozione della voce da fstab saltata.",
|
||||
"— restore proceeds as same-version (verified safe)": "— il ripristino procede come per la stessa versione (sicurezza verificata)",
|
||||
|
||||
+190
-24
File diff suppressed because it is too large
Load Diff
+176
-10
@@ -37,6 +37,7 @@
|
||||
"/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (predvolená cesta Proxmox vzdump)",
|
||||
"/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (predvolené v Proxmoxe)",
|
||||
"1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx pre všetkých. Zdieľaná skupina nie je potrebná.",
|
||||
"====== PBS UPDATE COMPLETED ======": "====== AKTUALIZÁCIA PBS DOKONČENÁ ======",
|
||||
"====== PVE UPDATE COMPLETED ======": "====== AKTUALIZÁCIA PVE DOKONČENÁ ======",
|
||||
">> Create a new backup job <<": ">> Vytvoriť novú úlohu zálohovania <<",
|
||||
">> Save and return <<": ">> Uložiť a vrátiť <<",
|
||||
@@ -87,6 +88,7 @@
|
||||
"A multi-container application is not recreated: its containers are updated together.": "Aplikácia s viacerými kontajnermi sa nevytvorí znova: jej kontajnery sa aktualizujú spoločne.",
|
||||
"A new ProxMenux version is available:": "Je dostupná nová verzia ProxMenux:",
|
||||
"A new kernel is staged for the next boot:": "Na najbližší štart je pripravený nový kernel:",
|
||||
"A newer kernel is installed. Reboot before the next backup window to use it.": "Je nainštalované novšie jadro. Aby ste ho mohli použiť, reštartujte ho pred ďalším oknom zálohy.",
|
||||
"A newer version is available:": "Je dostupná novšia verzia:",
|
||||
"A pending operation exists for": "Čaká na operation existuje",
|
||||
"A pending stack assembly already exists; it is not overwritten": "Nevyriešená zostava stohov už existuje; nie je prepísaná",
|
||||
@@ -136,7 +138,7 @@
|
||||
"ALL DATA ON": "VŠETKY DÁTA NA",
|
||||
"ALL DATA ON THIS DISK WILL BE PERMANENTLY LOST!": "VŠETKY DÁTA NA TOMTO DISKU BUDÚ NATRVALO STRATENÉ!",
|
||||
"ALL Utilities": "VŠETKY nástroje",
|
||||
"ALLOWED_HOSTS cannot contain line breaks": "UDELENÉ HOSTS nemôžu obsahovať prestávky na čiare",
|
||||
"ALLOWED_HOSTS cannot contain line breaks": "ALLOWED_HOSTS nesmie obsahovať zalomenia riadkov",
|
||||
"AList initial login": "AList začiatočné prihlásenie",
|
||||
"AMD (ROCm)": "AMD (ROCm)",
|
||||
"AMD (VA-API and ROCm detection)": "AMD (detekcia VA-API a ROCm)",
|
||||
@@ -169,6 +171,8 @@
|
||||
"APT language configuration updated": "Nastavenie jazykov pre APT bolo aktualizované",
|
||||
"APT language downloads restored": "Sťahovanie jazykov cez APT bolo obnovené",
|
||||
"APT package lists updated": "Zoznamy APT balíkov aktualizované",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "Obnovenie APT zlyhalo. Skontrolujte predplatné a repozitáre PBS v časti Správa > Repozitáre. Denník:",
|
||||
"APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "Obnovenie indexov APT zlyhalo. Skontrolujte predplatné a repozitáre PBS v pôvodnom rozhraní PBS; žiadne zdroje sa nezmenili.",
|
||||
"APT update failed. Check repository access; NVIDIA installation stopped.": "Aktualizácia APT zlyhala. Skontrolujte prístup k úložisku; inštalácia NVIDIA sa zastavila.",
|
||||
"Aborted": "Zrušené",
|
||||
"About to restore": "Chystá sa obnova",
|
||||
@@ -192,6 +196,7 @@
|
||||
"Access bridge for Tandoor": "Prístupový mostík pre Tandoor",
|
||||
"Access could not be granted completely; the application may be unable to write to:": "Prístup nebolo možné úplne udeliť; aplikácia možno nebude môcť zapisovať do:",
|
||||
"Access could not be granted; the application cannot write to:": "Prístup nebolo možné udeliť; aplikácia nemôže písať na:",
|
||||
"Access denied by {registry}. Check the image name and sign in from Registry accounts.": "Prístup odmietnutý {registry}. Skontrolujte názov obrázka a prihláste sa z účtov Registry.",
|
||||
"Access granted to the application on:": "Prístup udelený aplikácii dňa:",
|
||||
"Access password of the web interface (optional)": "Heslo na prístup do webového rozhrania (voliteľné)",
|
||||
"Access profile:": "Profil prístupu:",
|
||||
@@ -310,13 +315,14 @@
|
||||
"Advanced": "Pokročilé",
|
||||
"Advanced Diagnostics": "Pokročilá diagnostika",
|
||||
"Advanced Network Diagnostics": "Pokročilá diagnostika siete",
|
||||
"Advanced settings": "Rozšírené nastavenia",
|
||||
"Advanced: every setting of the container": "Pokročilé: každé nastavenie kontajnera",
|
||||
"Affected LXC containers": "Dotknuté LXC kontajnery",
|
||||
"After completing GPU setup, start the VM manually when the host is ready.": "Po dokončení nastavenia GPU spustite VM ručne, keď bude server pripravený.",
|
||||
"After confirming, you will be asked to choose the NVIDIA driver version to install.": "Po potvrdení si vyberiete verziu ovládača NVIDIA, ktorú chcete nainštalovať.",
|
||||
"After detailed analysis, no changes are needed.": "Po podrobnej analýze nie sú potrebné žiadne zmeny.",
|
||||
"After detailed analysis, no cleanup is needed.": "Po podrobnej analýze nie je potrebné žiadne čistenie.",
|
||||
"After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "Po prihlásení spustite príkaz: ip a, aby ste zistili IP adresu.\nPotom túto IP adresu zadajte do prehliadača takto:\n http://IP_ADRESA\n\nOtvorí sa tým webové rozhranie Umbral OS.",
|
||||
"After logging in, run: ip a to obtain the IP address.\nThen, enter that IP address in your web browser like this:\n http://IP_ADDRESS\n\nThis will open the Umbral OS dashboard.": "Po prihlásení zistite IP adresu príkazom ip a.\nPotom ju zadajte do prehliadača v tomto tvare:\n http://IP_ADDRESS\n\nOtvorí sa panel Umbral OS.",
|
||||
"After pasting on the server, ensure the file is chmod 600 and owned by": "Po vložení na serveri overte, že súbor má chmod 600 a vlastní ho",
|
||||
"After reboot the system will be fully accessible (SSH, web UI, login), but the following components will be reinstalled in BACKGROUND — until they finish, commands like nvidia-smi may not yet be available:": "Po reštarte bude systém plne dostupný (SSH, webové rozhranie, prihlásenie), ale tieto súčasti sa budú doinštalovávať NA POZADÍ - kým neskončia, príkazy ako nvidia-smi ešte nemusia byť dostupné:",
|
||||
"After reboot, these components will reinstall in background:": "Po reštarte sa tieto súčasti doinštalujú na pozadí:",
|
||||
@@ -403,6 +409,7 @@
|
||||
"Another OCI operation is using the instance registry. Wait for it to finish and open this menu again; no container is modified.": "Ďalšia OCI operation používa inštančný register. Počkajte, kým to dokončíte a znovu otvoríte toto menu; žiadna nádoba nie je upravená.",
|
||||
"Another OCI operation is using the instance registry. Wait for it to finish.": "Ďalšia operácia OCI používa register inštancií. Počkajte, kým to skončí.",
|
||||
"Another OCI operation is using the registry. This operation was not started.": "Ďalšia OCI operation používa register. Táto operation sa nezačala.",
|
||||
"Another ProxMenux OCI operation is running. Try again when it finishes.": "Prebieha ďalšia operácia ProxMenux OCI. Skúste to znova, keď to skončí.",
|
||||
"Another ZRAM configuration exists or could not be inspected; systemd-zram-generator was retained": "Existuje iná konfigurácia ZRAM alebo ju nebolo možné skontrolovať; systemd-zram-generator bol zachovaný",
|
||||
"Another ZRAM generator configuration exists; refusing to overwrite it:": "Existuje iná konfigurácia generátora ZRAM; refusing to overwrite it:",
|
||||
"Another ZRAM swap setup exists; refusing to replace it:": "Existuje ďalšie nastavenie výmeny ZRAM; odmietnutie jeho výmeny:",
|
||||
@@ -410,6 +417,7 @@
|
||||
"Another instance uses": "Iný prípad použitia",
|
||||
"Another job that writes to the same storage still starts them.": "Iná úloha, ktorá zapisuje do rovnakého úložiska, ich spúšťa naďalej.",
|
||||
"Another node, typed by hand": "Ďalší uzol, zadaný ručne",
|
||||
"Another registry account operation is running. Try again when it finishes.": "Prebieha iná operácia registrového účtu. Skúste to znova, keď to skončí.",
|
||||
"Another stack operation is pending": "Ďalší zásobník operation čaká",
|
||||
"Application": "Uplatňovanie",
|
||||
"Application registered on this host:": "Aplikácia registrovaná na tomto hostiteľovi:",
|
||||
@@ -421,6 +429,7 @@
|
||||
"Apply": "Použiť",
|
||||
"Apply AMD CPU fixes": "Použiť opravy pre AMD procesory",
|
||||
"Apply Available Updates": "Nainštalovať dostupné aktualizácie",
|
||||
"Apply PBS package upgrade": "Použiť aktualizáciu balíkov PBS",
|
||||
"Apply and restart services:": "Použiť zmeny a reštartovať služby:",
|
||||
"Apply available updates": "Nainštalovať dostupné aktualizácie",
|
||||
"Apply boot/initramfs changes": "Použiť zmeny boot/initramfs",
|
||||
@@ -434,12 +443,15 @@
|
||||
"Apply options from the current catalog template? New required paths and settings may be requested. Review the resulting configuration before recreating the CT.": "Použiť možnosti z aktuálnej šablóny katalógu? Môžu sa vyžadovať nové požadované cesty a nastavenia. Pred opätovným vytvorením CT skontrolujte výslednú konfiguráciu.",
|
||||
"Apply read+write access for 'others' on the host directory?": "Nastaviť pre 'others' čítanie aj zápis na priečinku hosta?",
|
||||
"Apply the options from the current catalog template? Your data and configuration are kept.": "Aplikovať možnosti z aktuálnej katalógovej šablóny? Vaše dáta a konfigurácia sú uchovávané.",
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Použiť teraz skontrolovanú aktualizáciu balíkov? Služby sa môžu reštartovať a balíky označené na odstránenie budú odstránené.",
|
||||
"Apply these changes?": "Použiť tieto zmeny?",
|
||||
"Applying AMD-specific fixes...": "Používam opravy špecifické pre AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Používajú sa zmeny CPU a RAM bez opätovného vytvorenia kontajnera...",
|
||||
"Applying Changes": "Používam zmeny",
|
||||
"Applying Controller/NVMe passthrough to VM": "Používam priame priradenie Controller/NVMe do VM",
|
||||
"Applying balanced memory optimization settings...": "Používam vyvážené nastavenia optimalizácie pamäte...",
|
||||
"Applying changes safely...": "Bezpečne používam zmeny...",
|
||||
"Applying container settings without rebuilding the container...": "Aplikujú sa nastavenia bez opätovného vytvorenia kontajnera...",
|
||||
"Applying custom restore": "Používam vlastnú obnovu",
|
||||
"Applying default VM configuration": "Používam predvolené nastavenie VM",
|
||||
"Applying host permissions for unprivileged LXC bind-mounts...": "Nastavujem oprávnenia hosta pre LXC bind mounty v neprivilegovaných kontajneroch...",
|
||||
@@ -502,6 +514,7 @@
|
||||
"Authentication": "Prihlásenie",
|
||||
"Authentication & Security": "Overovanie a zabezpečenie",
|
||||
"Authentication Error": "Chyba prihlásenia",
|
||||
"Authentication failed for {registry}. Check the account and token permissions.": "Autentifikácia pre {registry} zlyhala. Skontrolujte oprávnenia účtu a tokenu.",
|
||||
"Authentication failed.": "Prihlásenie zlyhalo.",
|
||||
"Authentication required:": "Vyžaduje prihlásenie:",
|
||||
"Authentication:": "Prihlásenie:",
|
||||
@@ -530,6 +543,7 @@
|
||||
"Available Borg targets:": "Dostupné Borg ciele:",
|
||||
"Available Disks on Host": "Dostupné disky na hostovi",
|
||||
"Available ISO Images": "Dostupné ISO obrazy",
|
||||
"Available PBS Version": "Dostupná verzia PBS",
|
||||
"Available PBS repositories:": "Dostupné PBS repozitáre:",
|
||||
"Available PVE Version": "Dostupná verzia PVE",
|
||||
"Available Shares:": "Dostupné zdieľania:",
|
||||
@@ -739,7 +753,9 @@
|
||||
"Cannot resolve the container home directory:": "Nedá sa vyriešiť domovský adresár kontajnera:",
|
||||
"Cannot validate credentials - no shares available for testing.": "Prihlasovacie údaje sa nedajú overiť - nie sú dostupné žiadne zdieľania na test.",
|
||||
"Cannot verify the reused disk:": "Nie je možné overiť znovu použitý disk:",
|
||||
"Cannot write the image archive to {storage}. Check storage access and mount status.": "Nie je možné zapísať archív obrázkov do {storage}. Skontrolujte prístup k úložisku a stav pripojenia.",
|
||||
"Capabilities cannot be kept and all dropped at the same time": "Nie je možné udržať si schopnosti a zároveň ich znížiť.",
|
||||
"Catalog options": "Možnosti katalógu",
|
||||
"Category": "Kategória",
|
||||
"Caution: Maximum mode generates more heat.": "Pozor: maximálny režim vytvára viac tepla.",
|
||||
"Ceph check skipped by user flag (--ignore-ceph-check)": "Kontrola Ceph preskočená používateľskou voľbou (--ignore-ceph-check)",
|
||||
@@ -799,6 +815,7 @@
|
||||
"Check Samba Status": "Skontrolovať stav Samby",
|
||||
"Check VM disk/PCI slots": "Skontrolovať diskové/PCI sloty VM",
|
||||
"Check VM passthrough settings": "Skontrolovať nastavenie VM passthrough",
|
||||
"Check access to an image": "Kontrola prístupu k obrázku",
|
||||
"Check connectivity with another host": "Skontrolovať spojenie s iným hostom",
|
||||
"Check container mount points": "Skontrolovať body pripojenia kontajnera",
|
||||
"Check if container is unprivileged:": "Skontrolovať, či je kontajner neprivilegovaný:",
|
||||
@@ -811,6 +828,7 @@
|
||||
"Check that:": "Skontrolujte, že:",
|
||||
"Check the error messages above for details.": "Podrobnosti nájdete v chybových hláškach vyššie.",
|
||||
"Check the image reference and registry access. If the registry is unavailable, try again later. A private registry needs credentials, which are not supported yet.": "Skontrolujte odkaz na obraz a prístup k registru. Ak register nie je dostupný, skúste to neskôr. Súkromný register vyžaduje prihlasovacie údaje, ktoré zatiaľ nie sú podporované.",
|
||||
"Check the image reference and registry access. If the registry is unavailable, try again later. For a private image, sign in from Registry accounts in the OCI menu.": "Skontrolujte odkaz na obrázok a prístup do registra. Ak register nie je dostupný, skúste to znova neskôr. Pre súkromný obrázok sa prihláste z účtov Registry v ponuke OCI.",
|
||||
"Check the service status manually if needed.": "V prípade potreby skontrolujte stav služieb ručne.",
|
||||
"Checking MOTD configuration...": "Kontrolujem nastavenie MOTD...",
|
||||
"Checking NVIDIA driver status with nvidia-smi": "Kontrolujem stav ovládača NVIDIA pomocou nvidia-smi",
|
||||
@@ -824,6 +842,7 @@
|
||||
"Checking free space in /var/cache/apt/archives...": "Kontrolujem voľné miesto v /var/cache/apt/archives...",
|
||||
"Checking if system disk is SSD or M.2...": "Kontrolujem, či je systémový disk SSD alebo M.2...",
|
||||
"Checking if the server belongs to OVH...": "Kontrolujem, či server patrí pod OVH...",
|
||||
"Checking image access and metadata. Please wait...": "Kontrola prístupu k obrázku a metadát. Čakajte prosím...",
|
||||
"Checking kernel headers and build tools...": "Kontrolujem kernel headers a nástroje na zostavenie...",
|
||||
"Checking remaining interfaces": "Kontrolujem zostávajúce rozhrania",
|
||||
"Checking that the container keeps running...": "Kontrola, či kontajner beží...",
|
||||
@@ -871,6 +890,7 @@
|
||||
"Choose a schedule (systemd calendar-event syntax):": "Vyberte plán (syntax systémového kalendára a udalosti):",
|
||||
"Choose a script to execute:": "Vyberte skript, ktorý sa má spustiť:",
|
||||
"Choose a server:": "Vyberte server:",
|
||||
"Choose a setting. Changes are applied only after the final review.": "Vyberte nastavenie. Zmeny sa použijú až po záverečnej kontrole.",
|
||||
"Choose a share to mount:": "Vyberte zdieľanie na pripojenie:",
|
||||
"Choose a specific supported GPU or USB node": "Vyberte konkrétny podporovaný uzol GPU alebo USB",
|
||||
"Choose action for this controller/NVMe:": "Vyberte akciu pre tento Controller/NVMe:",
|
||||
@@ -897,6 +917,7 @@
|
||||
"Choose iperf3 mode:": "Vyberte režim iperf3:",
|
||||
"Choose options to configure:": "Vyberte možnosti, ktoré chcete nastaviť:",
|
||||
"Choose specific VMs/CTs": "Vyberte konkrétne VM/CT",
|
||||
"Choose storage for the downloaded image.": "Vyberte úložisko pre stiahnutý obrázok.",
|
||||
"Choose the driver version for Coral M.2:": "Vyberte verziu ovládača pre Coral M.2:",
|
||||
"Choose the filesystem for the new partition:": "Vyberte súborový systém pre nový oddiel:",
|
||||
"Choose the release channel to use:": "Vyberte vetvu vydania, ktorú chcete používať:",
|
||||
@@ -950,6 +971,7 @@
|
||||
"Code-server is VS Code running on a remote server, accessible through the browser.": "Kód server je VS kód beží na vzdialenom serveri, prístupný cez prehliadač.",
|
||||
"CodeProject.AI Server": "CodeProject. AI Server",
|
||||
"Command": "Príkaz",
|
||||
"Command failed": "Príkaz zlyhal",
|
||||
"Command override for an unknown service:": "Prepísať príkaz pre neznámu službu:",
|
||||
"Commenting any residual Bookworm lines in *.list...": "Komentujem zostávajúce riadky Bookworm v *.list...",
|
||||
"Commenting legacy PVE 8 repository .list files (if any)...": "Komentujem staré .list súbory repozitárov PVE 8 (ak existujú)...",
|
||||
@@ -1364,17 +1386,20 @@
|
||||
"Could not publish pending restore. Previous pending restore was kept.": "Nepodarilo sa zverejniť čakajúce obnovenie. Predchádzajúce čakajúce obnovenie bolo zachované.",
|
||||
"Could not push the key. Check the password and that": "Kľúč sa nepodarilo odoslať. Skontrolujte heslo a to, že",
|
||||
"Could not query the image registry": "Nepodarilo sa nájsť obrazový register",
|
||||
"Could not reach {registry}. Check its address, DNS and network access.": "Nepodarilo sa dosiahnuť {registry}. Skontrolujte jeho adresu, DNS a prístup k sieti.",
|
||||
"Could not read SMART data from": "SMART dáta sa nepodarilo prečítať z",
|
||||
"Could not read VM configuration.": "Nastavenie VM sa nepodarilo prečítať.",
|
||||
"Could not read the CUDA compute capability": "Nepodarilo sa prečítať výpočtovú schopnosť CUDA",
|
||||
"Could not read the NVIDIA driver version": "Nepodarilo sa prečítať verziu ovládača NVIDIA",
|
||||
"Could not read the host firewall rules": "Pravidlá firewallu hostiteľa sa nepodarilo načítať",
|
||||
"Could not read the image from {registry}. Check the reference and sign in from Registry accounts.": "Nepodarilo sa prečítať obrázok z {registry}. Skontrolujte referenciu a prihláste sa z účtov Registra.",
|
||||
"Could not read this job.": "Túto úlohu sa nepodarilo prečítať.",
|
||||
"Could not release ZRAM pages; existing profile remains active": "Nepodarilo sa uvoľniť stránky ZRAM; existujúci profil zostáva aktívny",
|
||||
"Could not remount automatically. Try manually or check credentials.": "Automatické znovupripojenie zlyhalo. Skúste to ručne alebo skontrolujte prihlasovacie údaje.",
|
||||
"Could not remove VM automatically. Run manually:": "VM sa nepodarilo odstrániť automaticky. Spustite ručne:",
|
||||
"Could not remove ZRAM profile; configuration was restored": "Nepodarilo sa odstrániť profil ZRAM; konfigurácia bola obnovená",
|
||||
"Could not remove ZRAM profile; configuration was restored but swap could not be restarted": "Nepodarilo sa odstrániť profil ZRAM; konfigurácia bola obnovená, ale swap nebolo možné reštartovať",
|
||||
"Could not remove credentials for {registry}; saved file credentials were not changed.": "Nepodarilo sa odstrániť poverenia pre {registry}; poverenia uloženého súboru neboli zmenené.",
|
||||
"Could not remove previous DKMS tree at": "Nepodarilo sa odstrániť predchádzajúci DKMS strom v",
|
||||
"Could not reserve a private network for the stack": "Nepodarilo sa rezervovať súkromnú sieť pre stack",
|
||||
"Could not resolve the Compose user:": "Nepodarilo sa vyriešiť používateľa Compose:",
|
||||
@@ -1389,6 +1414,7 @@
|
||||
"Could not set VM virtual display to vga: std": "Virtuálne zobrazenie VM sa nepodarilo nastaviť na vga: std",
|
||||
"Could not set boot order for": "Poradie bootovania sa nepodarilo nastaviť pre",
|
||||
"Could not set the container entrypoint": "Nepodarilo sa nastaviť vstupný bod kontajnera",
|
||||
"Could not sign in to {registry}. Check registry access and token permissions.": "Nepodarilo sa prihlásiť do {registry}. Skontrolujte prístup k registrom a povolenia tokenov.",
|
||||
"Could not stage pending restore path:": "Nepodarilo sa pripraviť cestu obnovenia:",
|
||||
"Could not stage pending restore. Nothing new was scheduled.": "Nepodarilo sa pripraviť čakajúce obnovenie. Nič nové nebolo naplánované.",
|
||||
"Could not stop LXC": "LXC sa nepodarilo zastaviť",
|
||||
@@ -1475,11 +1501,15 @@
|
||||
"Creating the private network...": "Vytváranie súkromnej siete...",
|
||||
"Creating the temporary data container": "Vytvorenie dočasného dátového kontajnera",
|
||||
"Creative & Design": "Kreativita a dizajn",
|
||||
"Credential helper": "Pomocník poverení",
|
||||
"Credential storage and access": "Úložisko poverení a prístup",
|
||||
"Credentials are correct": "Prihlasovacie údaje sú správne",
|
||||
"Credentials cleared. jwt_secret and API tokens preserved.": "Prihlasovacie údaje sú vymazané. jwt_secret a API tokeny zostali zachované.",
|
||||
"Credentials file created securely.": "Súbor s prihlasovacími údajmi bol bezpečne vytvorený.",
|
||||
"Credentials file removed.": "Súbor s prihlasovacími údajmi bol odstránený.",
|
||||
"Credentials file:": "Súbor s prihlasovacími údajmi:",
|
||||
"Credentials file:\n{path}\n\nOwned by root; directory mode 700 and file mode 600.\nBasic credentials are base64 encoded, not encrypted.\nCredential helpers use their own storage.\n\nUse an access token with permission to pull images.\nConfigure credentials on each node that downloads images.": "Súbor poverení:\n{path}\n\nVo vlastníctve root; režim adresárov 700 a režim súborov 600.\nZákladné poverenia sú zakódované pomocou base64, nie sú zašifrované.\nPomocníci poverení využívajú svoje vlastné úložisko.\n\nPoužite prístupový token s povolením na stiahnutie obrázkov.\nNakonfigurujte poverenia na každom uzle, ktorý sťahuje obrázky.",
|
||||
"Credentials file:\n{path}\n\nOwned by root; directory mode 700 and file mode 600.\nBasic credentials are base64 encoded, not encrypted.\nCredential helpers use their own storage.\n\nUse an access token with permission to pull images.\nEvery node of the cluster uses the accounts saved here.": "Súbor poverení:\n{path}\n\nVo vlastníctve root; režim adresárov 700 a režim súborov 600.\nZákladné poverenia sú zakódované pomocou base64, nie sú zašifrované.\nPomocníci poverení využívajú svoje vlastné úložisko.\n\nPoužite prístupový token s povolením na stiahnutie obrázkov.\nKaždý uzol klastra používa tu uložené účty.",
|
||||
"Credentials validated successfully": "Prihlasovacie údaje boli úspešne overené",
|
||||
"Cross-host restore: guest IDs in backup overlap live IDs on target:": "Obnova medzi hostami: ID hostí zo zálohy sa prekrývajú so živými ID na cieli:",
|
||||
"Cross-kernel restore — kernel-tied paths merged, not copied": "Obnova medzi kernelmi - cesty viazané na kernel boli zlúčené, nie skopírované",
|
||||
@@ -1498,6 +1528,7 @@
|
||||
"Current NVIDIA inventory resolved: a refresh is required": "Aktuálne NVIDIA inventár vyriešený: obnovovanie je required",
|
||||
"Current NVIDIA inventory resolved: no refresh is required": "Aktuálne NVIDIA inventár vyriešený: žiadne obnovenie je required",
|
||||
"Current Network Configuration": "Aktuálne nastavenie siete",
|
||||
"Current PBS Version": "Aktuálna verzia PBS",
|
||||
"Current PVE Version": "Aktuálna verzia PVE",
|
||||
"Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Aktuálne host skripty ProxMenux registrujú vzdialené zdieľania ako úložiská Proxmoxu pomocou pvesm.",
|
||||
"Current ProxMenux version:": "Aktuálna verzia ProxMenux:",
|
||||
@@ -1617,6 +1648,7 @@
|
||||
"Default login": "Štandardné prihlásenie",
|
||||
"Default options": "Predvolené možnosti",
|
||||
"Default options read/write": "Predvolené možnosti čítanie/zápis",
|
||||
"Default settings": "Predvolené nastavenia",
|
||||
"Default will be used:": "Použije sa predvolené:",
|
||||
"Default:": "Predvolené:",
|
||||
"Default: only what the application needs": "Štandardne: len to, čo aplikácia potrebuje",
|
||||
@@ -1662,6 +1694,7 @@
|
||||
"Detected Coral hardware:": "Nájdený Coral hardvér:",
|
||||
"Detected GPU(s):": "Nájdené GPU:",
|
||||
"Detected Intel CPU": "Zistený Intel procesor",
|
||||
"Detected Proxmox Backup Server": "Zistený Proxmox Backup Server",
|
||||
"Detected Proxmox VE": "Zistený Proxmox VE",
|
||||
"Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Zistený Proxmox VE 8.x - inštalujem Ceph Squid pre Debian",
|
||||
"Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Zistený Proxmox VE 9.x - inštalujem Ceph Squid pre Debian Trixie",
|
||||
@@ -1743,6 +1776,9 @@
|
||||
"Disabling and stopping rpcbind service and socket...": "Zakázanie a zastavenie služby rpcbind a soketu...",
|
||||
"Disabling non-free firmware warnings...": "Vypínam upozornenia na non-free firmware...",
|
||||
"Disabling portmapper/rpcbind for security...": "Z bezpečnostných dôvodov vypínam portmapper/rpcbind...",
|
||||
"Discard changes": "Zahodiť zmeny",
|
||||
"Discard unapplied changes?": "Zahodiť nepoužité zmeny?",
|
||||
"Discover NFS exports on a server": "Zistiť NFS exporty servera",
|
||||
"Discover targets on portal:": "Vyhľadať targety na portáli:",
|
||||
"Discovering iSCSI targets...": "Vyhľadávam iSCSI targety...",
|
||||
"Discovery successful": "Vyhľadanie bolo úspešné",
|
||||
@@ -1822,6 +1858,7 @@
|
||||
"Do you want to proceed with the system update?": "Chcete pokračovať v aktualizácii systému?",
|
||||
"Do you want to proceed?": "Chcete pokračovať?",
|
||||
"Do you want to reboot now?": "Chcete reštartovať teraz?",
|
||||
"Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "Chcete odstrániť banner predplatného Proxmox z webového rozhrania Proxmox Backup Server?",
|
||||
"Do you want to remove the stale entry from vfio.conf and continue?": "Chcete odstrániť zastaraný záznam z vfio.conf a pokračovať?",
|
||||
"Do you want to remove the stale entry from vfio.conf?": "Chcete odstrániť zastaraný záznam z vfio.conf?",
|
||||
"Do you want to restart the network service now to apply changes?": "Chcete teraz reštartovať sieťovú službu, aby sa zmeny prejavili?",
|
||||
@@ -1851,6 +1888,7 @@
|
||||
"Doplarr_rs is a Discord bot for requesting media through *arr backends, written in Rust.": "Doplarr_rs je robot Discord pre žiadanie médií cez arr backends, napísané v Rust.",
|
||||
"Double Commander is a free cross platform open source file manager with two panels side by side. It is inspired by Total Commander and features some new ideas.": "Double Commander je free cross platform open source správca súborov s dvoma panelmi vedľa seba. Je inšpirovaný Total Commander a obsahuje niektoré nové nápady.",
|
||||
"Download Spotify music with album art and metadata": "Stiahnuť Spotify hudbu s albumom a metadátami",
|
||||
"Download an image to template storage": "Stiahnite si obrázok do úložiska šablón",
|
||||
"Download an oci-compose from an address": "Stiahnuť oci-compose z adresy",
|
||||
"Download failed for all attempted URLs": "Sťahovanie zlyhalo zo všetkých vyskúšaných adries",
|
||||
"Download its Compose file from an address": "Stiahnuť súbor Compose z URL adresy",
|
||||
@@ -1866,6 +1904,7 @@
|
||||
"Downloading Proxmology logo...": "Sťahujem logo Proxmology...",
|
||||
"Downloading UUP converter...": "Sťahujem UUP konvertor...",
|
||||
"Downloading amdgpu_top": "Sťahujem amdgpu_top",
|
||||
"Downloading and verifying the image.\nThis can take a few minutes. Please wait...": "Stiahnutie a overenie obrázka.\nMôže to trvať niekoľko minút. Čakajte prosím...",
|
||||
"Downloading official installer...": "Sťahujem oficiálny inštalátor...",
|
||||
"Downloading pre-existing encrypted backups from this host will fail unless you kept a copy of the current key.": "Stiahnutie už existujúcich šifrovaných záloh z tohto hosta zlyhá, ak ste si nenechali kópiu aktuálneho kľúča.",
|
||||
"Downloading the image:": "Sťahovanie obrázku:",
|
||||
@@ -1999,9 +2038,11 @@
|
||||
"Enter ZFS pool name for the selected disk:": "Zadajte názov ZFS poolu pre vybraný disk:",
|
||||
"Enter a name for the mount point (used as /mnt/<name>):": "Zadajte názov mount pointu (použije sa ako /mnt/<name>):",
|
||||
"Enter a name for the new virtual machine:": "Zadajte názov novej virtuálnej mašiny:",
|
||||
"Enter a non-empty password or access token on a single line.": "Zadajte neprázdne heslo alebo prístupový token na jeden riadok.",
|
||||
"Enter a number, or write or paste a command:": "Zadajte číslo alebo napíšte či vložte príkaz:",
|
||||
"Enter a systemd calendar event, e.g.:": "Zadajte udalosť systémového kalendára, napr.:",
|
||||
"Enter a usable IPv4 address with its prefix, for example": "Zadajte použiteľnú IPv4 adresu s predponou, napríklad",
|
||||
"Enter a valid registry username.": "Zadajte platné používateľské meno registra.",
|
||||
"Enter backup file (.zst):": "Zadajte súbor zálohy (.zst):",
|
||||
"Enter backup path (.tar.zst):": "Zadajte cestu k zálohe (.tar.zst):",
|
||||
"Enter backup path (.vma.zst):": "Zadajte cestu k zálohe (.vma.zst):",
|
||||
@@ -2243,6 +2284,7 @@
|
||||
"Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "Repozitár log2ram sa nepodarilo stiahnuť. Skontrolujte /tmp/log2ram_install.log",
|
||||
"Failed to configure EFI disk": "EFI disk sa nepodarilo nastaviť",
|
||||
"Failed to configure IOMMU automatically.": "IOMMU sa nepodarilo nastaviť automaticky.",
|
||||
"Failed to configure Proxmox Backup Server repositories": "Repozitáre Proxmox Backup Server sa nepodarilo nastaviť",
|
||||
"Failed to configure Proxmox repositories": "Repozitáre Proxmoxu sa nepodarilo nastaviť",
|
||||
"Failed to configure TPM device in VM": "TPM zariadenie sa vo VM nepodarilo nastaviť",
|
||||
"Failed to configure repositories.": "Repozitáre sa nepodarilo nastaviť.",
|
||||
@@ -2287,12 +2329,12 @@
|
||||
"Failed to download Proxmology logo": "Logo Proxmology sa nepodarilo stiahnuť",
|
||||
"Failed to download VirtIO driver ISO.": "VirtIO ISO s ovládačmi sa nepodarilo stiahnuť.",
|
||||
"Failed to download amdgpu_top": "amdgpu_top sa nepodarilo stiahnuť",
|
||||
"Failed to download loader image.": "Nepodarilo sa stiahnuť obrázok načítania.",
|
||||
"Failed to download loader image.": "Nepodarilo sa stiahnuť obraz zavádzača.",
|
||||
"Failed to edit /etc/fstab — remove the line manually.": "Nepodarilo sa upraviť /etc/fstab. Riadok odstráňte ručne.",
|
||||
"Failed to enable ZFS autotrim for pool:": "ZFS autotrim sa nepodarilo zapnúť pre pool:",
|
||||
"Failed to enable automatic time synchronization": "Automatickú synchronizáciu času sa nepodarilo zapnúť",
|
||||
"Failed to extract OVA file:": "OVA súbor sa nepodarilo rozbaliť:",
|
||||
"Failed to extract a nonempty loader image.": "Nepodarilo sa extrahovať neprázdny obrázok načítania.",
|
||||
"Failed to extract a nonempty loader image.": "Nepodarilo sa extrahovať neprázdny obraz zavádzača.",
|
||||
"Failed to extract package": "Balík sa nepodarilo rozbaliť",
|
||||
"Failed to fetch the Google Coral GPG key. Check": "Nepodarilo sa stiahnuť Google Coral GPG kľúč. Skontrolujte",
|
||||
"Failed to format disk as": "Disk sa nepodarilo naformátovať ako",
|
||||
@@ -2349,7 +2391,7 @@
|
||||
"Failed to retrieve Fastfetch download URL.": "Adresu na stiahnutie Fastfetch sa nepodarilo získať.",
|
||||
"Failed to retrieve loader release metadata.": "Nepodarilo sa načítať metadáta vydania zavádzača.",
|
||||
"Failed to run log2ram installer. Check /tmp/log2ram_install.log": "Inštalátor log2ram sa nepodarilo spustiť. Skontrolujte /tmp/log2ram_install.log",
|
||||
"Failed to save loader image.": "Nepodarilo sa uložiť obrázok načítania.",
|
||||
"Failed to save loader image.": "Nepodarilo sa uložiť obraz zavádzača.",
|
||||
"Failed to set VM description": "Popis VM sa nepodarilo nastaviť",
|
||||
"Failed to set timezone - keeping current settings": "Časové pásmo sa nepodarilo nastaviť - ponechávam aktuálne nastavenie",
|
||||
"Failed to start CT": "CT sa nepodarilo spustiť",
|
||||
@@ -2645,7 +2687,7 @@
|
||||
"HAOS One is a community image that runs Docker inside the container. The LXC stays unprivileged, but the inner AppArmor profiles may not be available. The first start downloads Home Assistant Core and its add-ons. If the check fails, the CT and /mnt/data are kept for diagnosis.": "HAOS One je obraz spoločenstva, ktorý prevádzkuje Docker vnútri kontajnera. LXC zostáva neprivilegovaný, ale vnútorné profily AppArmor nemusí byť k dispozícii. Prvý štart stiahne Home Assistant jadro a jeho doplnky. Ak kontrola zlyhá, CT a / mnt/ dáta sa uchovávajú na určenie diagnózy.",
|
||||
"HAOS One profile declined": "Profil HAOS One klesol",
|
||||
"HAOS One requires an unprivileged unmanaged LXC with nesting and keyctl, 2 cores, 2048 MB RAM, rootfs of at least 12 GB and /mnt/data of at least 16 GB on a container volume included in backups": "HAOS One requires an unprivileged unmanaged LXC with nestering and keyctl, 2 cores, 2048 MB RAM, rootfs najmenej 12 GB a /mnt/data najmenej 16 GB on a container volume included in backions",
|
||||
"HTML5 Network Speed Test Server.": "HTML5 Network Speed Test Server.",
|
||||
"HTML5 Network Speed Test Server.": "HTML5 Network Speed Test Server.",
|
||||
"HTTP service check without a saved URL": "Kontrola služby HTTP bez uloženého URL",
|
||||
"Habridge emulates Philips Hue API to other home automation gateways such as an Amazon Echo/Dot Gen 1 (gen 2 has issues discovering ha-bridge) or other systems that support Philips Hue. The Bridge handles basic commands such as On, Off and brightness commands of the hue protocol. This bridge can control most devices that have a distinct API.": "Habridge napodobňuje Philipsa Spoločnosť Amazon tvrdí, že spoločnosť LuxOpCo by mala mať prístup k webovým stránkam spoločnosti LuxOpCo. Most riadi základné príkazy, ako je On, Off a jas príkazy protokolu odtieňov. Tento most môže ovládať väčšinu zariadení, ktoré majú odlišné API.",
|
||||
"HandBrake is an open-source tool, built by volunteers, for converting video from nearly any format to a selection of modern, widely supported codecs.": "HandBrake je open-source nástroj, postavený dobrovoľníkmi, pre konverziu video z takmer akéhokoľvek formátu na výber moderných, široko podporovaných kodekov.",
|
||||
@@ -2676,6 +2718,7 @@
|
||||
"Help and Info": "Pomoc a informácie",
|
||||
"Help and Info Commands": "Pomocné a informačné príkazy",
|
||||
"Helper-Scripts logo applied": "Logo Helper-Scripts bolo použité",
|
||||
"Helper:": "Pomocník:",
|
||||
"Hermes WebUI": "Hermes WebUI",
|
||||
"Hidden for safety": "Skryté kvôli bezpečnosti",
|
||||
"Hidden:": "Skryté:",
|
||||
@@ -2890,16 +2933,20 @@
|
||||
"If your DHCP has a static reservation for the old MAC, update it.": "Ak máte v DHCP statickú rezerváciu pre starú MAC adresu, aktualizujte ju.",
|
||||
"Image": "Image",
|
||||
"Image Source Directory": "Zdrojový priečinok obrazov",
|
||||
"Image access confirmed.\n\n{reference}\n\nArchitecture: {architecture}\nVersion: {version}\nDownload size: {size}\nDigest: {digest}": "Prístup k obrázku bol potvrdený.\n\n{reference}\n\nArchitektúra: {architecture}\nVerzia: {version}\nVeľkosť na stiahnutie: {size}\nZhrnutie: {digest}",
|
||||
"Image cache": "Comment",
|
||||
"Image compatibility restored:": "Kompatibilita s obrázkom obnovená:",
|
||||
"Image compatibility verified:": "Kompatibilita s obrázkom overená:",
|
||||
"Image directory:": "Priečinok s obrazmi:",
|
||||
"Image download failed:": "Sťahovanie obrázku zlyhalo:",
|
||||
"Image downloaded": "Stiahnutý obrázok",
|
||||
"Image downloaded and verified.\n\n{reference}\n\nStorage: {storage}\nArchive size: {size}\nArchive: {archive}\n\nNo container was created.\nProxMenux removes unused cached images after successful updates.": "Obrázok bol stiahnutý a overený.\n\n{reference}\n\nSkladovanie: {storage}\nVeľkosť archívu: {size}\nArchív: {archive}\n\nNebol vytvorený žiadny kontajner.\nProxMenux po úspešných aktualizáciách odstráni nepoužívané obrázky z vyrovnávacej pamäte.",
|
||||
"Image file not found:": "Súbor obrazu sa nenašiel:",
|
||||
"Image imported:": "Obraz importovaný:",
|
||||
"Image integrity verification failed. The partial download was discarded; try again.": "Overenie integrity obrázka zlyhalo. Čiastočné stiahnutie bolo zahodené; skúste to znova.",
|
||||
"Image integrity verified": "Potvrdená integrita obrázku",
|
||||
"Image not allowed for the host monitor profile": "Obrázok nie je povolený pre profil monitora hostiteľa",
|
||||
"Image reference": "Odkaz na obrázok",
|
||||
"Image reference (for example ghcr.io/user/application:latest)": "Odkaz na image (napríklad ghcr.io/user/aplikácia:latest)",
|
||||
"Image selection was cancelled": "Výber image bol zrušený",
|
||||
"Image that is not in the catalog": "Image, ktorý nie je v katalógu",
|
||||
@@ -2945,6 +2992,7 @@
|
||||
"Inaccessible device during VM startup": "nedostupné zariadenie počas štartu VM",
|
||||
"Inactive": "Neaktívne",
|
||||
"Inactive (entry in fstab, not currently mounted)": "Neaktívne (záznam je vo fstab, ale teraz nie je pripojený)",
|
||||
"Include these changes in oci-compose?": "Zahrnúť tieto zmeny do oci-compose?",
|
||||
"Include these resources in oci-compose?": "Zahrnúť tieto zdroje do oci-compose?",
|
||||
"Includes /etc/network (may drop SSH immediately)": "Obsahuje /etc/network (môže okamžite prerušiť SSH)",
|
||||
"Includes /etc/zfs": "Obsahuje /etc/zfs",
|
||||
@@ -3001,7 +3049,7 @@
|
||||
"Initiator IQN is authorised on the target": "IQN iniciátora je na targete povolené",
|
||||
"Initiator IQN:": "IQN iniciátora:",
|
||||
"Inkscape is professional quality vector graphics software which runs on Linux, Mac OS X and Windows desktop computers.": "Inkscape je profesionálne kvalitný vektorový grafický softvér, ktorý beží na počítačoch Linux, Mac OS X a Windows.",
|
||||
"Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "Vnútri LXC, vytvoriť administrátor: konzola kimai:používateľ:vytvorte VAŠE VYUŽITE VAŠE EMAIL ROLE_SUPER_ADMIN",
|
||||
"Inside the LXC, create the administrator: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN": "V LXC vytvorte správcu príkazom: console kimai:user:create YOUR_USERNAME YOUR_EMAIL ROLE_SUPER_ADMIN",
|
||||
"Inspect disks before any action": "Skontrolovať disky pred akoukoľvek akciou",
|
||||
"Inspect host device nodes": "Skontrolovať device nodes na hostovi",
|
||||
"Inspect passthrough/kernel events": "Skontrolovať passthrough/kernel udalosti",
|
||||
@@ -3027,6 +3075,7 @@
|
||||
"Install Samba inside CT:": "Nainštalovať Sambu vo vnútri CT:",
|
||||
"Install ZFS auto-snapshot": "Nainštalovať ZFS auto-snapshot",
|
||||
"Install a version from the branch the kernel names.": "Nainštalujte verziu z vetvy s názvami jadra.",
|
||||
"Install an image as a native LXC": "Nainštalujte obrázok ako natívny LXC",
|
||||
"Install an image that is not in the catalog": "Inštalovať obraz, ktorý nie je v katalógu",
|
||||
"Install analysis tools": "Nainštalovať analytické nástroje",
|
||||
"Install and configure": "Nainštalovať a nastaviť",
|
||||
@@ -3066,6 +3115,7 @@
|
||||
"Installation finished but drivers are not loaded. A reboot may be required.": "Inštalácia sa dokončila, ale ovládače nie sú načítané. Možno bude potrebný reštart.",
|
||||
"Installation incomplete. These containers and their data are kept:": "Inštalácia neúplná. Tieto nádoby a ich údaje sa uchovávajú:",
|
||||
"Installation log:": "Záznam inštalácie:",
|
||||
"Installation settings": "Nastavenia inštalácie",
|
||||
"Installation summary": "Zhrnutie inštalácie",
|
||||
"Installed": "Nainštalované",
|
||||
"Installed at:": "Nainštalované v:",
|
||||
@@ -3176,9 +3226,11 @@
|
||||
"Interrupted operation:": "Prerušená operation:",
|
||||
"Interrupted stack operation found": "Prerušený zásobník operation nájdený",
|
||||
"Invalid 'proxmox-ve' candidate (not 9.x or none). Please verify your repository configuration and network, then retry.": "Neplatný kandidát 'proxmox-ve' (nie je 9.x alebo chýba). Skontrolujte nastavenie repozitárov a sieť, potom skúste znova.",
|
||||
"Invalid ALLOWED_HOSTS value": "Neplatná hodnota UDELENÁ HOSTS",
|
||||
"Invalid ALLOWED_HOSTS value": "Neplatná hodnota ALLOWED_HOSTS",
|
||||
"Invalid Home Assistant OS check timeout": "Neplatný čas odhlásenia Home Assistant OS",
|
||||
"Invalid ID": "Neplatné ID",
|
||||
"Invalid IPv4 address": "Neplatná adresa IPv4",
|
||||
"Invalid IPv4 gateway": "Neplatná brána IPv4",
|
||||
"Invalid Intel render path": "Neplatná cesta k zobrazeniu informácií",
|
||||
"Invalid Jellyfin path:": "Neplatná cesta Jellyfin:",
|
||||
"Invalid MAC address:": "Neplatná adresa MAC:",
|
||||
@@ -3211,6 +3263,7 @@
|
||||
"Invalid administrator email": "Neplatný administrátorský mail",
|
||||
"Invalid administrator user name": "Neplatné meno správcu",
|
||||
"Invalid base VMID": "Neplatná základňa VMID",
|
||||
"Invalid bridge": "Neplatný sieťový most",
|
||||
"Invalid check package:": "Neplatný kontrolný balík:",
|
||||
"Invalid configuration path:": "Neplatná konfiguračná dráha:",
|
||||
"Invalid consume/export volumes": "Neplatné objemy spotreby/vývozu",
|
||||
@@ -3239,6 +3292,7 @@
|
||||
"Invalid host path:": "Neplatná cesta k hostiteľovi:",
|
||||
"Invalid host-monitor firewall plan": "Neplatný plán firewallu pre monitor hostiteľa",
|
||||
"Invalid image probe descriptor": "Neplatný deskriptor obrazovej sondy",
|
||||
"Invalid image tag.": "Neplatná značka obrázka.",
|
||||
"Invalid input": "Neplatný vstup",
|
||||
"Invalid internal volume": "Neplatný vnútorný objem",
|
||||
"Invalid list:": "Neplatný zoznam:",
|
||||
@@ -3273,6 +3327,7 @@
|
||||
"Invalid remote name": "Neplatné vzdialené meno",
|
||||
"Invalid remote path": "Neplatná vzdialená cesta",
|
||||
"Invalid repair version:": "Neplatná verzia opravy:",
|
||||
"Invalid resource value": "Neplatná hodnota prostriedkov",
|
||||
"Invalid resources": "Neplatné zdroje",
|
||||
"Invalid restored volume path": "Neplatná cesta obnoveného objemu",
|
||||
"Invalid running state": "Neplatný jazdný stav",
|
||||
@@ -3291,11 +3346,13 @@
|
||||
"Invalid stack members": "Neplatní členovia stohu",
|
||||
"Invalid stack name": "Neplatný názov stohu",
|
||||
"Invalid stack operation": "Neplatný zásobník operation",
|
||||
"Invalid storage": "Neplatné úložisko",
|
||||
"Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "Neplatné ID úložiska. Použite iba písmená, čísla, pomlčky a podčiarkovníky.",
|
||||
"Invalid storage name": "Neplatný názov úložiska",
|
||||
"Invalid suite application": "Neplatná aplikácia",
|
||||
"Invalid sysctl value:": "Neplatná sysctl hodnota:",
|
||||
"Invalid template storage": "Neplatná pamäť šablóny",
|
||||
"Invalid template storage name.": "Neplatný názov úložiska šablóny.",
|
||||
"Invalid tmpfs options:": "Neplatné možnosti tmpfs:",
|
||||
"Invalid tmpfs path:": "Neplatná cesta tmpfs:",
|
||||
"Invalid tmpfs size:": "Neplatná veľkosť tmpfs:",
|
||||
@@ -3404,7 +3461,7 @@
|
||||
"Keyfile removed.": "Keyfile odstránený.",
|
||||
"Keyrings method failed; trying apt-key fallback": "Metóda keyrings zlyhala; skúšam náhradný apt-key postup",
|
||||
"KiCad - A Cross Platform and Open Source Electronics Design Automation Suite.": "KiCad - Cross Platform a Open Source Electronics Design Automation Suite.",
|
||||
"Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai nemá predvolený účet. Zadajte kontajner s: pct zadajte {main vmid}",
|
||||
"Kimai has no default account. Enter the container with: pct enter {main_vmid}": "Kimai nemá predvolený účet. Do kontajnera vstúpite príkazom: pct enter {main_vmid}",
|
||||
"Kimai is a professional grade time-tracking application, free and open-source.": "Kimai je profesionálna časová aplikácia, zadarmo a open-source.",
|
||||
"Kometa is a powerful tool designed to give you complete control over your media libraries. With Kometa, you can take your customization to the next level, with granular control over metadata, collections, overlays, and much more.": "Kometa je výkonný nástroj navrhnutý tak, aby vám plnú kontrolu nad vaše mediálne knižnice. S Kometa, môžete vziať svoje prispôsobenie na ďalšiu úroveň, s granulovanou kontrolou nad metaúdaje, zbierky, prekrývanie, a oveľa viac.",
|
||||
"Kometa reads its configuration from /config/config.yml and the container only ships /config/config.yml.template. Copy the template to config.yml, fill in the required Plex and TMDb connections, then restart the container.": "Kometa číta svoju konfiguráciu z /config/config.yml a kontajner iba lode /config/config.yml.templát. Skopírujte šablónu pre config.yml, vyplňte required Plex a TMDb pripojenia, potom reštartujte kontajner.",
|
||||
@@ -3535,6 +3592,8 @@
|
||||
"Log2RAM write scheduled every": "Zápis z Log2RAM je naplánovaný každých",
|
||||
"Log:": "Záznam:",
|
||||
"Logged-in users": "Prihlásení používatelia",
|
||||
"Login to {registry} timed out. Saved credentials were not changed.": "Prihlásenie do {registry} vypršalo. Uložené poverenia sa nezmenili.",
|
||||
"Logout from {registry} timed out. Saved file credentials were not changed.": "Odhlásenie z {registry} vypršalo. Prihlasovacie údaje uloženého súboru sa nezmenili.",
|
||||
"Logrotate optimization completed": "Optimalizácia logrotate je dokončená",
|
||||
"Logrotate service restarted successfully": "Služba logrotate bola úspešne reštartovaná",
|
||||
"Lollypop is a lightweight modern music player designed to work excellently on the GNOME desktop environment.": "Lollypop je ľahký moderný hudobný prehrávač navrhnutý tak, aby pracoval vynikajúco na GNOME desktop prostredí.",
|
||||
@@ -3597,6 +3656,8 @@
|
||||
"Manage custom paths (add / remove your folders)": "Spravovať vlastné cesty (pridať / odstrániť priečinky)",
|
||||
"Manage installed OCI applications": "Spravovať nainštalované aplikácie OCI",
|
||||
"Manage local backup target": "Spravovať lokálny cieľ záloh",
|
||||
"Manage private image registries.\nSaved credentials let ProxMenux access your private images.": "Správa súkromných registrov obrázkov.\nUložené poverenia umožňujú ProxMenux prístup k vašim súkromným obrázkom.",
|
||||
"Manage registry accounts as root on the Proxmox node.": "Spravujte účty registra ako root na uzle Proxmox.",
|
||||
"Managed disks must have backup enabled and a valid size": "Spravované disky musia mať zapnuté zálohovanie a platnú veľkosť",
|
||||
"Managing Nginx proxy hosts with a simple, powerful interface.": "Správa Nginx proxy hostiteľov s jednoduchým, výkonným rozhraním.",
|
||||
"Manual CLI Guide (Disk and Storage Manager)": "Ručný návod v termináli (disky a úložiská)",
|
||||
@@ -4114,8 +4175,10 @@
|
||||
"No accessible Samba servers found.": "Nenašli sa dostupné Samba servery.",
|
||||
"No accessible exports found.": "Nenašli sa žiadne dostupné exporty.",
|
||||
"No accessible shares found.": "Nenašli sa dostupné zdieľania.",
|
||||
"No active Proxmox storage accepts OCI template archives.": "Žiadne aktívne úložisko Proxmox neakceptuje archívy šablón OCI.",
|
||||
"No active exports or showmount not available": "Nie sú aktívne exporty alebo príkaz showmount nie je dostupný",
|
||||
"No active session": "Žiadna aktívna relácia",
|
||||
"No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PBS.Neinteraktívny režim nemôže zmeniť zdroje APT;nakonfigurujte ich v Administrácia > Repozitáre.",
|
||||
"No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "Žiadne aktívne predplatné a žiadne použiteľné úložisko PVE. Neinteraktívny režim nemôže zmeniť zdroje APT; nakonfigurujte ich v časti Uzol > Aktualizácie > Úložiská.",
|
||||
"No additional GPU can be added.": "Nie je možné pridať ďalšiu GPU.",
|
||||
"No additional device needs to be added.": "Netreba pridávať žiadne ďalšie zariadenie.",
|
||||
@@ -4220,6 +4283,7 @@
|
||||
"No password provided.": "Nebolo zadané heslo.",
|
||||
"No paths available": "Nie sú dostupné žiadne cesty",
|
||||
"No paths selected. Select at least one path.": "Nie sú vybrané žiadne cesty. Vyberte aspoň jednu cestu.",
|
||||
"No pending changes": "Žiadne čakajúce zmeny",
|
||||
"No pending paths to schedule for reboot.": "Nie sú žiadne čakajúce cesty, ktoré by sa mali naplánovať na reštart.",
|
||||
"No permanent NFS mounts configured.": "Nie sú nastavené žiadne trvalé NFS pripojenia.",
|
||||
"No permanent Samba mounts configured.": "Nie sú nastavené žiadne trvalé Samba pripojenia.",
|
||||
@@ -4233,6 +4297,7 @@
|
||||
"No recent": "Žiadne nedávne",
|
||||
"No recent Samba servers found.": "Nenašli sa žiadne nedávne Samba servery.",
|
||||
"No registered OCI containers are available for selection on this host.": "Na tomto hostiteľovi nie sú dostupné žiadne registrované kontajnery OCI.",
|
||||
"No registry API was found at {registry}. Check the hostname and port.": "Na {registry} sa nenašlo žiadne API registra. Skontrolujte názov hostiteľa a port.",
|
||||
"No restored OCI application is waiting to be recovered.": "Žiadna obnovená aplikácia OCI nečaká na obnovenie.",
|
||||
"No results": "Žiadne výsledky",
|
||||
"No routing information found.": "Nenašli sa žiadne informácie o smerovaní.",
|
||||
@@ -4297,7 +4362,9 @@
|
||||
"Not an OVH server, skipping RTM installation": "Toto nie je OVH server, inštalácia RTM sa preskočí",
|
||||
"Not currently mounted": "Momentálne nepripojené",
|
||||
"Not currently mounted — skipping umount.": "Nie je pripojené, odpojenie preskakujem.",
|
||||
"Not declared": "Nedeklarované",
|
||||
"Not enough free space for the backup": "Nedostatok voľného priestoru pre zálohu",
|
||||
"Not enough free space on {storage}. Free some space or select another storage.": "Nedostatok voľného miesta na {storage}. Uvoľnite miesto alebo vyberte iné úložisko.",
|
||||
"Not found": "Nenájdené",
|
||||
"Not found in the OCI archive:": "Nenájdené v archíve OCI:",
|
||||
"Not imported:": "Neimportované:",
|
||||
@@ -4360,6 +4427,7 @@
|
||||
"OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM bol odstránený (Puppet súbory môže byť potrebné vyčistiť ručne)",
|
||||
"OVH server detected": "Zistený OVH server",
|
||||
"OVH server detection and RTM installation process completed": "Kontrola OVH servera a inštalácia RTM sú dokončené",
|
||||
"Object detector": "Detektor objektov",
|
||||
"Object detector for Frigate": "Detektor objektov pre Frigate",
|
||||
"Observer is not responding on port 4357": "Pozorovateľ nereaguje na prístav 4357",
|
||||
"Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian je note-prijímajúce aplikácie, ktoré vám umožní vytvoriť, odkaz, a usporiadať poznámky na vašom zariadení, so stovkami pluginov a tém prispôsobiť svoje pracovné postupy. Môžete tiež zverejniť svoje poznámky on-line, prístup je offline, a synchronizovať ich bezpečne s end-to-end šifrovanie.",
|
||||
@@ -4390,6 +4458,7 @@
|
||||
"Only Intel/AMD DRM, Coral and USB nodes can be adopted automatically": "Automaticky je možné prijať iba uzly Intel/AMD DRM, Coral a USB",
|
||||
"Only Intel/AMD DRM, Coral, NPU and USB nodes can be adopted automatically": "Automaticky je možné prijať iba uzly Intel/AMD DRM, Coral, NPU a USB",
|
||||
"Only a Docker Swarm uses these settings, so they are not applied:": "Tieto nastavenia používa len roj Docker, takže sa nepoužívajú:",
|
||||
"Only a full sha256 image digest is supported.": "Podporovaný je iba úplný súhrn obrázkov sha256.",
|
||||
"Only an installed standalone OCI instance can adopt external changes": "Externé zmeny môže prijať iba nainštalovaná samostatná inštancia OCI",
|
||||
"Only backed-up Proxmox volumes can be adopted": "Je možné prijať iba zálohované zväzky Proxmox",
|
||||
"Only convert to privileged if absolutely necessary for your use case.": "Na privilegovaný kontajner prevádzajte iba vtedy, keď je to naozaj potrebné.",
|
||||
@@ -4484,11 +4553,17 @@
|
||||
"PBS backup error log": "Záznam chyby zálohy do PBS",
|
||||
"PBS backup failed.": "Záloha do PBS zlyhala.",
|
||||
"PBS certificate": "PBS certifikát",
|
||||
"PBS datastores": "Dátové úložiská PBS",
|
||||
"PBS encryption keyfile (show / replace / remove)": "Šifrovací keyfile pre PBS (zobraziť / nahradiť / odstrániť)",
|
||||
"PBS encryption keyfile — detailed info": "Šifrovací keyfile pre PBS - podrobnosti",
|
||||
"PBS extraction failed": "Rozbalenie z PBS zlyhalo",
|
||||
"PBS host or IP address:": "PBS host alebo IP adresa:",
|
||||
"PBS package update": "Aktualizácia balíkov PBS",
|
||||
"PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Aktualizácia balíkov PBS bola dokončená. Pred ďalším zálohovacím oknom skontrolujte, či je potrebný reštart.",
|
||||
"PBS package upgrade failed. Review the APT output before retrying.": "Aktualizácia balíkov PBS zlyhala. Pred ďalším pokusom skontrolujte výstup APT.",
|
||||
"PBS upload": "Nahrávanie do PBS",
|
||||
"PBS version and packages": "Verzia PBS a balíky",
|
||||
"PBS: using the configured APT repositories without changing sources.": "PBS: používajú sa nakonfigurované repozitáre APT bez zmeny zdrojov.",
|
||||
"PCI Address": "PCI adresa",
|
||||
"PCI passthrough, TPM state, cloud-init configuration, Proxmox hooks": "PCI passthrough, stav TPM, cloud-init nastavenie, Proxmox hooky",
|
||||
"PCI passthrough, TPM state, cloud-init, snapshots, Proxmox-specific hooks": "PCI passthrough, stav TPM, cloud-init, snapshoty, Proxmox špecifické hooky",
|
||||
@@ -4543,6 +4618,7 @@
|
||||
"Passthrough may still work without a ROM file.": "Passthrough môže fungovať aj bez ROM súboru.",
|
||||
"Passthrough may work, but startup/restart reliability is not guaranteed.": "Priame priradenie môže fungovať, ale spoľahlivý štart alebo reštart VM nie je zaručený.",
|
||||
"Password": "Heslo",
|
||||
"Password / access token (image pull permission)": "Heslo / prístupový token (povolenie na stiahnutie obrázka)",
|
||||
"Password Mismatch": "Heslá sa nezhodujú",
|
||||
"Password cannot be empty.": "Heslo nesmie byť prázdne.",
|
||||
"Password confirmation cannot be empty.": "Potvrdenie hesla nesmie byť prázdne.",
|
||||
@@ -4808,7 +4884,9 @@
|
||||
"ProxMenux attaches directories, not single files, so this image cannot be installed yet.": "ProxMenux pripája adresáre, nie jednotlivé súbory, takže tento obrázok ešte nie je možné nainštalovať.",
|
||||
"ProxMenux can apply open permissions on this NFS directory from the host so the container can read and write:": "ProxMenux môže na hostovi nastaviť otvorené oprávnenia pre tento NFS priečinok, aby kontajner mohol čítať aj zapisovať:",
|
||||
"ProxMenux can remount it with open permissions so any LXC can read and write.": "ProxMenux ho môže znovu pripojiť s otvorenými oprávneniami, aby každý LXC mohol čítať aj zapisovať.",
|
||||
"ProxMenux cannot install this image automatically. Import its Compose file instead.": "ProxMenux nemôže nainštalovať tento obrázok automaticky. Namiesto toho importujte jeho súbor Compose.",
|
||||
"ProxMenux cannot override NFS server-side permissions from the host.": "ProxMenux nevie z hosta prepísať oprávnenia nastavené na NFS serveri.",
|
||||
"ProxMenux could not translate this image. Try importing its Compose file from OCI manager Apps.": "ProxMenux nemohol preložiť tento obrázok. Skúste importovať jeho súbor Compose z OCI manager Apps.",
|
||||
"ProxMenux customizations removed from bashrc": "Úpravy ProxMenux boli odstránené z bashrc",
|
||||
"ProxMenux does not give a container the system of its host.": "ProxMenux nedáva kontajner systém svojho hostiteľa.",
|
||||
"ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux ich neinštaluje. Inštalácia sa pýta, ako sa k nim pripojiť.",
|
||||
@@ -4822,12 +4900,17 @@
|
||||
"Proxmology logo applied": "Logo Proxmology bolo použité",
|
||||
"Proxmox 9 system update allready": "Systém Proxmox 9 je už aktualizovaný",
|
||||
"Proxmox APT repositories configured": "APT repozitáre Proxmoxu sú nastavené",
|
||||
"Proxmox Backup Server": "Proxmox Backup Server",
|
||||
"Proxmox Backup Server (PBS) destinations": "Ciele Proxmox Backup Servera (PBS)",
|
||||
"Proxmox Backup Server APT repositories configured": "APT repozitáre Proxmox Backup Server sú nastavené",
|
||||
"Proxmox Backup Server Update": "Aktualizácia Proxmox Backup Server",
|
||||
"Proxmox Backup Server update completed.": "Aktualizácia Proxmox Backup Server bola dokončená.",
|
||||
"Proxmox CIFS Storage Status:": "Stav CIFS úložiska v Proxmoxe:",
|
||||
"Proxmox NFS Storage Status:": "Stav NFS úložiska v Proxmoxe:",
|
||||
"Proxmox System Update": "Aktualizácia systému Proxmox",
|
||||
"Proxmox Update": "Aktualizácia Proxmoxu",
|
||||
"Proxmox VE": "Proxmox VE",
|
||||
"Proxmox VE 8 package upgrade failed. Review the APT output before retrying.": "Aktualizácia balíkov Proxmox VE 8 zlyhala. Pred ďalším pokusom skontrolujte výstup APT.",
|
||||
"Proxmox VE 8 system update completed successfully": "Aktualizácia systému Proxmox VE 8 bola úspešne dokončená",
|
||||
"Proxmox VE 8 to 9 Manual Upgrade Guide": "Ručný sprievodca aktualizáciou Proxmox VE 8 na 9",
|
||||
"Proxmox VE 9.x no-subscription repository created": "Proxmox VE 9.x no-subscription repozitár vytvorený",
|
||||
@@ -4835,6 +4918,7 @@
|
||||
"Proxmox VE configuration completed.": "Nastavenie Proxmox VE je dokončené.",
|
||||
"Proxmox VE safe update completed": "Bezpečná aktualizácia Proxmox VE je dokončená",
|
||||
"Proxmox auth logger service created and started": "Služba na logovanie Proxmox prihlásení bola vytvorená a spustená",
|
||||
"Proxmox could not apply the container settings": "Proxmox nedokázal použiť nastavenia kontajnera",
|
||||
"Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Proxmox enterprise repozitár môže chýbať alebo byť nedostupný. Skúšam prepnúť na no-subscription...",
|
||||
"Proxmox filter configured": "Proxmox filter nastavený",
|
||||
"Proxmox iSCSI Storage Status:": "Stav iSCSI úložiska v Proxmoxe:",
|
||||
@@ -4929,6 +5013,7 @@
|
||||
"Reading SMART data...": "Čítam SMART dáta...",
|
||||
"Reading SMART self-test log...": "Čítam log SMART samo-testu...",
|
||||
"Reading full SMART report...": "Čítam úplnú SMART správu...",
|
||||
"Reading image metadata. Please wait...": "Čítanie metadát obrázkov. Čakajte prosím...",
|
||||
"Real-time Performance Monitoring": "Monitorovanie výkonnosti v reálnom čase",
|
||||
"Real-time bandwidth usage (press q to exit)": "Využitie priepustnosti v reálnom čase (ukončíte klávesom q)",
|
||||
"Real-time collaborative document editor": "Kolektívne editor dokumentov v reálnom čase",
|
||||
@@ -4950,6 +5035,7 @@
|
||||
"Rebuilding initramfs to apply nouveau blacklist before installation...": "Znovu vytváram initramfs, aby sa blacklist nouveau použil ešte pred inštaláciou...",
|
||||
"Rebuilding the container and recovering it from its backup are tested on the same image; the first update to a newer image is still to be observed.": "Prebudovanie kontajnera a jeho obnovenie zo zálohy sa testuje na rovnakom obrázku; prvá aktualizácia na novší obrázok je ešte potrebné pozorovať.",
|
||||
"Recent": "Nedávne",
|
||||
"Recent PBS tasks": "Nedávne úlohy PBS",
|
||||
"Recent Samba Servers": "Posledné Samba servery",
|
||||
"Recent Samba server": "Posledný Samba server",
|
||||
"Recent logs:": "Posledné záznamy:",
|
||||
@@ -5009,6 +5095,7 @@
|
||||
"Recreation completed. Data kept.": "Rekreácia dokončená. Údaje uchovávané.",
|
||||
"Recreation prepared": "Pripravené rekreácie",
|
||||
"Refresh APT index and verify repositories:": "Obnovte APT index a overte repozitáre:",
|
||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Obnoviť indexy nakonfigurovaných repozitárov APT a skontrolovať aktualizáciu balíkov PBS? Súbory repozitárov sa zachovajú.",
|
||||
"Refresh token of the connector, from the Twingate admin console": "Obnovovací token konektora z administrátorskej konzoly Twingate",
|
||||
"Refresh your browser (Ctrl+Shift+R) to see changes": "Obnovte prehliadač (Ctrl+Shift+R), aby ste videli zmeny",
|
||||
"Refresh your browser to see changes (server restart may be required)": "Obnovte stránku v prehliadači, aby ste videli zmeny (môže byť potrebný reštart servera)",
|
||||
@@ -5021,6 +5108,9 @@
|
||||
"Register mount path in Proxmox:": "Zaregistrovať cestu pripojenia v Proxmoxe:",
|
||||
"Registering disk as Proxmox storage...": "Registrujem disk ako úložisko Proxmoxu...",
|
||||
"Registering module with DKMS...": "Registrujem modul v DKMS...",
|
||||
"Registry accounts": "Účty v registri",
|
||||
"Registry hostname": "Názov hostiteľa registra",
|
||||
"Registry: {registry}\nAccount: {account}\nAuthentication: {method}\n\nSaved credentials are verified when an image is accessed.": "Register: {registry}\nÚčet: {account}\nOverenie: {method}\n\nUložené poverenia sa overia pri prístupe k obrázku.",
|
||||
"Reinstall / update Coral drivers": "Preinštalovať / aktualizovať ovládače Coral",
|
||||
"Reinstall Fail2Ban": "Preinštalovať Fail2Ban",
|
||||
"Reinstall Lynis": "Preinštalovať Lynis",
|
||||
@@ -5092,8 +5182,12 @@
|
||||
"Remove obsolete systemd-boot meta-package": "Odstrániť zastaraný meta-balík systemd-boot",
|
||||
"Remove old repository files:": "Odstráňte staré súbory repozitárov:",
|
||||
"Remove passthrough device from VM": "Odstrániť passthrough zariadenie z VM",
|
||||
"Remove saved credentials": "Odstráňte uložené poverenia",
|
||||
"Remove subscription banner": "Odstrániť hlásenie o predplatnom",
|
||||
"Remove the application? Its container disks are deleted, and only a backup can bring them back.": "Odstrániť aplikáciu? Jeho kontajnerové disky sa vymažú a iba záloha ich môže vrátiť späť.",
|
||||
"Remove the lock left on a VM by an interrupted task": "Odstrániť zámok, ktorý na VM zanechala prerušená úloha",
|
||||
"Remove the lock left on a container by an interrupted task": "Odstrániť zámok, ktorý na kontajneri zanechala prerušená úloha",
|
||||
"Remove the saved credentials for {registry}?\nRunning apps will keep working.": "Odstrániť uložené poverenia pre {registry}?\nSpustené aplikácie budú naďalej fungovať.",
|
||||
"Remove the unprivileged flag from configuration:": "Odstrániť príznak neprivilegovaného kontajnera z nastavenia:",
|
||||
"Remove unused packages and their config": "Odstrániť nepoužívané balíky aj ich nastavenia",
|
||||
"Remove: delete the application and its containers": "Odstrániť: odstrániť aplikáciu a jej obaly",
|
||||
@@ -5174,6 +5268,7 @@
|
||||
"Repository helper unavailable. NVIDIA installation stopped.": "Pomocník úložiska nie je k dispozícii. Inštalácia NVIDIA sa zastavila.",
|
||||
"Repository policy returned an unexpected result.": "Politika úložiska vrátila neočakávaný výsledok.",
|
||||
"Repository ready.": "Repozitár je pripravený.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená;zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Administrácia > Repozitáre a zopakujte apt-get update.",
|
||||
"Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Zdroje úložiska sa zmenili, ale obnovenie zoznamu balíkov APT zlyhalo. Prevádzka zastavená; zdroje neboli vrátené späť. Pred pokračovaním skontrolujte Node > Updates > Repository a zopakujte apt-get update.",
|
||||
"Repository switch declined; no APT source changed.": "Prepnutie úložiska bolo odmietnuté; žiadny zdroj APT sa nezmenil.",
|
||||
"Repository:": "Repozitár:",
|
||||
@@ -5268,7 +5363,9 @@
|
||||
"Reverting ZFS ARC tuning...": "Vraciam späť ladenie ZFS ARC...",
|
||||
"Reverting pigz wrapper...": "Vraciam späť wrapper pigz...",
|
||||
"Reverting vzdump speed tuning...": "Vraciam späť ladenie rýchlosti vzdump...",
|
||||
"Review and apply": "Skontrolovať a použiť",
|
||||
"Review external OCI changes": "Skontrolujte externé zmeny OCI",
|
||||
"Review package upgrade": "Skontrolovať aktualizáciu balíkov",
|
||||
"Review passthrough config files": "Skontrolovať konfiguračné súbory passthrough",
|
||||
"Review what will be removed": "Skontrolujte, čo sa odstráni",
|
||||
"Rip DVD and Blu-ray media from a browser": "Rip DVD a Blu-ray médiá z prehliadača",
|
||||
@@ -5311,6 +5408,7 @@
|
||||
"Run the following inside the VM:": "Vo vnútri VM spustite:",
|
||||
"Run the release-channel switch from an SSH session or the Proxmox host console with:": "Prepnutie vetvy vydania spustite cez SSH alebo priamo z konzoly Proxmox servera pomocou:",
|
||||
"Run the update from an SSH session or the Proxmox host console with:": "Spustite aktualizáciu zo SSH relácie alebo z konzoly Proxmox hosta pomocou:",
|
||||
"Run this operation as root.": "Túto operáciu spustite ako root.",
|
||||
"Run upgrade checklist script:": "Spustite kontrolný skript aktualizácie:",
|
||||
"Running": "Beží",
|
||||
"Running Lynis security audit...": "Spúšťam bezpečnostný audit Lynis...",
|
||||
@@ -5415,6 +5513,7 @@
|
||||
"Save and classify articles. Read them later. Freely.": "Uložiť a klasifikovať články. Prečítajte si ich neskôr. Slobodne.",
|
||||
"Save the passphrase somewhere safe NOW, before continuing.": "Uložte si frázu TERAZ na bezpečné miesto, ešte pred pokračovaním.",
|
||||
"Save this Borg target so you don't need to enter the details again?": "Uložiť tento Borg cieľ, aby ste údaje nemuseli zadávať znova?",
|
||||
"Saved credentials for {registry} were removed.\nOther Skopeo/Docker credential files are managed separately.": "Uložené poverenia pre {registry} boli odstránené.\nOstatné súbory poverení Skopeo/Docker sa spravujú samostatne.",
|
||||
"Saved record removed": "Uložený záznam odstránený",
|
||||
"Saving changes...": "Ukladajú sa zmeny...",
|
||||
"Saving the new configuration": "Ukladanie novej konfigurácie",
|
||||
@@ -5724,6 +5823,7 @@
|
||||
"ShareCoveX panel": "Panel ShareCoveX",
|
||||
"Shared Directory Ready:": "Zdieľaný priečinok je pripravený:",
|
||||
"Shared Group": "Zdieľaná skupina",
|
||||
"Shared directories": "Zdieľané priečinky",
|
||||
"Shared directory created:": "Vytvorený zdieľaný adresár:",
|
||||
"Shared directory for consume and export": "Zdieľaný adresár pre spotrebu a export",
|
||||
"Shared directory for copy/sync operations": "Zdieľaný adresár pre kopírovanie/sync operations",
|
||||
@@ -5753,6 +5853,7 @@
|
||||
"Should show pve-manager/9.x.x": "Malo by sa zobraziť pve-manager/9.x.x",
|
||||
"Shoutrrr addresses for the notifications (optional)": "Adresy Shoutrrr pre upozornenia (voliteľné)",
|
||||
"Show All Scripts": "Zobraziť všetky skripty",
|
||||
"Show CT users and their UID/GID on the host": "Používatelia CT a ich UID/GID na hostiteľovi",
|
||||
"Show CT users in table format": "Zobraziť používateľov CT v tabuľke",
|
||||
"Show Container Privilege Status": "Zobraziť stav oprávnení kontajnerov",
|
||||
"Show DNS resolution status": "Zobraziť stav DNS prekladu",
|
||||
@@ -5795,9 +5896,11 @@
|
||||
"Shutdown timeout": "Čas na vypnutie vypršal",
|
||||
"SiYuan access code": "Prístupový kód SiYuan",
|
||||
"SickGear provides management of TV shows and/or Anime, it detects new episodes, links downloader apps, and more..": "SickGear poskytuje správu televíznych relácií a / alebo Anime, detekuje nové epizódy, odkazy aplikácie pre downloader, a ďalšie..",
|
||||
"Sign in to a registry": "Prihláste sa do registra",
|
||||
"Sign in to the panel and create a Samba user in Settings before publishing a directory over SMB.": "Pred publikovaním adresára cez SMB sa prihláste na panel a vytvorte používateľa Samba v Nastaveniach.",
|
||||
"Signal is a messaging app with privacy at its core. It is free and easy to use, with strong end-to-end encryption that keeps your communication completely private.": "Signal je aplikácia správ s ochranou súkromia v jej jadre. Je zadarmo a ľahko použiteľný, so silným end-to-end šifrovaním, ktoré udržuje vašu komunikáciu úplne súkromnú.",
|
||||
"Signatures removed. Partition table preserved.": "Podpisy boli odstránené. Tabuľka oddielov zostala zachovaná.",
|
||||
"Signed in to {registry} as {username}.\n\nCredentials are saved for ProxMenux.\nSelect the account to check, download or install an image.": "Prihlásený do {registry} ako {username}.\n\nPoverenia sú uložené pre ProxMenux.\nVyberte účet, ktorý chcete skontrolovať, stiahnuť alebo nainštalovať obrázok.",
|
||||
"Simple and easy to use DDNS": "Jednoduché a jednoduché použitie DDNS",
|
||||
"Single GPU Warning": "Upozornenie na jednu GPU",
|
||||
"Single target found — selected automatically:": "Našiel sa jeden target - vybraný automaticky:",
|
||||
@@ -5823,6 +5926,8 @@
|
||||
"Skipping SR-IOV device": "Preskakujem SR-IOV zariadenie",
|
||||
"Skipping installation.": "Inštalácia sa preskakuje.",
|
||||
"Skipping manual patches — feranick fork already supports this kernel.": "Ručné záplaty preskakujem - fork feranick už tento kernel podporuje.",
|
||||
"Skopeo is not available. Restore the ProxMenux OCI dependencies and try again.": "Skopeo nie je k dispozícii. Obnovte závislosti ProxMenux OCI a skúste to znova.",
|
||||
"Skopeo returned an invalid credential file; saved credentials were not changed.": "Skopeo vrátilo neplatný súbor poverení; uložené prihlasovacie údaje neboli zmenené.",
|
||||
"Sleek podcast downloader with GPodder sync": "Sleek podcast downloader s GPodder synchronizácia",
|
||||
"Smart restore plan — hardware compatibility check": "Inteligentný plán obnovy - kontrola kompatibility hardvéru",
|
||||
"Smokeping keeps track of your network latency. For a full example of what this application is capable of visit UCDavis.": "Smokeping sleduje latenciu vašej siete. Úplný príklad toho, čo je táto aplikácia schopná navštíviť UCDavis.",
|
||||
@@ -5910,6 +6015,7 @@
|
||||
"Start the VM to begin Windows installation from the mounted ISO.": "Spustite VM a začnite inštaláciu Windowsu z pripojeného ISO.",
|
||||
"Start the applications once they are registered? Answer No if the original containers are still running on another host: both would use the same addresses.": "Spustiť aplikácie po ich zaregistrovaní? Odpoveď Nie, ak pôvodné kontajnery stále bežia na inom hostiteľovi: oba by používali rovnaké adresy.",
|
||||
"Start the converted container:": "Spustiť prevedený kontajner:",
|
||||
"Start the interactive Proxmox package upgrade": "Spustiť interaktívnu aktualizáciu balíkov Proxmoxu",
|
||||
"Start the main system upgrade:": "Spustite hlavnú aktualizáciu systému:",
|
||||
"Start the stack with Proxmox": "Spustiť zásobník s Proxmox",
|
||||
"Start uploading to PBS — sets a recovery passphrase": "Spustiť nahrávanie do PBS - nastaví obnovovaciu frázu",
|
||||
@@ -6080,6 +6186,7 @@
|
||||
"TCP optimizations configuration completed": "Nastavenie TCP optimalizácií je dokončené",
|
||||
"TCP optimizations reverted (defaults restored on next reboot)": "TCP optimalizácie boli vrátené späť (predvolené hodnoty sa obnovia pri ďalšom reštarte)",
|
||||
"TCP port 5201 is reachable (firewall on server)": "TCP port 5201 je dostupný (firewall na serveri)",
|
||||
"TLS verification failed for {registry}. Install the registry CA in /etc/containers/certs.d/{registry}/ca.crt.": "Overenie TLS pre {registry} zlyhalo. Nainštalujte certifikačnú autoritu registra v /etc/containers/certs.d/{registry}/ca.crt.",
|
||||
"TPM device added to VM": "TPM zariadenie bolo pridané do VM",
|
||||
"TPM storage selection failed or was cancelled. VM creation aborted.": "Výber úložiska pre TPM zlyhal alebo bol zrušený. Vytvorenie VM bolo prerušené.",
|
||||
"TROUBLESHOOTING:": "RIEŠENIE PROBLÉMOV:",
|
||||
@@ -6187,6 +6294,7 @@
|
||||
"The Paperless persistent volumes need at least 8 GB": "Trvalé objemy bez dokumentov potrebujú najmenej 8 GB",
|
||||
"The PostgreSQL volume needs at least 4 GB": "Objem PostgreSQL potrebuje najmenej 4 GB",
|
||||
"The PostgreSQL volume needs at least 8 GB": "Objem PostgreSQL potrebuje najmenej 8 GB",
|
||||
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "Aktualizácia Proxmox VE 8 sa nedokončila. Prechod na Proxmox VE 9 sa nespustil.",
|
||||
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Chýba zväzok kľúčov archívu Proxmox;Inštalácia Ceph nemôže bezpečne pokračovať",
|
||||
"The Proxmox inventory and the local configurations differ": "Súpis Proxmox a miestne konfigurácie sa líšia",
|
||||
"The ROCm image has no support for the AMD GPU of this host:": "Obraz ROCm nemá podporu pre AMD GPU tohto hostiteľa:",
|
||||
@@ -6257,6 +6365,7 @@
|
||||
"The build could not be checked beforehand; continuing without that check.": "Zostavenie nebolo možné vopred skontrolovať;pokračovanie bez tejto kontroly.",
|
||||
"The cached image does not match the current digest": "Comment",
|
||||
"The cached image is damaged; it will be downloaded again.": "Comment",
|
||||
"The changes need the application installer": "Zmeny vyžadujú inštalačný program aplikácie",
|
||||
"The character device does not exist:": "Znakové zariadenie neexistuje:",
|
||||
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Príkaz vyžaduje heslo, ktoré nie je ozvene. Po vytvorení užívateľov, webové rozhranie žiada o užívateľské meno a heslo.",
|
||||
"The command does not name an image": "Príkaz nepomenuje obrázok",
|
||||
@@ -6272,6 +6381,7 @@
|
||||
"The configuration must run as root on Proxmox VE": "Konfigurácia musí bežať ako koreň na Proxmox VE",
|
||||
"The configuration of a new member changed after it was created": "Nastavenie nového člena sa zmenilo po jeho vytvorení",
|
||||
"The configuration stopped because of an unexpected error": "Konfigurácia sa zastavila kvôli neočakávanej chybe",
|
||||
"The configured credential helper could not be used. Check its installation and credential store.": "Nepodarilo sa použiť nakonfigurovaného pomocníka poverení. Skontrolujte jeho inštaláciu a úložisko poverení.",
|
||||
"The console of the container could not be configured": "Konzolu kontajnera nebolo možné nakonfigurovať",
|
||||
"The consume/export volumes need at least 1 GB": "Objem spotreby/vývozu potrebuje najmenej 1 GB",
|
||||
"The container could not be removed automatically:": "Nádoba sa nedala automaticky odstrániť:",
|
||||
@@ -6310,6 +6420,7 @@
|
||||
"The containers do not need them any more; an update downloads the new versions when there are any.": "Kontajnery ich už nepotrebujú; aktualizácia stiahne nové verzie, ak nejaké existujú.",
|
||||
"The containers were created from downloaded OCI images": "Kontajnery boli vytvorené zo stiahnutých obrázkov OCI",
|
||||
"The coordinated backup was modified": "Koordinovaná záloha bola zmenená",
|
||||
"The credential file changed during this operation. Try again; it was not overwritten.": "Počas tejto operácie sa zmenil súbor poverení. Skúste to znova;nebolo to prepísané.",
|
||||
"The current driver will be completely uninstalled before installing the new version. Continue?": "Aktuálny ovládač sa pred inštaláciou novej verzie úplne odinštaluje. Pokračovať?",
|
||||
"The current image of the saved channel will be checked and downloaded. Resources, paths and GPU are kept. The CT is stopped during the replacement and a native backup is created first.": "Aktuálny obraz uloženého kanála bude skontrolovaný a stiahnutý. Zdroje, cesty a GPU sú udržiavané. CT je zastavený počas výmeny a natívne zálohovanie je vytvorené ako prvé.",
|
||||
"The current record is missing for": "Aktuálny záznam chýba",
|
||||
@@ -6374,8 +6485,10 @@
|
||||
"The host bind source does not exist:": "Zdroj hostiteľskej väzby neexistuje:",
|
||||
"The host bind source is not a regular file or directory:": "Zdroj odkazu hostiteľa nie je bežný súbor alebo adresár:",
|
||||
"The host directory changed before it was mounted": "Adresár hostiteľa sa zmenil predtým, ako bol pripojený",
|
||||
"The host directory does not exist": "Adresár na hostiteľovi neexistuje",
|
||||
"The host directory may not be accessible from an unprivileged container.": "Priečinok na hostovi nemusí byť dostupný z neprivilegovaného kontajnera.",
|
||||
"The host has no IPv4 address on the selected bridge": "Hostiteľ nemá IPv4 adresu na vybranom moste",
|
||||
"The host loses access to them. If the group includes a network or storage controller the host needs, cancel at the summary.": "Hostiteľ k nim stratí prístup. Ak skupina obsahuje sieťový alebo úložný radič, ktorý hostiteľ potrebuje, zrušte to v súhrne.",
|
||||
"The host monitor does not see the real host memory": "Monitor hostiteľa nevidí skutočnú pamäť hostiteľa",
|
||||
"The host monitor does not share this host namespace:": "Monitor hostiteľa nezdieľa tento menový priestor hostiteľa:",
|
||||
"The host monitor include is unknown or differs from the saved record": "Include monitora hostiteľa je neznámy alebo sa líši od uloženého záznamu",
|
||||
@@ -6392,7 +6505,9 @@
|
||||
"The host-monitor firewall subnet changed; no firewall rule was added": "Podsieť firewallu pre monitor hostiteľa sa zmenila; žiadne pravidlo sa nepridalo",
|
||||
"The host-monitor web interface uses the host network.": "Webové rozhranie monitora hostiteľa používa sieť hostiteľa.",
|
||||
"The identity of a member was replaced": "Identita člena bola nahradená",
|
||||
"The image cache path is unsafe. Check the template storage before trying again.": "Cesta vyrovnávacej pamäte obrázkov nie je bezpečná. Pred opätovným pokusom skontrolujte úložisko šablóny.",
|
||||
"The image changes the user expected by the adapter": "Obrázok mení používateľa očakávaný adaptérom",
|
||||
"The image could not be downloaded or verified. Check registry and storage access, then try again.": "Obrázok sa nepodarilo stiahnuť alebo overiť. Skontrolujte prístup k registrom a ukladaciemu priestoru a skúste to znova.",
|
||||
"The image could not be read from its registry:": "Obrázok sa nepodarilo prečítať z jeho registra:",
|
||||
"The image declares data paths that are still stored in the rootfs": "Obrázok deklaruje dátové cesty, ktoré sú stále uložené v rootfs",
|
||||
"The image did not grant the application user access to the devices; check its native init. Host permissions were not relaxed.": "Obrázok neumožňoval používateľovi aplikácie prístup k zariadeniam; skontrolujte jeho rodený init. Povolenie hostiteľa nebolo uvoľnené.",
|
||||
@@ -6405,6 +6520,8 @@
|
||||
"The image is in its registry, for one architecture.": "Obraz je vo svojom registri, pre jednu architektúru.",
|
||||
"The image is in its registry.": "Obrázok je v jeho registri.",
|
||||
"The image is in its registry:": "Obrázok je vo svojom registri:",
|
||||
"The image manifest failed integrity verification. Try again or contact the image publisher.": "Overenie integrity manifestu obrázka zlyhalo. Skúste to znova alebo kontaktujte vydavateľa obrázkov.",
|
||||
"The image or tag was not found on {registry}, or this account cannot access it. Check the reference.": "Obrázok alebo značka sa na {registry} nenašla alebo k nim tento účet nemá prístup. Skontrolujte referenciu.",
|
||||
"The image requests NVIDIA, but the host has no working NVIDIA driver": "Obrázok požaduje NVIDIA, ale hostiteľ nemá žiadny funkčný ovládač NVIDIA",
|
||||
"The image requests disabling part of the AppArmor or seccomp confinement. Continue only if you trust the image and accept this risk.": "Obraz si vyžaduje vypnutie časti AppArmor alebo seccomp väzenia. Pokračujte len vtedy, ak dôverujete obrazu a prijmete toto riziko.",
|
||||
"The image requests disabling part of the AppArmor or seccomp confinement. The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "Obraz si vyžaduje vypnutie časti AppArmor alebo seccomp väzenia. Skladba ponúka voliteľnú AppArmor alebo seccomp relaxáciu; zostane vypnutá, ak si ju užívateľ nevyberie. Pokračujte len vtedy, ak dôverujete obrazu a prijmete toto riziko.",
|
||||
@@ -6542,7 +6659,15 @@
|
||||
"The record or diagnosis could not be completed; no update was run.": "Záznam alebo diagnóza nemohla byť dokončená; žiadna aktualizácia nebola spustená.",
|
||||
"The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "Obnova sa nedokončila. Skontrolujte záznam a v ponuke Správca OCI aplikácií pri tomto kontajneri znova vyberte \"Obnoviť\".",
|
||||
"The recovery did not complete. Review the log and choose \"{choice}\" again for this container in the OCI management menu.": "Obnova sa nedokončila. Skontrolujte záznam a v ponuke Správca OCI aplikácií pri tomto kontajneri znova vyberte \"{choice}\".",
|
||||
"The registry authentication file is invalid; it was not changed.": "Overovací súbor registra je neplatný;nebolo to zmenené.",
|
||||
"The registry authentication file must be owned by root with mode 600.": "Overovací súbor registra musí vlastniť root s režimom 600.",
|
||||
"The registry authentication file must have an absolute path.": "Overovací súbor registra musí mať absolútnu cestu.",
|
||||
"The registry authentication path cannot contain symbolic links.": "Cesta na overenie registra nemôže obsahovať symbolické odkazy.",
|
||||
"The registry authentication path has an unsafe parent directory.": "Cesta na overenie registra obsahuje nebezpečný nadradený adresár.",
|
||||
"The registry credential directory must be owned by root with mode 700.": "Adresár poverení registra musí vlastniť root s režimom 700.",
|
||||
"The registry did not return the saved image digest": "Register nevrátil uložený image digest",
|
||||
"The registry returned incomplete or unsupported image metadata.": "Register vrátil neúplné alebo nepodporované metadáta obrázka.",
|
||||
"The registry returned invalid or unsupported image metadata.": "Register vrátil neplatné alebo nepodporované metadáta obrázka.",
|
||||
"The remote does not exist; create and authorize it first in the WebUI:": "Vzdialenosť neexistuje; vytvoriť a schváliť ju ako prvý na WebUI:",
|
||||
"The remote installer must run as root on Proxmox VE": "Diaľkový inštalátor musí bežať ako koreň na Proxmox VE",
|
||||
"The remote must already be created and authorized in the Rclone web UI. This operation restarts the CT and publishes two FUSE views on the host.": "Vzdialený musí byť už vytvorený a schválený v Rclone web UI. Tento operation reštartuje CT a zverejňuje dva pohľady FUSE na hostiteľa.",
|
||||
@@ -6553,6 +6678,7 @@
|
||||
"The repair must preserve the image dependencies:": "Oprava musí zachovať závislosť obrazu:",
|
||||
"The requested VMID block is already in use": "Požadovaný blok VMID sa už používa",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Požadovaný profil strojového učenia sa GPU nefunguje; nie je nahradený CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Zmenu prostriedkov sa nepodarilo úplne vrátiť späť. Znova otvorte správu aplikácií, skontrolujte aktuálne nastavenia systému Proxmox a prevezmite ich.",
|
||||
"The restored service did not pass its health check": "Obnovená služba neprešla kontrolou stavu",
|
||||
"The restored service stopped; the recovery is not confirmed": "Obnovená služba sa zastavila; obnova sa nepotvrdila",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Preskúmaný Tandoor stack require privilegovaný LXC.",
|
||||
@@ -6598,6 +6724,7 @@
|
||||
"The selected VM": "Vybraná VM",
|
||||
"The selected VM is running.": "Vybraná VM beží.",
|
||||
"The selected base folder does not exist and could not be created:": "Vybraný základný priečinok neexistuje a nepodarilo sa ho vytvoriť:",
|
||||
"The selected bridge does not exist": "Vybraný sieťový most neexistuje",
|
||||
"The selected bridge has no IPv4 subnet for the host-monitor firewall": "Vybraný bridge nemá IPv4 podsieť pre firewall monitora hostiteľa",
|
||||
"The selected configuration needs to start the LXC during the installation": "Vybraná konfigurácia musí spustiť LXC počas inštalácie",
|
||||
"The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Vybraný kontajner je neprivilegovaný. Na priame priradenie zariadenia je potrebný privilegovaný kontajner.",
|
||||
@@ -6620,6 +6747,7 @@
|
||||
"The service builds its own image; only a published image can be installed": "Služba buduje svoj vlastný obraz; môže byť nainštalovaný iba zverejnený obrázok",
|
||||
"The service declares no image:": "Služba neprehlasuje žiadny obrázok:",
|
||||
"The setting has no final value:": "Nastavenie nemá žiadnu konečnú hodnotu:",
|
||||
"The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Nepodarilo sa vrátiť všetky nastavenia. Pred pokračovaním skontrolujte kontajner a uloženú zmenu.",
|
||||
"The share already exists in smb.conf:": "Zdieľanie už existuje v smb.conf:",
|
||||
"The shared destination is not a directory": "Zdieľaná destinácia nie je adresár",
|
||||
"The shared directory points to a protected host path": "Zdieľaný adresár ukazuje na chránenú cestu hostiteľa",
|
||||
@@ -6687,20 +6815,26 @@
|
||||
"These Proxmox resources were added outside ProxMenux:": "Tieto zdroje Proxmox boli pridané mimo ProxMenux:",
|
||||
"These VMIDs are not free:": "Tieto VMID nie sú zadarmo:",
|
||||
"These are the changes that will be made": "Tieto zmeny sa vykonajú",
|
||||
"These changes were made in Proxmox outside ProxMenux:": "Tieto zmeny boli vykonané v Proxmox mimo ProxMenux:",
|
||||
"These container mount paths have an extension-like suffix:": "Tieto cesty pripojenia v kontajneri majú príponu podobnú koncovke súboru:",
|
||||
"These containers were restored from a backup and this host has no record of their OCI application:": "Tieto kontajnery boli obnovené zo zálohy a tento hostiteľ nemá žiadny záznam o ich aplikácii OCI:",
|
||||
"These devices share the IOMMU group of the selected GPU and will be assigned to the VM with it:": "Tieto zariadenia zdieľajú skupinu IOMMU vybranej GPU a priradia sa k VM spolu s ňou:",
|
||||
"These host backups run with this job and expect its storage:": "Tieto zálohy hostiteľa sa spúšťajú s touto úlohou a očakávajú jej uloženie:",
|
||||
"These host backups run with this job and will stop running:": "Tieto zálohy hostiteľa sa spúšťajú s touto úlohou a prestanú sa spúšťať:",
|
||||
"These interface configurations will be removed": "Tieto nastavenia rozhraní sa odstránia",
|
||||
"These manual changes cannot be adopted safely:": "Tieto manuálne zmeny nie je možné bezpečne prijať:",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Tieto cesty sa neobnovia za behu a rozbalia sa na ručnú obnovu.",
|
||||
"These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Tieto nastavenia vyžadujú úpravu špecifickú pre aplikáciu. Obraz sa nestiahol a kontajner sa nevytvoril znova.",
|
||||
"These values are asked during the installation:": "Tieto hodnoty sa požadujú počas inštalácie:",
|
||||
"They are described in the image documentation:": "Ich popis nájdete v dokumentácii image:",
|
||||
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Pred pokračovaním budú pridané do oci-compose. Nepodporované alebo zmenené zdroje sa neimportujú.",
|
||||
"They will be saved in oci-compose before continuing.": "Pred pokračovaním budú uložené v oci-compose.",
|
||||
"This CIFS share is mounted with restrictive permissions.": "Toto CIFS zdieľanie je pripojené s obmedzujúcimi oprávneniami.",
|
||||
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Táto GPU sa v ProxMenux považuje za nekompatibilnú s GPU passthrough do VM.",
|
||||
"This NFS share is fully restricted — even the host root cannot write to it.": "Toto NFS zdieľanie je úplne obmedzené - ani root na hostovi doň nevie zapisovať.",
|
||||
"This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Tento Proxmox Backup Server nemá aktívne predplatné. Prepnúť na repozitár bez predplatného? Nedostupný zdroj Enterprise PBS bude vypnutý.",
|
||||
"This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Táto VM vyžaduje ďalšie kroky inštalácie. Návod nájdete tu:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144",
|
||||
"This account is for {registry}. Enter an image from that registry.": "Tento účet je pre {registry}. Zadajte obrázok z tohto registra.",
|
||||
"This action will:": "Táto akcia vykoná:",
|
||||
"This application cannot be recovered yet:": "Túto aplikáciu zatiaľ nie je možné obnoviť:",
|
||||
"This application cannot be recovered yet; nothing was changed:": "Túto aplikáciu zatiaľ nie je možné obnoviť; nič sa nezmenilo:",
|
||||
@@ -6732,8 +6866,10 @@
|
||||
"This explicitly marks the container as privileged": "Týmto sa kontajner výslovne označí ako privilegovaný",
|
||||
"This file decides what is mounted and with which privileges. Install it only if it comes from a source you trust.": "Tento súbor rozhoduje o tom, čo sa pripojí a s akými oprávneniami. Nainštalujte ho iba vtedy, ak pochádza zo zdroja, ktorému dôverujete.",
|
||||
"This guarantees that device nodes are available before applying LXC GPU config.": "Tým sa zabezpečí, že device nodes budú dostupné ešte pred použitím GPU nastavenia pre LXC.",
|
||||
"This host architecture is not supported by the OCI image importer.": "Táto hostiteľská architektúra nie je podporovaná importérom obrázkov OCI.",
|
||||
"This host has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PVE source will be disabled. Enterprise Ceph sources, if present, will be disabled without choosing a replacement Ceph channel; configure Ceph separately if needed.": "Tento hostiteľ nemá žiadne aktívne predplatné, prepnúť na úložisko bez predplatného? Neprístupný zdroj Enterprise PVE bude zakázaný. Enterprise Ceph zdroje, ak sú prítomné, budú deaktivované bez výberu náhradného Ceph kanála; v prípade potreby nakonfigurujte Ceph samostatne.",
|
||||
"This image cannot be installed as it is described:": "Tento obrázok nie je možné nainštalovať, ako je popísané:",
|
||||
"This image has no compatible Linux image for this host ({architecture}).": "Tento obrázok nemá žiadny kompatibilný obraz Linuxu pre tohto hostiteľa ({architecture}).",
|
||||
"This image requires the host module": "Tento obrázok requires hostiteľský modul",
|
||||
"This import rejects these paths without checking whether they are files or directories.": "Tento import odmieta tieto cesty bez kontroly, či ide o súbory alebo adresáre.",
|
||||
"This installation will:": "Táto inštalácia urobí toto:",
|
||||
@@ -6758,10 +6894,13 @@
|
||||
"This may take several minutes...": "Môže to trvať niekoľko minút...",
|
||||
"This means Proxmox handles mount lifecycle natively (no manual /etc/fstab needed for NFS/CIFS host storages).": "To znamená, že Proxmox sa o pripojenie stará priamo; pre NFS/CIFS úložiská na hostovi netreba ručne upravovať /etc/fstab.",
|
||||
"This means the credentials are incorrect.": "To znamená, že prihlasovacie údaje nie sú správne.",
|
||||
"This menu requires Proxmox Backup Server.": "Táto ponuka vyžaduje Proxmox Backup Server.",
|
||||
"This might indicate network connectivity issues.": "Môže to znamenať problém so sieťovým pripojením.",
|
||||
"This monitor uses a privileged LXC, shares processes and network with Proxmox and disables AppArmor in the CT. It uses the IP address and firewall of the host. A compromised image could affect the host; do not expose its web UI to the Internet.": "Tento monitor používa privilegovaný LXC, zdieľa procesy a sieť s Proxmox a vypne AppArmor v CT. Používa IP adresu a firewall hostiteľa. Kompromitovaný obraz by mohol mať vplyv na hostiteľa; nevystavujte svoje webové UI internetu.",
|
||||
"This only removes the schedule. Existing backup archives are NOT deleted.": "Týmto sa odstráni iba plán. Existujúce zálohy NEBUDÚ vymazané.",
|
||||
"This operation may take several minutes and requires internet connectivity.": "Táto operácia môže trvať niekoľko minút a vyžaduje pripojenie na internet.",
|
||||
"This operation requires a Proxmox VE host. Use the PBS menu on Proxmox Backup Server.": "Táto operácia vyžaduje hostiteľa Proxmox VE. Na Proxmox Backup Serveri použite ponuku PBS.",
|
||||
"This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.": "Táto operácia podporuje PBS 3 na Debiane 12 a PBS 4 na Debiane 13.",
|
||||
"This package was installed by older versions of the ProxMenux Coral installer that placed the M.2 kernel driver on every system, including USB-only setups. It is not needed for Coral USB devices, which use libedgetpu1-std / libedgetpu1-max only.": "Tento balík bol nainštalovaný staršími verziami inštalačného programu ProxMenux Coral, ktorý umiestnil ovládač jadra M.2 na každý systém, vrátane nastavení iba cez USB.Nie je potrebný pre zariadenia Coral USB, ktoré používajú iba libedgetpu1-std / libedgetpu1-max.",
|
||||
"This passphrase is the ONLY way to access encrypted Borg backups.": "Táto fráza je JEDINÝ spôsob, ako sa dostať k šifrovaným Borg zálohám.",
|
||||
"This path is already used as a mount point in this container.": "Táto cesta už je v tomto kontajneri použitá ako mount point.",
|
||||
@@ -6777,6 +6916,7 @@
|
||||
"This script cannot be executed from the Proxmox web terminal.": "Tento skript sa nedá spustiť z webového terminálu Proxmoxu.",
|
||||
"This script must be run as root.": "Tento skript musí byť spustený ako root.",
|
||||
"This script must be run on a Proxmox host.": "Tento skript musí byť spustený na Proxmox serveri.",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Tento skript použije nasledujúce optimalizácie a pokročilé úpravy na váš Proxmox Backup Server",
|
||||
"This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Tento skript použije tieto optimalizácie a pokročilé úpravy na vašom serveri Proxmox VE",
|
||||
"This script will update your Proxmox VE system with advanced options:": "Tento skript aktualizuje váš Proxmox VE systém s pokročilými možnosťami:",
|
||||
"This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Táto relácia beží v termináli Monitora. Ak by sa to spustilo odtiaľto, pripojenie sa počas inštalácie preruší a prepnutie môže zostať v zlom stave.",
|
||||
@@ -6849,6 +6989,7 @@
|
||||
"To use the GPU again in LXC, run Add GPU to LXC from GPUs and Coral-TPU Menu": "Ak chcete GPU znova používať v LXC, spustite Pridať GPU do LXC v menu GPUs and Coral-TPU",
|
||||
"To use the VM without issues, the host must be restarted before starting it.": "Aby VM fungovala bez problémov, server musí byť pred jej spustením reštartovaný.",
|
||||
"To use this share from an LXC, bind-mount it via:": "Ak chcete toto zdieľanie používať z LXC, pripojte ho ako bind mount cez:",
|
||||
"Token": "Token",
|
||||
"Tool exit code:": "Návratový kód nástroja:",
|
||||
"Tool output:": "Výstup nástroja:",
|
||||
"Tools": "Nástroje",
|
||||
@@ -6907,6 +7048,7 @@
|
||||
"Unable to identify the root filesystem. Aborting.": "Nepodarilo sa identifikovať koreňový súborový systém. Prerušuje sa.",
|
||||
"Unable to resolve system ZFS pool disks. Aborting.": "Nepodarilo sa vyriešiť systémové disky ZFS fondu. Prerušuje sa.",
|
||||
"Unable to resolve system disk topology. Aborting.": "Nepodarilo sa vyriešiť topológiu systémového disku. Prerušuje sa.",
|
||||
"Unapplied changes": "Nepoužité zmeny",
|
||||
"Understand the security implications of privileged containers": "Uistite sa, že rozumiete bezpečnostným dôsledkom privilegovaných kontajnerov",
|
||||
"Unexpected Proxmox inventory; recovery blocked": "Neočakávaný inventár Proxmox; zablokovaná obnova",
|
||||
"Unexpected formatting directory in the new Valkey volume": "Neočakávaný formátovanie adresára v novom hlasitosti Valkey",
|
||||
@@ -6993,6 +7135,7 @@
|
||||
"Unsafe private configuration path": "Nebezpečná konfiguračná dráha",
|
||||
"Unsafe qBittorrent configuration path": "Nebezpečná konfiguračná dráha qBittorrent",
|
||||
"Unsafe record": "Nebezpečný záznam",
|
||||
"Unsafe registry credential lock.": "Nebezpečný zámok poverení registra.",
|
||||
"Unsafe registry directory": "Nebezpečný adresár registrov",
|
||||
"Unsafe registry lock": "Nebezpečný zámok registra",
|
||||
"Unsafe rootfs for the capture": "Nebezpečné rootfs pre zachytenie",
|
||||
@@ -7005,6 +7148,7 @@
|
||||
"Unsupported OCI-LXC AppArmor profile:": "Nepodporovaný profil OCI-LXC AppArmor:",
|
||||
"Unsupported OCI-LXC seccomp profile:": "Nepodporovaný profil OCI-LXC seccomp:",
|
||||
"Unsupported Terminal": "Nepodporovaný terminál",
|
||||
"Unsupported architecture for Figurine:": "Nepodporovaná architektúra pre Figurine:",
|
||||
"Unsupported architecture:": "Nepodporovaná architektúra:",
|
||||
"Unsupported backup compression": "Nepodporovaná kompresia zálohy",
|
||||
"Unsupported credential pattern:": "Nepodporovaný credential vzor:",
|
||||
@@ -7020,6 +7164,7 @@
|
||||
"Unsupported mount type": "Nepodporovaný typ pripojenia",
|
||||
"Unsupported mount type:": "Nepodporovaný typ pripojenia:",
|
||||
"Unsupported native device type:": "Nepodporovaný natívne typ zariadenia:",
|
||||
"Unsupported network settings": "Nepodporované sieťové nastavenia",
|
||||
"Unsupported operation": "Nepodporovaná operation",
|
||||
"Unsupported or unknown Proxmox version; no repository changed.": "Nepodporovaná alebo neznáma verzia Proxmox; žiadne úložisko sa nezmenilo.",
|
||||
"Unsupported output format:": "Nepodporovaný výstupný formát:",
|
||||
@@ -7047,6 +7192,7 @@
|
||||
"Update NVIDIA in LXC Containers": "Aktualizovať NVIDIA v LXC kontajneroch",
|
||||
"Update OCI": "Aktualizovať OCI",
|
||||
"Update OCI stack": "Aktualizovať OCI stack",
|
||||
"Update PBS using configured repositories": "Aktualizovať PBS pomocou nakonfigurovaných repozitárov",
|
||||
"Update PVE enterprise repository (Only if using enterprise):": "Aktualizujte PVE enterprise repozitár (iba ak používate enterprise):",
|
||||
"Update Proxmox VE Appliance Manager": "Aktualizovať správcu aplikácií Proxmox VE",
|
||||
"Update Proxmox package lists": "Aktualizovať zoznamy Proxmox balíkov",
|
||||
@@ -7057,6 +7203,7 @@
|
||||
"Update cancelled by user": "Aktualizácia zrušená používateľom",
|
||||
"Update completed. Data kept.": "Aktualizácia dokončená. Údaje uchovávané.",
|
||||
"Update completed. Press Enter to continue...": "Aktualizácia dokončená. Pokračujte stlačením Enter...",
|
||||
"Update credentials": "Aktualizujte poverenia",
|
||||
"Update every container of the application": "Aktualizovať každý kontajner aplikácie",
|
||||
"Update kernel to compatible version": "Aktualizovať kernel na kompatibilnú verziu",
|
||||
"Update now?": "Aktualizácia?",
|
||||
@@ -7098,6 +7245,7 @@
|
||||
"Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Asistent aktualizácie: Proxmox VE 8 → 9 (Trixie)",
|
||||
"Upgrade canceled by user": "Aktualizácia zrušená používateľom",
|
||||
"Upgrade one node at a time": "Aktualizujte vždy iba jeden uzol naraz",
|
||||
"Upgrade the system, offering the": "Aktualizujte systém a ponúknite",
|
||||
"Upgrade to Proxmox VE 9": "Aktualizovať na Proxmox VE 9",
|
||||
"Upgrading NVIDIA utils (Arch)...": "Aktualizujem NVIDIA nástroje (Arch)...",
|
||||
"Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "Nahrať šifrovanú kópiu kľúča do PBS, aby ste ju vedeli obnoviť na preinštalovanom hostovi iba pomocou frázy?",
|
||||
@@ -7123,8 +7271,10 @@
|
||||
"Use a custom path": "Použiť vlastnú cestu",
|
||||
"Use a privileged LXC for NFS server/client workflows.": "Pre NFS server/klient používajte privilegovaný LXC.",
|
||||
"Use a privileged LXC for Samba client/server workflows.": "Pre Samba klient/server používajte privilegovaný LXC.",
|
||||
"Use a registry hostname, for example ghcr.io or registry.example.com:5000.": "Použite názov hostiteľa registra, napríklad ghcr.io alebo registry.example.com:5000.",
|
||||
"Use an existing SSH private key file on this host": "Použiť existujúci súbor súkromného SSH kľúča na tomto hostovi",
|
||||
"Use an existing keyfile": "Použiť existujúci keyfile",
|
||||
"Use an image reference, for example ghcr.io/owner/app:latest.": "Použite odkaz na obrázok, napríklad ghcr.io/owner/app:latest.",
|
||||
"Use as-is — keep data and filesystem": "Použiť tak, ako je - ponechať dáta aj súborový systém",
|
||||
"Use content types according to your use case.": "Typy obsahu zvoľte podľa toho, na čo bude úložisko slúžiť.",
|
||||
"Use default": "Použiť predvolené",
|
||||
@@ -7164,6 +7314,7 @@
|
||||
"User-friendly WebUI for LLMs (Formerly Ollama WebUI)": "Užívateľsky prívetivé WebUI pre LLM (predovšetkým Ollama WebUI)",
|
||||
"Username": "Používateľské meno",
|
||||
"Username (e.g. root@pam or user@pbs!token):": "Používateľské meno (napr. root@pam alebo user@pbs!token):",
|
||||
"Username / token": "Používateľské meno / token",
|
||||
"Username and password": "Meno a heslo",
|
||||
"Username and password will be cleared.": "Meno používateľa a heslo sa vymažú.",
|
||||
"Username cannot be empty.": "Meno používateľa nesmie byť prázdne.",
|
||||
@@ -7263,6 +7414,7 @@
|
||||
"Verifying Ceph packages availability...": "Overujem dostupnosť balíkov Ceph...",
|
||||
"Verifying all utilities status": "Overujem stav všetkých nástrojov",
|
||||
"Verifying disk accessibility in CT": "Overujem dostupnosť disku v CT",
|
||||
"Verifying registry access. Please wait...": "Overenie prístupu do registra. Čakajte prosím...",
|
||||
"Verifying the backups...": "Overujem zálohy...",
|
||||
"Verifying the image integrity...": "Overuje integritu obrazu...",
|
||||
"Version": "Verzia",
|
||||
@@ -7531,6 +7683,7 @@
|
||||
"active VF(s)": "aktívne VF",
|
||||
"active VFs": "aktívnymi VF",
|
||||
"active Virtual Functions. Changing its driver binding would destroy every VF.": "aktívnymi Virtual Functions. Zmena naviazania ovládača by zrušila každú VF.",
|
||||
"added in Proxmox": "pridané v Proxmox",
|
||||
"after copy; source tree is incomplete.": "po kopírovaní; zdrojový strom je neúplný.",
|
||||
"after update.": "po aktualizácii.",
|
||||
"all guests": "všetci hostia",
|
||||
@@ -7562,6 +7715,7 @@
|
||||
"assembling": "montáž",
|
||||
"automatically. Install it manually inside the container.": "automaticky. Nainštalujte ho ručne vo vnútri kontajnera.",
|
||||
"automatically. Reboot LXC to fully release.": "automaticky. Pre úplné uvoľnenie reštartujte LXC.",
|
||||
"available": "K dispozícii",
|
||||
"available for LXC bind-mounts via 'LXC Mount Manager'": "dostupné pre LXC bind mounty cez 'Správca LXC mountov'",
|
||||
"available in this same GPU and TPU menu.": "dostupnej v tomto istom GPU a TPU menu.",
|
||||
"backup at /etc/fstab.proxmenux.bak": "záloha v /etc/fstab.proxmenux.bak",
|
||||
@@ -7897,6 +8051,7 @@
|
||||
"remapped users": "remapovanými používateľmi",
|
||||
"remove the (now-empty) directory if possible": "odstrániť teraz prázdny priečinok, ak to bude možné",
|
||||
"removed from Proxmox": "odstránené z Proxmoxu",
|
||||
"removed in Proxmox": "odstránené v Proxmox",
|
||||
"removed successfully from Proxmox.": "úspešne odstránené z Proxmoxu.",
|
||||
"requires running the official installer, which restarts the Monitor service.": "vyžaduje spustenie oficiálneho inštalátora, ktorý reštartuje službu Monitor.",
|
||||
"requires the package": "vyžaduje balík",
|
||||
@@ -7914,6 +8069,7 @@
|
||||
"runs in": "beží v",
|
||||
"safe paths now (configs, packages, /etc, /root, ...)": "bezpečné cesty teraz (nastavenia, balíky, /etc, /root, ...)",
|
||||
"same MAC": "rovnaká MAC",
|
||||
"saved": "Uložené",
|
||||
"seconds (default)": "sekúnd (predvolené)",
|
||||
"see log:": "pozrite záznam:",
|
||||
"selected by default": "vybrané štandardne",
|
||||
@@ -7999,18 +8155,20 @@
|
||||
"using": "cez",
|
||||
"using direct path (not guaranteed to survive reboots).": "používa sa priama cesta (nie je zaručené, že prežije reštarty).",
|
||||
"vCPUs:": "vCPU:",
|
||||
"value changed in Proxmox": "hodnota zmenená v Proxmox",
|
||||
"vfio-pci IDs configured": "vfio-pci ID nastavené",
|
||||
"vfio-pci IDs in /etc/modprobe.d/vfio.conf": "vfio-pci ID v /etc/modprobe.d/vfio.conf",
|
||||
"video": "video",
|
||||
"video + recognition": "video + rozpoznávanie",
|
||||
"video only": "iba video",
|
||||
"vzdump backup speed optimization completed": "Optimalizácia rýchlosti záloh vzdump je dokončená",
|
||||
"wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/<CTID>.conf with the container stopped, and start it again.": "wallabag buduje svoje odkazy z adresy uvedenej počas inštalácie. Ak nezodpovedá adrese kontajnera, upravte prostredie lxc. čas: SYMFONY ENV DOMAIN NAME in /etc/pve/lxc/ <CTID>.conf s kontajnerom zastavil, a začať znova.",
|
||||
"wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/<CTID>.conf with the container stopped, and start it again.": "wallabag vytvára odkazy z adresy zadanej pri inštalácii. Ak sa nezhoduje s adresou kontajnera, zastavte ho, upravte lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME v /etc/pve/lxc/<CTID>.conf a znova ho spustite.",
|
||||
"wallabag listens on port 80 of the container and stores its data in SQLite.": "wallabag počúva port 80 kontajnera a ukladá svoje údaje v SQLite.",
|
||||
"wallabag web interface": "Webové rozhranie wallabag",
|
||||
"was": "bolo",
|
||||
"was installed, but the kernel reports:": "bol nainštalovaný, ale jadro hlási:",
|
||||
"when finished": "po dokončení",
|
||||
"when the server has no subscription (you will be asked)": "keď server nemá predplatné (budete požiadaný)",
|
||||
"will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "pri ďalšom reštarte znova naviaže GPU na vfio-pci, čím sa preruší ovládač, ktorý sa má nainštalovať.",
|
||||
"wipefs failed on": "wipefs zlyhalo na",
|
||||
"with": "za",
|
||||
@@ -8032,8 +8190,16 @@
|
||||
"zfs-auto-snapshot removed (existing snapshots preserved on the pool)": "zfs-auto-snapshot bol odstránený (existujúce snapshoty v poole zostali zachované)",
|
||||
"zpool command not found. Install zfsutils-linux and retry.": "Príkaz zpool sa nenašiel. Nainštalujte zfsutils-linux a skúste to znova.",
|
||||
"zpool not found. Install zfsutils-linux and retry.": "zpool sa nenašiel. Nainštalujte zfsutils-linux a skúste to znova.",
|
||||
"{count} saved account.": "{count} uložený účet.",
|
||||
"{count} saved accounts.": "{count} uložené účty.",
|
||||
"{path} already exists and belongs to another user of the host, so {app} cannot write to it.\n\nProxMenux can add access rules (ACL) to this directory, to everything inside it and to what is created there later, for the users and groups {app} runs as on the host (IDs {ids}). The owner of the files does not change and whatever reads or writes them today keeps doing so; the owner of the directory also gets access to the files {app} creates.\n\nGrant {app} access to this directory?": "{path} už existuje a patrí inému používateľovi hostiteľa, takže {app} naň nemôže zapisovať.\n\nProxMenux môže pridať pravidlá prístupu (ACL) k tomuto adresáru, ku všetkému v ňom a k tomu, čo sa v ňom vytvorí neskôr, pre používateľov a skupiny, pod ktorými {app} beží na hostiteľovi (ID {ids}). Vlastník súborov sa nemení a čokoľvek, čo ich dnes číta alebo zapisuje, to robí aj naďalej; vlastník adresára tiež získa prístup k súborom, ktoré {app} vytvorí.\n\nUdeliť {app} prístup k tomuto adresáru?",
|
||||
"{path} already exists and belongs to another user of the host, so {app} cannot write to it.\n\nProxMenux can add an access rule (ACL) for the identity {app} uses on the host (UID {uid}). Nothing that is there changes owner or permissions: whatever reads or writes those files today keeps doing so, and the owner of the directory also gets access to the files {app} creates.\n\nGrant {app} access to this directory?": "{path} už existuje a patrí inému používateľovi hostiteľa, takže {app} naň nemôže zapisovať.\n\nProxMenux môže pridať pravidlo prístupu (ACL) pre identitu, ktorú {app} používa na hostiteľovi (UID {uid}).Nič z toho, čo tam je, nemení vlastníka ani oprávnenia: čokoľvek, čo dnes číta alebo zapisuje tieto súbory, to robí aj naďalej a vlastník adresára tiež získa prístup k súborom, ktoré {app} vytvorí.\n\nUdeliť {app} prístup k tomuto adresáru?",
|
||||
"{registry} answered with plain HTTP. Use a registry with HTTPS; TLS verification remains enabled.": "{registry} odpovedal jednoduchým HTTP. Použite register s HTTPS; Overenie TLS zostáva povolené.",
|
||||
"{registry} did not respond within {timeout} seconds. Try again later.": "{registry} neodpovedal do {timeout} sekúnd. Skúste to znova neskôr.",
|
||||
"{registry} is rate limiting requests. Wait a while and try again.": "{registry} sú požiadavky obmedzujúce rýchlosť. Chvíľu počkajte a skúste to znova.",
|
||||
"{registry} refused access to this image:\n{reference}\n\nIf the image is private, sign in with an account that can pull it.\n\nSign in now?": "{registry} odmietol prístup k tomuto obrázku:\n{reference}\n\nAk je obrázok súkromný, prihláste sa pomocou účtu, ktorý ho môže stiahnuť.\n\nPrihláste sa teraz?",
|
||||
"{registry} reported a server error. Try again later.": "{registry} ohlásil chybu servera. Skúste to znova neskôr.",
|
||||
"{registry} returned invalid or unsupported image metadata.": "{registry} vrátilo neplatné alebo nepodporované metadáta obrázka.",
|
||||
"— Full/Custom restore will skip kernel-tied paths": "- úplná/vlastná obnova preskočí cesty viazané na kernel",
|
||||
"— disk may be busy. Skipping fstab removal.": "- disk sa možno používa. Preskakujem odstránenie z fstab.",
|
||||
"— restore proceeds as same-version (verified safe)": "- obnova pokračuje ako pri rovnakej verzii (overené ako bezpečné)",
|
||||
|
||||
+190
-24
File diff suppressed because it is too large
Load Diff
+46
-1
@@ -10,6 +10,7 @@ This document describes the engine in this directory: how it is organised, how a
|
||||
- [Structure](#structure)
|
||||
- [How a container is created](#how-a-container-is-created)
|
||||
- [Update, modify, recreate and remove](#update-modify-recreate-and-remove)
|
||||
- [Private image registries](#private-image-registries)
|
||||
- [From a Compose file or a docker run command to an oci-compose](#from-a-compose-file-or-a-docker-run-command-to-an-oci-compose)
|
||||
- [The oci-compose, block by block](#the-oci-compose-block-by-block)
|
||||
- [Writing and testing an oci-compose](#writing-and-testing-an-oci-compose)
|
||||
@@ -60,6 +61,46 @@ There are three places an oci-compose comes from:
|
||||
|
||||
On a node, the engine is installed in `/usr/local/share/proxmenux/oci/engine/`. Every installation keeps its record in `/usr/local/share/proxmenux/oci/instances/<VMID>/oci-compose.json`: the oci-compose it was installed from, under `template`, together with the answers given during the installation and the configuration Proxmox VE ended with. Updates, changes and removals read that record.
|
||||
|
||||
## Private image registries
|
||||
|
||||
Open **OCI manager Apps → Registry accounts** on the Proxmox node. Sign in
|
||||
with a registry hostname (for example `ghcr.io` or `registry.example.com:5443`),
|
||||
a username and a password or token with image pull permission. Passwords are
|
||||
entered in hidden fields and confirmed before the registry is contacted.
|
||||
|
||||
Select a saved account to check access to an image, download it to template
|
||||
storage, install it with the existing LXC wizard, update its credentials or
|
||||
remove them. Image references retain their tag or full `sha256` digest.
|
||||
The normal custom-image importer can also use the saved credentials, including
|
||||
Compose files whose services come from private registries. When a registry
|
||||
refuses an image there, the importer offers to sign in on the spot and reads
|
||||
the image again. Updates, Modify, Recreate, the watchdog and the update check
|
||||
of the Monitor use the saved accounts too.
|
||||
|
||||
ProxMenux keeps the accounts in `/etc/pve/priv/proxmenux/registry-auth.json`,
|
||||
where Proxmox keeps its own storage passwords: readable only by root and shared
|
||||
by every node of the cluster. The host backup includes it, and uninstalling
|
||||
ProxMenux removes it. A `REGISTRY_AUTH_FILE` set by the user is respected;
|
||||
without the cluster filesystem, Skopeo's default `/root/.config/containers/auth.json`
|
||||
is used. The
|
||||
credential directory must be owned by root with mode `700`, and the file with
|
||||
mode `600`. Basic credentials are base64 encoded, not encrypted. Configured
|
||||
Skopeo credential helpers use their own stores. Failed sign-in attempts leave
|
||||
the previous file credentials intact; removing an account leaves running
|
||||
applications unchanged. Other Skopeo/Docker authentication files are managed
|
||||
separately and may still grant access.
|
||||
|
||||
TLS verification remains enabled. Install a private registry CA in
|
||||
`/etc/containers/certs.d/REGISTRY[:PORT]/ca.crt` when required, on each node
|
||||
that pulls images. Registry credentials are not written into the application
|
||||
template or copied into its LXC.
|
||||
|
||||
Image access checks have a 30-second timeout per request; sign-in has a 45-second
|
||||
timeout. Errors identify rejected credentials, missing images, incompatible
|
||||
architectures, network/TLS failures, rate limits and registry outages. Downloads
|
||||
reuse the native update cache and layer integrity checks. Damaged cached images
|
||||
are downloaded again, and storage failures leave existing applications alone.
|
||||
|
||||
## How a container is created
|
||||
|
||||
Selecting an application, or giving a definition, opens the same sequence.
|
||||
@@ -101,7 +142,7 @@ An application made of several images is one entry of the catalog and one instal
|
||||
| Operation | What changes | What is kept |
|
||||
|---|---|---|
|
||||
| **Update** | The rootfs is replaced by the image the saved tag publishes today | Record, container disks, host directories, network and devices |
|
||||
| **Modify** | An editor opens with the current resources, network, paths and GPU; the container is rebuilt with the changes | The data of container disks and host directories |
|
||||
| **Modify** | Edit and confirm the configuration; supported native settings apply directly, while other changes use the existing recreation flow | Native edits keep the installed image and rootfs; both paths preserve persistent data |
|
||||
| **Recreate** (multi-container) | Every container is rebuilt from the image it was installed with | Data, network and devices |
|
||||
| **Watchdog** | Whether the application is started again when it stops on its own | Everything; only the choice is saved |
|
||||
| **Remove** | The LXC, or every container of the application, its disks and its record | Host directories, with their content |
|
||||
@@ -118,6 +159,10 @@ Settings changed in Proxmox VE after the installation, such as memory, cores or
|
||||
|
||||
A rolling tag is not an unattended update: the digest changes only through an explicit update.
|
||||
|
||||
For standalone applications, CPU and RAM changes apply live. Supported network settings, environment overrides, directory mounts, additional managed volumes and native devices use a controlled stop/start when needed. Modify does not query the registry or download an image for these native edits, even if the cached archive has already been cleaned. Removing a managed mount detaches its disk without deleting its data; subsequent image updates park that unused disk and return it to the container. An optional retained backup does not force recreation.
|
||||
|
||||
The native path checks the saved configuration and installation identity, keeps a private before/after journal, and restores the original settings if applying them or checking the restarted service fails. It refuses to overwrite concurrent edits. Changes that require installer adaptations, such as NVIDIA runtime changes, GPU permission/environment setup, file mounts, completion notes or a refreshed catalog template, use the existing validated recreation flow. The route is chosen before applying any settings, so one proposal is not partially applied by both paths. The recreation flow reuses a verified cached archive when it matches the resolved image; it downloads an image when a suitable archive is unavailable. Image changes and coordinated stack operations retain their separate update flow.
|
||||
|
||||
## From a Compose file or a docker run command to an oci-compose
|
||||
|
||||
The converter reads a Compose file with one service. A `docker run` command and an image reference alone are first written as a Compose file and follow the same path, so the three produce the same kind of oci-compose.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
# A registry login saved by ProxMenux lets skopeo read private images.
|
||||
if [[ -z ${REGISTRY_AUTH_FILE:-} && -f /etc/pve/priv/proxmenux/registry-auth.json ]]; then
|
||||
export REGISTRY_AUTH_FILE=/etc/pve/priv/proxmenux/registry-auth.json
|
||||
fi
|
||||
|
||||
TEMPLATE_FILE=${1:?template JSON required}
|
||||
DEPLOYMENT_FILE=${2:?deployment JSON required}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
# A registry login saved by ProxMenux lets skopeo read private images.
|
||||
if [[ -z ${REGISTRY_AUTH_FILE:-} && -f /etc/pve/priv/proxmenux/registry-auth.json ]]; then
|
||||
export REGISTRY_AUTH_FILE=/etc/pve/priv/proxmenux/registry-auth.json
|
||||
fi
|
||||
|
||||
TEMPLATE_FILE=${1:?template JSON required}
|
||||
DEPLOYMENT_FILE=${2:?deployment JSON required}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user