diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py index 95825a29..b5797010 100644 --- a/.github/scripts/build_translation_cache.py +++ b/.github/scripts/build_translation_cache.py @@ -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", diff --git a/.github/scripts/build_web_docs_i18n.py b/.github/scripts/build_web_docs_i18n.py index e0567c15..cf77fd5f 100644 --- a/.github/scripts/build_web_docs_i18n.py +++ b/.github/scripts/build_web_docs_i18n.py @@ -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"(?. + re.compile(r"(?]*>.*?", + r"<(code|codeNw|codeXs|kbd|pre)\b[^>]*>.*?", re.IGNORECASE | re.DOTALL, ) @@ -109,16 +131,23 @@ ANY_TAG_RE = re.compile(r"]*>") 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 "" 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'(?:|)(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( ( diff --git a/.github/scripts/check_terminal_catalogs.py b/.github/scripts/check_terminal_catalogs.py new file mode 100644 index 00000000..ba5795da --- /dev/null +++ b/.github/scripts/check_terminal_catalogs.py @@ -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()) diff --git a/.github/scripts/check_web_docs_catalogs.py b/.github/scripts/check_web_docs_catalogs.py new file mode 100644 index 00000000..422b39cb --- /dev/null +++ b/.github/scripts/check_web_docs_catalogs.py @@ -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()) diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 9bd13abc..fe6c26d4 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -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", diff --git a/.github/scripts/tests/test_build_web_docs_i18n.py b/.github/scripts/tests/test_build_web_docs_i18n.py index ea2693ca..691a6639 100644 --- a/.github/scripts/tests/test_build_web_docs_i18n.py +++ b/.github/scripts/tests/test_build_web_docs_i18n.py @@ -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 ("apt update", + "net0 / net1.", + "/api/task-log/<upid>", "/api/vms//config"): + with self.subTest(source=source): + self.assertTrue(MODULE.should_copy(source, ("cell",))) + self.assertFalse(MODULE.should_copy("Run apt update 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." + 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"", f"0")) + self.assertTrue(MODULE.should_copy(f"<{tag}>pct config 100", ("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: Datacenter → Backup → Retention:" + 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 pct config first.', 'Spustite pct config0 najprv.'), + ('Restart=on-failure restarts the service.', + '0Restart=on-failure 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 echo0.', + 'Výsledok je echo0.') + + 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"} diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index 93186be9..f3b48675 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -80,177 +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"]) - messages["vmLxc"]["scheduled"].setdefault( - "reasonStoppedContainer", - english["vmLxc"]["scheduled"]["reasonStoppedContainer"], - ) - messages["vmLxc"]["scheduled"].setdefault( - "reasonStoppedContainerHistorical", - english["vmLxc"]["scheduled"]["reasonStoppedContainerHistorical"], - ) - messages["vmLxc"]["scheduled"].setdefault( - "runSkippedStatus", - english["vmLxc"]["scheduled"]["runSkippedStatus"], - ) - 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, \ @@ -262,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() diff --git a/.github/scripts/tests/test_documentation_markdown.py b/.github/scripts/tests/test_documentation_markdown.py new file mode 100644 index 00000000..98255351 --- /dev/null +++ b/.github/scripts/tests/test_documentation_markdown.py @@ -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() diff --git a/.github/scripts/tests/test_documentation_review_workflows.py b/.github/scripts/tests/test_documentation_review_workflows.py new file mode 100644 index 00000000..49d8cd93 --- /dev/null +++ b/.github/scripts/tests/test_documentation_review_workflows.py @@ -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() diff --git a/.github/scripts/tests/test_installer_languages.py b/.github/scripts/tests/test_installer_languages.py new file mode 100644 index 00000000..0aa0ccef --- /dev/null +++ b/.github/scripts/tests/test_installer_languages.py @@ -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() diff --git a/.github/scripts/tests/test_menu_search.py b/.github/scripts/tests/test_menu_search.py index 2b867a90..72e0db8b 100644 --- a/.github/scripts/tests/test_menu_search.py +++ b/.github/scripts/tests/test_menu_search.py @@ -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) diff --git a/.github/scripts/tests/test_oci_recovery_inventory_wording.py b/.github/scripts/tests/test_oci_recovery_inventory_wording.py index 77e7afaa..338698ae 100644 --- a/.github/scripts/tests/test_oci_recovery_inventory_wording.py +++ b/.github/scripts/tests/test_oci_recovery_inventory_wording.py @@ -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}): diff --git a/.github/scripts/tests/test_oci_removal_firewall_wording.py b/.github/scripts/tests/test_oci_removal_firewall_wording.py index bde90937..8041f923 100644 --- a/.github/scripts/tests/test_oci_removal_firewall_wording.py +++ b/.github/scripts/tests/test_oci_removal_firewall_wording.py @@ -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) diff --git a/.github/scripts/tests/test_oci_selection_setup_wording.py b/.github/scripts/tests/test_oci_selection_setup_wording.py index 9d35d7d5..7baa2ad5 100644 --- a/.github/scripts/tests/test_oci_selection_setup_wording.py +++ b/.github/scripts/tests/test_oci_selection_setup_wording.py @@ -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'} diff --git a/.github/scripts/tests/test_oci_update_preservation_wording.py b/.github/scripts/tests/test_oci_update_preservation_wording.py index 44146970..e3fdae92 100644 --- a/.github/scripts/tests/test_oci_update_preservation_wording.py +++ b/.github/scripts/tests/test_oci_update_preservation_wording.py @@ -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, diff --git a/.github/scripts/tests/test_terminal_catalogs.py b/.github/scripts/tests/test_terminal_catalogs.py new file mode 100644 index 00000000..f74966cb --- /dev/null +++ b/.github/scripts/tests/test_terminal_catalogs.py @@ -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() diff --git a/.github/scripts/tests/test_vm_backup_jobs_wiring.py b/.github/scripts/tests/test_vm_backup_jobs_wiring.py index a8ecf4f5..930c82e3 100644 --- a/.github/scripts/tests/test_vm_backup_jobs_wiring.py +++ b/.github/scripts/tests/test_vm_backup_jobs_wiring.py @@ -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})') diff --git a/.github/scripts/tests/test_web_docs_catalogs.py b/.github/scripts/tests/test_web_docs_catalogs.py new file mode 100644 index 00000000..3d851425 --- /dev/null +++ b/.github/scripts/tests/test_web_docs_catalogs.py @@ -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 apt update."}) + self.write_catalog(root, "de", {"body": "Führe apt aktualisieren 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) diff --git a/.github/scripts/tests/test_web_export_language.py b/.github/scripts/tests/test_web_export_language.py new file mode 100644 index 00000000..143b0ccb --- /dev/null +++ b/.github/scripts/tests/test_web_export_language.py @@ -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 = '

Text

' + 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(' "$target/banner" + test -s "$target/banner" + printf 'PASS: %s version and login banner\n' "$arch" + done <<'ASSETS' + amd64 f2b1c076942c94009d133df0f304a234a0994bf590b40f2927668324d578306a + arm64 611550ddb31a75437be3f8587bcdd029d0d890f4d820b7f494a2c6615fecba10 + arm 429e66bded486d49f730c4a6559e728cd1e9a163a0f56a85867e2cbec010171d + ASSETS diff --git a/.github/workflows/test-guest-backups.yml b/.github/workflows/test-guest-backups.yml new file mode 100644 index 00000000..60da0353 --- /dev/null +++ b/.github/workflows/test-guest-backups.yml @@ -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 diff --git a/.github/workflows/test-i18n.yml b/.github/workflows/test-i18n.yml index 3a2fc5f0..7f4dfb51 100644 --- a/.github/workflows/test-i18n.yml +++ b/.github/workflows/test-i18n.yml @@ -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 diff --git a/.github/workflows/test-lvm-disk-policy.yml b/.github/workflows/test-lvm-disk-policy.yml new file mode 100644 index 00000000..c7e9ea92 --- /dev/null +++ b/.github/workflows/test-lvm-disk-policy.yml @@ -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 diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 9612c0d3..a4f0b150 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -28,7 +28,55 @@ jobs: 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 diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml index 29af7416..a562c6b3 100644 --- a/.github/workflows/test-offline-qualified.yml +++ b/.github/workflows/test-offline-qualified.yml @@ -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' diff --git a/.github/workflows/test-pbs.yml b/.github/workflows/test-pbs.yml new file mode 100644 index 00000000..2dbc71b0 --- /dev/null +++ b/.github/workflows/test-pbs.yml @@ -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 diff --git a/AppImage/components/apprise-service-help.tsx b/AppImage/components/apprise-service-help.tsx new file mode 100644 index 00000000..de9d132b --- /dev/null +++ b/AppImage/components/apprise-service-help.tsx @@ -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
+ {t("settings.notifications.ui.appriseServiceHelp")} +
+
+

Slack

+

{t("settings.notifications.ui.appriseSlackSteps")}

+ https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C + + {t("settings.notifications.ui.appriseSlackDocs")} + +
+
+

Matrix

+

{t("settings.notifications.ui.appriseMatrixSteps")}

+ matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com + + {t("settings.notifications.ui.appriseMatrixDocs")} + +
+
+

{t("settings.notifications.ui.appriseServiceFinish")}

+
+} diff --git a/AppImage/components/backup-storage-retention.tsx b/AppImage/components/backup-storage-retention.tsx new file mode 100644 index 00000000..4d15c6a5 --- /dev/null +++ b/AppImage/components/backup-storage-retention.tsx @@ -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(null) + const [fields, setFields] = useState>({}) + 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 + + {t("backup.storageRetention.title")} + + + +

{t("backup.storageRetention.description")}

+ {message &&

{message}

} + {error ?

{t("backup.storageRetention.loadFailed")}

: !data ? + : !data.storages.length ? +

{t("backup.storageRetention.empty")}

: + data.storages.map(storage =>
+ {storage.id}{storage.type} + {storage.prune_backups || t("backup.storageRetention.default")} + +
)} +
+ { if (!open && !busy) setEditing(null) }}> + + {t("backup.storageRetention.editTitle", { storage: editing?.id ?? "" })} + {t("backup.storageRetention.effect")} + + +
+ {RETENTION.map(key =>
+ + setFields(previous => ({ ...previous, [key]: event.target.value }))} /> +
)} +
+ {saveError &&

{saveError}

} + + +
+
+
+} diff --git a/AppImage/components/backup-tab.tsx b/AppImage/components/backup-tab.tsx index ae62e19c..b97c6232 100644 --- a/AppImage/components/backup-tab.tsx +++ b/AppImage/components/backup-tab.tsx @@ -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) => ( ))} @@ -51,7 +53,8 @@ export function BackupTab() {
- {view === "guests" && } + {view === "guests" && <>} + {view === "pbs" && } ) } diff --git a/AppImage/components/guest-backup-list.tsx b/AppImage/components/guest-backup-list.tsx new file mode 100644 index 00000000..3f6878f7 --- /dev/null +++ b/AppImage/components/guest-backup-list.tsx @@ -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 + storageColor?: (storage: string) => { bg: string; text: string; border: string } +}) { + const t = useT() + const [selected, setSelected] = useState([]) + 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 <> +
+
+ {t("vmLxc.backups.available")} + {backups.length} +
+
+ + +
+
+ {message &&

{message}

} + {error &&

{error}

} + {loading ?
+ {t("vmLxc.backups.loading")} +
: backups.length === 0 ?
+ {t("vmLxc.backups.empty")} + {t("vmLxc.backups.emptyHint")} +
: <> + +
+ {backups.map(backup =>