diff --git a/.github/scripts/build_i18n_messages.py b/.github/scripts/build_i18n_messages.py new file mode 100644 index 00000000..81a552b2 --- /dev/null +++ b/.github/scripts/build_i18n_messages.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +Auto-translate missing keys in AppImage/messages//common.json +against the English source (AppImage/messages/en/common.json). + +Guardrails: + - Keys already translated in a target locale are PRESERVED. A key is + considered "already translated" when the target value is non-empty + AND differs from the English source. This protects human-curated + locales (Vaso73's sk) from being overwritten. + - `{placeholder}` tokens (next-intl style: `{vmid}`, `{appName}`, etc.) + are extracted before translation and restored afterwards, so the + interpolation contract stays intact regardless of what the + translation provider does with the surrounding text. + - `sk` IS translated by default too. Guardrail #1 protects every key + Vaso73 has curated by hand; auto-translation only fills the keys + that are still on the English fallback in sk. + +Reuses the same translation providers as build_translation_cache.py so +the CI environment (googletrans pinning, AppImage provider) stays +identical. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import time +from pathlib import Path + +# Reuse providers + cleaner from the CLI translation script. +sys.path.insert(0, str(Path(__file__).parent)) +from build_translation_cache import ( # noqa: E402 + clean_translation, + protect_technical_terms, + restore_technical_terms, + translate_appimage, + translate_google_web, + translate_googletrans, +) + + +# sk IS included in the default. Guardrail #1 (never overwrite a key +# whose target value differs from EN) protects every string Vaso73 has +# already curated by hand — auto-translation only ever touches keys +# that are still on the English fallback in sk. Trade-off accepted: +# users on sk see a decent auto-translation for new keys instead of raw +# English while the human maintainer catches up, and Vaso73 keeps full +# ownership of the wording via follow-up PRs. +DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv") +DEFAULT_CONTEXT = "Context: Technical UI text for a Proxmox management dashboard. Translate:" + +# next-intl / ICU-style placeholders: {name}, {vmid}, {count}, {app_name}. +# We deliberately do NOT match `{{ escaped }}` or nested braces — the +# codebase uses only the simple form. +PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}") + + +def flatten(node: dict, prefix: str = "") -> dict[str, str]: + """Depth-first flatten of a nested dict into ``{"a.b.c": "value"}``. + Non-string leaves are coerced to str (should not happen in messages + catalogs, but keeps the function total).""" + out: dict[str, str] = {} + for key, value in node.items(): + path = f"{prefix}{key}" if not prefix else f"{prefix}.{key}" + if isinstance(value, dict): + out.update(flatten(value, path)) + elif value is None: + out[path] = "" + else: + out[path] = str(value) + return out + + +def unflatten(flat: dict[str, str]) -> dict: + """Inverse of ``flatten``: rebuild nested structure from dotted keys.""" + out: dict = {} + for path, value in flat.items(): + parts = path.split(".") + cursor = out + for part in parts[:-1]: + existing = cursor.get(part) + if not isinstance(existing, dict): + existing = {} + cursor[part] = existing + cursor = existing + cursor[parts[-1]] = value + return out + + +def protect_placeholders(text: str) -> tuple[str, list[str]]: + """Swap each ``{xxx}`` for an opaque token that machine translators + tend to leave alone. Order is preserved so restore_placeholders can + walk it linearly.""" + placeholders: list[str] = [] + + def _swap(match: re.Match) -> str: + placeholders.append(match.group(0)) + return f"__PMX_PH_{len(placeholders) - 1}__" + + return PLACEHOLDER_RE.sub(_swap, text), placeholders + + +def restore_placeholders(text: str, placeholders: list[str]) -> str: + """Reverse of ``protect_placeholders``. If the provider mangled a + token beyond recognition we leave the mangled form in place — the + fallback assignment (target = existing or EN) upstream catches + the worst case.""" + for i, original in enumerate(placeholders): + text = text.replace(f"__PMX_PH_{i}__", original) + return text + + +def translate_one( + text: str, + lang: str, + provider: str, + context: str, + timeout: int, + appimage_path: Path, +) -> str: + """Dispatch to the correct provider. Reuses the same three + implementations as build_translation_cache.py so there is exactly + one place to fix if a provider changes upstream.""" + if provider == "googletrans": + raw = translate_googletrans(text, lang, context) + elif provider == "google-web": + raw = translate_google_web(text, lang, context, timeout) + elif provider == "appimage": + raw = translate_appimage(text, lang, context, timeout, appimage_path) + else: + raise ValueError(f"Unknown provider: {provider}") + return clean_translation(raw) or text + + +def read_json(path: Path) -> dict: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except json.JSONDecodeError as exc: + raise SystemExit(f"Invalid JSON at {path}: {exc}") from exc + + +def write_json(path: Path, data: dict) -> None: + """Write with indent=2, no sort_keys — we want to keep the same + top-level ordering the maintainer uses in en/common.json so diffs + stay readable side-by-side.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text( + json.dumps(data, ensure_ascii=False, indent=2, sort_keys=False) + "\n", + encoding="utf-8", + ) + tmp.replace(path) + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--source", + type=Path, + default=Path("AppImage/messages/en/common.json"), + help="Path to the English source catalog.", + ) + parser.add_argument( + "--messages-dir", + type=Path, + default=Path("AppImage/messages"), + help="Directory that contains per-locale subdirectories.", + ) + parser.add_argument( + "--languages", + default=",".join(DEFAULT_LANGUAGES), + help=( + "Comma-separated target locales. Includes sk by default; " + "guardrail #1 never overwrites keys whose sk value differs " + "from EN, so Vaso73's curated translations are safe." + ), + ) + parser.add_argument( + "--provider", + choices=("appimage", "googletrans", "google-web"), + default="googletrans", + help="Translation provider. Default matches build_translation_cache.", + ) + parser.add_argument( + "--appimage-path", + type=Path, + default=Path("/usr/local/share/proxmenux/ProxMenux-Monitor.AppImage"), + ) + parser.add_argument("--context", default=DEFAULT_CONTEXT) + parser.add_argument("--timeout", type=int, default=30) + parser.add_argument("--sleep", type=float, default=0.15) + parser.add_argument( + "--refresh", + action="store_true", + help=( + "Re-translate EVERY key, ignoring existing translations. " + "Dangerous: this DOES overwrite human-curated strings. " + "Use only when you know what you are doing." + ), + ) + parser.add_argument( + "--limit", + type=int, + default=0, + help="Only translate the first N missing keys per locale (test runs).", + ) + parser.add_argument( + "--save-every", + type=int, + default=50, + help="Write the locale JSON every N translated keys so a crash mid-run leaves partial progress on disk.", + ) + return parser + + +def main() -> int: + args = build_arg_parser().parse_args() + + source = args.source.resolve() + messages_dir = args.messages_dir.resolve() + languages = [lang.strip() for lang in args.languages.split(",") if lang.strip()] + + if not source.is_file(): + print(f"Source not found: {source}", file=sys.stderr) + return 1 + if not languages: + print("No destination languages selected.", file=sys.stderr) + return 1 + + en_nested = read_json(source) + en_flat = flatten(en_nested) + print(f"Source: {source}", flush=True) + print(f"EN keys: {len(en_flat)}", flush=True) + print(f"Target locales: {', '.join(languages)}", flush=True) + print(f"Provider: {args.provider}", flush=True) + print(f"Sleep between calls: {args.sleep}s", flush=True) + + total_failures: list[tuple[str, str, str]] = [] + + for lang in languages: + locale_path = messages_dir / lang / "common.json" + target_flat = flatten(read_json(locale_path)) + + # Decide what needs translating. Same rule as + # build_translation_cache.py: only touch keys whose target + # value is empty. Never overwrite an existing value — that + # covers three legitimate cases in one line: + # 1. Human-curated translations (protected trivially). + # 2. Universal tokens the maintainer left equal to EN on + # purpose (Hardware, Terminal, Normal, SMART, OK, CPU %, + # {count}h, product names, etc.). Around 120 keys in + # es/common.json — the old `existing == en_value` rule + # kept resending these to Google every run, and the + # provider sometimes mangled them (`Terminal` → + # `terminal`, `CPU %` → `% de CPU`, ...). + # 3. Auto-fills from prior runs whose output happened to + # match EN — leaving them alone is the intended + # steady-state, not a bug. + # When someone genuinely wants to redo everything, --refresh + # is still available (and is destructive by design). + missing: list[str] = [] + for key, en_value in en_flat.items(): + if not en_value: + continue + existing = target_flat.get(key, "") + if args.refresh or not existing: + missing.append(key) + + if args.limit > 0: + missing = missing[: args.limit] + + print(f"\n=== {lang}: {len(missing)} keys to translate ===", flush=True) + if not missing: + print(f" {lang}: nothing to do", flush=True) + continue + + failures_for_lang: list[tuple[str, str, str]] = [] + + for index, key in enumerate(missing, start=1): + en_value = en_flat[key] + protected, placeholders = protect_placeholders(en_value) + protected, technical_terms = protect_technical_terms(protected) + + try: + translated = translate_one( + protected, + lang, + args.provider, + args.context, + args.timeout, + args.appimage_path, + ) + translated = restore_technical_terms(translated, technical_terms) + target_flat[key] = restore_placeholders(translated, placeholders) + print( + f" [{lang} {index}/{len(missing)}] {key}: " + f"{en_value[:60]!r} → {target_flat[key][:60]!r}", + flush=True, + ) + except Exception as exc: # noqa: BLE001 + # Fall back to whatever we already had (or EN) so the + # runtime fallback still kicks in for this key. + target_flat[key] = target_flat.get(key) or en_value + failures_for_lang.append((lang, key, str(exc))) + print(f" [{lang}] {key}: FAILED — {exc}", file=sys.stderr, flush=True) + + if args.save_every > 0 and index % args.save_every == 0: + # Preserve keys already in target_flat + write partial progress. + write_json(locale_path, unflatten(target_flat)) + time.sleep(args.sleep) + + write_json(locale_path, unflatten(target_flat)) + print(f" wrote {locale_path}", flush=True) + total_failures.extend(failures_for_lang) + + if total_failures: + print( + f"\nCompleted with {len(total_failures)} translation failures " + f"(partial progress persisted, next run will retry).", + file=sys.stderr, + flush=True, + ) + for lang, key, error in total_failures[:20]: + print(f" - {lang}: {key} → {error}", file=sys.stderr, flush=True) + if len(total_failures) > 20: + print(f" ... and {len(total_failures) - 20} more.", file=sys.stderr, flush=True) + # Exit 0 on partial failure so the workflow's Commit + push + # step still runs and the keys that DID translate reach + # develop. The un-translated keys stay empty and the next + # workflow tick (or a manual dispatch) retries them. + # Previously we returned 2, which failed the whole run and + # discarded 36 out of 38 successful translations because + # 2 googletrans timeouts hit sv at the start of the burst. + return 0 + + print("\ni18n messages generated successfully.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py index e3603aaa..394d2784 100644 --- a/.github/scripts/build_translation_cache.py +++ b/.github/scripts/build_translation_cache.py @@ -16,6 +16,8 @@ from __future__ import annotations import argparse import ast +import asyncio +import inspect import json import os import subprocess @@ -28,14 +30,77 @@ from urllib.parse import quote from urllib.request import Request, urlopen -DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt") +DEFAULT_LANGUAGES = ("es", "fr", "de", "it", "pt", "sk", "sv") DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:" +# googletrans and the public Google endpoint used by this workflow do not +# support Cloud Translation glossaries. Protect product names, package names +# and command identifiers with opaque tokens before sending text to any +# provider, then restore the exact source spelling afterwards. Keep longer +# terms first so ``gasket`` cannot consume part of ``gasket-dkms``. +PROTECTED_TECHNICAL_TERMS = ( + "google/gasket-driver", + "feranick/gasket-driver", + "libedgetpu1-std", + "Proxmox VE Helper-Scripts", + "Docker Compose", + "gasket-driver", + "gasket-dkms", + "libedgetpu1", + "libedgetpu", + "Google Coral", + "Edge TPU", + "ProxMenux", + "Proxmox", + "AppImage", + "smartctl", + "systemctl", + "pveproxy", + "apt-get", + "Frigate", + "Docker", + "Coral", + "gasket", + "apex", + "lspci", + "dpkg", + "DKMS", + "QEMU", + "LXC", + "ZFS", + "SSH", + "fork", +) +TECHNICAL_TERM_RE = re.compile( + "|".join( + rf"(?["'])(?P(?:\\.|(?! (?P=quote) ).)*?)(?P=quote)""", re.VERBOSE | re.DOTALL, ) +def protect_technical_terms(text: str) -> tuple[str, list[str]]: + """Replace glossary terms with stable tokens before translation.""" + protected: list[str] = [] + + def _swap(match: re.Match[str]) -> str: + protected.append(match.group(0)) + return f"__PMX_TERM_{len(protected) - 1}__" + + return TECHNICAL_TERM_RE.sub(_swap, text), protected + + +def restore_technical_terms(text: str, protected: list[str]) -> str: + """Restore glossary terms exactly as they appeared in the source.""" + for index, original in enumerate(protected): + text = text.replace(f"__PMX_TERM_{index}__", original) + return text + + def iter_script_files( scripts_dir: Path, extra_files: Iterable[Path] = () ) -> Iterable[Path]: @@ -97,7 +162,10 @@ def translate_googletrans(text: str, dest_lang: str, context: str) -> str: translator = Translator() full_text = f"{context} {text}".strip() - return translator.translate(full_text, dest=dest_lang).text + result = translator.translate(full_text, dest=dest_lang) + if inspect.isawaitable(result): + result = asyncio.run(result) + return result.text def translate_google_web(text: str, dest_lang: str, context: str, timeout: int) -> str: @@ -163,8 +231,23 @@ def translate_appimage( def clean_translation(value: str) -> str: separator = r"[\s\u00a0]*[::]" - translate_labels = "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir" - context_labels = "Context|Contexto|Contexte|Kontext|Contesto" + # `Translate` pivot in every locale currently supported. Without + # the target-language variant here, the cleaner can't locate the + # boundary between the context prompt and the real translation, + # and the whole payload leaks through as the translated context. + # Caught on the 2026-08-14 workflow run — every sk / sv key ended + # up as "Technický text používateľského rozhrania…" or + # "Teknisk gränssnittstext för en Proxmox-hanteringspanel.Övers" + # because Preložiť / Översätt / Översätta were missing. + translate_labels = ( + "Translate|Traducir|Traduire|Übersetzen|Tradurre|Traduci|Traduzir" + "|Preložiť|Prelož|Preloz" # sk + "|Översätta|Översätt" # sv + ) + context_labels = ( + "Context|Contexto|Contexte|Kontext|Contesto" + "|Sammanhang" # sv alternate + ) value = re.sub( rf"^.*?({translate_labels}){separator}", "", @@ -194,15 +277,19 @@ def translate_text( timeout: int, appimage_path: Path, ) -> str: + protected_text, protected_terms = protect_technical_terms(text) if provider == "googletrans": - translated = translate_googletrans(text, dest_lang, context) + translated = translate_googletrans(protected_text, dest_lang, context) elif provider == "google-web": - translated = translate_google_web(text, dest_lang, context, timeout) + translated = translate_google_web(protected_text, dest_lang, context, timeout) elif provider == "appimage": - translated = translate_appimage(text, dest_lang, context, timeout, appimage_path) + translated = translate_appimage( + protected_text, dest_lang, context, timeout, appimage_path + ) else: raise ValueError(f"Unknown provider: {provider}") - return clean_translation(translated) or text + translated = restore_technical_terms(clean_translation(translated), protected_terms) + return translated or text def load_language_cache(path: Path) -> dict[str, str]: @@ -260,7 +347,7 @@ def build_arg_parser() -> argparse.ArgumentParser: parser.add_argument( "--languages", default=",".join(DEFAULT_LANGUAGES), - help="Comma-separated destination languages. Default: es,fr,de,it,pt", + help="Comma-separated destination languages. Default: es,fr,de,it,pt,sk", ) parser.add_argument( "--provider", @@ -373,12 +460,20 @@ def main() -> int: write_language_cache(output_dir / f"{lang}.json", cache) if failures: - print(f"Completed with {len(failures)} translation failures.", file=sys.stderr, flush=True) + print( + f"Completed with {len(failures)} translation failures " + f"(partial progress persisted, next run will retry).", + file=sys.stderr, flush=True, + ) for text, lang, error in failures[:20]: print(f"- {lang}: {text[:80]} -> {error}", file=sys.stderr, flush=True) if len(failures) > 20: print(f"... and {len(failures) - 20} more.", file=sys.stderr, flush=True) - return 2 + # Exit 0 on partial failure so the workflow's Commit + push + # step still runs. Otherwise a couple of transient googletrans + # timeouts would fail the whole workflow and discard every + # successful translation from the same batch. + return 0 print("Translation cache generated successfully.", flush=True) return 0 diff --git a/.github/scripts/build_web_docs_i18n.py b/.github/scripts/build_web_docs_i18n.py new file mode 100644 index 00000000..9c8e5c94 --- /dev/null +++ b/.github/scripts/build_web_docs_i18n.py @@ -0,0 +1,853 @@ +#!/usr/bin/env python3 +"""Build missing translations for the ProxMenux documentation catalog.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +sys.path.insert(0, str(Path(__file__).parent)) +from build_translation_cache import ( # noqa: E402 + clean_translation, + translate_appimage, + translate_google_web, + translate_googletrans, +) + + +DEFAULT_LANGUAGES = ("es", "de", "fr", "it", "pt", "sk", "sv") +DEFAULT_CONTEXT = ( + "Context: ProxMenux technical documentation for Proxmox VE users. " + "Preserve product names, commands, paths, variables and placeholders. Translate:" +) + +TECHNICAL_TERMS = ( + "Proxmox VE Helper-Scripts", + "Proxmox Backup Server", + "Proxmox Mail Gateway", + "Proxmox VE", + "ProxMenux Monitor", + "ProxMenux Scripts", + "Docker Compose", + "Docker Engine", + "Google Coral", + "Edge TPU", + "Let's Encrypt", + "Cloudflare", + "Pushover", + "Telegram", + "Discord", + "Microsoft Teams", + "GitHub", + "Gotify", + "Apprise", + "Frigate", + "Vaultwarden", + "Portainer", + "ProxMenux", + "AppImage", + "systemctl", + "journalctl", + "smartctl", + "pveproxy", + "apt-get", + "gasket-dkms", + "libedgetpu", + "QEMU", + "LXC", + "ZFS", + "Ceph", + "Docker", + "OpenAI", + "WebSocket", + "OAuth", + "DKMS", + "SSH", + "API", +) + +PROTECTED_PATTERNS = ( + # Keep rich-text tags visible to Google Translate. It preserves their + # structure while translating the enclosed prose, whereas replacing + # opening/closing tags with adjacent sentinels can make the provider drop + # one side of the pair. The contract is validated after translation. + re.compile(r"`[^`]+`"), + re.compile(r"https?://[^\s<>]+"), + re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}"), + re.compile(r"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"), + re.compile(r"(?]*>.*?", + re.IGNORECASE | re.DOTALL, +) + +TERM_RE = re.compile( + "|".join( + rf"(?") +PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}") +NON_TRANSLATABLE_KEYS = { + "command", + "code", + "href", + "icon", + "id", + "path", + "route", + "slug", + "src", + "url", +} +SOURCE_STATE_VERSION = 1 + + +@dataclass(frozen=True) +class Leaf: + path: tuple[str | int, ...] + source: str + + +def read_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON in {path}: {exc}") from exc + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps(value, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + temporary.replace(path) + + +def iter_leaves(node: Any, path: tuple[str | int, ...] = ()) -> list[Leaf]: + leaves: list[Leaf] = [] + if isinstance(node, dict): + for key, value in node.items(): + leaves.extend(iter_leaves(value, path + (key,))) + elif isinstance(node, list): + for index, value in enumerate(node): + leaves.extend(iter_leaves(value, path + (index,))) + elif isinstance(node, str): + leaves.append(Leaf(path, node)) + return leaves + + +def get_at_path(node: Any, path: tuple[str | int, ...]) -> Any: + current = node + try: + for part in path: + if isinstance(part, int): + if not isinstance(current, list): + return None + current = current[part] + else: + if not isinstance(current, dict): + return None + current = current[part] + except (IndexError, KeyError, TypeError): + return None + return current + + +def path_key(path: tuple[str | int, ...]) -> str: + return ".".join(str(part) for part in path) + + +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 "" + 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): + return True + if re.fullmatch(r"[A-Z0-9_.:/+-]{2,}", source): + return True + return False + + +def leaf_token(path: tuple[str | int, ...]) -> str: + return json.dumps(path, ensure_ascii=False, separators=(",", ":")) + + +def source_fingerprints(source: Any) -> dict[str, str]: + return { + leaf_token(leaf.path): hashlib.sha256(leaf.source.encode("utf-8")).hexdigest()[:20] + for leaf in iter_leaves(source) + } + + +def needs_translation( + source: str, + target: Any, + path: tuple[str | int, ...], + refresh: bool, + forced_tokens: set[str] | None = None, +) -> bool: + if should_copy(source, path): + return False + if refresh: + return True + if forced_tokens and leaf_token(path) in forced_tokens: + return True + return not isinstance(target, str) or not target.strip() or target == source + + +def protect_rich_tags(text: str) -> tuple[str, dict[str, str]]: + """Give translatable rich-text tags opaque names during translation. + + Google can remove semantic tags such as ``strong`` or ``em`` after + translating their contents. Unknown tag names are retained, so rename + non-literal tags temporarily and restore them before contract validation. + Literal ``code``, ``kbd`` and ``pre`` elements stay untouched because the + provider preserves both their markup and their contents. + """ + + names: dict[str, str] = {} + reverse: dict[str, str] = {} + + def replace(match: re.Match[str]) -> str: + original = match.group(1) + if original.lower() in {"code", "kbd", "pre"}: + return match.group(0) + key = original.lower() + internal = names.get(key) + if internal is None: + internal = f"pmxrich{len(names):04d}" + names[key] = internal + reverse[internal] = original + slash = "/" if match.group(0).startswith("" + + return TAG_RE.sub(replace, text), reverse + + +def restore_rich_tags(text: str, mapping: dict[str, str]) -> str: + for internal, original in mapping.items(): + text = re.sub( + rf"<(/?){re.escape(internal)}>", + lambda match: f"<{match.group(1)}{original}>", + text, + flags=re.IGNORECASE, + ) + return text + + +def protect_text(text: str) -> tuple[str, dict[str, str]]: + # Google already keeps both the markup and the contents of literal rich- + # text blocks. Replacing a filename or path inside one of these blocks can + # leave the sentinel as its only child, which the provider may discard. + literal_ranges = [(match.start(), match.end()) for match in LITERAL_TAG_RE.finditer(text)] + + def overlaps_literal(start: int, end: int) -> bool: + return any(start < literal_end and end > literal_start for literal_start, literal_end in literal_ranges) + + candidates: list[tuple[int, int]] = [] + for pattern in PROTECTED_PATTERNS: + candidates.extend( + (match.start(), match.end()) + for match in pattern.finditer(text) + if not overlaps_literal(match.start(), match.end()) + ) + candidates.extend( + (match.start(), match.end()) + for match in TERM_RE.finditer(text) + if not overlaps_literal(match.start(), match.end()) + ) + candidates.sort(key=lambda item: (item[0], -(item[1] - item[0]))) + + selected: list[tuple[int, int]] = [] + cursor = -1 + for start, end in candidates: + if start >= cursor: + selected.append((start, end)) + cursor = end + + mapping: dict[str, str] = {} + chunks: list[str] = [] + cursor = 0 + for index, (start, end) in enumerate(selected): + # Google Translate can drop underscore-delimited sentinels when they + # sit directly beside inline markup (for example an opening + # token followed by translated prose). Triple brackets remain opaque + # in that position and preserve the complete rich-text contract. + token = f"[[[PMXDOC{index:04d}]]]" + chunks.append(text[cursor:start]) + chunks.append(token) + mapping[token] = text[start:end] + cursor = end + chunks.append(text[cursor:]) + return "".join(chunks), mapping + + +def restore_text(text: str, mapping: dict[str, str]) -> str: + missing = [token for token in mapping if token not in text] + if missing: + raise ValueError(f"translation provider changed protected token {missing[0]}") + for token, original in mapping.items(): + text = text.replace(token, original) + return text + + +def validate_contract(source: str, target: str) -> None: + if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)): + raise ValueError("rich-text tag contract changed") + if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)): + raise ValueError("placeholder contract changed") + + +def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]: + def translate(text: str, language: str) -> str: + if args.provider == "googletrans": + raw = translate_googletrans(text, language, args.context) + elif args.provider == "google-web": + raw = translate_google_web(text, language, args.context, args.timeout) + else: + raw = translate_appimage( + text, + language, + args.context, + args.timeout, + args.appimage_path, + ) + return clean_translation(raw).strip() + + return translate + + +def translate_with_retry( + source: str, + language: str, + translate: Callable[[str, str], str], + retries: int, + delay: float, +) -> str: + rich_text, rich_mapping = protect_rich_tags(source) + protected, mapping = protect_text(rich_text) + last_error: Exception | None = None + for attempt in range(retries + 1): + try: + translated = translate(protected, language) + if not translated: + raise ValueError("translation provider returned an empty value") + translated = restore_text(translated, mapping) + translated = restore_rich_tags(translated, rich_mapping) + validate_contract(source, translated) + return translated + except Exception as exc: # network/provider errors are retried together + last_error = exc + if attempt < retries: + time.sleep(delay * (attempt + 1)) + raise RuntimeError(str(last_error)) from last_error + + +def merge_tree( + source: Any, + target: Any, + translations: dict[tuple[str | int, ...], str], + path: tuple[str | int, ...] = (), +) -> Any: + if isinstance(source, dict): + target_dict = target if isinstance(target, dict) else {} + return { + key: merge_tree(value, target_dict.get(key), translations, path + (key,)) + for key, value in source.items() + } + if isinstance(source, list): + target_list = target if isinstance(target, list) else [] + return [ + merge_tree( + value, + target_list[index] if index < len(target_list) else None, + translations, + path + (index,), + ) + for index, value in enumerate(source) + ] + if isinstance(source, str): + if path in translations: + return translations[path] + if should_copy(source, path): + return source + if isinstance(target, str) and target.strip(): + return target + return source + return source + + +def source_files(source_root: Path, section: str) -> list[Path]: + scope = (source_root / section).resolve() + root = source_root.resolve() + if scope != root and root not in scope.parents: + raise ValueError("section must stay inside the English messages directory") + if scope.is_file(): + if scope.suffix != ".json": + raise ValueError("section file must be JSON") + return [scope] + if not scope.is_dir(): + raise ValueError(f"section does not exist: {scope}") + return sorted( + path + for path in scope.rglob("*.json") + if not any(part.startswith(".") for part in path.relative_to(root).parts) + ) + + +def collect_memory(source_root: Path, messages_root: Path, language: str) -> dict[str, str]: + memory: dict[str, str] = {} + conflicts: set[str] = set() + for source_path in sorted(source_root.rglob("*.json")): + if any(part.startswith(".") for part in source_path.relative_to(source_root).parts): + continue + target_path = messages_root / language / source_path.relative_to(source_root) + source = read_json(source_path) + target = read_json(target_path) + if target is None: + continue + for leaf in iter_leaves(source): + translated = get_at_path(target, leaf.path) + if not isinstance(translated, str) or not translated.strip() or translated == leaf.source: + continue + previous = memory.get(leaf.source) + if previous is not None and previous != translated: + conflicts.add(leaf.source) + else: + memory[leaf.source] = translated + for source in conflicts: + memory.pop(source, None) + return memory + + +def pending_leaves( + source: Any, + target: Any, + refresh: bool, + forced_tokens: set[str] | None = None, +) -> list[Leaf]: + return [ + leaf + for leaf in iter_leaves(source) + if needs_translation( + leaf.source, + get_at_path(target, leaf.path), + leaf.path, + refresh, + forced_tokens, + ) + ] + + +def schema_matches(source: Any, target: Any) -> bool: + if type(source) is not type(target): + return False + if isinstance(source, dict): + return list(source) == list(target) and all( + schema_matches(source[key], target[key]) for key in source + ) + if isinstance(source, list): + return len(source) == len(target) and all( + schema_matches(left, right) for left, right in zip(source, target) + ) + if isinstance(source, str): + # Localized strings intentionally differ from the English source. + return True + # Booleans, numbers and null values are structural data and must stay in + # sync with the English catalog instead of retaining an obsolete value. + return source == target + + +def empty_source_state() -> dict[str, Any]: + return { + "version": SOURCE_STATE_VERSION, + "initialized": False, + "source": {}, + "pending": {}, + } + + +def load_source_state(path: Path) -> dict[str, Any]: + if not path.exists(): + return empty_source_state() + try: + value = read_json(path) + except ValueError: + return empty_source_state() + if not isinstance(value, dict) or value.get("version") != SOURCE_STATE_VERSION: + return empty_source_state() + if not isinstance(value.get("source"), dict) or not isinstance(value.get("pending"), dict): + return empty_source_state() + value.setdefault("initialized", True) + return value + + +def state_pending_tokens(state: dict[str, Any], language: str, relative: str) -> set[str]: + language_state = state.setdefault("pending", {}).setdefault(language, {}) + values = language_state.get(relative, []) + if not isinstance(values, list): + return set() + return {str(value) for value in values} + + +def set_state_pending_tokens( + state: dict[str, Any], language: str, relative: str, tokens: set[str] +) -> None: + language_state = state.setdefault("pending", {}).setdefault(language, {}) + if tokens: + language_state[relative] = sorted(tokens) + else: + language_state.pop(relative, None) + if not language_state: + state["pending"].pop(language, None) + + +def translate_file( + source: Any, + target: Any, + leaves: list[Leaf], + language: str, + memory: dict[str, str], + translate: Callable[[str, str], str], + args: argparse.Namespace, +) -> tuple[Any | None, list[str], int]: + resolved: dict[tuple[str | int, ...], str] = {} + failures: list[str] = [] + jobs: dict[str, list[tuple[str | int, ...]]] = {} + + for leaf in leaves: + if not args.refresh and leaf.source in memory: + resolved[leaf.path] = memory[leaf.source] + continue + jobs.setdefault(leaf.source, []).append(leaf.path) + + if jobs: + with ThreadPoolExecutor(max_workers=args.workers) as executor: + futures = { + executor.submit( + translate_with_retry, + text, + language, + translate, + args.retries, + args.retry_delay, + ): text + for text in jobs + } + for future in as_completed(futures): + text = futures[future] + try: + translated = future.result() + memory[text] = translated + for path in jobs[text]: + resolved[path] = translated + except Exception as exc: + failures.append(f"{text[:90]}: {exc}") + if args.sleep: + time.sleep(args.sleep) + + if failures: + return None, failures, len(jobs) + return merge_tree(source, target, resolved), [], len(jobs) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-dir", type=Path, default=Path("web/messages/en")) + parser.add_argument("--messages-dir", type=Path, default=Path("web/messages")) + parser.add_argument("--languages", default=",".join(DEFAULT_LANGUAGES)) + parser.add_argument( + "--section", + default=".", + help="Relative file or directory below the English messages directory.", + ) + parser.add_argument( + "--provider", + choices=("google-web", "googletrans", "appimage"), + default="googletrans", + ) + parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage")) + parser.add_argument( + "--source-state", + type=Path, + default=None, + help=( + "Source fingerprint state used to detect changed English strings. " + "Defaults to /.docs-i18n-source-state.json." + ), + ) + parser.add_argument("--context", default=DEFAULT_CONTEXT) + parser.add_argument("--timeout", type=int, default=30) + parser.add_argument("--workers", type=int, default=4) + parser.add_argument("--retries", type=int, default=3) + parser.add_argument("--retry-delay", type=float, default=2.0) + parser.add_argument("--sleep", type=float, default=0.0) + parser.add_argument( + "--max-files", + type=int, + default=0, + help="Maximum pending files per locale; zero processes all files.", + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--check", action="store_true") + parser.add_argument( + "--refresh", + action="store_true", + help="Overwrite existing translations in the selected scope.", + ) + parser.add_argument("--strict", action="store_true") + return parser + + +def main() -> int: + args = build_parser().parse_args() + if args.workers < 1 or args.retries < 0 or args.max_files < 0: + print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr) + return 2 + + source_root = args.source_dir.resolve() + messages_root = args.messages_dir.resolve() + source_state_path = ( + args.source_state.resolve() + if args.source_state is not None + else messages_root / ".docs-i18n-source-state.json" + ) + languages = [item.strip() for item in args.languages.split(",") if item.strip()] + try: + files = source_files(source_root, args.section) + except ValueError as exc: + print(exc, file=sys.stderr) + return 2 + + if not languages: + print("No target languages selected.", file=sys.stderr) + return 2 + if args.refresh: + print("WARNING: --refresh overwrites existing translations in the selected scope.") + + translate = provider_function(args) + state = load_source_state(source_state_path) + state_was_initialized = bool(state.get("initialized")) + previous_source_state = state.setdefault("source", {}) + + file_info: dict[ + str, tuple[Path, Any, dict[str, str], set[str], set[str]] + ] = {} + selected_relatives: set[str] = set() + for source_path in files: + relative = source_path.relative_to(source_root).as_posix() + selected_relatives.add(relative) + source = read_json(source_path) + current_fingerprints = source_fingerprints(source) + translatable_tokens = { + leaf_token(leaf.path) + for leaf in iter_leaves(source) + if not should_copy(leaf.source, leaf.path) + } + previous_fingerprints = previous_source_state.get(relative, {}) + if not isinstance(previous_fingerprints, dict): + previous_fingerprints = {} + if state_was_initialized: + changed_tokens = { + token + for token, fingerprint in current_fingerprints.items() + if token in translatable_tokens + # Existing English leaves whose text changed must be sent to + # the provider even when the target still contains the old, + # non-empty translation. New leaves are handled by the usual + # missing-target detection, preserving a translation supplied + # manually in the same commit. + and token in previous_fingerprints + and previous_fingerprints[token] != fingerprint + } + else: + # The first run establishes the baseline without replacing + # existing human/Codex translations. Missing target values are + # still discovered separately for every locale below. + changed_tokens = set() + file_info[relative] = ( + source_path, + source, + current_fingerprints, + changed_tokens, + translatable_tokens, + ) + + # Materialize every locale's pending queue before advancing the shared + # English baseline. If the runner stops halfway through, unprocessed + # locales retain the exact changed leaf tokens for the next run. + for language in languages: + for relative, ( + _, + source, + _, + changed_tokens, + translatable_tokens, + ) in file_info.items(): + target_path = messages_root / language / relative + target = read_json(target_path) + pending_tokens = ( + state_pending_tokens(state, language, relative) + & translatable_tokens + ) + pending_tokens.update(changed_tokens) + pending_tokens.update( + leaf_token(leaf.path) + for leaf in pending_leaves(source, target, args.refresh) + ) + set_state_pending_tokens(state, language, relative, pending_tokens) + + for relative, (_, _, current_fingerprints, _, _) in file_info.items(): + previous_source_state[relative] = current_fingerprints + state["initialized"] = True + + # A complete run also mirrors deletion of an English catalog. Partial + # --section runs deliberately leave unrelated paths untouched. + if args.section in (".", ""): + removed_files = set(previous_source_state) - selected_relatives + for relative in sorted(removed_files): + for language in languages: + target_path = messages_root / language / relative + if target_path.exists() and not (args.check or args.dry_run): + target_path.unlink() + print(f"[{language}] removed obsolete catalog {target_path}") + set_state_pending_tokens(state, language, relative, set()) + previous_source_state.pop(relative, None) + + if not (args.check or args.dry_run): + write_json(source_state_path, state) + + total_failures = 0 + total_written = 0 + print(f"English files: {len(files)} | locales: {', '.join(languages)}") + + for language in languages: + memory = collect_memory(source_root, messages_root, language) + # A target value paired with a newly changed English source is the old + # translation, not valid translation memory for the new sentence. + # Remove every queued source text before provider reuse; successful + # translations repopulate memory normally for later files. + queued_source_texts: set[str] = set() + for relative_key, (_, source, _, _, _) in file_info.items(): + queued_tokens = state_pending_tokens(state, language, relative_key) + queued_source_texts.update( + leaf.source + for leaf in iter_leaves(source) + if leaf_token(leaf.path) in queued_tokens + ) + for source_text in queued_source_texts: + memory.pop(source_text, None) + pending: list[ + tuple[Path, Path, Any, Any, list[Leaf], set[str], bool] + ] = [] + total_strings = 0 + missing_strings = 0 + + for source_path in files: + relative = source_path.relative_to(source_root) + relative_key = relative.as_posix() + target_path = messages_root / language / relative + source = file_info[relative_key][1] + target = read_json(target_path) + queued_tokens = state_pending_tokens(state, language, relative_key) + leaves = pending_leaves(source, target, args.refresh, queued_tokens) + total_strings += len(iter_leaves(source)) + missing_strings += len(leaves) + schema_changed = not schema_matches(source, target) + if leaves or schema_changed: + pending.append( + ( + source_path, + target_path, + source, + target, + leaves, + queued_tokens, + schema_changed, + ) + ) + + print( + f"[{language}] {missing_strings}/{total_strings} strings pending " + f"across {len(pending)} files; reusable translations: {len(memory)}" + ) + if args.check or args.dry_run: + continue + if args.max_files: + pending = pending[: args.max_files] + + for index, ( + source_path, + target_path, + source, + target, + leaves, + queued_tokens, + schema_changed, + ) in enumerate(pending, 1): + relative = source_path.relative_to(source_root) + relative_key = relative.as_posix() + suffix = " + schema sync" if schema_changed else "" + print( + f"[{language} {index}/{len(pending)}] {relative} " + f"({len(leaves)} strings{suffix})", + flush=True, + ) + built, failures, calls = translate_file( + source, + target, + leaves, + language, + memory, + translate, + args, + ) + if failures: + total_failures += len(failures) + set_state_pending_tokens(state, language, relative_key, queued_tokens) + write_json(source_state_path, state) + print( + f" skipped atomically after {len(failures)} failures " + f"({calls} calls)", + file=sys.stderr, + ) + for failure in failures[:5]: + print(f" - {failure}", file=sys.stderr) + continue + write_json(target_path, built) + set_state_pending_tokens(state, language, relative_key, set()) + write_json(source_state_path, state) + total_written += 1 + print(f" wrote {target_path} ({calls} provider calls)", flush=True) + + if args.check or args.dry_run: + return 0 + print(f"Completed: {total_written} files written; {total_failures} failed strings.") + return 1 if args.strict and total_failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/generate_app_tracking_catalog.py b/.github/scripts/generate_app_tracking_catalog.py new file mode 100644 index 00000000..9171c40b --- /dev/null +++ b/.github/scripts/generate_app_tracking_catalog.py @@ -0,0 +1,1423 @@ +#!/usr/bin/env python3 +"""Build a verified ProxMenux LXC application-version tracking catalog. + +The source of truth is a pinned snapshot of community-scripts/ProxmoxVE, +downloaded through the GitHub REST API. Only ct/*.sh launchers are considered. + +An operational hint is emitted only when two independent pieces of the helper +scripts agree: + +* file: the LXC update script reads the cache written by the shared deploy + helper, and the matching install script deploys the same app/repository; or +* dpkg/apk: the package is installed by the install script and checked or + explicitly upgraded by the LXC update script. + +Everything else is retained in the audit report rather than guessed into the +runtime JSON. The script uses only Python's standard library and runs on macOS. +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import re +import shutil +import sys +import tarfile +import tempfile +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + + +DEFAULT_REPOSITORY = "community-scripts/ProxmoxVE" +DEFAULT_REF = "main" +API_VERSION = "2022-11-28" +USER_AGENT = "ProxMenux-app-tracking-catalog/1.0" + +# Both the helper cache and common GitHub tags are handled. The first capture +# group is deliberately the normalized version consumed by lxc_apps.py. +DEFAULT_VERSION_REGEX = ( + r"(?i)(?:v|release[-_/]?)?" + r"(\d+(?:\.\d+){1,3}(?:[-+._][0-9A-Za-z.-]+)?)" +) +VERSION_FORMATS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r'(?i)^v?\d{6,14}$'), r"(?i)v?(\d{6,14})"), + ( + re.compile(r'(?i)^\d{6,14}-[0-9a-f]{6,40}$'), + r"(?i)(\d{6,14}(?:-[0-9a-f]{6,40})?)", + ), + (re.compile(r'(?i)^r\d{4,}$'), r"(?i)r?(\d{4,})"), + ( + re.compile(r'(?i)^SQUID_\d+(?:_\d+){1,3}$'), + r"(?i)(?:SQUID_)?(\d+(?:[._]\d+){1,3})", + ), + ( + re.compile(r'(?i)^release\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:\.\d+)?$'), + r"(?i)(?:release\.)?(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:\.\d+)?)", + ), +] + +APP_RE = re.compile(r'^\s*APP=["\']([^"\']+)["\']', re.MULTILINE) +CHECK_RE = re.compile( + r'\bcheck_for_gh_release\s+["\']([^"\']+)["\']\s+["\']' + r'([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)["\']' +) +FETCH_RE = re.compile( + r'\bfetch_and_deploy_gh_release\s+["\']([^"\']+)["\']\s+["\']' + r'([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)["\']' +) +HEADER_GITHUB_RE = re.compile( + r'Github:\s*https?://github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)', + re.IGNORECASE, +) +HEADER_SOURCE_RE = re.compile(r'^\s*#\s*Source:\s*(https?://\S+)', re.IGNORECASE | re.MULTILINE) +EXPLICIT_VERSION_FILE_RE = re.compile( + r'(?:>|tee\s+)(?:["\']?)' + r'(?:~|\$HOME|\$\{HOME\})/(\.[A-Za-z0-9_.-]+)' +) +DOCKER_IMAGE_RE = re.compile( + r'(?:docker\s+(?:run|pull)\b[\s\S]{0,800}?)' + r'((?:ghcr\.io|docker\.io|quay\.io|lscr\.io)/[A-Za-z0-9_./-]+:[A-Za-z0-9_.-]+)', + re.IGNORECASE, +) +DOCKER_NAME_RE = re.compile(r'\bdocker\s+run\b[\s\S]{0,1200}?--name(?:=|\s+)([A-Za-z0-9_.-]+)', re.IGNORECASE) +EXECSTART_RE = re.compile(r'^\s*ExecStart=(/[A-Za-z0-9_./+@:-]+)', re.MULTILINE) +EXISTENCE_PATH_RE = re.compile(r'\[\[?[^\n]{0,80}?!?\s+-[fx]\s+(/[A-Za-z0-9_./+@:-]+)') +PACKAGE_TOKEN_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9.+:@_-]*$') + + +class CatalogError(RuntimeError): + pass + + +class GitHubClient: + def __init__(self, token: str | None = None) -> None: + self.token = token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + self.rate_remaining: str | None = None + + def request(self, url: str, *, accept: str = "application/vnd.github+json") -> bytes: + headers = { + "Accept": accept, + "X-GitHub-Api-Version": API_VERSION, + "User-Agent": USER_AGENT, + } + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=90) as response: + self.rate_remaining = response.headers.get("X-RateLimit-Remaining") + return response.read() + except urllib.error.HTTPError as exc: + remaining = exc.headers.get("X-RateLimit-Remaining") + if exc.code == 403 and remaining == "0": + raise CatalogError( + "GitHub API rate limit exhausted. Set GITHUB_TOKEN (or GH_TOKEN) " + "and run again." + ) from exc + raise CatalogError(f"GitHub API HTTP {exc.code} for {url}") from exc + except urllib.error.URLError as exc: + raise CatalogError(f"Cannot reach GitHub API: {exc}") from exc + + def json(self, path: str) -> Any: + url = path if path.startswith("https://") else f"https://api.github.com{path}" + return json.loads(self.request(url).decode("utf-8")) + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def safe_extract_tar(payload: bytes, destination: Path) -> None: + destination.mkdir(parents=True, exist_ok=True) + root = destination.resolve() + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive: + members = archive.getmembers() + for member in members: + parts = Path(member.name).parts + if len(parts) < 2: + continue + relative = Path(*parts[1:]) + target = (destination / relative).resolve() + if target != root and root not in target.parents: + raise CatalogError(f"Unsafe path in GitHub tarball: {member.name}") + if member.issym() or member.islnk() or member.isdev(): + raise CatalogError(f"Unsupported link/device in GitHub tarball: {member.name}") + if not (member.isfile() or member.isdir()): + continue + member.name = str(relative) + if member.name != ".": + # Python 3.9 (the system Python on several macOS releases) + # predates tarfile's `filter=` argument. Paths and special + # members have already been validated above. + archive.extract(member, destination) + + +def obtain_snapshot( + client: GitHubClient, + repository: str, + ref: str, + cache_dir: Path, +) -> tuple[Path, str, str]: + commit = client.json(f"/repos/{repository}/commits/{ref}") + commit_sha = str(commit.get("sha") or "") + if not re.fullmatch(r"[0-9a-f]{40}", commit_sha): + raise CatalogError(f"Unexpected commit SHA for {repository}@{ref}") + + snapshot_dir = cache_dir / repository.replace("/", "--") / commit_sha + marker = snapshot_dir / ".snapshot-complete" + if marker.exists() and (snapshot_dir / "ct").is_dir(): + return snapshot_dir, commit_sha, marker.read_text(encoding="utf-8").strip() + + payload = client.request( + f"https://api.github.com/repos/{repository}/tarball/{commit_sha}", + accept="application/vnd.github+json", + ) + archive_sha = hashlib.sha256(payload).hexdigest() + temp_parent = snapshot_dir.parent + temp_parent.mkdir(parents=True, exist_ok=True) + temp_dir = Path(tempfile.mkdtemp(prefix=f"{commit_sha}.tmp-", dir=temp_parent)) + try: + safe_extract_tar(payload, temp_dir) + (temp_dir / ".snapshot-complete").write_text(archive_sha + "\n", encoding="utf-8") + if snapshot_dir.exists(): + shutil.rmtree(snapshot_dir) + temp_dir.rename(snapshot_dir) + except Exception: + shutil.rmtree(temp_dir, ignore_errors=True) + raise + return snapshot_dir, commit_sha, archive_sha + + +def normalize(value: str) -> str: + return re.sub(r"[^a-z0-9]", "", value.lower()) + + +def cache_key(app: str) -> str: + # Mirrors tools.func: lower-case then `tr -d ' '`. + return app.lower().replace(" ", "") + + +def version_regex_for_tag(tag: str) -> str: + if not tag or re.search(DEFAULT_VERSION_REGEX, tag): + return DEFAULT_VERSION_REGEX + for matcher, pattern in VERSION_FORMATS: + if matcher.fullmatch(tag): + return pattern + return DEFAULT_VERSION_REGEX + + +def read_text(path: Path | None) -> str: + if path is None or not path.is_file(): + return "" + return path.read_text(encoding="utf-8", errors="replace") + + +def marker_stores_url(text: str, marker: str) -> bool: + marker_pattern = re.escape(marker) + for line in text.splitlines(): + if not re.search(rf'(?:~|\$HOME|\$\{{HOME\}})/{marker_pattern}\b', line): + continue + variable = re.search(r'echo\s+["\']?\$\{?([A-Za-z_][A-Za-z0-9_]*)', line) + if not variable: + continue + assignment = re.search( + rf'^\s*{re.escape(variable.group(1))}=([^\n]*(?:\n(?![A-Za-z_][A-Za-z0-9_]*=)[^\n]*){{0,3}})', + text, + re.MULTILINE, + ) + if assignment and re.search(r"grep\s+-o[^\n]*https?://|DownloadLocation", assignment.group(0), re.IGNORECASE): + return True + return False + + +def unique_pairs(items: Iterable[tuple[str, str]]) -> list[tuple[str, str]]: + result: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for item in items: + key = (item[0], item[1].lower()) + if key not in seen: + seen.add(key) + result.append(item) + return result + + +def extract_command_blocks(text: str) -> list[str]: + lines = text.splitlines() + blocks: list[str] = [] + i = 0 + while i < len(lines): + line = lines[i].strip() + block = line + while block.rstrip().endswith("\\") and i + 1 < len(lines): + block = block.rstrip()[:-1] + " " + lines[i + 1].strip() + i += 1 + blocks.append(block) + i += 1 + return blocks + + +def packages_from_command(text: str, manager: str) -> set[str]: + packages: set[str] = set() + command_re = ( + re.compile(r'\b(?:apt|apt-get)\b[^\n]*?\b(?:install|upgrade)\b\s+(.+)$') + if manager == "dpkg" + else re.compile(r'\bapk\b[^\n]*?\b(?:add|upgrade)\b\s+(.+)$') + ) + for block in extract_command_blocks(text): + match = command_re.search(block) + if not match: + continue + for token in re.split(r"\s+", match.group(1)): + token = token.strip("'\"") + if ( + not token + or token.startswith("-") + or token.startswith("$") + or "/" in token + or "=" in token + or not PACKAGE_TOKEN_RE.fullmatch(token) + ): + continue + packages.add(token) + return packages + + +def checked_packages(text: str, manager: str) -> set[str]: + patterns = ( + [r'\bdpkg\s+-s\s+([A-Za-z0-9.+:@_-]+)', r'\bdpkg-query\b[^\n]*?\s([A-Za-z0-9.+:@_-]+)\s*(?:[>&]|$)'] + if manager == "dpkg" + else [r'\bapk\s+info\b[^\n]*?\s([A-Za-z0-9.+:@_-]+)\s*(?:[>&]|$)'] + ) + result: set[str] = set() + for pattern in patterns: + result.update(re.findall(pattern, text)) + return result + + +def load_helper_catalog(path: Path | None) -> dict[str, dict[str, Any]]: + if path is None or not path.is_file(): + return {} + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, list): + raise CatalogError("helpers_cache.json must contain a list") + result: dict[str, dict[str, Any]] = {} + for item in raw: + if not isinstance(item, dict) or item.get("type") != "lxc": + continue + slug = str(item.get("slug") or "") + if slug and slug not in result: + result[slug] = item + return result + + +@dataclass +class Candidate: + app: str + repo: str + score: int + reasons: list[str] = field(default_factory=list) + install_fetch: bool = False + update_fetch: bool = False + + +def select_release_candidate( + slug: str, + app_name: str, + launcher: str, + installer: str, +) -> tuple[Candidate | None, list[dict[str, Any]], str | None]: + checks = unique_pairs(CHECK_RE.findall(launcher)) + update_fetches = {(a, r.lower()) for a, r in FETCH_RE.findall(launcher)} + install_fetches = {(a, r.lower()) for a, r in FETCH_RE.findall(installer)} + candidates: list[Candidate] = [] + + for app, repo in checks: + key = (app, repo.lower()) + score = 0 + identity_score = 0 + reasons: list[str] = [] + n_app, n_slug, n_name = normalize(app), normalize(slug), normalize(app_name) + n_repo = normalize(repo.split("/", 1)[1]) + if n_app == n_slug: + score += 100 + identity_score += 100 + reasons.append("check app name matches LXC slug") + elif n_app and (n_app in n_slug or n_slug in n_app): + score += 55 + identity_score += 55 + reasons.append("check app name closely matches LXC slug") + if n_app == n_name: + score += 45 + identity_score += 45 + reasons.append("check app name matches APP label") + if n_repo == n_slug or (n_repo and (n_repo in n_slug or n_slug in n_repo)): + score += 30 + identity_score += 30 + reasons.append("repository name matches LXC slug") + install_fetch = key in install_fetches + update_fetch = key in update_fetches + if install_fetch: + score += 80 + reasons.append("matching deploy call exists in install script") + if update_fetch: + score += 30 + reasons.append("matching deploy call exists in update script") + if identity_score == 0: + # Auxiliary components (Ollama inside Open WebUI, web vault + # assets inside Vaultwarden, etc.) must never become the primary + # application merely because their deploy helper is present. + score -= 1000 + reasons.append("does not identify the primary LXC application") + candidates.append(Candidate(app, repo, score, reasons, install_fetch, update_fetch)) + + candidates.sort(key=lambda candidate: candidate.score, reverse=True) + audit_candidates = [ + { + "app": c.app, + "repo": c.repo, + "cache_file": f"/root/.{cache_key(c.app)}", + "score": c.score, + "install_fetch": c.install_fetch, + "update_fetch": c.update_fetch, + "reasons": c.reasons, + } + for c in candidates + ] + if not candidates: + return None, audit_candidates, "no literal check_for_gh_release call" + winner = candidates[0] + if winner.score < 70: + return None, audit_candidates, "no release check identifies the primary LXC application" + if len(candidates) > 1 and winner.score == candidates[1].score: + return None, audit_candidates, "ambiguous primary GitHub application" + if not winner.install_fetch: + return None, audit_candidates, "version cache is not proven to exist immediately after installation" + return winner, audit_candidates, None + + +def select_install_only_release( + slug: str, + app_name: str, + installer: str, + header_repos: list[str], +) -> Candidate | None: + candidates: list[Candidate] = [] + for app, repo in unique_pairs(FETCH_RE.findall(installer)): + n_app, n_slug, n_name = normalize(app), normalize(slug), normalize(app_name) + n_repo = normalize(repo.split("/", 1)[1]) + score = 0 + reasons: list[str] = [] + if n_app == n_slug: + score += 100 + reasons.append("deploy app matches LXC slug") + elif n_app and (n_app in n_slug or n_slug in n_app): + score += 50 + reasons.append("deploy app closely matches LXC slug") + if n_app == n_name: + score += 45 + reasons.append("deploy app matches APP label") + if n_repo == n_slug or (n_repo and (n_repo in n_slug or n_slug in n_repo)): + score += 30 + reasons.append("repository name matches LXC slug") + if repo.lower() in {item.lower() for item in header_repos}: + score += 20 + reasons.append("repository matches script header") + candidates.append(Candidate(app, repo, score, reasons, install_fetch=True)) + candidates.sort(key=lambda candidate: candidate.score, reverse=True) + if not candidates or candidates[0].score < 70: + return None + if len(candidates) > 1 and candidates[0].score == candidates[1].score: + return None + return candidates[0] + + +def choose_package( + slug: str, + app_name: str, + launcher: str, + installer: str, + manager: str, +) -> tuple[str | None, dict[str, Any]]: + installed = packages_from_command(installer, manager) + updated = packages_from_command(launcher, manager) + checked = checked_packages(launcher, manager) + raw_proven = installed & (updated | checked) + # Some official repositories are updated with a plain `apt upgrade` or + # `apk upgrade`, so the package is not repeated in the update command. + # Accept only an exact app/slug match in that case; dependencies remain + # excluded. + has_generic_upgrade = bool( + re.search(r'\b(?:apt|apt-get)\b[^\n]*\bupgrade\b', launcher) + if manager == "dpkg" + else re.search(r'\bapk\b[^\n]*\bupgrade\b', launcher) + ) + if has_generic_upgrade: + expected = {normalize(slug), normalize(app_name)} + raw_proven.update(package for package in installed if normalize(package) in expected) + expected = {normalize(slug), normalize(app_name)} + + def is_app_package(package: str) -> bool: + normalized = normalize(package) + return any( + candidate and ( + normalized == candidate + or normalized in candidate + or candidate in normalized + ) + for candidate in expected + ) + + proven = {package for package in raw_proven if is_app_package(package)} + evidence = { + "installed_packages": sorted(installed), + "updated_packages": sorted(updated), + "checked_packages": sorted(checked), + "proven_packages": sorted(proven), + "rejected_unrelated_packages": sorted(raw_proven - proven), + "generic_upgrade": has_generic_upgrade, + } + if not proven: + return None, evidence + + def score(package: str) -> tuple[int, int, str]: + n_pkg = normalize(package) + n_slug = normalize(slug) + n_name = normalize(app_name) + value = 0 + if n_pkg == n_slug: + value += 100 + elif n_pkg in n_slug or n_slug in n_pkg: + value += 45 + if n_pkg == n_name: + value += 50 + if package in checked: + value += 30 + return value, -len(package), package + + ranked = sorted(proven, key=score, reverse=True) + if len(ranked) > 1 and score(ranked[0])[:2] == score(ranked[1])[:2]: + return None, evidence + return ranked[0], evidence + + +def repo_from_helper(item: dict[str, Any]) -> str: + repo = str(item.get("github_repo") or "").strip() + if re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo): + return repo + raw = str(item.get("github") or "").strip() + match = re.search(r'(?:github\.com/)?([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)', raw) + return match.group(1) if match else "" + + +def build_catalog( + source: Path, + helpers: dict[str, dict[str, Any]], +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + catalog: dict[str, Any] = {} + v2_apps: dict[str, Any] = {} + records: list[dict[str, Any]] = [] + launchers = sorted((source / "ct").glob("*.sh")) + tools_func = source / "misc" / "tools.func" + tools_text = read_text(tools_func) + shared_helper_verified = bool( + re.search(r'local version_file="\$HOME/\.\$\{app_lc\}"', tools_text) + and re.search(r'echo "\$version" >"\$version_file"', tools_text) + and re.search(r'local current_file="\$HOME/\.\$\{app_lc\}"', tools_text) + ) + if not shared_helper_verified: + raise CatalogError("Could not verify the shared GitHub release cache contract in misc/tools.func") + + for launcher_path in launchers: + slug = launcher_path.stem + launcher = read_text(launcher_path) + installer_path = source / "install" / f"{slug}-install.sh" + installer = read_text(installer_path) + app_match = APP_RE.search(launcher) + app_name = app_match.group(1) if app_match else slug + helper_item = helpers.get(slug, {}) + helper_repo = repo_from_helper(helper_item) + helper_version = str(helper_item.get("github_version") or "").strip() + version_regex = version_regex_for_tag(helper_version) + header_repos = HEADER_GITHUB_RE.findall(launcher + "\n" + installer) + official_sources = sorted(set(HEADER_SOURCE_RE.findall(launcher + "\n" + installer))) + docker_images = sorted(set(DOCKER_IMAGE_RE.findall(installer))) + docker_names = sorted(set(DOCKER_NAME_RE.findall(installer))) + launcher_version_files = set(EXPLICIT_VERSION_FILE_RE.findall(launcher)) + installer_version_files = set(EXPLICIT_VERSION_FILE_RE.findall(installer)) + explicit_files = sorted(launcher_version_files | installer_version_files) + relevant_markers = sorted( + marker + for marker in launcher_version_files & installer_version_files + if normalize(marker.lstrip(".")) in {normalize(slug), normalize(app_name)} + and not marker_stores_url(launcher, marker) + and not marker_stores_url(installer, marker) + ) + + package_evidence: dict[str, Any] = {} + package_detectors: list[dict[str, Any]] = [] + for manager in ("dpkg", "apk"): + package, evidence = choose_package(slug, app_name, launcher, installer, manager) + package_evidence[manager] = evidence + if package and helper_repo: + upstream_verified = bool( + helper_version and re.search(version_regex, helper_version) + ) + package_detectors.append( + { + "installed_via": manager, + "package": package, + "repo": helper_repo, + "github_source": "releases", + "tag_regex": version_regex, + "install_scope": ["community-script", "manual-if-same-package"], + "verification": ( + "verified-static" + if upstream_verified + else "candidate-needs-upstream-verification" + ), + "evidence": { + "install": f"install/{installer_path.name}: package installation", + "update": f"ct/{launcher_path.name}: package check or named upgrade", + }, + } + ) + + binary_paths = sorted(set(EXECSTART_RE.findall(installer)) & set(EXISTENCE_PATH_RE.findall(launcher))) + binary_detectors = [ + { + "installed_via": "binary", + "binary_path": path, + "binary_args": ["--version"], + "repo": helper_repo or None, + "github_source": "releases", + "tag_regex": version_regex, + "install_scope": ["community-script", "manual-if-same-path"], + "verification": "candidate-needs-version-probe", + "evidence": { + "install": f"install/{installer_path.name}: systemd ExecStart", + "update": f"ct/{launcher_path.name}: installation existence check", + }, + } + for path in binary_paths + ] + + docker_detectors: list[dict[str, Any]] = [] + if docker_names or docker_images: + # Keep all discovered data when a script has multiple containers; + # pairing by shell position is intentionally left to an override. + docker_detectors.append( + { + "installed_via": "docker", + "container_names": docker_names, + "images": docker_images, + "version_sources": [ + {"type": "oci_label", "name": "org.opencontainers.image.version"}, + {"type": "image_ref_tag"}, + ], + "repo": helper_repo or None, + "github_source": "releases", + "tag_regex": version_regex, + "install_scope": ["community-script", "manual-docker"], + "verification": "requires-detector-change", + "evidence": {"install": f"install/{installer_path.name}: docker run/pull"}, + } + ) + + record: dict[str, Any] = { + "slug": slug, + "name": app_name, + "launcher": f"ct/{launcher_path.name}", + "installer": f"install/{installer_path.name}" if installer_path.is_file() else None, + "status": "excluded", + "method": None, + "reason": None, + "helper_repo": helper_repo or None, + "helper_upstream_version": helper_version or None, + "header_repositories": header_repos, + "official_sources": official_sources, + "docker_images": docker_images, + "docker_names": docker_names, + "explicit_version_files": [f"/root/{item}" for item in explicit_files], + } + + release, release_candidates, release_error = select_release_candidate( + slug, app_name, launcher, installer + ) + # Some current helpers do not install through + # fetch_and_deploy_gh_release, but explicitly write the release to + # the same marker used by check_for_gh_release. Actual Budget is a + # representative example (npm install + ~/.actualbudget). Recover + # the repository from the release check only when that marker maps + # to one unique candidate; never guess it from a generic header. + marker_release_repo: str | None = None + if len(relevant_markers) == 1: + marker_path = f"/root/{relevant_markers[0]}".lower() + marker_repos = { + str(candidate.get("repo") or "") + for candidate in release_candidates + if str(candidate.get("cache_file") or "").lower() == marker_path + and candidate.get("repo") + } + if len(marker_repos) == 1: + marker_release_repo = marker_repos.pop() + install_only_release = select_install_only_release( + slug, app_name, installer, header_repos + ) if release is None else None + record["release_candidates"] = release_candidates + v2_detectors: list[dict[str, Any]] = [] + if release is not None: + hint = { + "installed_via": "file", + "file_path": f"/root/.{cache_key(release.app)}", + "file_regex": DEFAULT_VERSION_REGEX, + "repo": release.repo, + "github_source": "releases", + "tag_regex": version_regex, + } + hint["file_regex"] = version_regex + catalog[slug] = hint + v2_detectors.append( + { + **hint, + "install_scope": ["community-script"], + "verification": "verified-static", + "evidence": { + "install": f"install/{installer_path.name}: matching deploy helper", + "update": f"ct/{launcher_path.name}: matching release check", + "contract": "misc/tools.func: shared version-cache contract", + }, + } + ) + v2_detectors.extend(package_detectors) + v2_detectors.extend(binary_detectors) + v2_detectors.extend(docker_detectors) + record.update( + { + "status": "verified", + "method": "file", + "reason": "install deploy and update check share the same helper cache", + "selected": hint, + "evidence": { + "install": f"install/{installer_path.name}: fetch_and_deploy_gh_release({release.app}, {release.repo})", + "update": f"ct/{launcher_path.name}: check_for_gh_release({release.app}, {release.repo})", + "contract": "misc/tools.func writes and reads /root/.", + }, + } + ) + records.append(record) + v2_apps[slug] = { + "name": app_name, + "repo": release.repo, + "official_sources": official_sources, + "detectors": v2_detectors, + } + continue + + if len(relevant_markers) == 1 and marker_release_repo: + marker = relevant_markers[0] + hint = { + "installed_via": "file", + "file_path": f"/root/{marker}", + "file_regex": version_regex, + "repo": marker_release_repo, + "github_source": "releases", + "tag_regex": version_regex, + } + catalog[slug] = hint + v2_detectors.append( + { + **hint, + "install_scope": ["community-script"], + "verification": "verified-static", + "evidence": { + "install": f"install/{installer_path.name}: writes {hint['file_path']}", + "update": f"ct/{launcher_path.name}: writes {hint['file_path']}", + }, + } + ) + v2_detectors.extend(package_detectors) + v2_detectors.extend(binary_detectors) + v2_detectors.extend(docker_detectors) + record.update( + { + "status": "verified", + "method": "file", + "reason": "install and update scripts write the same app-specific version marker", + "selected": hint, + "evidence": v2_detectors[0]["evidence"], + } + ) + records.append(record) + v2_apps[slug] = { + "name": app_name, + "repo": marker_release_repo, + "official_sources": official_sources, + "detectors": v2_detectors, + } + continue + + if install_only_release is not None: + v2_detectors.append( + { + "installed_via": "file", + "file_path": f"/root/.{cache_key(install_only_release.app)}", + "file_regex": version_regex, + "repo": install_only_release.repo, + "github_source": "releases", + "tag_regex": version_regex, + "install_scope": ["community-script"], + "verification": "candidate-install-cache-may-stale", + "evidence": { + "install": f"install/{installer_path.name}: deploy helper writes the version cache", + "limitation": "no matching update check proves that later updates refresh this cache", + }, + } + ) + + package_selected = False + for detector in package_detectors: + manager = detector["installed_via"] + if not package_selected and detector["verification"] == "verified-static": + hint = {key: value for key, value in detector.items() if key in { + "installed_via", "package", "repo", "github_source", "tag_regex" + }} + catalog[slug] = hint + record.update( + { + "status": "verified", + "method": manager, + "reason": "package is present in both install and update/check paths", + "selected": hint, + "evidence": package_evidence[manager], + } + ) + package_selected = True + record["package_evidence"] = package_evidence + if package_selected: + v2_detectors.extend(package_detectors) + v2_detectors.extend(binary_detectors) + v2_detectors.extend(docker_detectors) + records.append(record) + v2_apps[slug] = { + "name": app_name, + "repo": helper_repo or None, + "official_sources": official_sources, + "detectors": v2_detectors, + } + continue + + if docker_images or re.search(r'\bsetup_docker\b|\bdocker\s+(?:run|compose|pull)\b', installer): + record["reason"] = "Docker installation requires detector support not present in lxc_apps.py" + record["required_detector"] = "docker" + elif release_error: + record["reason"] = release_error + elif not installer_path.is_file(): + record["reason"] = "no matching install script" + elif helper_repo and any( + item["proven_packages"] for item in package_evidence.values() + ): + record["reason"] = "package detector needs upstream repository/release verification" + elif not helper_repo: + record["reason"] = "no verified upstream GitHub repository for package tracking" + else: + record["reason"] = "no supported detection method could be proven from both scripts" + records.append(record) + v2_detectors.extend(package_detectors) + v2_detectors.extend(binary_detectors) + v2_detectors.extend(docker_detectors) + v2_apps[slug] = { + "name": app_name, + "repo": helper_repo or (header_repos[0] if header_repos else None), + "official_sources": official_sources, + "detectors": v2_detectors, + } + + status_counts: dict[str, int] = {} + method_counts: dict[str, int] = {} + reason_counts: dict[str, int] = {} + tag_validation = {"matched": 0, "missing": 0, "mismatched": []} + for record in records: + status_counts[record["status"]] = status_counts.get(record["status"], 0) + 1 + method = record.get("method") or "none" + method_counts[method] = method_counts.get(method, 0) + 1 + reason = record.get("reason") or "none" + reason_counts[reason] = reason_counts.get(reason, 0) + 1 + if record.get("status") == "verified": + upstream = record.get("helper_upstream_version") + pattern = (record.get("selected") or {}).get("tag_regex") + if not upstream: + tag_validation["missing"] += 1 + elif pattern and re.search(pattern, upstream): + tag_validation["matched"] += 1 + else: + tag_validation["mismatched"].append( + {"slug": record["slug"], "version": upstream, "tag_regex": pattern} + ) + + audit = { + "summary": { + "lxc_launchers": len(launchers), + "operational_hints": len(catalog), + "coverage_percent": round((len(catalog) / len(launchers) * 100), 2) if launchers else 0, + "status_counts": status_counts, + "method_counts": method_counts, + "reason_counts": reason_counts, + "shared_release_cache_contract_verified": shared_helper_verified, + "helper_upstream_tag_validation": tag_validation, + }, + "records": records, + } + v2 = { + "schema_version": 2, + "detector_policy": { + "strategy": "try detectors in order and retain the first successful detector", + "operational_verification": ["verified-static", "verified-runtime"], + "non_operational_verification": [ + "candidate-needs-version-probe", + "requires-detector-change", + "candidate-needs-runtime-validation", + "candidate-install-cache-may-stale", + "candidate-needs-upstream-verification", + "candidate-helper-marker", + ], + }, + "apps": dict(sorted(v2_apps.items())), + } + return dict(sorted(catalog.items())), audit, v2 + + +def compare_existing(generated: dict[str, Any], existing_path: Path | None) -> dict[str, Any]: + if existing_path is None or not existing_path.is_file(): + return {"existing_file": None, "added": sorted(generated), "removed": [], "changed": []} + existing = json.loads(existing_path.read_text(encoding="utf-8")) + if not isinstance(existing, dict): + raise CatalogError("Existing catalog must be a JSON object") + return { + "existing_file": str(existing_path), + "added": sorted(set(generated) - set(existing)), + "removed": sorted(set(existing) - set(generated)), + "changed": sorted( + slug for slug in set(existing) & set(generated) if existing[slug] != generated[slug] + ), + "unchanged": sorted( + slug for slug in set(existing) & set(generated) if existing[slug] == generated[slug] + ), + } + + +def demote_generic_helper_markers( + catalog: dict[str, Any], + v2: dict[str, Any], + audit: dict[str, Any], +) -> list[str]: + """Remove modern helper markers when compatibility-only output is wanted. + + Current community-scripts installers maintain ``/root/.`` as their + version contract, so these are valid detectors for modern helper-owned + containers. They are not universal: legacy helpers and official/manual + installs may not have them. The default conservative mode therefore keeps + them in v2; production generation can opt in with + ``--include-helper-markers`` and the runtime reports their distinct source. + """ + demoted: list[str] = [] + apps = v2.get("apps", {}) + for record in audit.get("records", []): + slug = record.get("slug") + hint = catalog.get(slug) + if not isinstance(hint, dict): + continue + path = hint.get("file_path") + if hint.get("installed_via") != "file" or not isinstance(path, str): + continue + if not re.fullmatch(r"/root/\.[A-Za-z0-9_.-]+", path): + continue + catalog.pop(slug, None) + record["status"] = "candidate" + record["reason"] = "modern helper marker is not guaranteed on legacy/manual installations" + for detector in (apps.get(slug) or {}).get("detectors", []): + if detector.get("installed_via") == "file" and detector.get("file_path") == path: + detector["verification"] = "candidate-helper-marker" + detector["limitation"] = ( + "Valid for modern helper installs; absent on legacy/manual LXC" + ) + demoted.append(slug) + return sorted(demoted) + + +def verify_upstream_releases( + client: GitHubClient, + catalog: dict[str, Any], + cache_dir: Path, +) -> dict[str, Any]: + """Verify repositories and current tags directly with the GitHub API.""" + repos = sorted({hint["repo"] for hint in catalog.values() if hint.get("repo")}) + if len(repos) > 40 and not client.token: + raise CatalogError( + f"--verify-upstream needs GITHUB_TOKEN or GH_TOKEN for {len(repos)} repositories " + "(the anonymous GitHub API limit is only 60 requests/hour)." + ) + cache_file = cache_dir / "upstream-releases.json" + cache_file.parent.mkdir(parents=True, exist_ok=True) + try: + cache = json.loads(cache_file.read_text(encoding="utf-8")) + if not isinstance(cache, dict): + cache = {} + except (OSError, json.JSONDecodeError): + cache = {} + + now = int(time.time()) + results: dict[str, Any] = {} + for index, repo in enumerate(repos, start=1): + cached = cache.get(repo, {}) + if isinstance(cached, dict) and now - int(cached.get("fetched_at", 0)) < 24 * 3600: + results[repo] = cached + continue + tag = "" + source = "releases" + error = "" + try: + payload = client.json(f"/repos/{repo}/releases/latest") + if isinstance(payload, dict): + tag = str(payload.get("tag_name") or payload.get("name") or "").strip() + except CatalogError as exc: + error = str(exc) + try: + tags = client.json(f"/repos/{repo}/tags?per_page=30") + if isinstance(tags, list) and tags and isinstance(tags[0], dict): + tag = str(tags[0].get("name") or "").strip() + source = "tags" + error = "" + except CatalogError as tag_exc: + error = f"release: {exc}; tags: {tag_exc}" + results[repo] = { + "tag": tag or None, + "source": source, + "error": error or None, + "fetched_at": now, + } + if index % 25 == 0: + print(f"Verified upstream repositories: {index}/{len(repos)}", file=sys.stderr) + cache_file.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8") + + matched: list[dict[str, str]] = [] + mismatched: list[dict[str, str]] = [] + unavailable: list[dict[str, str]] = [] + for slug, hint in catalog.items(): + result = results.get(hint.get("repo"), {}) + tag = result.get("tag") + if not tag: + unavailable.append({"slug": slug, "repo": hint.get("repo", ""), "error": result.get("error") or "no tag"}) + elif re.search(hint["tag_regex"], tag): + matched.append({"slug": slug, "repo": hint["repo"], "tag": tag}) + else: + mismatched.append( + {"slug": slug, "repo": hint["repo"], "tag": tag, "tag_regex": hint["tag_regex"]} + ) + return { + "repositories_queried": len(repos), + "matched": len(matched), + "mismatched": mismatched, + "unavailable": unavailable, + "results": results, + } + + +def merge_existing_as_runtime_candidates(v2: dict[str, Any], existing_path: Path | None) -> dict[str, Any]: + """Retain hand-curated/manual-install hints without declaring them proven. + + Existing entries are valuable for official/manual layouts, but static + analysis found that several no longer match current Community Scripts. + They therefore enter v2 as runtime-validation candidates and never enter + the compatible v1 output automatically. + """ + result = {"merged": [], "unmatched": [], "skipped_duplicates": []} + if existing_path is None or not existing_path.is_file(): + return result + raw = json.loads(existing_path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + return result + apps = v2.get("apps", {}) + allowed = { + "installed_via", "package", "file_path", "file_regex", "binary_path", + "repo", "github_source", "tag_regex", + } + for slug, hint in raw.items(): + if slug not in apps or not isinstance(hint, dict): + result["unmatched"].append(slug) + continue + detector = {key: value for key, value in hint.items() if key in allowed} + if not detector.get("installed_via"): + continue + signature = json.dumps(detector, sort_keys=True) + existing_signatures = { + json.dumps({key: value for key, value in item.items() if key in allowed}, sort_keys=True) + for item in apps[slug]["detectors"] + } + if signature in existing_signatures: + result["skipped_duplicates"].append(slug) + continue + detector.update( + { + "install_scope": ["manual", "legacy-catalog"], + "verification": "candidate-needs-runtime-validation", + "evidence": {"catalog": str(existing_path)}, + } + ) + apps[slug]["detectors"].append(detector) + result["merged"].append(slug) + for key in result: + result[key].sort() + return result + + +def enrich_catalog_metadata( + catalog: dict[str, Any], + v2: dict[str, Any], + helpers: dict[str, dict[str, Any]], + existing_path: Path | None, +) -> dict[str, int]: + """Add presentation metadata without weakening detector verification. + + Community Scripts provides one primary port and a curated logo. Existing + manual `default_ports` take precedence because they may describe multi-port + applications. Detector fields and their evidence remain untouched. + """ + existing: dict[str, Any] = {} + if existing_path and existing_path.is_file(): + try: + payload = json.loads(existing_path.read_text(encoding="utf-8")) + if isinstance(payload, dict): + existing = payload + except (OSError, json.JSONDecodeError): + pass + + counts = {"apps_with_ports": 0, "apps_with_logos": 0, "selfhst_logos": 0} + apps = v2.get("apps", {}) + for slug, app in apps.items(): + helper = helpers.get(slug, {}) + prior = existing.get(slug, {}) if isinstance(existing.get(slug), dict) else {} + + ports: list[int] = [] + prior_ports = prior.get("default_ports") + if isinstance(prior_ports, list): + for value in prior_ports: + try: + port = int(value) + except (TypeError, ValueError): + continue + if 1 <= port <= 65535 and port not in ports: + ports.append(port) + if not ports: + raw_port = helper.get("port") + if isinstance(raw_port, int) and 1 <= raw_port <= 65535: + ports.append(raw_port) + + logo = str(prior.get("logo") or helper.get("logo") or "").strip() + if logo and not re.match(r"^https://[A-Za-z0-9.-]+/", logo): + logo = "" + website = str(helper.get("website") or "").strip() + + if ports: + app["default_ports"] = ports + counts["apps_with_ports"] += 1 + if logo: + app["logo"] = logo + app["logo_source"] = ( + "selfh.st/icons via jsDelivr" + if "cdn.jsdelivr.net/gh/selfhst/icons@" in logo + else "community-scripts catalog" + ) + counts["apps_with_logos"] += 1 + if app["logo_source"].startswith("selfh.st"): + counts["selfhst_logos"] += 1 + if website.startswith("https://"): + app["website"] = website + + # v1 only contains operationally verified apps. Extra metadata is + # ignored safely by validate_config but is available to suggestions/UI. + if slug in catalog: + if ports: + catalog[slug]["default_ports"] = ports + if logo: + catalog[slug]["logo"] = logo + if website.startswith("https://"): + catalog[slug]["website"] = website + return counts + + +def apply_runtime_overrides( + catalog: dict[str, Any], + v2: dict[str, Any], + overrides_path: Path | None, +) -> dict[str, Any]: + """Apply detectors proven against real containers. + + The generated/static catalog is intentionally conservative. This optional + overlay promotes only detectors carrying runtime evidence. The compatible + catalog now supports every detector implemented by ``lxc_apps.py``; + retaining an older dpkg/file/binary-only allow-list silently discarded + proven Python and Docker detectors. + """ + result: dict[str, Any] = { + "file": str(overrides_path) if overrides_path else None, + "promoted_to_v1": [], + "v2_only": [], + "runtime_only": [], + "invalid": [], + } + if overrides_path is None or not overrides_path.is_file(): + return result + raw = json.loads(overrides_path.read_text(encoding="utf-8")) + apps_raw = raw.get("apps") if isinstance(raw, dict) else None + if not isinstance(apps_raw, dict): + raise CatalogError("runtime overrides must contain an 'apps' object") + + supported_v1 = { + "dpkg", "apk", "file", "binary", "python_dist", + "docker_label", "docker_exec", "command", "manual", + } + v2_apps = v2.get("apps", {}) + detector_keys = { + "installed_via", "package", "file_path", "file_regex", + "binary_path", "binary_args", "python_path", "distribution", + "container_name", "label", "command_argv", "installed_version", + "repo", "github_source", "tag_regex", "installed_regex", + "upstream_type", "upstream_url", "upstream_json_path", "docker_image", + } + passthrough_keys = { + "file_fallbacks", "alt_detectors", "default_ports", "logo", "website", + } + for slug, spec in apps_raw.items(): + if not isinstance(spec, dict) or not isinstance(spec.get("detector"), dict): + result["invalid"].append(slug) + continue + detector = {k: v for k, v in spec["detector"].items() if k in detector_keys} + method = detector.get("installed_via") + if not isinstance(method, str) or not method: + result["invalid"].append(slug) + continue + evidence = spec.get("evidence") if isinstance(spec.get("evidence"), list) else [] + v2_detector = { + **detector, + "install_scope": spec.get("install_scope") or ["runtime-observed"], + "verification": "verified-runtime", + "evidence": evidence, + } + app = v2_apps.get(slug) + if not isinstance(app, dict): + # Official/manual and nested Docker applications do not + # necessarily have a community-scripts ct/.sh launcher. + # A runtime-proven override is sufficient to create their v2 + # entry; rejecting it here silently reintroduced the old + # helper-only limitation. + display_name = str(spec.get("name") or "").strip() + if not display_name: + display_name = slug.replace("-", " ").replace("_", " ").title() + app = { + "name": display_name, + "repo": detector.get("repo"), + "official_sources": [], + "detectors": [], + } + v2_apps[slug] = app + result["runtime_only"].append(slug) + + # Preserve every statically-proven modern community-scripts marker + # when a stronger runtime detector becomes primary. This gives new + # helper installs their official /root/. checker while keeping + # package/binary/python detectors first for legacy/manual installs. + helper_markers: list[dict[str, str]] = [] + for candidate in app.get("detectors", []): + marker_path = candidate.get("file_path") + marker_regex = candidate.get("file_regex") + if ( + candidate.get("installed_via") == "file" + and isinstance(marker_path, str) + and re.fullmatch(r"/root/\.[A-Za-z0-9_.-]+", marker_path) + and isinstance(marker_regex, str) + and marker_regex + ): + helper_markers.append({ + "path": marker_path, + "regex": marker_regex, + "source": "helper_marker", + }) + app.setdefault("detectors", []).insert(0, v2_detector) + app["runtime_evidence"] = evidence + + if bool(spec.get("remove_from_v1")): + catalog.pop(slug, None) + + operational = bool(spec.get("operational", True)) + if operational and method in supported_v1: + # Preserve presentation metadata already enriched from helpers. + presentation_source = dict(app) + presentation_source.update(catalog.get(slug, {})) + presentation = { + key: value + for key, value in presentation_source.items() + if key in {"default_ports", "logo", "website"} + } + # Every method-specific field is required at runtime. The old + # compatibility filter removed python_path/distribution, + # binary_args and container fields, producing catalog entries + # that validated statically but could never execute. + hint = dict(detector) + for key in passthrough_keys: + if key in spec: + hint[key] = spec[key] + fallbacks = [ + dict(item) for item in hint.get("file_fallbacks", []) + if isinstance(item, dict) + ] + known_paths = { + item.get("path") for item in fallbacks if isinstance(item.get("path"), str) + } + primary_path = hint.get("file_path") if hint.get("installed_via") == "file" else None + for marker in helper_markers: + if marker["path"] != primary_path and marker["path"] not in known_paths: + fallbacks.append(marker) + known_paths.add(marker["path"]) + if fallbacks: + hint["file_fallbacks"] = fallbacks + hint.update(presentation) + catalog[slug] = hint + result["promoted_to_v1"].append(slug) + else: + result["v2_only"].append(slug) + + for key in ("promoted_to_v1", "v2_only", "runtime_only", "invalid"): + result[key].sort() + return result + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", default=DEFAULT_REPOSITORY) + parser.add_argument("--ref", default=DEFAULT_REF) + parser.add_argument( + "--source-dir", + type=Path, + help="Analyze an existing checkout/snapshot instead of downloading through GitHub API", + ) + parser.add_argument( + "--cache-dir", + type=Path, + default=Path.home() / ".cache" / "proxmenux-app-tracking", + ) + parser.add_argument("--helpers-cache", type=Path) + parser.add_argument("--existing", type=Path) + parser.add_argument( + "--runtime-overrides", + type=Path, + help="JSON overlay with detectors verified against real LXC installations", + ) + parser.add_argument( + "--include-helper-markers", + action="store_true", + help="Keep generic /root/.app helper caches in v1 (not recommended for legacy/manual LXC)", + ) + parser.add_argument("--output", type=Path, default=Path("app_tracking_hints.generated.json")) + parser.add_argument("--audit-output", type=Path, default=Path("app_tracking_hints.audit.json")) + parser.add_argument("--v2-output", type=Path, default=Path("app_tracking_catalog.v2.json")) + parser.add_argument( + "--verify-upstream", + action="store_true", + help="Verify every repository's current release/tag directly through GitHub API (token recommended)", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + started = time.time() + client = GitHubClient() + commit_sha = "local-source" + archive_sha = "not-applicable" + try: + if args.source_dir: + source = args.source_dir.resolve() + if re.fullmatch(r"[0-9a-fA-F]{40}", source.name): + commit_sha = source.name.lower() + else: + source, commit_sha, archive_sha = obtain_snapshot( + client, args.repository, args.ref, args.cache_dir.expanduser().resolve() + ) + if not (source / "ct").is_dir() or not (source / "misc" / "tools.func").is_file(): + raise CatalogError(f"Not a valid ProxmoxVE source tree: {source}") + helpers = load_helper_catalog(args.helpers_cache) + catalog, audit, v2 = build_catalog(source, helpers) + audit["demoted_helper_markers"] = ( + [] if args.include_helper_markers else demote_generic_helper_markers(catalog, v2, audit) + ) + audit["metadata"] = enrich_catalog_metadata(catalog, v2, helpers, args.existing) + audit["v2_existing_candidates"] = merge_existing_as_runtime_candidates(v2, args.existing) + audit["runtime_overrides"] = apply_runtime_overrides( + catalog, v2, args.runtime_overrides + ) + if args.verify_upstream: + audit["github_upstream_verification"] = verify_upstream_releases( + client, catalog, args.cache_dir.expanduser().resolve() + ) + audit["provenance"] = { + "repository": args.repository, + "ref": args.ref, + "commit_sha": commit_sha, + "archive_sha256": archive_sha, + "source_dir": str(source), + "generated_at_unix": int(time.time()), + "generator_sha256": sha256_file(Path(__file__).resolve()), + "github_api_rate_remaining": client.rate_remaining, + "helpers_cache": str(args.helpers_cache) if args.helpers_cache else None, + } + audit["existing_comparison"] = compare_existing(catalog, args.existing) + audit["summary"]["operational_hints"] = len(catalog) + audit["summary"]["coverage_percent"] = round( + len(catalog) / max(1, audit["summary"]["lxc_launchers"]) * 100, 2 + ) + method_counts: dict[str, int] = {} + for hint in catalog.values(): + method = str(hint.get("installed_via") or "none") + method_counts[method] = method_counts.get(method, 0) + 1 + method_counts["none"] = max( + 0, audit["summary"]["lxc_launchers"] - len(catalog) + ) + audit["summary"]["method_counts"] = method_counts + audit["summary"]["elapsed_seconds"] = round(time.time() - started, 3) + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.audit_output.parent.mkdir(parents=True, exist_ok=True) + args.v2_output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + args.audit_output.write_text(json.dumps(audit, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + v2["provenance"] = audit["provenance"] + args.v2_output.write_text(json.dumps(v2, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + except (CatalogError, OSError, json.JSONDecodeError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + summary = audit["summary"] + print(f"Pinned source: {args.repository}@{commit_sha}") + print(f"LXC launchers analyzed: {summary['lxc_launchers']}") + print(f"Verified operational hints: {summary['operational_hints']} ({summary['coverage_percent']}%)") + print(f"Methods: {summary['method_counts']}") + print(f"Catalog: {args.output.resolve()}") + print(f"Audit: {args.audit_output.resolve()}") + print(f"Multi-detector catalog v2: {args.v2_output.resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/tests/test_build_web_docs_i18n.py b/.github/scripts/tests/test_build_web_docs_i18n.py new file mode 100644 index 00000000..e9c832c8 --- /dev/null +++ b/.github/scripts/tests/test_build_web_docs_i18n.py @@ -0,0 +1,221 @@ +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +SCRIPT = Path(__file__).parents[1] / "build_web_docs_i18n.py" +SPEC = importlib.util.spec_from_file_location("build_web_docs_i18n", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC and SPEC.loader +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class WebDocsI18nTests(unittest.TestCase): + def run_generator(self, root, provider): + source_root = root / "messages" / "en" + messages_root = root / "messages" + state_path = messages_root / ".docs-i18n-source-state.json" + argv = [ + str(SCRIPT), + "--source-dir", + str(source_root), + "--messages-dir", + str(messages_root), + "--languages", + "de", + "--source-state", + str(state_path), + "--workers", + "1", + ] + with mock.patch.object(sys, "argv", argv), mock.patch.object( + MODULE, "provider_function", return_value=provider + ): + return MODULE.main() + + def test_protected_contract_round_trip(self): + source = ( + "Run systemctl restart pveproxy in Proxmox VE, " + "then open Settings at {host}." + ) + protected, mapping = MODULE.protect_text(source) + self.assertIn("systemctl restart pveproxy", protected) + self.assertNotIn("Proxmox VE", protected) + self.assertIn("", protected) + self.assertIn("", protected) + self.assertIn("", protected) + self.assertIn("", protected) + self.assertNotIn("", mapping.values()) + self.assertNotIn("", mapping.values()) + self.assertNotIn("systemctl", mapping.values()) + self.assertEqual(MODULE.restore_text(protected, mapping), source) + + def test_human_translation_is_not_pending(self): + source = {"title": "Updates", "url": "https://example.com"} + target = {"title": "Actualizaciones", "url": "https://example.com"} + self.assertEqual(MODULE.pending_leaves(source, target, refresh=False), []) + + def test_changed_source_text_is_pending_even_with_existing_translation(self): + source = {"title": "Updated installation guidance"} + target = {"title": "Vorherige Installationsanleitung"} + token = MODULE.leaf_token(("title",)) + leaves = MODULE.pending_leaves( + source, + target, + refresh=False, + forced_tokens={token}, + ) + self.assertEqual([leaf.path for leaf in leaves], [("title",)]) + + def test_source_fingerprints_change_per_leaf(self): + before = MODULE.source_fingerprints({"title": "One", "body": "Same"}) + after = MODULE.source_fingerprints({"title": "Two", "body": "Same"}) + self.assertNotEqual( + before[MODULE.leaf_token(("title",))], + after[MODULE.leaf_token(("title",))], + ) + self.assertEqual( + before[MODULE.leaf_token(("body",))], + after[MODULE.leaf_token(("body",))], + ) + + def test_schema_mismatch_detects_removed_keys(self): + self.assertFalse( + MODULE.schema_matches( + {"title": "Title"}, + {"title": "Título", "removed": "Old"}, + ) + ) + + def test_schema_mismatch_detects_changed_structural_values(self): + self.assertFalse( + MODULE.schema_matches( + {"enabled": True, "retries": 2}, + {"enabled": False, "retries": 2}, + ) + ) + + def test_schema_allows_localized_string_values(self): + self.assertTrue( + MODULE.schema_matches( + {"title": "Updates"}, + {"title": "Aktualisierungen"}, + ) + ) + + def test_translatable_rich_tags_use_opaque_names(self): + source = ( + "Keep this, that and " + "systemctl restart pveproxy." + ) + protected, mapping = MODULE.protect_rich_tags(source) + self.assertIn("this", protected) + self.assertIn("that", protected) + self.assertIn("systemctl restart pveproxy", protected) + self.assertEqual(MODULE.restore_rich_tags(protected, mapping), source) + + def test_source_schema_drives_output(self): + source = {"title": "Title", "items": ["One", "Two"]} + target = { + "title": "Título", + "items": ["Uno", "Dos", "Obsoleto"], + "removed": "Old", + } + translated = { + ("items", 0): "Uno", + ("items", 1): "Dos", + } + self.assertEqual( + MODULE.merge_tree(source, target, translated), + {"title": "Título", "items": ["Uno", "Dos"]}, + ) + + def test_translation_memory_ignores_conflicts(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source_root = root / "messages" / "en" + target_root = root / "messages" / "es" + source_root.mkdir(parents=True) + target_root.mkdir(parents=True) + (source_root / "one.json").write_text( + json.dumps({"label": "Settings"}), encoding="utf-8" + ) + (target_root / "one.json").write_text( + json.dumps({"label": "Ajustes"}), encoding="utf-8" + ) + (source_root / "two.json").write_text( + json.dumps({"label": "Settings"}), encoding="utf-8" + ) + (target_root / "two.json").write_text( + json.dumps({"label": "Configuración"}), encoding="utf-8" + ) + memory = MODULE.collect_memory(source_root, root / "messages", "es") + self.assertNotIn("Settings", memory) + + def test_source_state_retranslates_an_existing_changed_leaf(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source_path = root / "messages" / "en" / "page.json" + target_path = root / "messages" / "de" / "page.json" + source_path.parent.mkdir(parents=True) + target_path.parent.mkdir(parents=True) + source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8") + target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8") + + self.assertEqual( + self.run_generator(root, lambda *_: self.fail("provider called during baseline")), + 0, + ) + self.assertEqual(read_json_file(target_path), {"title": "Ursprünglich"}) + + source_path.write_text(json.dumps({"title": "Changed"}), encoding="utf-8") + calls = [] + + def provider(text, language): + calls.append((text, language)) + return "Geändert" + + self.assertEqual(self.run_generator(root, provider), 0) + self.assertEqual(calls, [("Changed", "de")]) + self.assertEqual(read_json_file(target_path), {"title": "Geändert"}) + + def test_new_leaf_preserves_translation_supplied_with_source_change(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source_path = root / "messages" / "en" / "page.json" + target_path = root / "messages" / "de" / "page.json" + source_path.parent.mkdir(parents=True) + target_path.parent.mkdir(parents=True) + source_path.write_text(json.dumps({"title": "Original"}), encoding="utf-8") + target_path.write_text(json.dumps({"title": "Ursprünglich"}), encoding="utf-8") + self.assertEqual(self.run_generator(root, lambda *_: "unused"), 0) + + source_path.write_text( + json.dumps({"title": "Original", "new": "New text"}), + encoding="utf-8", + ) + target_path.write_text( + json.dumps({"title": "Ursprünglich", "new": "Neuer Text"}), + encoding="utf-8", + ) + self.assertEqual( + self.run_generator(root, lambda *_: self.fail("manual translation overwritten")), + 0, + ) + self.assertEqual( + read_json_file(target_path), + {"title": "Ursprünglich", "new": "Neuer Text"}, + ) + + +def read_json_file(path): + return json.loads(path.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/build-i18n-messages.yml b/.github/workflows/build-i18n-messages.yml new file mode 100644 index 00000000..392de3b5 --- /dev/null +++ b/.github/workflows/build-i18n-messages.yml @@ -0,0 +1,138 @@ +name: Build i18n messages + +# Auto-translate missing keys in AppImage/messages//common.json +# against the English source whenever the source changes. +# +# The Monitor's i18n layer (AppImage/lib/i18n/provider.tsx) does its own +# runtime fallback (locale → en → key), so this workflow doesn't break +# anything if it misses a key: it just eliminates the visible-English +# blocks in non-en locales. +# +# Guardrails baked into build_i18n_messages.py: +# - Never overwrites a key whose target value differs from EN (i.e. +# already translated by a human). This is what makes it safe to +# include sk in the default set: Vaso73's curated strings are +# protected end-to-end; auto only fills keys still on the EN +# fallback. +# - `{placeholder}` tokens are protected end-to-end. +# +# Triggers: +# - push to develop touching AppImage/messages/en/common.json +# - manual via workflow_dispatch + +on: + push: + branches: [develop] + paths: + # Only en/ triggers the run — it's the source of truth. Other + # locales are destinations, and the bot auto-commits them at + # the end of every run; if they were in the trigger too, each + # auto-commit would fire another (empty) run. + # Manual edits to a curated locale that empty a key for the + # workflow to refill are the exception — dispatch this + # workflow manually from Actions in that case, or piggy-back + # a trivial en/ change onto the commit. + - 'AppImage/messages/en/common.json' + - '.github/scripts/build_i18n_messages.py' + - '.github/workflows/build-i18n-messages.yml' + workflow_dispatch: + inputs: + refresh: + description: 'Re-translate every key (overwrites human translations!)' + type: boolean + default: false + languages: + description: 'Comma-separated locales. Default: es,de,fr,it,pt,sk,sv (guardrail protects Vaso73 sk).' + default: 'es,de,fr,it,pt,sk,sv' + +# Prevent two runs from racing on the same branch and fighting over the +# auto-commit. cancel-in-progress:false because a full first-run may take +# ~30 min for the initial bootstrap and interrupting mid-flight would +# waste the calls already made. +concurrency: + group: build-i18n-messages-${{ github.ref }} + cancel-in-progress: false + +jobs: + translate: + runs-on: ubuntu-latest + permissions: + contents: write # auto-commit AppImage/messages/*/common.json to develop + # First-run bootstrap of ~5 locales × 3.8k keys can take a while at + # 0.15s/call with rate-limit backoffs. 90 min headroom. + timeout-minutes: 90 + + steps: + - name: Checkout develop + uses: actions/checkout@v4 + with: + ref: develop + # Full history so the auto-commit doesn't drift when another + # push landed between trigger and this job start. + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install googletrans + run: | + python -m pip install --upgrade pip + # Same pinning as build-translation-cache.yml so the two + # workflows share behavior. Bump both in lockstep. + pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0' + + - name: Translate missing keys + run: | + REFRESH_FLAG="" + if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then + REFRESH_FLAG="--refresh" + fi + LANGS="${{ github.event.inputs.languages }}" + # Keep this fallback in sync with the workflow_dispatch input + # default above AND with DEFAULT_LANGUAGES in the Python script — + # `push` triggers hit this branch (inputs are empty on push). + LANGS="${LANGS:-es,de,fr,it,pt,sk,sv}" + python .github/scripts/build_i18n_messages.py \ + --source AppImage/messages/en/common.json \ + --messages-dir AppImage/messages \ + --languages "$LANGS" \ + --provider googletrans \ + $REFRESH_FLAG + + - name: Commit + push if changed + run: | + if git diff --quiet -- AppImage/messages/; then + echo "No translation changes — skipping commit." + exit 0 + fi + git config user.name "ProxMenuxBot" + git config user.email "bot@proxmenux.local" + git add AppImage/messages/ + git commit -m "chore(i18n): auto-fill missing translations in messages/{locale}/common.json + + Source: ${GITHUB_SHA::7} + Triggered by: ${{ github.event_name }}" + # Rebase-and-retry against develop: between the initial + # checkout and this push another workflow (e.g. + # build-translation-cache auto-commit) or a manual push can + # land on develop first, and a naked push fails with + # non-fast-forward — losing the freshly generated translation + # commit. Rebasing the local i18n commit on top of the newer + # HEAD is safe: paths don't overlap with cache/script + # workflows, and the same-branch collisions between two i18n + # runs are already blocked by the concurrency group above. + for attempt in 1 2 3 4 5; do + git fetch origin develop + if git rebase origin/develop && git push origin develop; then + echo "push succeeded on attempt ${attempt}" + exit 0 + fi + echo "push attempt ${attempt} failed, retrying..." + git rebase --abort 2>/dev/null || true + sleep $(( attempt * 3 )) + done + echo "push failed after 5 attempts" + exit 1 diff --git a/.github/workflows/build-translation-cache.yml b/.github/workflows/build-translation-cache.yml index 9ed029d1..aa3f67fe 100644 --- a/.github/workflows/build-translation-cache.yml +++ b/.github/workflows/build-translation-cache.yml @@ -96,4 +96,23 @@ jobs: Source: ${GITHUB_SHA::7} Triggered by: ${{ github.event_name }}" - git push origin develop + # Rebase-and-retry against develop: between the initial + # checkout and this push another workflow (e.g. + # build-i18n-messages auto-commit) or a manual push can land + # on develop first, and a naked push fails with + # non-fast-forward — losing the freshly built cache commit. + # Paths don't overlap with the other workflows, and + # same-branch collisions between two cache runs are already + # blocked by the concurrency group. + for attempt in 1 2 3 4 5; do + git fetch origin develop + if git rebase origin/develop && git push origin develop; then + echo "push succeeded on attempt ${attempt}" + exit 0 + fi + echo "push attempt ${attempt} failed, retrying..." + git rebase --abort 2>/dev/null || true + sleep $(( attempt * 3 )) + done + echo "push failed after 5 attempts" + exit 1 diff --git a/.github/workflows/build-web-docs-i18n.yml b/.github/workflows/build-web-docs-i18n.yml new file mode 100644 index 00000000..6583f44f --- /dev/null +++ b/.github/workflows/build-web-docs-i18n.yml @@ -0,0 +1,119 @@ +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' + workflow_dispatch: + inputs: + languages: + description: 'Comma-separated locales' + default: 'es,de,fr,it,pt,sk,sv' + section: + description: 'File or directory below web/messages/en' + default: '.' + max_files: + description: 'Maximum pending files per locale; 0 means all' + default: '0' + refresh: + description: 'Overwrite existing translations in the selected scope' + type: boolean + default: false + dry_run: + description: 'Report pending coverage without writing files' + type: boolean + default: false + +concurrency: + group: build-web-docs-i18n-${{ github.ref }} + cancel-in-progress: false + +jobs: + translate: + runs-on: ubuntu-latest + permissions: + contents: write + timeout-minutes: 120 + + steps: + - name: Checkout develop + uses: actions/checkout@v4 + with: + ref: develop + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install translation provider + run: | + python -m pip install --upgrade pip + pip install 'googletrans==4.0.0-rc1' 'httpx==0.13.3' 'httpcore==0.9.1' 'h11==0.9.0' + + - name: Build missing translations + shell: bash + run: | + LANGUAGES="${{ github.event.inputs.languages }}" + LANGUAGES="${LANGUAGES:-es,de,fr,it,pt,sk,sv}" + SECTION="${{ github.event.inputs.section }}" + SECTION="${SECTION:-.}" + MAX_FILES="${{ github.event.inputs.max_files }}" + MAX_FILES="${MAX_FILES:-0}" + + EXTRA_ARGS=() + if [[ "${{ github.event.inputs.refresh }}" == "true" ]]; then + EXTRA_ARGS+=(--refresh) + fi + if [[ "${{ github.event.inputs.dry_run }}" == "true" ]]; then + EXTRA_ARGS+=(--dry-run) + fi + + python .github/scripts/build_web_docs_i18n.py \ + --source-dir web/messages/en \ + --messages-dir web/messages \ + --languages "$LANGUAGES" \ + --section "$SECTION" \ + --max-files "$MAX_FILES" \ + --provider googletrans \ + --workers 4 \ + "${EXTRA_ARGS[@]}" + + - name: Validate catalogs + run: | + python .github/scripts/build_web_docs_i18n.py \ + --source-dir web/messages/en \ + --messages-dir web/messages \ + --languages "${{ github.event.inputs.languages || 'es,de,fr,it,pt,sk,sv' }}" \ + --check + + - name: Commit and push changes + if: ${{ github.event.inputs.dry_run != 'true' }} + shell: bash + run: | + if git diff --quiet -- web/messages/; then + echo "No documentation translations changed." + exit 0 + fi + + git config user.name "ProxMenuxBot" + git config user.email "bot@proxmenux.local" + git add web/messages/ + git commit -m "docs(i18n): update documentation translations" + + for attempt in 1 2 3 4 5; do + git fetch origin develop + if git rebase origin/develop && git push origin develop; then + exit 0 + fi + git rebase --abort 2>/dev/null || true + sleep $((attempt * 3)) + done + exit 1 diff --git a/.github/workflows/update-app-tracking-hints.yml b/.github/workflows/update-app-tracking-hints.yml new file mode 100644 index 00000000..82a1f5ab --- /dev/null +++ b/.github/workflows/update-app-tracking-hints.yml @@ -0,0 +1,164 @@ +name: Update App Tracking Hints + +on: + # Manual trigger from the Actions UI + workflow_dispatch: + + # Re-merge whenever the generator, workflow, or the maintainer- + # curated runtime overrides change. `runtime_verified_overrides.json` + # is the file to edit when a real LXC reveals a canonical path the + # community-scripts helper doesn't ship (legacy /app/package.json, + # /opt/vaultwarden/bin/vaultwarden, etc.) — the generator folds it + # into the operational catalog every run. + push: + branches: [main] + paths: + - ".github/scripts/generate_app_tracking_catalog.py" + - ".github/workflows/update-app-tracking-hints.yml" + - "json/runtime_verified_overrides.json" + + # Regen every 6h — picks up new community-scripts LXC apps and + # detector-relevant script edits without needing a manual trigger. + schedule: + - cron: "0 */6 * * *" + +jobs: + update-hints: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: ⬇️ Checkout the repository + uses: actions/checkout@v6 + + - name: 🐍 Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + + - name: ⚙️ Generate app_tracking_hints.generated.json (intermediate) + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # The generator writes 4 files; only `.generated.json` is + # consumed downstream by the merge step. The v2 catalog and + # per-app audit are useful for local review but not kept in + # the repo — written under /tmp so they never appear as + # dirty files here. + # + # `--runtime-overrides` folds real-CT evidence into the + # operational hints (canonical paths, cross-method fallbacks + # per app) so the runtime doesn't get fed helper-marker + # false-positives. Modern helper markers are included as their + # official version contract; runtime keeps them distinguishable from + # canonical package/binary/manual detectors for legacy compatibility. + run: | + python .github/scripts/generate_app_tracking_catalog.py \ + --helpers-cache json/helpers_cache.json \ + --existing json/app_tracking_hints.json \ + --runtime-overrides json/runtime_verified_overrides.json \ + --include-helper-markers \ + --output json/app_tracking_hints.generated.json \ + --v2-output /tmp/app_tracking_catalog.v2.json \ + --audit-output /tmp/app_tracking_hints.audit.json + + - name: 🧬 Smart-merge generated into app_tracking_hints.json + # Single source of truth: `app_tracking_hints.json` is the ONE + # file. It contains 3 kinds of entries: + # 1. Auto-verified from community-scripts (the generator + # manages every "generator-owned" field on these). + # 2. User-edited additions to those entries — extra fields + # the generator doesn't touch (default_ports, + # file_fallbacks, custom logo overrides…). + # 3. User-only entries the generator can't verify (Docker, + # AdGuard, Pi-hole, WireGuard, …) — left alone. + # Merge rule: for slugs the generator produces, refresh only + # the whitelisted fields; preserve everything else. For slugs + # NOT in the generator's output, keep the existing entry + # untouched. + run: | + python - <<'PY' + import json + from pathlib import Path + + GEN = Path("json/app_tracking_hints.generated.json") + OUT = Path("json/app_tracking_hints.json") + + # Fields owned by the generator — refreshed on every run. + # These are all populated deterministically by the generator + # (the audit script folds `runtime_verified_overrides.json` + # in as it runs), so a local hand-edit for a generator-known + # slug would get overwritten on the next tick. To add a new + # canonical path or a cross-method fallback for a slug the + # generator already knows, edit `runtime_verified_overrides + # .json` — that file IS the maintainer-controlled input. + # + # For user-only slugs (Docker, WireGuard, Pi-hole and any + # other entry not in the generator's output) EVERY field is + # preserved verbatim by the merge below — the whitelist only + # governs generator-covered slugs. + GENERATOR_FIELDS = { + "installed_via", "package", "file_path", "file_regex", + "binary_path", "binary_args", "python_path", "distribution", + "container_name", "label", "command_argv", "installed_version", + "repo", "github_source", "tag_regex", "installed_regex", + # Upstream source discriminator + per-type fields + # (http_json + docker_hub). Kept in the whitelist so a + # curated entry in runtime_verified_overrides.json can + # supply them and the smart merge won't drop them on the + # next regeneration. + "upstream_type", "upstream_url", "upstream_json_path", + "docker_image", + "logo", "website", + "default_ports", "file_fallbacks", "alt_detectors", + } + + generated = json.loads(GEN.read_text(encoding="utf-8")) + existing = {} + if OUT.is_file(): + try: + existing = json.loads(OUT.read_text(encoding="utf-8")) + if not isinstance(existing, dict): + existing = {} + except json.JSONDecodeError: + existing = {} + + merged = {} + for slug, gen_entry in generated.items(): + base = dict(existing.get(slug) or {}) + # Refresh generator-owned fields (add/update). + for k, v in gen_entry.items(): + if k in GENERATOR_FIELDS: + base[k] = v + # Drop generator-owned fields that the generator no + # longer emits for this slug (e.g. path renamed away). + for k in list(base): + if k in GENERATOR_FIELDS and k not in gen_entry: + del base[k] + merged[slug] = base + # Preserve user-only entries the generator can't verify. + for slug, entry in existing.items(): + if slug not in generated and isinstance(entry, dict): + merged[slug] = dict(entry) + + OUT.write_text(json.dumps(merged, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + added = sorted(set(generated) - set(existing)) + removed = sorted(set(existing) - set(generated) - {s for s, e in existing.items() if not ( + set(e.keys()) - GENERATOR_FIELDS + )}) + print(f"merged: {len(merged)} entries " + f"(generated={len(generated)}, existing={len(existing)})") + if added: + print(f" new from generator: {len(added)}") + # Clean up the intermediate file so it doesn't get committed. + GEN.unlink() + PY + + - name: 📤 Commit + push if changed + run: | + git config user.name "ProxMenuxBot" + git config user.email "bot@proxmenux.local" + git add json/app_tracking_hints.json + git diff --cached --quiet || git commit -m "Update app tracking hints" + git push diff --git a/AppImage/README.md b/AppImage/README.md index 8c221020..8f51ab23 100644 --- a/AppImage/README.md +++ b/AppImage/README.md @@ -201,6 +201,20 @@ After setting up your password, you can enable 2FA using any TOTP authenticator ![2FA Setup](https://raw.githubusercontent.com/MacRimi/ProxMenux/main/web/public/monitor/2fa-setup.png) +### Embedding in Trusted Iframes + +By default, ProxMenux Monitor blocks embedding in iframes with `frame-ancestors 'none'` and `X-Frame-Options: DENY`. + +If you run a trusted local portal or monitoring page and need to embed the Monitor, set `PROXMENUX_ALLOWED_FRAME_ANCESTORS` to the exact parent origins that may frame it: + +```bash +PROXMENUX_ALLOWED_FRAME_ANCESTORS="https://portal.example.com http://raspberrypi.local:8080" +``` + +Only exact `http://` or `https://` origins are accepted. Paths, wildcards, broad schemes, credentials, and malformed values are ignored. When this setting is present, the Monitor sends a matching CSP `frame-ancestors` allowlist and omits `X-Frame-Options`, because that legacy header cannot express multiple allowed parents. + +`ALLOWED_FRAME_ANCESTORS` is also accepted as a compatibility alias when `PROXMENUX_ALLOWED_FRAME_ANCESTORS` is not set. + ### Security Best Practices for API Tokens **IMPORTANT**: Never hardcode your API tokens directly in configuration files or scripts. Instead, use environment variables or secrets management. diff --git a/AppImage/app/layout.tsx b/AppImage/app/layout.tsx index 3b664966..d9c48067 100644 --- a/AppImage/app/layout.tsx +++ b/AppImage/app/layout.tsx @@ -5,6 +5,7 @@ import { GeistMono } from "geist/font/mono" import { ThemeProvider } from "../components/theme-provider" import { PwaRegister } from "../components/pwa-register" import { PwaInstallPrompt } from "../components/pwa-install-prompt" +import { I18nProvider } from "../lib/i18n/provider" import { Suspense } from "react" import "./globals.css" @@ -43,13 +44,15 @@ export default function RootLayout({ return ( - Loading...}> - - {children} - + + + + {children} + + + - ) diff --git a/AppImage/app/page.tsx b/AppImage/app/page.tsx index 810f2766..c52df589 100644 --- a/AppImage/app/page.tsx +++ b/AppImage/app/page.tsx @@ -5,8 +5,10 @@ import { ProxmoxDashboard } from "../components/proxmox-dashboard" import { Login } from "../components/login" import { AuthSetup } from "../components/auth-setup" import { getApiUrl } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" export default function Home() { + const t = useT() const [authStatus, setAuthStatus] = useState<{ loading: boolean authEnabled: boolean @@ -113,8 +115,8 @@ export default function Home() {
-
Loading...
-

Connecting to ProxMenux Monitor

+
{t("app.loading")}
+

{t("app.connecting")}

) diff --git a/AppImage/components/about.tsx b/AppImage/components/about.tsx index 86f91ef3..d23f341c 100644 --- a/AppImage/components/about.tsx +++ b/AppImage/components/about.tsx @@ -13,6 +13,7 @@ import { } from "lucide-react" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" import { APP_VERSION } from "./release-notes-modal" +import { useT } from "../lib/i18n/provider" // Issue #191: a dedicated About tab. Centralises project metadata // (version, license, author) and every external link the project @@ -22,8 +23,8 @@ import { APP_VERSION } from "./release-notes-modal" // without re-cluttering the dashboard footer. interface LinkRow { - label: string - description: string + labelKey: string + descriptionKey: string href: string Icon: React.ComponentType<{ className?: string }> accent?: keyof typeof ACCENT_CLASSES @@ -42,29 +43,29 @@ const ACCENT_CLASSES = { const PROJECT_LINKS: LinkRow[] = [ { - label: "GitHub repository", - description: "Source code, releases and issue tracker.", + labelKey: "about.links.repository.label", + descriptionKey: "about.links.repository.description", href: "https://github.com/MacRimi/ProxMenux", Icon: Github, accent: "gray", }, { - label: "Documentation", - description: "Full user guide for ProxMenux and the Monitor.", + labelKey: "about.links.documentation.label", + descriptionKey: "about.links.documentation.description", href: "https://proxmenux.com", Icon: BookOpen, accent: "blue", }, { - label: "Discussions", - description: "Ask questions, share custom AI prompts, swap ideas.", + labelKey: "about.links.discussions.label", + descriptionKey: "about.links.discussions.description", href: "https://github.com/MacRimi/ProxMenux/discussions", Icon: MessageSquare, accent: "purple", }, { - label: "Report a bug or request a feature", - description: "Open an issue on GitHub — bugs, ideas, regressions.", + labelKey: "about.links.issues.label", + descriptionKey: "about.links.issues.description", href: "https://github.com/MacRimi/ProxMenux/issues", Icon: Bug, accent: "red", @@ -73,8 +74,8 @@ const PROJECT_LINKS: LinkRow[] = [ const SUPPORT_LINKS: LinkRow[] = [ { - label: "Support the project on Ko-fi", - description: "ProxMenux is free and open source. Donations cover hosting and dev time.", + labelKey: "about.links.support.label", + descriptionKey: "about.links.support.description", href: "https://ko-fi.com/macrimi", Icon: Heart, accent: "pink", @@ -82,6 +83,7 @@ const SUPPORT_LINKS: LinkRow[] = [ ] function LinkCard({ row }: { row: LinkRow }) { + const t = useT() const accentClass = ACCENT_CLASSES[row.accent ?? "blue"] // Style mirrors the PCI Devices cards in the Hardware tab: subtle // translucent background by default, slightly lighter on hover, no @@ -101,16 +103,17 @@ function LinkCard({ row }: { row: LinkRow }) {
- {row.label} + {t(row.labelKey)}
-

{row.description}

+

{t(row.descriptionKey)}

) } export function About() { + const t = useT() return (
{/* Hero — logo, name, version, one-line description. */} @@ -120,7 +123,7 @@ export function About() {
ProxMenux logo

- A web dashboard and management layer for Proxmox VE — health monitoring, - notifications, terminal, optimization tracker and more, packaged as a single - AppImage. + {t("about.heroDescription")}

@@ -151,7 +152,7 @@ export function About() { const href = isPrerelease ? "https://github.com/MacRimi/ProxMenux/releases" : "https://proxmenux.com/en/changelog" - const label = isPrerelease ? "Release notes" : "Changelog" + const label = isPrerelease ? t("about.releaseNotes") : t("about.changelog") return ( - Project + {t("about.project.title")} - Repository, documentation and community channels. + {t("about.project.description")}
@@ -195,11 +196,10 @@ export function About() { - Support & License + {t("about.support.title")} - ProxMenux is free and open source under the GPL-3.0 license. If it's useful to - you, a one-off contribution helps keep it that way. + {t("about.support.description")} @@ -218,11 +218,11 @@ export function About() {
- GPL-3.0 license + {t("about.license.label")}

- Free software — see the LICENSE file for the full text. + {t("about.license.description")}

diff --git a/AppImage/components/apps-dashboard.tsx b/AppImage/components/apps-dashboard.tsx new file mode 100644 index 00000000..9bd7e6fb --- /dev/null +++ b/AppImage/components/apps-dashboard.tsx @@ -0,0 +1,727 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import useSWR from "swr" +import { ArrowUpCircle, Check, ExternalLink, Pencil, Plus, Search } from "lucide-react" +import { fetchApi } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" +import { ThemeAwareLogo } from "./lxc-app-panel" +import { CustomLinkEditor, type CustomLink, type GuestOption } from "./custom-link-editor" +import { Button } from "./ui/button" +import { categoryChipStyle, useIsLightTheme } from "../lib/category-color" + +// ─── Local subset of /api/vms shape ───────────────────────────── +// Kept narrow on purpose — this component only needs what feeds a +// launcher card. Full VMData / LxcAppWatch types live in +// virtual-machines.tsx. + +interface AppPort { + port: number + description?: string + scheme?: "http" | "https" + web_path?: string + logo_url?: string | null + category?: string + custom_url?: string +} + +interface AppWatch { + id: string + name: string | null + logo_url?: string | null + ports?: AppPort[] + installed_version: string | null + latest_version: string | null + update_available: boolean | null + managed_oci_app_id?: string | null + helper_slug?: string +} + +interface DockerImageUpdate { + reference: string + display_name?: string | null + used_by?: string[] + update_available: boolean | null +} + +// Locate the docker_inventory image whose lifecycle matches a given +// Web Link. The port's `description` is a user-typed label (e.g. +// "Paperless") so exact match on `used_by` (real container names +// like "paperless-webserver-1") almost never hits. Fall back through: +// 1. exact match in `used_by` +// 2. case-insensitive substring either way in `used_by` +// 3. substring in `display_name` +// 4. substring in `reference` (the full image path) +// Returns undefined when nothing matches — the caller treats that as +// "no upstream update signal for this port". +function findDockerImageForPort( + port: AppPort, + images: DockerImageUpdate[], +): DockerImageUpdate | undefined { + const desc = (port.description || "").trim().toLowerCase() + if (!desc || !images.length) return undefined + const exact = images.find((i) => + (i.used_by || []).some((c) => c.toLowerCase() === desc), + ) + if (exact) return exact + const inclUsedBy = images.find((i) => + (i.used_by || []).some((c) => { + const cl = c.toLowerCase() + return cl.includes(desc) || desc.includes(cl) + }), + ) + if (inclUsedBy) return inclUsedBy + const byDisplay = images.find((i) => { + const d = (i.display_name || "").toLowerCase() + return !!d && (d.includes(desc) || desc.includes(d)) + }) + if (byDisplay) return byDisplay + return images.find((i) => (i.reference || "").toLowerCase().includes(desc)) +} + +interface VM { + vmid: number + name: string + ip?: string + type: string + app_watches?: AppWatch[] + docker_inventory?: { images?: DockerImageUpdate[] } +} + +interface LaunchLink { + key: string + // Present for LXC-registered apps and for custom links with a + // guest binding. Absent when the link is an unbound custom entry + // (e.g. an external service). + vmid: number | null + guestType: "lxc" | "qemu" | null + ctName: string + appName: string + logoUrl: string | null + weblink: string + category: string + updateAvailable: boolean + // Set for user-defined custom links so the card can offer edit + // and delete actions in edit mode. + isCustom: boolean + customId?: string +} + +// ─── Helpers ───────────────────────────────────────────────────── + +// Same URL construction as the Web Link row in the App tab. +// Duplicated (small) on purpose — buildWebUrl in lxc-app-panel.tsx +// is scoped to that module, and copying keeps this component free of +// hidden cross-file dependencies. A per-port `custom_url` overrides +// the ip:port composition entirely — used for apps served behind a +// reverse-proxy domain. +function buildWebUrl(ip: string | undefined, port: AppPort): string | null { + const custom = (port.custom_url || "").trim() + if (custom) return custom + const raw = (ip || "").trim().split("/")[0] + if (!raw || raw === "DHCP" || !port?.port) return null + const host = raw.includes(":") && !raw.startsWith("[") ? `[${raw}]` : raw + const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http") + const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : "" + return `${scheme}://${host}:${port.port}${path}` +} + +type SortMode = "name" | "ct" | "category" +const SORT_STORAGE_KEY = "proxmenux-apps-sort" +const ALL_CATEGORIES = "__all__" + +const fetcher = async (url: string) => fetchApi(url) + +// ─── Component ─────────────────────────────────────────────────── + +export function AppsDashboard() { + const t = useT() + const { data: vms } = useSWR("/api/vms", fetcher, { refreshInterval: 5000, revalidateOnFocus: false }) + // Custom links persisted in /etc/proxmenux/custom_links.json. Small + // and rarely changes, so we don't poll on an interval — mutate() is + // called explicitly after create / update / delete. + const { data: customLinks, mutate: mutateCustomLinks } = useSWR( + "/api/apps/custom-links", fetcher, { revalidateOnFocus: false }, + ) + // Category presets for the "+ Add link" modal. + const { data: categoryPresets } = useSWR( + "/api/apps/categories", fetcher, { revalidateOnFocus: false }, + ) + + const isLightTheme = useIsLightTheme() + + // Flatten VMs → LaunchLinks. One card per (app × port with weblink) + // for LXC-registered apps, plus one card per user-defined custom + // link. A custom link with a binding resolves its ctName from the + // matching guest in `vms` so renames stay in sync automatically. + const links = useMemo(() => { + const out: LaunchLink[] = [] + const vmsList = Array.isArray(vms) ? vms : [] + for (const vm of vmsList) { + const apps = vm.app_watches || [] + if (!apps.length) continue + for (const app of apps) { + // Skip the synthetic entry ProxMenux inserts for managed + // OCI apps (Secure Gateway) — it has no user-assigned + // Web Link and doesn't belong in a launcher. + if (app.managed_oci_app_id) continue + // `app.update_available` refers to the app itself. For a + // Docker registration that app is the Docker engine, and its + // ports are containers running INSIDE Docker (Portainer, + // Frigate…) — each with an independent image update + // lifecycle in `vm.docker_inventory.images[]`. Propagating + // the engine-level flag to every container card would falsely + // mark Portainer/Frigate as updatable when only the engine + // needs bumping; missing the per-image flag would hide real + // Portainer/Frigate updates that ARE tracked in the App tab + // and fire notifications. Resolution: for each Docker port, + // find the image entry whose `used_by` includes the port's + // container name (== `port.description`) and use THAT image's + // update_available. Engine update stays out of the port cards + // — it belongs in the Updates tab. + const isDockerApp = app.helper_slug === "docker" + const dockerImages = vm.docker_inventory?.images || [] + for (const port of app.ports || []) { + const url = buildWebUrl(vm.ip, port) + if (!url) continue + let updateAvailable = false + if (isDockerApp) { + const img = findDockerImageForPort(port, dockerImages) + updateAvailable = img?.update_available === true + } else { + updateAvailable = app.update_available === true + } + out.push({ + key: `lxc-${vm.vmid}-${app.id}-${port.port}`, + vmid: vm.vmid, + guestType: "lxc", + ctName: vm.name, + appName: (port.description || app.name || vm.name || "").trim(), + logoUrl: port.logo_url || app.logo_url || null, + weblink: url, + category: (port.category || "").trim(), + updateAvailable, + isCustom: false, + }) + } + } + } + // Merge user-defined custom links. Their `binding` decides how the + // CT/VM reference renders and where clicking it navigates. + const guestByVmid = new Map() + for (const vm of vmsList) guestByVmid.set(vm.vmid, { name: vm.name, type: vm.type }) + for (const link of customLinks || []) { + let ctName = "" + let vmid: number | null = null + let guestType: "lxc" | "qemu" | null = null + if (link.binding) { + const guest = guestByVmid.get(link.binding.vmid) + vmid = link.binding.vmid + guestType = link.binding.guest_type + ctName = guest?.name || "" + } + out.push({ + key: `custom-${link.id}`, + vmid, + guestType, + ctName, + appName: link.name, + logoUrl: link.logo_url || null, + weblink: link.url, + category: (link.category || "").trim(), + updateAvailable: false, + isCustom: true, + customId: link.id, + }) + } + return out + }, [vms, customLinks]) + + // Category list for the filter dropdown — built from the data so + // it always reflects reality (presets and custom-entered names). + const categoryCounts = useMemo(() => { + const map = new Map() + for (const l of links) { + const key = l.category || t("apps.uncategorized") + map.set(key, (map.get(key) || 0) + 1) + } + return map + }, [links, t]) + const sortedCategoryEntries = useMemo( + () => Array.from(categoryCounts.entries()).sort((a, b) => a[0].localeCompare(b[0])), + [categoryCounts], + ) + + // ─── Controls state ──────────────────────────────────────────── + + const [query, setQuery] = useState("") + const [currentCat, setCurrentCat] = useState(ALL_CATEGORIES) + const [sortMode, setSortMode] = useState("name") + const [searchExpanded, setSearchExpanded] = useState(false) + const searchInputRef = useRef(null) + + // Restore sort from localStorage on mount. + useEffect(() => { + try { + const saved = localStorage.getItem(SORT_STORAGE_KEY) + if (saved === "name" || saved === "ct" || saved === "category") { + setSortMode(saved) + } + } catch (_) { /* private mode / storage disabled — silent */ } + }, []) + + // Persist sort choice — only this one preference survives reload; + // category filter and search reset each visit so the dashboard + // always opens showing every app. + useEffect(() => { + try { localStorage.setItem(SORT_STORAGE_KEY, sortMode) } catch (_) {} + }, [sortMode]) + + // Filter category resets if the user removes/renames the currently + // selected one and it disappears from the list. + useEffect(() => { + if (currentCat === ALL_CATEGORIES) return + if (!categoryCounts.has(currentCat)) setCurrentCat(ALL_CATEGORIES) + }, [currentCat, categoryCounts]) + + // ─── Custom link editor state ────────────────────────────────── + + const [editorOpen, setEditorOpen] = useState(false) + const [editingLink, setEditingLink] = useState(null) + const [editMode, setEditMode] = useState(false) + + // Guest list feeds the binding dropdown in the editor modal. + const guestOptions = useMemo(() => { + if (!Array.isArray(vms)) return [] + return vms + .filter((v) => v.type === "lxc" || v.type === "qemu") + .map((v) => ({ + vmid: v.vmid, + name: v.name, + type: v.type as "lxc" | "qemu", + })) + }, [vms]) + + const openNewLink = () => { + setEditingLink(null) + setEditorOpen(true) + } + const openEditForLink = (customId: string) => { + const found = (customLinks || []).find((l) => l.id === customId) + if (!found) return + setEditingLink(found) + setEditorOpen(true) + } + + // ─── Filter + sort ───────────────────────────────────────────── + + const shown = useMemo(() => { + const q = query.trim().toLowerCase() + const uncatKey = t("apps.uncategorized") + let filtered = links + if (currentCat !== ALL_CATEGORIES) { + filtered = filtered.filter((l) => (l.category || uncatKey) === currentCat) + } + if (q) { + filtered = filtered.filter((l) => + l.appName.toLowerCase().includes(q) || + (l.ctName || "").toLowerCase().includes(q) || + (l.vmid != null && String(l.vmid).includes(q)) || + (l.category || "").toLowerCase().includes(q) + ) + } + const sorted = [...filtered] + sorted.sort((a, b) => { + if (sortMode === "name") return a.appName.localeCompare(b.appName) + if (sortMode === "ct") { + // Unbound custom links have no vmid; sort them after every + // bound entry, ordered alphabetically by app name. + if (a.vmid == null && b.vmid == null) return a.appName.localeCompare(b.appName) + if (a.vmid == null) return 1 + if (b.vmid == null) return -1 + return (a.vmid - b.vmid) || a.appName.localeCompare(b.appName) + } + // category — grouped alphabetically, then by app name inside + const catA = a.category || uncatKey + const catB = b.category || uncatKey + const c = catA.localeCompare(catB) + return c !== 0 ? c : a.appName.localeCompare(b.appName) + }) + return sorted + }, [links, query, currentCat, sortMode, t]) + + // ─── Empty state ─────────────────────────────────────────────── + + const hasAnyData = links.length > 0 || (customLinks && customLinks.length > 0) + if (vms && !hasAnyData) { + return ( + <> +
+
{t("apps.emptyTitle")}
+
{t("apps.emptyHint")}
+ +
+ mutateCustomLinks()} + /> + + ) + } + + // ─── Render ──────────────────────────────────────────────────── + + const countLabel = shown.length === 1 + ? t("apps.countOne") + : t("apps.countMany", { n: shown.length }) + + return ( +
+ {/* Toolbar */} +
+ {/* Search — icon-only until tapped on narrow screens */} +
+ {!searchExpanded && ( + + )} +
+ + setQuery(e.target.value)} + onBlur={() => { if (!query.trim()) setSearchExpanded(false) }} + placeholder={t("apps.searchPlaceholder")} + aria-label={t("apps.searchAriaLabel")} + className="w-full h-9 pl-8 pr-3 text-sm bg-card border border-border rounded-md text-foreground placeholder:text-muted-foreground focus:border-border/80 focus:outline-none focus:ring-1 focus:ring-ring" + /> +
+
+ + {/* Category filter */} + + + {/* Sort */} + + + {/* Count — desktop only. Mobile gives the horizontal room to + the + button instead so everything stays on one line. */} + + {countLabel} + + + {/* + Add custom link. Icon-only on mobile (mirrors the search + icon-toggle pattern) so the toolbar fits in one line even + in the narrowest viewport. On desktop shows label + icon. */} + + + {/* Edit mode toggle — only shown when at least one custom link + exists, since it's the only card type that carries per-card + edit/delete actions. LXC-registered apps are edited in the + LXC App tab of their guest modal. */} + {(customLinks && customLinks.length > 0) && ( + + )} +
+ + {/* Grid — grouped headers when sorted by category */} + + + mutateCustomLinks()} + /> +
+ ) +} + +// ─── Cards grid + card ─────────────────────────────────────────── + +function CardsGrid({ + links, + grouped, + uncategorizedLabel, + openLabel, + isLightTheme, + editMode, + onEditCustom, +}: { + links: LaunchLink[] + grouped: boolean + uncategorizedLabel: string + openLabel: string + isLightTheme: boolean + editMode: boolean + onEditCustom: (customId: string) => void +}) { + if (!links.length) { + return ( +
+
+ {/* No results after filter/search */} +
+
+ ) + } + + if (!grouped) { + return ( +
+ {links.map((link) => ( + + ))} +
+ ) + } + + // Group by category, insert header rows spanning the full grid width. + const groups: Array<[string, LaunchLink[]]> = [] + let currentCat: string | null = null + let bucket: LaunchLink[] = [] + for (const link of links) { + const cat = link.category || uncategorizedLabel + if (cat !== currentCat) { + if (bucket.length) groups.push([currentCat!, bucket]) + currentCat = cat + bucket = [] + } + bucket.push(link) + } + if (bucket.length) groups.push([currentCat!, bucket]) + + return ( +
+ {groups.map(([cat, items]) => ( +
+

+ {cat} + {items.length} +

+ {items.map((link) => ( + + ))} +
+ ))} +
+ ) +} + +// hueForCategory / categoryChipStyle / readIsLightTheme moved to +// lib/category-color.ts so the LXC App tab can render the same chip. + +// Dispatch the pair of events that jumps from the Apps dashboard to +// the VMs modal on the App tab for a given CT. Two events by design: +// `changeTab` switches the outer tab (dashboard-level) and +// `openLxcAppModal` tells VirtualMachines which guest to open and on +// which inner tab to land. Both fire in the same tick. +function openLxcModalOnAppTab(vmid: number) { + window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } })) + window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } })) +} + +// Same pattern for a QEMU guest: land on the modal's Status tab +// (QEMU guests don't have the App tab). Used by custom links whose +// binding is a VM instead of an LXC. +function openVmModalOnStatusTab(vmid: number) { + window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } })) + window.dispatchEvent(new CustomEvent("openVmStatusModal", { detail: { vmid } })) +} + +function AppCard({ + link, + openLabel, + isLightTheme, + editMode, + onEditCustom, +}: { + link: LaunchLink + openLabel: string + isLightTheme: boolean + editMode: boolean + onEditCustom: (customId: string) => void +}) { + const t = useT() + // Navigate to the bound guest's modal on the appropriate inner tab: + // LXC → App tab (where the weblink was registered), VM → Status tab + // (VMs don't have an App tab). Unbound custom links have no CT ref + // to click, so this handler is only wired when `link.vmid` exists. + const goToBoundGuest = (e: React.MouseEvent | React.KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + if (link.vmid == null) return + if (link.guestType === "qemu") { + openVmModalOnStatusTab(link.vmid) + } else { + openLxcModalOnAppTab(link.vmid) + } + } + + const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + if (link.customId) onEditCustom(link.customId) + } + + const guestPrefix = link.guestType === "qemu" ? "VM" : "CT" + const hasBinding = link.vmid != null + + return ( + + {/* Head: logo + name (+ update icon) */} +
+
+ {link.logoUrl ? ( + + ) : ( + {link.appName.slice(0, 2)} + )} +
+
+
{link.appName}
+
+ {/* Edit mode on a custom card takes over the update-icon slot + with a proper edit button — custom links never carry the + update signal, so nothing is displaced. Falls back to the + update icon in every other case. */} + {editMode && link.isCustom ? ( + + ) : link.updateAvailable && ( +
+ + {/* Foot: weblink + CT ref + category chip */} +
+
+ + {link.weblink} +
+
+ {/* Guest ref → click opens that guest's modal (LXC → App + tab, VM → Status tab). stopPropagation keeps the outer + anchor from firing at the same time. Unbound custom + links: in edit mode show the edit button here, otherwise + show nothing. */} + {hasBinding && ( + + )} + {link.category && ( + + {link.category} + + )} +
+
+
+ ) +} diff --git a/AppImage/components/auth-setup.tsx b/AppImage/components/auth-setup.tsx index 1b137709..9c07fd6b 100644 --- a/AppImage/components/auth-setup.tsx +++ b/AppImage/components/auth-setup.tsx @@ -7,12 +7,14 @@ import { Input } from "./ui/input" import { Label } from "./ui/label" import { Shield, Lock, User, AlertCircle, Eye, EyeOff, Upload, Trash2 } from "lucide-react" import { getApiUrl } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" interface AuthSetupProps { onComplete: () => void } export function AuthSetup({ onComplete }: AuthSetupProps) { + const t = useT() const [open, setOpen] = useState(false) const [step, setStep] = useState<"choice" | "setup">("choice") const [username, setUsername] = useState("") @@ -74,7 +76,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { const data = await response.json() if (!response.ok) { - throw new Error(data.error || "Failed to skip authentication") + throw new Error(data.error || t("authSetup.skipFailed")) } if (data.auth_declined) { @@ -86,7 +88,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { onComplete() } catch (err) { console.error("Auth skip error:", err) - setError(err instanceof Error ? err.message : "Failed to save preference") + setError(err instanceof Error ? err.message : t("authSetup.savePreferenceFailed")) } finally { setLoading(false) } @@ -108,17 +110,17 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { setError("") if (!username || !password) { - setError("Please fill in all fields") + setError(t("authSetup.fillFields")) return } if (password !== confirmPassword) { - setError("Passwords do not match") + setError(t("authSetup.passwordMismatch")) return } if (password.length < 6) { - setError("Password must be at least 6 characters") + setError(t("authSetup.passwordTooShort")) return } @@ -137,7 +139,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { const data = await response.json() if (!response.ok) { - throw new Error(data.error || "Failed to setup authentication") + throw new Error(data.error || t("authSetup.setupFailed")) } if (data.token) { @@ -204,7 +206,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { onComplete() } catch (err) { console.error("Auth setup error:", err) - setError(err instanceof Error ? err.message : "Failed to setup authentication") + setError(err instanceof Error ? err.message : t("authSetup.setupFailed")) } finally { setLoading(false) } @@ -214,7 +216,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { - {step === "choice" ? "Setup Dashboard Protection" : "Create Password"} + {step === "choice" ? t("authSetup.choiceTitle") : t("authSetup.passwordTitle")} {step === "choice" ? (
@@ -222,16 +224,16 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
-

Protect Your Dashboard?

+

{t("authSetup.protectTitle")}

- Add an extra layer of security to protect your Proxmox data when accessing from non-private networks. + {t("authSetup.protectDescription")}

-

You can always enable this later in Settings

+

{t("authSetup.enableLater")}

) : (
@@ -252,8 +254,8 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
-

Setup Authentication

-

Create a username and password to protect your dashboard

+

{t("authSetup.setupTitle")}

+

{t("authSetup.setupDescription")}

{error && ( @@ -266,14 +268,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setUsername(e.target.value)} className="pl-10 text-base" @@ -285,14 +287,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setPassword(e.target.value)} className="pl-10 text-base" @@ -312,14 +314,14 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
setConfirmPassword(e.target.value)} className="pl-10 text-base" @@ -345,19 +347,19 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { setup endpoint returns the JWT. */}

- Profile · optional + {t("authSetup.profileOptional")}

setDisplayName(e.target.value)} maxLength={64} @@ -366,12 +368,12 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { />

- Leave empty to render the username itself. Up to 64 characters. + {t("authSetup.displayNameHint")}

- +
{avatarPreviewUrl ? ( // eslint-disable-next-line @next/next/no-img-element @@ -407,7 +409,7 @@ export function AuthSetup({ onComplete }: AuthSetupProps) { className="h-7 text-xs" > - {avatarFile ? "Change" : "Choose image"} + {avatarFile ? t("authSetup.change") : t("authSetup.chooseImage")} {avatarFile && ( )}

- PNG, JPEG, WebP or GIF · up to 2 MB · pre-crop square for best results. + {t("authSetup.avatarHint")}

@@ -434,10 +436,10 @@ export function AuthSetup({ onComplete }: AuthSetupProps) {
diff --git a/AppImage/components/avatar-menu.tsx b/AppImage/components/avatar-menu.tsx index 7efdffe5..76f0574d 100644 --- a/AppImage/components/avatar-menu.tsx +++ b/AppImage/components/avatar-menu.tsx @@ -11,6 +11,7 @@ import { DropdownMenuTrigger, } from "./ui/dropdown-menu" import { fetchApi, getApiUrl, getAuthToken } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" interface AuthStatus { auth_enabled?: boolean @@ -57,6 +58,8 @@ interface AvatarMenuProps { * proper /api/auth/logout that revokes the JWT server-side too. */ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: AvatarMenuProps) { + const t = useT() + // IMPORTANT — all hooks must run unconditionally on every render. The // previous version short-circuited with `if (!auth_enabled) return null` // BEFORE the avatar blob hooks, so the hook count changed between @@ -201,7 +204,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata
)} {!profile?.display_name && ( -
Signed in
+
{t("account.signedIn")}
)}
@@ -257,13 +260,13 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata {onOpenProfile && ( - View profile + {t("account.viewProfile")} )} {onOpenSecurity && ( - Security + {t("account.security")} )} {(onOpenProfile || onOpenSecurity) && } @@ -272,7 +275,7 @@ export function AvatarMenu({ size = "lg", onOpenSecurity, onOpenProfile }: Avata className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400" > - Sign out + {t("account.signOut")} diff --git a/AppImage/components/custom-link-editor.tsx b/AppImage/components/custom-link-editor.tsx new file mode 100644 index 00000000..e35c93bd --- /dev/null +++ b/AppImage/components/custom-link-editor.tsx @@ -0,0 +1,306 @@ +"use client" + +import { useEffect, useState } from "react" +import { Trash2 } from "lucide-react" +import { fetchApi } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog" +import { Button } from "./ui/button" +import { Input } from "./ui/input" +import { Label } from "./ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" + +// Minimal shape we need from the /api/vms poll. Kept narrow so this +// component stays independent of the fuller VMData type used in +// virtual-machines.tsx. +export interface GuestOption { + vmid: number + name: string + type: "lxc" | "qemu" +} + +export interface CustomLink { + id: string + name: string + url: string + logo_url: string + category: string + binding: { vmid: number; guest_type: "lxc" | "qemu" } | null + created_at?: number + updated_at?: number +} + +export interface DraftCustomLink { + name: string + url: string + logo_url: string + category: string + bindingKey: string +} + +const UNBOUND_KEY = "__none__" + +function buildKey(binding: CustomLink["binding"]): string { + if (!binding) return UNBOUND_KEY + return `${binding.guest_type}:${binding.vmid}` +} + +function parseKey(key: string): CustomLink["binding"] { + if (!key || key === UNBOUND_KEY) return null + const [type, vmid] = key.split(":") + if (type !== "lxc" && type !== "qemu") return null + const n = Number(vmid) + if (!Number.isFinite(n)) return null + return { guest_type: type, vmid: n } +} + +export function CustomLinkEditor({ + open, + onOpenChange, + editing, + guests, + categoryPresets, + onSaved, +}: { + open: boolean + onOpenChange: (v: boolean) => void + /** null = create; existing link = edit */ + editing: CustomLink | null + /** VMs + LXCs from /api/vms so the user can bind a link to a guest */ + guests: GuestOption[] + /** Populated from /api/apps/categories */ + categoryPresets: string[] + /** Called on successful save/delete so the parent can refresh */ + onSaved: () => void +}) { + const t = useT() + const [draft, setDraft] = useState({ + name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY, + }) + const [customCategoryMode, setCustomCategoryMode] = useState(false) + const [saving, setSaving] = useState(false) + const [deleting, setDeleting] = useState(false) + const [error, setError] = useState(null) + + // Reset the draft whenever the modal opens with a new target. + useEffect(() => { + if (!open) return + setError(null) + if (editing) { + setDraft({ + name: editing.name, + url: editing.url, + logo_url: editing.logo_url || "", + category: editing.category || "", + bindingKey: buildKey(editing.binding), + }) + setCustomCategoryMode( + !!editing.category && !categoryPresets.includes(editing.category), + ) + } else { + setDraft({ name: "", url: "", logo_url: "", category: "", bindingKey: UNBOUND_KEY }) + setCustomCategoryMode(false) + } + }, [open, editing, categoryPresets]) + + const canSave = draft.name.trim() && draft.url.trim() && !saving + + const handleSave = async () => { + setError(null) + setSaving(true) + try { + const payload = { + name: draft.name.trim(), + url: draft.url.trim(), + logo_url: draft.logo_url.trim(), + category: draft.category.trim(), + binding: parseKey(draft.bindingKey), + } + if (editing) { + await fetchApi(`/api/apps/custom-links/${editing.id}`, { + method: "PUT", + body: JSON.stringify(payload), + headers: { "Content-Type": "application/json" }, + }) + } else { + await fetchApi("/api/apps/custom-links", { + method: "POST", + body: JSON.stringify(payload), + headers: { "Content-Type": "application/json" }, + }) + } + onSaved() + onOpenChange(false) + } catch (e: any) { + setError((e && e.message) || t("apps.customLinkSaveError")) + } finally { + setSaving(false) + } + } + + const handleDelete = async () => { + if (!editing) return + setError(null) + setDeleting(true) + try { + await fetchApi(`/api/apps/custom-links/${editing.id}`, { method: "DELETE" }) + onSaved() + onOpenChange(false) + } catch (e: any) { + setError((e && e.message) || t("apps.customLinkDeleteError")) + } finally { + setDeleting(false) + } + } + + // Sort guests by vmid so the dropdown is easy to scan + const sortedGuests = [...guests].sort((a, b) => a.vmid - b.vmid) + + return ( + + + + + {editing ? t("apps.customLinkEditTitle") : t("apps.customLinkNewTitle")} + + + +
+
+ + setDraft((d) => ({ ...d, name: e.target.value }))} + placeholder={t("apps.customLinkNamePlaceholder")} + maxLength={80} + className="text-sm" + /> +
+ +
+ + setDraft((d) => ({ ...d, url: e.target.value }))} + placeholder="https://example.com" + maxLength={512} + className="text-sm font-mono" + /> +
+ +
+ + setDraft((d) => ({ ...d, logo_url: e.target.value }))} + placeholder={t("apps.customLinkLogoPlaceholder")} + maxLength={512} + className="text-sm font-mono" + /> +
+ +
+ + {customCategoryMode ? ( + setDraft((d) => ({ ...d, category: e.target.value }))} + placeholder={t("vmLxc.appEditor.portCategoryCustomPlaceholder")} + maxLength={60} + className="text-sm" + onBlur={() => { if (!draft.category.trim()) setCustomCategoryMode(false) }} + /> + ) : ( + + )} +
+ +
+ + +

+ {t("apps.customLinkBindingHelp")} +

+
+ + {error && ( +
+ {error} +
+ )} +
+ + + {editing ? ( + + ) :
} +
+ + +
+ + +
+ ) +} diff --git a/AppImage/components/disk-temperature-card.tsx b/AppImage/components/disk-temperature-card.tsx index 1a9904e8..d2a34584 100644 --- a/AppImage/components/disk-temperature-card.tsx +++ b/AppImage/components/disk-temperature-card.tsx @@ -6,6 +6,7 @@ import { Badge } from "./ui/badge" import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts" import { fetchApi } from "@/lib/api-config" import { useDiskTempThresholds } from "@/lib/health-thresholds" +import { useT } from "@/lib/i18n/provider" interface TempPoint { timestamp: number @@ -24,11 +25,11 @@ interface DiskTemperatureCardProps { // Disk-temperature thresholds come from the user-configurable backend // (lib/health-thresholds.ts). The classifier here takes the resolved // pair so the consumer can read it from the hook once per render. -function statusFor(temp: number, t: { warn: number; hot: number }) { - if (temp <= 0) return { label: "N/A", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" } - if (temp >= t.hot) return { label: "Hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" } - if (temp >= t.warn) return { label: "Warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" } - return { label: "Normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" } +function statusFor(temp: number, thresholds: { warn: number; hot: number }) { + if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" } + if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" } + if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" } + return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" } } const MiniTooltip = ({ active, payload }: any) => { @@ -55,6 +56,7 @@ export function DiskTemperatureCard({ diskType, onOpenDetail, }: DiskTemperatureCardProps) { + const t = useT() const [data, setData] = useState([]) const [loading, setLoading] = useState(true) const cancelled = useRef(false) @@ -98,7 +100,7 @@ export function DiskTemperatureCard({ })() const status = statusFor(liveTemperature, dt) const lineColor = status.color - const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : "N/A" + const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable") const samples = data.length const interactive = !!onOpenDetail @@ -112,11 +114,11 @@ export function DiskTemperatureCard({ "w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]", interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "", ].join(" ")} - title={interactive ? "Open temperature history" : undefined} + title={interactive ? t("details.temperature.openHistory") : undefined} >
-

Temperature

+

{t("details.temperature.diskTitle")}

{tempDisplay}

@@ -124,7 +126,7 @@ export function DiskTemperatureCard({
- {status.label} + {t(status.labelKey)}
@@ -134,7 +136,7 @@ export function DiskTemperatureCard({
) : samples < 2 ? (
- Collecting samples — chart populates after ~2 minutes + {t("details.temperature.collectingSamples")}
) : ( diff --git a/AppImage/components/disk-temperature-detail-modal.tsx b/AppImage/components/disk-temperature-detail-modal.tsx index 0d2dba52..e1e36bff 100644 --- a/AppImage/components/disk-temperature-detail-modal.tsx +++ b/AppImage/components/disk-temperature-detail-modal.tsx @@ -8,12 +8,13 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai import { useIsMobile } from "../hooks/use-mobile" import { fetchApi } from "@/lib/api-config" import { useDiskTempThresholds, type DiskTempThreshold } from "@/lib/health-thresholds" +import { useT } from "@/lib/i18n/provider" const TIMEFRAME_OPTIONS = [ - { value: "hour", label: "1 Hour" }, - { value: "day", label: "24 Hours" }, - { value: "week", label: "7 Days" }, - { value: "month", label: "30 Days" }, + { value: "hour", labelKey: "details.temperature.timeframes.hour" }, + { value: "day", labelKey: "details.temperature.timeframes.day" }, + { value: "week", labelKey: "details.temperature.timeframes.week" }, + { value: "month", labelKey: "details.temperature.timeframes.month" }, ] interface TempHistoryPoint { @@ -69,10 +70,10 @@ function colorFor(temp: number, t: DiskTempThreshold): string { } function statusInfoFor(temp: number, t: DiskTempThreshold) { - if (temp <= 0) return { status: "N/A", color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } - if (temp >= t.hot) return { status: "Hot", color: "bg-red-500/10 text-red-500 border-red-500/20" } - if (temp >= t.warn) return { status: "Warm", color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } - return { status: "Normal", color: "bg-green-500/10 text-green-500 border-green-500/20" } + if (temp <= 0) return { color: "bg-gray-500/10 text-gray-500 border-gray-500/20" } + if (temp >= t.hot) return { color: "bg-red-500/10 text-red-500 border-red-500/20" } + if (temp >= t.warn) return { color: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } + return { color: "bg-green-500/10 text-green-500 border-green-500/20" } } export function DiskTemperatureDetailModal({ @@ -83,6 +84,7 @@ export function DiskTemperatureDetailModal({ liveTemperature, diskType, }: DiskTemperatureDetailModalProps) { + const t = useT() const [timeframe, setTimeframe] = useState("day") const [data, setData] = useState([]) const [stats, setStats] = useState({ min: 0, max: 0, avg: 0, current: 0 }) @@ -168,7 +170,7 @@ export function DiskTemperatureDetailModal({ {TIMEFRAME_OPTIONS.map((opt) => ( - {opt.label} + {t(opt.labelKey)} ))} @@ -181,24 +183,24 @@ export function DiskTemperatureDetailModal({
-
Current
-
{currentTemp > 0 ? `${currentTemp}°C` : "N/A"}
+
{t("details.temperature.current")}
+
{currentTemp > 0 ? `${currentTemp}°C` : t("common.notAvailable")}
- Min + {t("details.temperature.min")}
{stats.min}°C
- Avg + {t("details.temperature.avg")}
{stats.avg}°C
- Max + {t("details.temperature.max")}
{stats.max}°C
@@ -216,8 +218,8 @@ export function DiskTemperatureDetailModal({
-

No temperature data yet for this disk

-

Samples are collected every 60 seconds

+

{t("details.temperature.noData")}

+

{t("details.temperature.sampleInterval")}

) : ( @@ -250,7 +252,7 @@ export function DiskTemperatureDetailModal({ { if (!isSriovActive) return "" - if (sriovInfo?.role === "vf") return "SR-IOV VF" - if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) return `SR-IOV ×${sriovInfo.vfCount}` - return "SR-IOV" + if (sriovInfo?.role === "vf") return t("hardware.gpuSwitch.sriovVf") + if (sriovInfo?.vfCount && sriovInfo.vfCount > 0) { + return t("hardware.gpuSwitch.sriovCount", { count: sriovInfo.vfCount }) + } + return t("hardware.gpuSwitch.sriov") })() return ( @@ -124,7 +129,7 @@ export function GpuSwitchModeIndicator({ className="text-[14px] font-bold transition-all duration-300" style={{ fontFamily: 'system-ui, sans-serif' }} > - GPU + {t("hardware.gpuSwitch.gpu")} @@ -268,7 +273,7 @@ export function GpuSwitchModeIndicator({ )} style={{ fontFamily: 'system-ui, sans-serif' }} > - LXC + {t("hardware.gpuSwitch.lxc")} )} {isSriovActive && ( @@ -279,7 +284,7 @@ export function GpuSwitchModeIndicator({ className="text-[9px] font-medium" style={{ fontFamily: 'system-ui, sans-serif' }} > - LXC + {t("hardware.gpuSwitch.lxc")} )} @@ -332,7 +337,7 @@ export function GpuSwitchModeIndicator({ )} style={{ fontFamily: 'system-ui, sans-serif' }} > - VM + {t("hardware.gpuSwitch.vm")} )} {isSriovActive && ( @@ -343,7 +348,7 @@ export function GpuSwitchModeIndicator({ className="text-[9px] font-medium" style={{ fontFamily: 'system-ui, sans-serif' }} > - VM + {t("hardware.gpuSwitch.vm")} )} @@ -363,34 +368,47 @@ export function GpuSwitchModeIndicator({ )} > {isSriovActive - ? "SR-IOV active" + ? t("hardware.gpuSwitch.sriovActive") : isLxcActive - ? "Ready for LXC containers" + ? t("hardware.gpuSwitch.readyForLxc") : isVmActive - ? "Ready for VM passthrough" - : "Mode unknown"} + ? t("hardware.gpuSwitch.readyForVm") + : t("hardware.gpuSwitch.modeUnknown")} {isSriovActive - ? "Virtual Functions managed externally" + ? t("hardware.gpuSwitch.virtualFunctionsExternal") : isLxcActive - ? "Native driver active" + ? t("hardware.gpuSwitch.nativeDriverActive") : isVmActive - ? "VFIO-PCI driver active" - : "No driver detected"} + ? t("hardware.gpuSwitch.vfioDriverActive") + : t("hardware.gpuSwitch.noDriverDetected")} {isSriovActive && sriovInfo && ( {sriovInfo.role === "vf" - ? `Virtual Function${sriovInfo.physfn ? ` · parent PF ${sriovInfo.physfn}` : ""}` + ? t( + sriovInfo.physfn + ? "hardware.gpuSwitch.virtualFunctionWithParent" + : "hardware.gpuSwitch.virtualFunction", + { parent: sriovInfo.physfn || "" }, + ) : sriovInfo.vfCount !== undefined - ? `1 PF + ${sriovInfo.vfCount} VF${sriovInfo.vfCount === 1 ? "" : "s"}${sriovInfo.totalvfs ? ` / ${sriovInfo.totalvfs} max` : ""}` + ? t( + sriovInfo.totalvfs + ? "hardware.gpuSwitch.physicalFunctionWithMax" + : "hardware.gpuSwitch.physicalFunction", + { + count: sriovInfo.vfCount, + max: sriovInfo.totalvfs || "", + }, + ) : null} )} {hasChanged && ( - Change pending... + {t("hardware.gpuSwitch.changePending")} )}
diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx index 28487e73..d079df9d 100644 --- a/AppImage/components/hardware.tsx +++ b/AppImage/components/hardware.tsx @@ -15,6 +15,7 @@ import { type GPU, type PCIDevice, type StorageDevice, + type Temperature, type CoralTPU, type UsbDevice, fetcher as swrFetcher, @@ -23,6 +24,10 @@ import { fetchApi } from "@/lib/api-config" import { ScriptTerminalModal } from "./script-terminal-modal" import { GpuSwitchModeIndicator } from "./gpu-switch-mode-indicator" import { Settings2, CheckCircle2 } from "lucide-react" +import { useT } from "../lib/i18n/provider" +import { cn } from "@/lib/utils" + +type TFunction = (key: string, params?: Record) => string const parseLsblkSize = (sizeStr: string | undefined): number => { if (!sizeStr) return 0 @@ -52,10 +57,10 @@ const parseLsblkSize = (sizeStr: string | undefined): number => { } } -const formatMemory = (memoryKB: number | string): string => { +const formatMemory = (memoryKB: number | string, t?: TFunction): string => { const kb = typeof memoryKB === "string" ? Number.parseFloat(memoryKB) : memoryKB - if (isNaN(kb)) return "N/A" + if (isNaN(kb)) return t ? t("common.notAvailable") : "N/A" // Convert KB to MB const mb = kb / 1024 @@ -166,26 +171,84 @@ const getDeviceTypeColor = (type: string): string => { return "bg-gray-500/10 text-gray-500 border-gray-500/20" } -const getMonitoringToolRecommendation = (vendor: string): string => { +const getMonitoringToolRecommendation = (vendor: string, t: TFunction): string => { const lowerVendor = vendor.toLowerCase() if (lowerVendor.includes("intel")) { - return "To get extended GPU monitoring information, please install intel-gpu-tools or igt-gpu-tools package." + return t("hardware.recommendations.intel") } if (lowerVendor.includes("nvidia")) { - return "For NVIDIA GPUs, real-time monitoring requires the proprietary drivers (nvidia-driver package). Install them only if your GPU is used directly by the host." + return t("hardware.recommendations.nvidia") } if (lowerVendor.includes("amd") || lowerVendor.includes("ati")) { - return "To get extended GPU monitoring information for AMD GPUs, please install amdgpu_top. You can download it from: https://github.com/Umio-Yasuno/amdgpu_top" + return t("hardware.recommendations.amd") } - return "To get extended GPU monitoring information, please install the appropriate GPU monitoring tools for your hardware." + return t("hardware.recommendations.generic") } -const groupAndSortTemperatures = (temperatures: any[]) => { +const formatHardwareValue = (value: string | null | undefined, t: TFunction): string => { + const text = value?.trim() + if (!text) return t("common.notAvailable") + + const normalized = text.toLowerCase() + if ( + normalized === "not specified" || + normalized === "not available" || + normalized === "to be filled by o.e.m." || + normalized === "to be filled by oem" || + normalized === "default string" + ) { + return t("hardware.values.notSpecified") + } + + return text +} + +const translateDeviceType = (type: string | null | undefined, t: TFunction): string => { + const text = type?.trim() + if (!text) return t("common.unknown") + + const normalized = text.toLowerCase() + const directMap: Record = { + "graphics": "hardware.deviceTypes.graphics", + "graphics card": "hardware.deviceTypes.graphicsCard", + "vga compatible controller": "hardware.deviceTypes.graphicsCard", + "3d controller": "hardware.deviceTypes.graphicsCard", + "display controller": "hardware.deviceTypes.graphicsCard", + "usb": "hardware.deviceTypes.usb", + "usb controller": "hardware.deviceTypes.usbController", + "audio": "hardware.deviceTypes.audio", + "audio device": "hardware.deviceTypes.audio", + "audio controller": "hardware.deviceTypes.audioController", + "network": "hardware.deviceTypes.network", + "network controller": "hardware.deviceTypes.networkController", + "ethernet": "hardware.deviceTypes.ethernet", + "ethernet controller": "hardware.deviceTypes.ethernet", + "wireless": "hardware.deviceTypes.wireless", + "wireless controller": "hardware.deviceTypes.wirelessController", + "wi-fi": "hardware.deviceTypes.wifi", + "wifi": "hardware.deviceTypes.wifi", + "storage": "hardware.deviceTypes.storage", + "storage controller": "hardware.deviceTypes.storageController", + "mass storage": "hardware.deviceTypes.storage", + "hid": "hardware.deviceTypes.hid", + "vendor specific": "hardware.deviceTypes.vendorSpecific", + "communications": "hardware.deviceTypes.communications", + "integrated": "hardware.deviceTypes.integrated", + "discrete": "hardware.deviceTypes.discrete", + } + + if (directMap[normalized]) return t(directMap[normalized]) + return text +} + +const groupAndSortTemperatures = (temperatures: Temperature[]) => { const groups = { CPU: [] as any[], GPU: [] as any[], NVME: [] as any[], + HDD: [] as any[], + SSD: [] as any[], PCI: [] as any[], OTHER: [] as any[], } @@ -194,7 +257,21 @@ const groupAndSortTemperatures = (temperatures: any[]) => { const nameLower = temp.name.toLowerCase() const adapterLower = temp.adapter?.toLowerCase() || "" - if (nameLower.includes("cpu") || nameLower.includes("core") || nameLower.includes("package")) { + if (temp.type === "cpu") { + groups.CPU.push(temp) + } else if (temp.type === "gpu") { + groups.GPU.push(temp) + } else if (temp.type === "nvme") { + groups.NVME.push(temp) + } else if (temp.type === "hdd") { + groups.HDD.push(temp) + } else if (temp.type === "ssd") { + groups.SSD.push(temp) + } else if (temp.type === "pci") { + groups.PCI.push(temp) + } else if (temp.type) { + groups.OTHER.push(temp) + } else if (nameLower.includes("cpu") || nameLower.includes("core") || nameLower.includes("package")) { groups.CPU.push(temp) } else if (nameLower.includes("gpu") || adapterLower.includes("gpu")) { groups.GPU.push(temp) @@ -210,7 +287,61 @@ const groupAndSortTemperatures = (temperatures: any[]) => { return groups } +const StorageTemperatureGroup = ({ title, temperatures }: { title: string; temperatures: Temperature[] }) => { + if (temperatures.length === 0) return null + + return ( +
1 ? "md:col-span-2" : ""}> +
+ +

{title}

+ + {temperatures.length} + +
+
1 ? "md:grid-cols-2" : ""}`}> + {temperatures.map((temp, index) => { + const percentage = temp.critical && temp.critical > 0 ? (temp.current / temp.critical) * 100 : temp.current + const isHot = temp.current > (temp.high || 80) + const isCritical = temp.current > (temp.critical || 90) + const devices = temp.devices?.length ? temp.devices : temp.device ? [temp.device] : [] + + return ( +
+
+ + {temp.model || temp.name} + + + {temp.current.toFixed(1)}°C + +
+
+
+
+ {devices.length > 0 ? ( + + {devices.map((device) => `/dev/${device}`).join(" · ")} + + ) : ( + temp.adapter && {temp.adapter} + )} +
+ ) + })} +
+
+ ) +} + export default function Hardware() { + const t = useT() + // Static data - loaded once on mount. Static fields (CPU, motherboard, memory // modules, PCI, disks, GPU list) don't change at runtime, so no auto-refresh. // `mutateStatic` is triggered explicitly after GPU switch-mode changes. @@ -300,16 +431,16 @@ export default function Hardware() { const nvidiaInstall = managedInstalls.find((it) => it.type === "nvidia_xfree86") const formatLastChecked = (iso?: string | null): string => { - if (!iso) return "never" + if (!iso) return t("hardware.values.never") const d = new Date(iso) - if (isNaN(d.getTime())) return "unknown" + if (isNaN(d.getTime())) return t("common.unknown") const now = Date.now() const ageMs = now - d.getTime() const sameDay = new Date(now).toDateString() === d.toDateString() const yesterday = new Date(now - 86_400_000).toDateString() === d.toDateString() const time = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) if (sameDay) return time - if (yesterday) return `yesterday ${time}` + if (yesterday) return t("hardware.time.yesterdayAt", { time }) if (ageMs < 7 * 86_400_000) { return d.toLocaleDateString([], { weekday: "short" }) + " " + time } @@ -509,8 +640,8 @@ export default function Hardware() {
-
Loading hardware data...
-

Detecting CPU, GPU, storage and PCI devices

+
{t("hardware.loading.title")}
+

{t("hardware.loading.description")}

) } @@ -522,7 +653,7 @@ export default function Hardware() {
-

System Information

+

{t("hardware.sections.systemInformation")}

@@ -536,34 +667,34 @@ export default function Hardware() {
{hardwareData.cpu.model && (
- Model + {t("hardware.labels.model")} {hardwareData.cpu.model}
)} {hardwareData.cpu.cores_per_socket && hardwareData.cpu.sockets && (
- Cores + {t("hardware.labels.cores")} {hardwareData.cpu.sockets} × {hardwareData.cpu.cores_per_socket} ={" "} - {hardwareData.cpu.sockets * hardwareData.cpu.cores_per_socket} cores + {t("hardware.values.cores", { count: hardwareData.cpu.sockets * hardwareData.cpu.cores_per_socket })}
)} {hardwareData.cpu.total_threads && (
- Threads + {t("hardware.labels.threads")} {hardwareData.cpu.total_threads}
)} {hardwareData.cpu.l3_cache && (
- L3 Cache + {t("hardware.labels.l3Cache")} {hardwareData.cpu.l3_cache}
)} {hardwareData.cpu.virtualization && (
- Virtualization + {t("hardware.labels.virtualization")} {hardwareData.cpu.virtualization}
)} @@ -576,36 +707,36 @@ export default function Hardware() {
-

Motherboard

+

{t("hardware.sections.motherboard")}

{hardwareData.motherboard.manufacturer && (
- Manufacturer - {hardwareData.motherboard.manufacturer} + {t("hardware.labels.manufacturer")} + {formatHardwareValue(hardwareData.motherboard.manufacturer, t)}
)} {hardwareData.motherboard.model && (
- Model - {hardwareData.motherboard.model} + {t("hardware.labels.model")} + {formatHardwareValue(hardwareData.motherboard.model, t)}
)} {hardwareData.motherboard.bios?.vendor && (
BIOS - {hardwareData.motherboard.bios.vendor} + {formatHardwareValue(hardwareData.motherboard.bios.vendor, t)}
)} {hardwareData.motherboard.bios?.version && (
- Version - {hardwareData.motherboard.bios.version} + {t("hardware.labels.version")} + {formatHardwareValue(hardwareData.motherboard.bios.version, t)}
)} {hardwareData.motherboard.bios?.date && (
- Date + {t("hardware.labels.date")} {hardwareData.motherboard.bios.date}
)} @@ -621,9 +752,9 @@ export default function Hardware() {
-

Memory Modules

+

{t("hardware.sections.memoryModules")}

- {hardwareData.memory_modules.length} installed + {t("hardware.counts.installed", { count: hardwareData.memory_modules.length })}
@@ -634,26 +765,26 @@ export default function Hardware() {
{module.size && (
- Size - {formatMemory(module.size)} + {t("hardware.labels.size")} + {formatMemory(module.size, t)}
)} {module.type && (
- Type + {t("hardware.labels.type")} {module.type}
)} {(module.configured_speed || module.max_speed) && (
- Speed + {t("hardware.labels.speed")} {module.configured_speed && module.max_speed && module.configured_speed !== module.max_speed ? ( {module.configured_speed} - (max: {module.max_speed}) + {t("hardware.values.max", { value: module.max_speed })} ) : ( {module.configured_speed || module.max_speed} @@ -663,7 +794,7 @@ export default function Hardware() { )} {module.manufacturer && (
- Manufacturer + {t("hardware.labels.manufacturer")} {module.manufacturer}
)} @@ -679,9 +810,9 @@ export default function Hardware() {
-

Thermal Monitoring

+

{t("hardware.sections.thermalMonitoring")}

- {hardwareData.temperatures.length} sensors + {t("hardware.counts.sensors", { count: hardwareData.temperatures.length })}
@@ -772,52 +903,18 @@ export default function Hardware() {
)} - {/* NVME Sensors */} - {groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 0 && ( -
1 ? "md:col-span-2" : "" - } - > -
- -

NVME

- - {groupAndSortTemperatures(hardwareData.temperatures).NVME.length} - -
-
1 ? "md:grid-cols-2" : ""}`} - > - {groupAndSortTemperatures(hardwareData.temperatures).NVME.map((temp, index) => { - const percentage = - temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100 - const isHot = temp.current > (temp.high || 80) - const isCritical = temp.current > (temp.critical || 90) - - return ( -
-
- {temp.name} - - {temp.current.toFixed(1)}°C - -
-
-
-
- {temp.adapter && {temp.adapter}} -
- ) - })} -
-
- )} + + + {/* PCI Sensors */} {groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 0 && ( @@ -873,7 +970,7 @@ export default function Hardware() { >
-

OTHER

+

{t("hardware.sections.otherSensors")}

{groupAndSortTemperatures(hardwareData.temperatures).OTHER.length} @@ -919,9 +1016,9 @@ export default function Hardware() {
-

Graphics Cards

+

{t("hardware.sections.graphicsCards")}

- {hardwareData.gpus.length} GPU{hardwareData.gpus.length > 1 ? "s" : ""} + {t("hardware.counts.gpu", { count: hardwareData.gpus.length })}
@@ -952,27 +1049,27 @@ return (
- Type - {gpu.type} + {t("hardware.labels.type")} + {translateDeviceType(gpu.type, t)}
{fullSlot && (
- PCI Slot + {t("hardware.labels.pciSlot")} {fullSlot}
)} {gpu.pci_driver && (
- Driver + {t("hardware.labels.driver")} {gpu.pci_driver}
)} {gpu.pci_kernel_module && (
- Kernel Module + {t("hardware.labels.kernelModule")} {gpu.pci_kernel_module}
)} @@ -986,23 +1083,24 @@ return ( {nvidiaInstall.update_check.available ? ( <>
- Last checked: {formatLastChecked(nvidiaInstall.update_check.last_check)} ·{" "} + {t("hardware.labels.lastChecked")}: {formatLastChecked(nvidiaInstall.update_check.last_check)} + {` · NVIDIA driver v${nvidiaInstall.current_version} · `} - NVIDIA driver v{nvidiaInstall.update_check.latest} available + {t("hardware.values.nvidiaDriverAvailable", { version: nvidiaInstall.update_check.latest || "" })}
{nvidiaInstall.menu_label && (
- Reinstall via ProxMenux post-install: {nvidiaInstall.menu_label} + {t("hardware.values.reinstallViaPostInstall", { label: nvidiaInstall.menu_label })}
)} ) : (
- Last checked: {formatLastChecked(nvidiaInstall.update_check.last_check)} + {t("hardware.labels.lastChecked")}: {formatLastChecked(nvidiaInstall.update_check.last_check)} {` · NVIDIA driver v${nvidiaInstall.current_version}`} {" · "} - No updates available + {t("hardware.values.noUpdatesAvailable")}
)}
@@ -1013,7 +1111,7 @@ return (
- Switch Mode + {t("hardware.labels.switchMode")}
{getGpuSwitchMode(gpu) === "sriov" ? ( @@ -1029,7 +1127,7 @@ return ( handleSwitchModeCancel(fullSlot, e) }} > - Cancel + {t("actions.cancel")} ) : ( @@ -1051,7 +1149,7 @@ return ( }} > - Edit + {t("actions.edit")} )}
@@ -1088,32 +1186,32 @@ return ( <> {selectedGPU.name} - GPU Real-Time Monitoring + {t("hardware.gpu.monitoringTitle")}

- Basic Information + {t("hardware.sections.basicInformation")}

- Vendor + {t("hardware.labels.vendor")} {selectedGPU.vendor}
- Type - {selectedGPU.type} + {t("hardware.labels.type")} + {translateDeviceType(selectedGPU.type, t)}
- PCI Slot + {t("hardware.labels.pciSlot")} {findPCIDeviceForGPU(selectedGPU)?.slot || selectedGPU.slot}
{(findPCIDeviceForGPU(selectedGPU)?.driver || selectedGPU.pci_driver) && (
- Driver + {t("hardware.labels.driver")} {/* CHANGE: Added monitoring availability indicator */}
@@ -1129,7 +1227,7 @@ return ( )} {(findPCIDeviceForGPU(selectedGPU)?.kernel_module || selectedGPU.pci_kernel_module) && (
- Kernel Module + {t("hardware.labels.kernelModule")} {findPCIDeviceForGPU(selectedGPU)?.kernel_module || selectedGPU.pci_kernel_module} @@ -1141,7 +1239,7 @@ return ( {detailsLoading ? (
-

Loading real-time data...

+

{t("hardware.loading.realtimeData")}

) : selectedGPU.sriov_role === "vf" ? ( // SR-IOV Virtual Function: per-VF telemetry is not exposed @@ -1156,11 +1254,9 @@ return (
-

SR-IOV Virtual Function

+

{t("hardware.gpu.sriovVirtualFunction")}

- This device is a Virtual Function spawned by a Physical Function. Per-VF - telemetry (temperature, utilization, memory) is not exposed by the kernel — - open the parent PF to see aggregate GPU metrics. + {t("hardware.gpu.sriovVirtualFunctionDescription")}

@@ -1168,10 +1264,10 @@ return (

- Virtual Function Detail + {t("hardware.gpu.virtualFunctionDetail")}

- Parent Physical Function + {t("hardware.gpu.parentPhysicalFunction")} {selectedGPU.sriov_physfn ? (
- Current Driver + {t("hardware.labels.currentDriver")} - {selectedGPU.pci_driver || "none"} + {selectedGPU.pci_driver || t("hardware.values.none")}
- Consumer + {t("hardware.labels.consumer")}
{realtimeGPUData?.sriov_consumer ? ( {realtimeGPUData.sriov_consumer.type.toUpperCase()} {realtimeGPUData.sriov_consumer.id} {realtimeGPUData.sriov_consumer.name && ` · ${realtimeGPUData.sriov_consumer.name}`} - {` · ${realtimeGPUData.sriov_consumer.running ? "running" : "stopped"}`} + {` · ${realtimeGPUData.sriov_consumer.running ? t("hardware.values.running") : t("hardware.values.stopped")}`} ) : ( - unused + {t("hardware.values.unused")} )}
@@ -1228,50 +1324,47 @@ return (
- SR-IOV active + {t("hardware.gpu.sriovActive")} - Metrics below reflect the Physical Function (aggregate across - {" "} - - {realtimeGPUData?.sriov_vf_count ?? selectedGPU.sriov_vf_count ?? "N"} - - {" "}VFs). + {t("hardware.gpu.sriovMetricsDescription", { + count: realtimeGPUData?.sriov_vf_count ?? selectedGPU.sriov_vf_count ?? "N", + })}
)}
- Updating every 3 seconds + {t("hardware.gpu.updatingEverySeconds", { seconds: 3 })}

- Real-Time Metrics + {t("hardware.gpu.realTimeMetrics")}

{realtimeGPUData.clock_graphics && (
- Graphics Clock + {t("hardware.labels.graphicsClock")} {formatClock(realtimeGPUData.clock_graphics)}
)} {realtimeGPUData.clock_memory && (
- Memory Clock + {t("hardware.labels.memoryClock")} {formatClock(realtimeGPUData.clock_memory)}
)} {realtimeGPUData.power_draw && realtimeGPUData.power_draw !== "0.00 W" && (
- Power Draw + {t("hardware.labels.powerDraw")} {realtimeGPUData.power_draw}
)} {realtimeGPUData.temperature !== undefined && realtimeGPUData.temperature !== null && (
- Temperature + {t("hardware.labels.temperature")} {realtimeGPUData.temperature}°C @@ -1287,7 +1380,7 @@ return ( realtimeGPUData.engine_video_enhance !== undefined) && (

- Engine Utilization (Total) + {t("hardware.gpu.engineUtilizationTotal")}

{realtimeGPUData.engine_render !== undefined && ( @@ -1378,7 +1471,7 @@ return ( {realtimeGPUData.processes && realtimeGPUData.processes.length > 0 && (

- Active Processes ({realtimeGPUData.processes.length}) + {t("hardware.gpu.activeProcesses", { count: realtimeGPUData.processes.length })}

{realtimeGPUData.processes.map((proc: any, idx: number) => ( @@ -1396,15 +1489,15 @@ return ( className="font-mono text-xs bg-green-500/10 text-green-500 border-green-500/20" > {typeof proc.memory === "object" - ? formatMemory(proc.memory.resident / 1024) - : formatMemory(proc.memory)} + ? formatMemory(proc.memory.resident / 1024, t) + : formatMemory(proc.memory, t)} )}
{proc.engines && Object.keys(proc.engines).length > 0 && (
-

Engine Utilization:

+

{t("hardware.gpu.engineUtilization")}:

{Object.entries(proc.engines).map(([engineName, engineData]: [string, any]) => { const utilization = typeof engineData === "object" ? engineData.busy || 0 : engineData @@ -1433,7 +1526,7 @@ return ( {realtimeGPUData.processes && realtimeGPUData.processes.length === 0 && (
-

No active processes using the GPU

+

{t("hardware.gpu.noActiveProcesses")}

)} @@ -1441,25 +1534,25 @@ return ( {realtimeGPUData.memory_total && (

- Memory + {t("hardware.labels.memory")}

- Total + {t("hardware.labels.total")} {realtimeGPUData.memory_total}
- Used + {t("hardware.labels.used")} {realtimeGPUData.memory_used}
- Free + {t("hardware.labels.free")} {realtimeGPUData.memory_free}
{realtimeGPUData.utilization_memory !== undefined && (
- Memory Utilization + {t("hardware.labels.memoryUtilization")} {realtimeGPUData.utilization_memory}%

- Virtual Functions + {t("hardware.gpu.virtualFunctions")}

{realtimeGPUData.sriov_vfs.map((vf: any) => ( @@ -1504,7 +1597,7 @@ return ( : "bg-muted text-muted-foreground" )} > - {vf.driver || "unbound"} + {vf.driver || t("hardware.values.unbound")} {vf.consumer ? ( ) : ( - unused + {t("hardware.values.unused")} )}
@@ -1542,9 +1635,9 @@ return (
-

GPU in Switch Mode VM

+

{t("hardware.gpu.switchModeVmTitle")}

- This GPU is assigned to a virtual machine via VFIO passthrough. Real-time monitoring is not available from the host because the GPU is controlled by the VM. + {t("hardware.gpu.switchModeVmDescription")}

@@ -1562,9 +1655,9 @@ return (
-

Extended Monitoring Not Available

+

{t("hardware.gpu.extendedMonitoringUnavailable")}

- {getMonitoringToolRecommendation(selectedGPU.vendor)} + {getMonitoringToolRecommendation(selectedGPU.vendor, t)}

{selectedGPU.vendor.toLowerCase().includes("nvidia") && ( )} @@ -1584,7 +1677,7 @@ return ( > <> - Install AMD GPU Tools + {t("hardware.actions.installAmdGpuTools")} )} @@ -1595,7 +1688,7 @@ return ( > <> - Install Intel GPU Tools + {t("hardware.actions.installIntelGpuTools")} )} @@ -1616,9 +1709,9 @@ return (
-

Coral TPU / AI Accelerators

+

{t("hardware.sections.coralTpu")}

- {hardwareData.coral_tpus.length} device{hardwareData.coral_tpus.length > 1 ? "s" : ""} + {t("hardware.counts.devices", { count: hardwareData.coral_tpus.length })}
@@ -1660,12 +1753,12 @@ return ( {coral.drivers_ready ? ( <> - Drivers ready + {t("hardware.values.driversReady")} ) : ( <> - Drivers not installed + {t("hardware.values.driversNotInstalled")} )}
@@ -1680,9 +1773,9 @@ return (
-

Install Coral TPU drivers

+

{t("hardware.actions.installCoralDrivers")}

- One or more detected Coral devices need drivers. A server reboot is required after installation. + {t("hardware.coral.driversNeededDescription")}

@@ -1691,7 +1784,7 @@ return ( className="bg-blue-600 hover:bg-blue-700 text-white shrink-0" > - Install Drivers + {t("hardware.actions.installDrivers")}
)} @@ -1703,13 +1796,13 @@ return ( {selectedCoral?.name} - Coral TPU Device Information + {t("hardware.coral.deviceInformation")} {selectedCoral && (
- Connection + {t("hardware.labels.connection")} - Form Factor + {t("hardware.labels.formFactor")} {selectedCoral.form_factor}
)} {selectedCoral.interface_speed && (
- Link + {t("hardware.labels.link")} {selectedCoral.interface_speed}
)}
- {selectedCoral.type === "usb" ? "Bus:Device" : "PCI Slot"} + {selectedCoral.type === "usb" ? t("hardware.labels.busDevice") : t("hardware.labels.pciSlot")} {selectedCoral.type === "usb" ? selectedCoral.bus_device : selectedCoral.slot} @@ -1745,20 +1838,20 @@ return (
- Vendor / Product ID + {t("hardware.labels.vendorProductId")} {selectedCoral.vendor_id}:{selectedCoral.device_id}
- Vendor + {t("hardware.labels.vendor")} {selectedCoral.vendor}
{selectedCoral.type === "pcie" && selectedCoral.kernel_driver && (
- Kernel Driver + {t("hardware.labels.kernelDriver")} {selectedCoral.kernel_driver} @@ -1767,7 +1860,7 @@ return ( {selectedCoral.kernel_modules && (
- Kernel Modules + {t("hardware.labels.kernelModules")}
gasket {selectedCoral.kernel_modules.gasket ? "✓" : "✗"} @@ -1781,7 +1874,7 @@ return ( {selectedCoral.device_nodes && selectedCoral.device_nodes.length > 0 && (
- Device Nodes + {t("hardware.labels.deviceNodes")} {selectedCoral.device_nodes.join(", ")} @@ -1790,17 +1883,17 @@ return ( {selectedCoral.type === "usb" && (
- Runtime State + {t("hardware.labels.runtimeState")} - {selectedCoral.programmed ? "Programmed (runtime loaded)" : "Unprogrammed (runtime not loaded)"} + {selectedCoral.programmed ? t("hardware.values.programmed") : t("hardware.values.unprogrammed")}
)}
- Edge TPU Runtime + {t("hardware.labels.edgeTpuRuntime")} - {selectedCoral.edgetpu_runtime || not installed} + {selectedCoral.edgetpu_runtime || {t("hardware.values.notInstalled")}}
@@ -1825,14 +1918,14 @@ return ( : "text-green-500" return (
- Temperature + {t("hardware.labels.temperature")}
{selectedCoral.temperature.toFixed(1)} °C {trips && trips.length > 0 && (
- Thresholds: {trips.map((t) => `${t.toFixed(0)}°C`).join(" · ")} + {t("hardware.labels.thresholds")}: {trips.map((trip) => `${trip.toFixed(0)}°C`).join(" · ")}
)}
@@ -1842,7 +1935,7 @@ return ( {selectedCoral.thermal_warnings && selectedCoral.thermal_warnings.length > 0 && (
- Hardware Warnings + {t("hardware.labels.hardwareWarnings")}
{selectedCoral.thermal_warnings.map((w) => (
@@ -1858,7 +1951,7 @@ return ( : "text-muted-foreground/70" } > - {w.enabled ? "enabled" : "disabled"} + {w.enabled ? t("status.active") : t("status.disabled")}
))} @@ -1875,7 +1968,7 @@ return ( className="w-full bg-blue-600 hover:bg-blue-700 text-white" > - Install Coral TPU Drivers + {t("hardware.actions.installCoralDrivers")} )}
@@ -1888,7 +1981,7 @@ return (
-

Power Consumption

+

{t("hardware.sections.powerConsumption")}

@@ -1901,7 +1994,7 @@ return (

{hardwareData.power_meter.watts.toFixed(1)} W

-

Current Draw

+

{t("hardware.labels.currentDraw")}

@@ -1913,9 +2006,9 @@ return (
-

Power Supplies

+

{t("hardware.sections.powerSupplies")}

- {hardwareData.power_supplies.length} PSUs + {t("hardware.counts.psus", { count: hardwareData.power_supplies.length })}
@@ -1929,7 +2022,7 @@ return ( )}

{psu.watts} W

-

Current Output

+

{t("hardware.labels.currentOutput")}

))}
@@ -1941,9 +2034,9 @@ return (
-

System Fans

+

{t("hardware.sections.systemFans")}

- {hardwareData.fans.length} fans + {t("hardware.counts.fans", { count: hardwareData.fans.length })}
@@ -1957,7 +2050,7 @@ return (
{fan.name} - {isPercentage ? `${fan.speed.toFixed(0)} percent` : `${fan.speed.toFixed(0)} ${fan.unit}`} + {isPercentage ? `${fan.speed.toFixed(0)} %` : `${fan.speed.toFixed(0)} ${fan.unit}`}
@@ -1976,9 +2069,9 @@ return (
-

UPS Status

+

{t("hardware.sections.upsStatus")}

- {hardwareData.ups.length} UPS + {t("hardware.counts.ups", { count: hardwareData.ups.length })}
@@ -2007,16 +2100,16 @@ return (
{ups.model || ups.name} - {ups.is_remote && Remote: {ups.host}} + {ups.is_remote && {t("hardware.labels.remote")}: {ups.host}}
- {ups.status || "Unknown"} + {ups.status || t("common.unknown")}
{ups.battery_charge && (
- Battery Charge + {t("hardware.labels.batteryCharge")} {ups.battery_charge}
@@ -2026,7 +2119,7 @@ return ( {ups.load_percent && (
- Load + {t("hardware.labels.load")} {ups.load_percent}
@@ -2035,7 +2128,7 @@ return ( {ups.time_left && (
- Runtime + {t("hardware.labels.runtime")}
{ups.time_left}
@@ -2044,7 +2137,7 @@ return ( {ups.input_voltage && (
- Input Voltage + {t("hardware.labels.inputVoltage")}
{ups.input_voltage}
@@ -2065,8 +2158,8 @@ return ( {selectedUPS.model || selectedUPS.name} - UPS Detailed Information - {selectedUPS.is_remote && ` • Remote: ${selectedUPS.host}`} + {t("hardware.ups.detailedInformation")} + {selectedUPS.is_remote && ` • ${t("hardware.labels.remote")}: ${selectedUPS.host}`} @@ -2074,11 +2167,11 @@ return ( {/* Status Overview */}

- Status Overview + {t("hardware.sections.statusOverview")}

- Status + {t("hardware.labels.status")} - {selectedUPS.status || "Unknown"} + {selectedUPS.status || t("common.unknown")}
- Connection + {t("hardware.labels.connection")} {selectedUPS.connection_type}
{selectedUPS.host && (
- Host + {t("hardware.labels.host")} {selectedUPS.host}
)} @@ -2109,13 +2202,13 @@ return ( {/* Battery Information */}

- Battery Information + {t("hardware.sections.batteryInformation")}

{selectedUPS.battery_charge && (
- Charge Level + {t("hardware.labels.chargeLevel")} {selectedUPS.battery_charge}
- Runtime Remaining + {t("hardware.labels.runtimeRemaining")} {selectedUPS.time_left} @@ -2137,13 +2230,13 @@ return ( )} {selectedUPS.battery_voltage && (
- Battery Voltage + {t("hardware.labels.batteryVoltage")} {selectedUPS.battery_voltage}
)} {selectedUPS.battery_date && (
- Battery Date + {t("hardware.labels.batteryDate")} {selectedUPS.battery_date}
)} @@ -2153,37 +2246,37 @@ return ( {/* Input/Output Information */}

- Power Information + {t("hardware.sections.powerInformation")}

{selectedUPS.input_voltage && (
- Input Voltage + {t("hardware.labels.inputVoltage")} {selectedUPS.input_voltage}
)} {selectedUPS.output_voltage && (
- Output Voltage + {t("hardware.labels.outputVoltage")} {selectedUPS.output_voltage}
)} {selectedUPS.input_frequency && (
- Input Frequency + {t("hardware.labels.inputFrequency")} {selectedUPS.input_frequency}
)} {selectedUPS.output_frequency && (
- Output Frequency + {t("hardware.labels.outputFrequency")} {selectedUPS.output_frequency}
)} {selectedUPS.load_percent && (
- Load + {t("hardware.labels.load")} {selectedUPS.load_percent}
- Real Power + {t("hardware.labels.realPower")} {selectedUPS.real_power}
)} {selectedUPS.apparent_power && (
- Apparent Power + {t("hardware.labels.apparentPower")} {selectedUPS.apparent_power}
)} @@ -2212,36 +2305,36 @@ return ( {/* Device Information */}

- Device Information + {t("hardware.sections.deviceInformation")}

{selectedUPS.manufacturer && (
- Manufacturer + {t("hardware.labels.manufacturer")} {selectedUPS.manufacturer}
)} {selectedUPS.model && (
- Model + {t("hardware.labels.model")} {selectedUPS.model}
)} {selectedUPS.serial && (
- Serial Number + {t("hardware.labels.serialNumber")} {selectedUPS.serial}
)} {selectedUPS.firmware && (
- Firmware + {t("hardware.labels.firmware")} {selectedUPS.firmware}
)} {selectedUPS.driver && (
- Driver + {t("hardware.labels.driver")} {selectedUPS.driver}
)} @@ -2258,9 +2351,9 @@ return (
-

PCI Devices

+

{t("hardware.sections.pciDevices")}

- {hardwareData.pci_devices.length} devices + {t("hardware.counts.devices", { count: hardwareData.pci_devices.length })}
@@ -2272,13 +2365,13 @@ return ( className="cursor-pointer rounded-lg border border-white/10 sm:border-border bg-white/5 sm:bg-card sm:hover:bg-white/5 p-3 transition-colors" >
- {device.type} + {translateDeviceType(device.type, t)} {device.slot}

{device.device}

{device.vendor}

{device.driver && ( -

Driver: {device.driver}

+

{t("hardware.labels.driver")}: {device.driver}

)}
))} @@ -2291,53 +2384,53 @@ return ( {selectedPCIDevice?.device} - PCI Device Information + {t("hardware.pci.deviceInformation")} {selectedPCIDevice && (
- Device Type - {selectedPCIDevice.type} + {t("hardware.labels.deviceType")} + {translateDeviceType(selectedPCIDevice.type, t)}
- PCI Slot + {t("hardware.labels.pciSlot")} {selectedPCIDevice.slot}
- Device Name + {t("hardware.labels.deviceName")} {selectedPCIDevice.device}
{selectedPCIDevice.sdevice && (
- Product Name + {t("hardware.labels.productName")} {selectedPCIDevice.sdevice}
)}
- Vendor + {t("hardware.labels.vendor")} {selectedPCIDevice.vendor}
- Class + {t("hardware.labels.class")} {selectedPCIDevice.class}
{selectedPCIDevice.driver && (
- Driver + {t("hardware.labels.driver")} {selectedPCIDevice.driver}
)} {selectedPCIDevice.kernel_module && (
- Kernel Module + {t("hardware.labels.kernelModule")} {selectedPCIDevice.kernel_module}
)} @@ -2352,9 +2445,11 @@ return (
-

Network Summary

+

{t("hardware.sections.networkSummary")}

- {hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length} interfaces + {t("hardware.counts.interfaces", { + count: hardwareData.pci_devices.filter((d) => d.type.toLowerCase().includes("network")).length, + })}
@@ -2376,12 +2471,12 @@ return ( : "bg-blue-500/10 text-blue-500 border-blue-500/20 px-2.5 py-0.5 shrink-0" } > - {device.network_subtype || "Ethernet"} + {translateDeviceType(device.network_subtype || "Ethernet", t)}

{device.vendor}

{device.driver && ( -

Driver: {device.driver}

+

{t("hardware.labels.driver")}: {device.driver}

)}
))} @@ -2394,41 +2489,41 @@ return ( {selectedNetwork?.device} - Network Interface Information + {t("hardware.network.interfaceInformation")} {selectedNetwork && (
- Device Type - {selectedNetwork.type} + {t("hardware.labels.deviceType")} + {translateDeviceType(selectedNetwork.type, t)}
- PCI Slot + {t("hardware.labels.pciSlot")} {selectedNetwork.slot}
- Vendor + {t("hardware.labels.vendor")} {selectedNetwork.vendor}
- Class + {t("hardware.labels.class")} {selectedNetwork.class}
{selectedNetwork.driver && (
- Driver + {t("hardware.labels.driver")} {selectedNetwork.driver}
)} {selectedNetwork.kernel_module && (
- Kernel Module + {t("hardware.labels.kernelModule")} {selectedNetwork.kernel_module}
)} @@ -2442,15 +2537,14 @@ return (
-

Storage Summary

+

{t("hardware.sections.storageSummary")}

- { - hardwareData.storage_devices.filter( + {t("hardware.counts.devices", { + count: hardwareData.storage_devices.filter( (device) => device.type === "disk" && !device.name.startsWith("zd") && !device.name.startsWith("loop"), - ).length - }{" "} - devices + ).length, + })}
@@ -2549,7 +2643,7 @@ return ( {device.name} {diskBadge.label}
- {device.size &&

{formatMemory(parseLsblkSize(device.size))}

} + {device.size &&

{formatMemory(parseLsblkSize(device.size), t)}

} {device.model && (

{device.model}

)} @@ -2557,7 +2651,7 @@ return (
{linkSpeed.text} {linkSpeed.maxText && linkSpeed.isWarning && ( - (max: {linkSpeed.maxText}) + {t("hardware.values.max", { value: linkSpeed.maxText })} )}
)} @@ -2573,18 +2667,18 @@ return ( {selectedDisk?.name} - Storage Device Hardware Information + {t("hardware.storage.deviceHardwareInformation")} {selectedDisk && (
- Device Name + {t("hardware.labels.deviceName")} {selectedDisk.name}
- Type + {t("hardware.labels.type")} {(() => { const diskType = getDiskType(selectedDisk.name, selectedDisk.rotation_rate) const badgeStyles: Record = { @@ -2608,14 +2702,14 @@ return ( {selectedDisk.size && (
- Capacity - {formatMemory(parseLsblkSize(selectedDisk.size))} + {t("hardware.labels.capacity")} + {formatMemory(parseLsblkSize(selectedDisk.size), t)}
)}

- Interface Information + {t("hardware.sections.interfaceInformation")}

@@ -2625,7 +2719,7 @@ return ( {selectedDisk.pcie_gen || selectedDisk.pcie_width ? ( <>
- Current Link Speed + {t("hardware.labels.currentLinkSpeed")} {selectedDisk.pcie_max_gen && selectedDisk.pcie_max_width && (
- Maximum Link Speed + {t("hardware.labels.maximumLinkSpeed")} {selectedDisk.pcie_max_gen} {selectedDisk.pcie_max_width} @@ -2650,8 +2744,8 @@ return ( ) : (
- PCIe Link Speed - Detecting... + {t("hardware.labels.pcieLinkSpeed")} + {t("hardware.values.detecting")}
)} @@ -2660,7 +2754,7 @@ return ( {/* SATA Information */} {!selectedDisk.name.startsWith("nvme") && selectedDisk.sata_version && (
- SATA Version + {t("hardware.labels.sataVersion")} {selectedDisk.sata_version}
)} @@ -2668,13 +2762,13 @@ return ( {/* SAS Information */} {!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_version && (
- SAS Version + {t("hardware.labels.sasVersion")} {selectedDisk.sas_version}
)} {!selectedDisk.name.startsWith("nvme") && selectedDisk.sas_speed && (
- SAS Speed + {t("hardware.labels.sasSpeed")} {selectedDisk.sas_speed}
)} @@ -2686,71 +2780,71 @@ return ( !selectedDisk.sata_version && !selectedDisk.sas_version && (
- Link Speed + {t("hardware.labels.linkSpeed")} {selectedDisk.link_speed}
)} {selectedDisk.model && (
- Model + {t("hardware.labels.model")} {selectedDisk.model}
)} {selectedDisk.family && (
- Family + {t("hardware.labels.family")} {selectedDisk.family}
)} {selectedDisk.serial && (
- Serial Number + {t("hardware.labels.serialNumber")} {selectedDisk.serial}
)} {selectedDisk.firmware && (
- Firmware + {t("hardware.labels.firmware")} {selectedDisk.firmware}
)} {selectedDisk.interface && (
- Interface + {t("hardware.labels.interface")} {selectedDisk.interface}
)} {selectedDisk.driver && (
- Driver + {t("hardware.labels.driver")} {selectedDisk.driver}
)} {selectedDisk.rotation_rate !== undefined && selectedDisk.rotation_rate !== null && (
- Rotation Rate + {t("hardware.labels.rotationRate")}
{typeof selectedDisk.rotation_rate === "number" && selectedDisk.rotation_rate === -1 - ? "N/A" + ? t("common.notAvailable") : typeof selectedDisk.rotation_rate === "number" && selectedDisk.rotation_rate > 0 ? `${selectedDisk.rotation_rate} rpm` : typeof selectedDisk.rotation_rate === "string" ? selectedDisk.rotation_rate - : "Solid State Device"} + : t("hardware.values.solidStateDevice")}
)} {selectedDisk.form_factor && (
- Form Factor + {t("hardware.labels.formFactor")} {selectedDisk.form_factor}
)} @@ -2766,9 +2860,9 @@ return (
-

USB Devices

+

{t("hardware.sections.usbDevices")}

- {hardwareData.usb_devices.length} device{hardwareData.usb_devices.length > 1 ? "s" : ""} + {t("hardware.counts.devices", { count: hardwareData.usb_devices.length })}
@@ -2784,7 +2878,7 @@ return ( {usb.name} - {usb.class_label} + {translateDeviceType(usb.class_label, t)}
@@ -2793,7 +2887,7 @@ return ( {usb.bus_device} · {usb.vendor_id}:{usb.product_id}
{usb.driver && ( -
Driver: {usb.driver}
+
{t("hardware.labels.driver")}: {usb.driver}
)}
@@ -2807,37 +2901,37 @@ return ( {selectedUsbDevice?.name} - USB Device Information + {t("hardware.usb.deviceInformation")} {selectedUsbDevice && (
- Class + {t("hardware.labels.class")} - {selectedUsbDevice.class_label} + {translateDeviceType(selectedUsbDevice.class_label, t)}
- Bus:Device + {t("hardware.labels.busDevice")} {selectedUsbDevice.bus_device}
- Device Name + {t("hardware.labels.deviceName")} {selectedUsbDevice.name}
{selectedUsbDevice.vendor && (
- Vendor + {t("hardware.labels.vendor")} {selectedUsbDevice.vendor}
)}
- Vendor / Product ID + {t("hardware.labels.vendorProductId")} {selectedUsbDevice.vendor_id}:{selectedUsbDevice.product_id} @@ -2845,7 +2939,7 @@ return ( {selectedUsbDevice.speed_label && (
- Speed + {t("hardware.labels.speed")} {selectedUsbDevice.speed_label} {selectedUsbDevice.speed_mbps > 0 && ( @@ -2856,20 +2950,20 @@ return ( )}
- Class Code + {t("hardware.labels.classCode")} 0x{selectedUsbDevice.class_code}
{selectedUsbDevice.driver && (
- Driver + {t("hardware.labels.driver")} {selectedUsbDevice.driver}
)} {selectedUsbDevice.serial && (
- Serial + {t("hardware.labels.serial")} {selectedUsbDevice.serial}
)} @@ -2881,8 +2975,8 @@ return ( {/* NVIDIA Installation Monitor */} {/* { setNvidiaSessionId(null) mutateStatic() @@ -2904,8 +2998,8 @@ return ( params={{ EXECUTION_MODE: "web", }} - title="NVIDIA Driver Installation" - description="Installing NVIDIA proprietary drivers for GPU monitoring..." + title={t("hardware.scripts.nvidiaTitle")} + description={t("hardware.scripts.nvidiaDescription")} /> {/* GPU Switch Mode Modal */} @@ -2961,8 +3055,13 @@ title="AMD GPU Tools Installation" EXECUTION_MODE: "web", GPU_SWITCH_PARAMS: `${switchModeParams.gpuSlot}|${switchModeParams.targetMode}`, }} - title={`GPU Switch Mode → ${switchModeParams.targetMode.toUpperCase()}`} - description={`Switching GPU ${switchModeParams.gpuSlot} to ${switchModeParams.targetMode === "vm" ? "VM (VFIO passthrough)" : "LXC (native driver)"} mode...`} + title={t("hardware.scripts.switchModeTitle", { mode: switchModeParams.targetMode.toUpperCase() })} + description={t("hardware.scripts.switchModeDescription", { + slot: switchModeParams.gpuSlot, + mode: switchModeParams.targetMode === "vm" + ? t("hardware.scripts.switchModeVm") + : t("hardware.scripts.switchModeLxc"), + })} /> )}
diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index 037ced01..cad28bb2 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -32,7 +32,7 @@ import { FileText, RefreshCw, Shield, - Download, + ArrowUpCircle, X, Clock, BellOff, @@ -41,6 +41,7 @@ import { HelpCircle, } from "lucide-react" import { ScriptTerminalModal } from "./script-terminal-modal" +import { useT } from "@/lib/i18n/provider" interface CategoryCheck { status: string @@ -104,19 +105,20 @@ interface HealthStatusModalProps { } const CATEGORIES = [ - { key: "cpu", category: "temperature", label: "CPU Usage & Temperature", Icon: Cpu }, - { key: "memory", category: "memory", label: "Memory & Swap", Icon: MemoryStick }, - { key: "storage", category: "storage", label: "Storage Mounts & Space", Icon: HardDrive }, - { key: "disks", category: "disks", label: "Disk I/O & Errors", Icon: Disc }, - { key: "network", category: "network", label: "Network Interfaces", Icon: Network }, - { key: "vms", category: "vms", label: "VMs & Containers", Icon: Box }, - { key: "services", category: "pve_services", label: "PVE Services", Icon: Settings }, - { key: "logs", category: "logs", label: "System Logs", Icon: FileText }, - { key: "updates", category: "updates", label: "System Updates", Icon: RefreshCw }, - { key: "security", category: "security", label: "Security & Certificates", Icon: Shield }, + { key: "cpu", category: "temperature", Icon: Cpu }, + { key: "memory", category: "memory", Icon: MemoryStick }, + { key: "storage", category: "storage", Icon: HardDrive }, + { key: "disks", category: "disks", Icon: Disc }, + { key: "network", category: "network", Icon: Network }, + { key: "vms", category: "vms", Icon: Box }, + { key: "services", category: "pve_services", Icon: Settings }, + { key: "logs", category: "logs", Icon: FileText }, + { key: "updates", category: "updates", Icon: RefreshCw }, + { key: "security", category: "security", Icon: Shield }, ] export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) { + const t = useT() const [loading, setLoading] = useState(true) const [healthData, setHealthData] = useState(null) const [dismissedItems, setDismissedItems] = useState([]) @@ -146,7 +148,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu if (!response.ok) { // Fallback to legacy endpoint const legacyResponse = await fetch(getApiUrl("/api/health/details"), { headers: authHeaders }) - if (!legacyResponse.ok) throw new Error("Failed to fetch health details") + if (!legacyResponse.ok) throw new Error(t("healthStatus.errors.fetchFailed")) const data = await legacyResponse.json() setHealthData(data) setDismissedItems([]) @@ -203,11 +205,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu }) window.dispatchEvent(event) } catch (err) { - setError(err instanceof Error ? err.message : "Unknown error") + setError(err instanceof Error ? err.message : t("healthStatus.errors.unknown")) } finally { setLoading(false) } - }, [getApiUrl]) + }, [getApiUrl, t]) // Tick counter to force re-render every 30s so "X minutes ago" stays current const [, setTick] = useState(0) @@ -277,21 +279,96 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu } const getStatusBadge = (status: string) => { - const statusUpper = status?.toUpperCase() - switch (statusUpper) { - case "OK": - return OK - case "INFO": - return Info - case "WARNING": - return Warning - case "CRITICAL": - return Critical - case "UNKNOWN": - return UNKNOWN - default: - return Unknown + const s = status?.toUpperCase() + const label = + s === "OK" ? t("healthStatus.status.ok") : + s === "INFO" ? t("healthStatus.status.info") : + s === "WARNING" ? t("healthStatus.status.warning") : + s === "CRITICAL" ? t("healthStatus.status.critical") : + t("healthStatus.status.unknown") + return {label} + } + + const formatStatus = (status: string) => { + const key = status?.toLowerCase() + return ["ok", "info", "warning", "critical", "unknown"].includes(key) + ? t(`healthStatus.status.${key}`) + : status + } + + const translateHealthText = (value?: string): string => { + if (!value) return "" + const exact: Record = { + "All systems operational": t("healthStatus.details.allOperational"), + "Normal": t("healthStatus.details.normal"), + "No I/O errors in dmesg": t("healthStatus.details.noIoErrors"), + "Mounted read-write, space OK": t("healthStatus.details.rootFilesystemOk"), + "No SMART warnings in journal": t("healthStatus.details.noSmartWarnings"), + "No critical errors": t("healthStatus.details.noCriticalErrors"), + "No cascading errors": t("healthStatus.details.noCascadingErrors"), + "No error spikes": t("healthStatus.details.noErrorSpikes"), + "No persistent patterns": t("healthStatus.details.noPersistentPatterns"), + "Certificate valid": t("healthStatus.details.certificateValid"), + "Cluster detected (corosync.conf present)": t("healthStatus.details.clusterDetected"), + "Active": t("healthStatus.details.active"), + "UP": t("healthStatus.details.up"), + "Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"), + "Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"), + "No security updates pending": t("healthStatus.details.noSecurityUpdates"), + "No container startup errors": t("healthStatus.details.noContainerErrors"), + "No OOM events detected": t("healthStatus.details.noOomEvents"), + "No QMP timeouts detected": t("healthStatus.details.noQmpTimeouts"), + "No VM startup failures": t("healthStatus.details.noVmFailures"), + "Dismissed by user": t("healthStatus.details.dismissedByUser"), } + if (exact[value]) return exact[value] + + let match = value.match(/^Latency ([\d.]+)ms to gateway$/) + if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] }) + match = value.match(/^(\d+) failed login attempts in 24h$/) + if (match) return t("healthStatus.details.failedLogins", { count: match[1] }) + match = value.match(/^(\d+) IP\(s\) currently banned by Fail2Ban \(jails: (.+)\)$/) + if (match) return t("healthStatus.details.fail2banBannedIps", { count: match[1], jails: match[2] }) + match = value.match(/^Uptime (\d+) days?$/) + if (match) return t("healthStatus.details.uptimeDays", { count: match[1] }) + match = value.match(/^(\d+) package\(s\) pending$/) + if (match) return t("healthStatus.details.pendingPackages", { count: match[1] }) + match = value.match(/^Last updated (\d+) day\(s\) ago$/) + if (match) return t("healthStatus.details.updatedDaysAgo", { count: match[1] }) + match = value.match(/^Storage: \d+ Proxmox storages unavailable: (.+) \(startup\)$/) + if (match) return t("healthStatus.details.startupStoragesChecking", { storages: match[1] }) + match = value.match(/^Storage: (.+) not yet available \(startup\)$/) + if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] }) + match = value.match(/^(.+) not yet available \(startup\)$/) + if (match) return t("healthStatus.details.startupStorageChecking", { storage: match[1] }) + match = value.match(/^\[Startup\] Storage '(.+)' is configured but not found on the server\. \(checking\.\.\.\)$/) + if (match) return t("healthStatus.details.startupStorageNotFound", { storage: match[1] }) + match = value.match(/^\[Startup\] Storage '(.+)' is not available \(connection error or backend issue\)\. \(checking\.\.\.\)$/) + if (match) return t("healthStatus.details.startupStorageUnavailable", { storage: match[1] }) + match = value.match(/^\[Startup\] Storage '(.+)' has status: (.+)\. \(checking\.\.\.\)$/) + if (match) return t("healthStatus.details.startupStorageStatus", { storage: match[1], status: match[2] }) + match = value.match(/^(.+) storage available$/) + if (match) return t("healthStatus.details.storageAvailable", { type: match[1] }) + match = value.match(/^(.+) mount reachable$/) + if (match) return t("healthStatus.details.mountReachable", { type: match[1] }) + match = value.match(/^rootfs ([\d.]+)% used \((.+)\)$/) + if (match) return t("healthStatus.details.rootfsUsed", { percent: match[1], size: match[2] }) + match = value.match(/^(\d+) running CT\(s\) within safe rootfs usage$/) + if (match) return t("healthStatus.details.runningCtsSafe", { count: match[1] }) + match = value.match(/^(\d+) PVE block storage\(s\) within safe usage$/) + if (match) return t("healthStatus.details.pveStorageSafe", { count: match[1] }) + match = value.match(/^(\d+) remote mount\(s\) healthy$/) + if (match) return t("healthStatus.details.remoteMountsHealthy", { count: match[1] }) + return value + } + + const formatDuration = (hours: number) => { + if (hours === -1) return t("healthStatus.permanent") + if (hours >= 8760) return t("healthStatus.duration.years", { count: Math.floor(hours / 8760) }) + if (hours >= 720) return t("healthStatus.duration.months", { count: Math.floor(hours / 720) }) + if (hours >= 168) return t("healthStatus.duration.weeks", { count: Math.floor(hours / 168) }) + if (hours >= 24) return t("healthStatus.duration.days", { count: Math.floor(hours / 24) }) + return t("healthStatus.duration.hours", { count: Math.round(hours) }) } // Get categories that have dismissed items (to show as INFO) @@ -444,11 +521,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu const now = new Date() const diffMs = now.getTime() - checkTime.getTime() const diffMin = Math.floor(diffMs / 60000) - if (diffMin < 1) return "just now" - if (diffMin === 1) return "1 minute ago" - if (diffMin < 60) return `${diffMin} minutes ago` + if (diffMin < 1) return t("healthStatus.time.justNow") + if (diffMin === 1) return t("healthStatus.time.oneMinuteAgo") + if (diffMin < 60) return t("healthStatus.time.minutesAgo", { count: diffMin }) const diffHours = Math.floor(diffMin / 60) - return `${diffHours}h ${diffMin % 60}m ago` + return t("healthStatus.time.hoursMinutesAgo", { hours: diffHours, minutes: diffMin % 60 }) } const getCategoryRowStyle = (status: string) => { @@ -471,49 +548,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu } const formatCheckLabel = (key: string): string => { - const labels: Record = { - // CPU - cpu_usage: "CPU Usage", - cpu_temperature: "Temperature", - // Memory - ram_usage: "RAM Usage", - swap_usage: "Swap Usage", - // Disk I/O - root_filesystem: "Root Filesystem", - smart_health: "SMART Health", - io_errors: "I/O Errors", - zfs_pools: "ZFS Pools", - lvm_volumes: "LVM Volumes", - lvm_check: "LVM Status", - // Network - connectivity: "Connectivity", - // VMs & CTs - qmp_communication: "QMP Communication", - container_startup: "Container Startup", - vm_startup: "VM Startup", - oom_killer: "OOM Killer", - // Services - cluster_mode: "Cluster Mode", - // Logs (prefixed with log_) - log_error_cascade: "Error Cascade", - log_error_spike: "Error Spike", - log_persistent_errors: "Persistent Errors", - log_critical_errors: "Critical Errors", - // Updates - pve_version: "Proxmox VE Version", - security_updates: "Security Updates", - system_age: "System Age", - pending_updates: "Pending Updates", - kernel_pve: "Kernel / PVE", - // Security - uptime: "Uptime", - certificates: "Certificates", - login_attempts: "Login Attempts", - fail2ban: "Fail2Ban", - // Storage (Proxmox) - proxmox_storages: "Proxmox Storages", - } - if (labels[key]) return labels[key] + const knownKeys = new Set([ + "cpu_usage", "cpu_temperature", "ram_usage", "swap_usage", "root_filesystem", + "smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity", + "qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode", + "log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors", + "pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", + "certificates", "login_attempts", "fail2ban", "proxmox_storages", + ]) + if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`) // Convert snake_case or camelCase to Title Case return key .replace(/_/g, " ") @@ -543,15 +586,15 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{getStatusIcon(checkData.dismissed ? "INFO" : checkData.status, "sm")} {formatCheckLabel(checkKey)} - {checkData.detail} + {translateHealthText(checkData.detail)} {checkData.dismissed && ( checkData.permanent ? ( - Permanent + {t("healthStatus.permanent")} ) : ( - Dismissed + {t("healthStatus.dismissed")} ) )} @@ -563,6 +606,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu handleAcknowledge(checkData.error_key || checkKey, hours) } busy={dismissingKey === (checkData.error_key || checkKey)} + t={t} /> )}
@@ -582,12 +626,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- System Health Status + {t("healthStatus.title")} {healthData &&
{getStatusBadge(healthData.overall)}
}
- Detailed health checks for all system components + {t("healthStatus.description")} {getTimeSinceCheck() && ( @@ -605,7 +649,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {error && (
-

Error loading health status

+

{t("healthStatus.errors.loading")}

{error}

)} @@ -616,47 +660,47 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
0 ? "grid-cols-5" : "grid-cols-4"}`}>
{stats.total}
-
Total
+
{t("healthStatus.stats.total")}
{stats.healthy}
-
Healthy
+
{t("healthStatus.stats.healthy")}
{stats.info > 0 && (
{stats.info}
-
Info
+
{t("healthStatus.stats.info")}
)}
{stats.warnings}
-
Warn
+
{t("healthStatus.stats.warning")}
{stats.critical}
-
Critical
+
{t("healthStatus.stats.critical")}
{stats.unknown > 0 && (
{stats.unknown}
-
Unknown
+
{t("healthStatus.stats.unknown")}
)}
{healthData.summary && healthData.summary !== "All systems operational" && (
-

{healthData.summary}

+

{translateHealthText(healthData.summary)}

)} {/* Category List */}
- {CATEGORIES.map(({ key, label, Icon }) => { + {CATEGORIES.map(({ key, Icon }) => { const categoryData = healthData.details[key as keyof typeof healthData.details] const originalStatus = categoryData?.status || "UNKNOWN" const status = getEffectiveStatus(key, originalStatus) - const reason = categoryData?.reason + const reason = translateHealthText(categoryData?.reason) const checks = categoryData?.checks const isExpanded = expandedCategories.has(key) const hasChecks = checks && Object.keys(checks).length > 0 @@ -677,7 +721,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
-

{label}

+

{t(`healthStatus.categories.${key}`)}

{hasChecks && ( ({Object.values(checks).filter(c => c.installed !== false).length}) @@ -690,7 +734,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- {status} + {formatStatus(status)} )}
@@ -722,7 +767,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu ) : (
- No issues detected + {t("healthStatus.noIssues")}
)} {/* Only offer "Update Now" when the category is not @@ -737,8 +782,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu onClick={() => setShowUpdateTerminal(true)} className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" > - - Update Now + + {t("healthStatus.updateNow")}
)} @@ -758,12 +803,12 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- Dismissed Items ({filteredDismissed.length}) + {t("healthStatus.dismissedItems", { count: filteredDismissed.length })}
{filteredDismissed.map((item) => { const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category) const CatIcon = catMeta?.Icon || BellOff - const catLabel = catMeta?.label || item.category + const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category const isPermanent = item.permanent || item.suppression_remaining_hours === -1 return ( @@ -778,34 +823,28 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu

{catLabel}

-

{item.reason}

+

{translateHealthText(item.reason)}

{isPermanent ? ( - Permanent + {t("healthStatus.permanent")} ) : ( - Dismissed + {t("healthStatus.dismissed")} )} - was {item.severity} + {t("healthStatus.wasStatus", { status: formatStatus(item.severity) })}

{isPermanent - ? "Permanently suppressed" - : `Suppressed for ${ - item.suppression_remaining_hours < 24 - ? `${Math.round(item.suppression_remaining_hours)}h` - : item.suppression_remaining_hours < 720 - ? `${Math.round(item.suppression_remaining_hours / 24)} days` - : `${Math.round(item.suppression_remaining_hours / 720)} month(s)` - } more` + ? t("healthStatus.permanentlySuppressed") + : t("healthStatus.suppressedForMore", { duration: formatDuration(item.suppression_remaining_hours) }) }

@@ -821,30 +860,20 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- Custom Suppression Settings + {t("healthStatus.customSuppressionSettings")}
{customSuppressions.map((cs) => { - const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category || c.label === cs.label) + const catMeta = CATEGORIES.find(c => c.category === cs.category || c.key === cs.category) const CatIcon = catMeta?.Icon || Settings2 - const durationLabel = cs.hours === -1 - ? "Permanent" - : cs.hours >= 8760 - ? `${Math.floor(cs.hours / 8760)} year(s)` - : cs.hours >= 720 - ? `${Math.floor(cs.hours / 720)} month(s)` - : cs.hours >= 168 - ? `${Math.floor(cs.hours / 168)} week(s)` - : cs.hours >= 72 - ? `${Math.floor(cs.hours / 24)} days` - : `${cs.hours}h` + const durationLabel = formatDuration(cs.hours) return (
- {cs.label} + {catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label}
{durationLabel} @@ -854,7 +883,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu })}

- Alerts in these categories are auto-suppressed when detected. + {t("healthStatus.autoSuppressedHint")}

@@ -862,7 +891,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {healthData.timestamp && (
- Last updated: {new Date(healthData.timestamp).toLocaleString()} + {t("healthStatus.lastUpdated", { date: new Date(healthData.timestamp).toLocaleString(document.documentElement.lang) })}
)}
@@ -882,8 +911,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu params={{ EXECUTION_MODE: "web", }} - title="Proxmox System Update" - description="Runs apt-get update + dist-upgrade and post-update cleanup on the host." + title={t("healthStatus.updateTerminalTitle")} + description={t("healthStatus.updateTerminalDescription")} /> ) @@ -896,9 +925,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu function DismissDropdown({ onSelect, busy, + t, }: { onSelect: (suppressionHours: number) => void busy: boolean + t: ReturnType }) { return ( @@ -915,27 +946,27 @@ function DismissDropdown({ ) : ( <> - Dismiss + {t("healthStatus.dismiss")} )} e.stopPropagation()}> - Silence this alert for + {t("healthStatus.silenceFor")} onSelect(24)} className="text-xs"> - 24 hours + {t("healthStatus.duration.24hours")} onSelect(168)} className="text-xs"> - 7 days + {t("healthStatus.duration.7days")} onSelect(-1)} className="text-xs text-red-500 focus:text-red-500 focus:bg-red-500/10" > - Permanently + {t("healthStatus.permanently")} diff --git a/AppImage/components/health-thresholds.tsx b/AppImage/components/health-thresholds.tsx index 935cc73c..2926f8b7 100644 --- a/AppImage/components/health-thresholds.tsx +++ b/AppImage/components/health-thresholds.tsx @@ -20,6 +20,7 @@ import { Waves, } from "lucide-react" import { getApiUrl, getAuthToken } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" // Local fetch wrapper that *preserves* the JSON body on non-2xx // responses so we can surface backend validation messages @@ -80,7 +81,7 @@ interface ThresholdLeaf { interface ThresholdsTree { cpu: { warning: ThresholdLeaf; critical: ThresholdLeaf } - memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_critical: ThresholdLeaf } + memory: { warning: ThresholdLeaf; critical: ThresholdLeaf; swap_high: ThresholdLeaf; available_min: ThresholdLeaf } host_storage: { warning: ThresholdLeaf; critical: ThresholdLeaf } lxc_rootfs: { warning: ThresholdLeaf; critical: ThresholdLeaf } cpu_temperature: { warning: ThresholdLeaf; critical: ThresholdLeaf } @@ -149,7 +150,8 @@ const SECTIONS: SectionDef[] = [ fields: [ { path: ["memory", "warning"], label: "Memory warning" }, { path: ["memory", "critical"], label: "Memory critical" }, - { path: ["memory", "swap_critical"], label: "Swap critical" }, + { path: ["memory", "swap_high"], label: "Swap high" }, + { path: ["memory", "available_min"], label: "Memory available minimum" }, ], }, // ── Heat ──────────────────────────────────────────────────────── @@ -282,6 +284,11 @@ function computeVisualRange( // ─── Component ─────────────────────────────────────────────────────────────── export function HealthThresholds() { + const t = useT() + const tFallback = (key: string, fallback: string) => { + const translated = t(key) + return translated === key ? fallback : translated + } const [tree, setTree] = useState(null) const [loading, setLoading] = useState(true) const [editMode, setEditMode] = useState(false) @@ -299,7 +306,7 @@ export function HealthThresholds() { ) if (res?.success && res.thresholds) setTree(res.thresholds) } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load thresholds") + setError(err instanceof Error ? err.message : t("settings.healthThresholds.loadFailed")) } finally { setLoading(false) } @@ -322,7 +329,7 @@ export function HealthThresholds() { if (trimmed === "") continue const num = Number(trimmed) if (!isFinite(num)) { - setError(`Invalid value for ${key}: must be a number`) + setError(t("settings.healthThresholds.invalidValue", { key })) return null } // Walk into payload mirroring the path @@ -362,7 +369,7 @@ export function HealthThresholds() { { method: "PUT", body: JSON.stringify(payload) }, ) if (!data.success || !data.thresholds) { - setError(data.message || "Save failed") + setError(data.message || t("status.saveFailed")) return } setTree(data.thresholds) @@ -371,14 +378,16 @@ export function HealthThresholds() { setSavedFlash(true) setTimeout(() => setSavedFlash(false), 2000) } catch (err) { - setError(err instanceof Error ? err.message : "Network error while saving") + setError(err instanceof Error ? err.message : t("status.networkErrorWhileSaving")) } finally { setSaving(false) } } const handleResetSection = async (sectionId: string) => { - if (!confirm(`Reset all "${SECTIONS.find((s) => s.id === sectionId)?.title}" thresholds to recommended values?`)) + const section = SECTIONS.find((s) => s.id === sectionId) + const sectionTitle = section ? tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title) : sectionId + if (!confirm(t("settings.healthThresholds.resetSectionConfirm", { section: sectionTitle }))) return try { const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>( @@ -386,7 +395,7 @@ export function HealthThresholds() { { method: "POST" }, ) if (!data.success || !data.thresholds) { - setError(data.message || "Reset failed") + setError(data.message || t("settings.healthThresholds.resetFailed")) return } setTree(data.thresholds) @@ -400,25 +409,25 @@ export function HealthThresholds() { return next }) } catch (err) { - setError(err instanceof Error ? err.message : "Network error while resetting") + setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting")) } } const handleResetAll = async () => { - if (!confirm("Reset ALL thresholds to recommended values? This affects every section.")) return + if (!confirm(t("settings.healthThresholds.resetAllConfirm"))) return try { const data = await fetchJson<{ success: boolean; thresholds: ThresholdsTree; message?: string }>( "/api/health/thresholds/reset", { method: "POST" }, ) if (!data.success || !data.thresholds) { - setError(data.message || "Reset failed") + setError(data.message || t("settings.healthThresholds.resetFailed")) return } setTree(data.thresholds) setPending({}) } catch (err) { - setError(err instanceof Error ? err.message : "Network error while resetting") + setError(err instanceof Error ? err.message : t("settings.healthThresholds.networkErrorWhileResetting")) } } @@ -441,7 +450,7 @@ export function HealthThresholds() { const isCustomised = leaf.customised && !(key in pending) const customisedClass = "border-blue-500 bg-blue-500/10 focus-visible:border-blue-500" const fieldClass = isCustomised ? customisedClass : severityClass - const recommendedTooltip = `Recommended: ${leaf.recommended}${leaf.unit}` + const recommendedTooltip = `${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${leaf.unit}` return (
@@ -524,12 +533,12 @@ export function HealthThresholds() { value={val} onChange={(e) => setPending((p) => ({ ...p, [key]: e.target.value }))} className={`absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background ${handleClass}`} - title={`Recommended: ${leaf.recommended}${unit}`} + title={`${t("settings.healthThresholds.recommended")}: ${leaf.recommended}${unit}`} />
- OK < {val}{unit} - {severity === "critical" ? "CRIT" : "WARN"} > {val}{unit} + {t("settings.healthThresholds.ok")} < {val}{unit} + {severity === "critical" ? t("settings.healthThresholds.crit") : t("settings.healthThresholds.warn")} > {val}{unit}
) @@ -641,7 +650,7 @@ export function HealthThresholds() { value={wVal} onChange={(e) => setVal(wKey, Number(e.target.value), cVal, true)} className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-amber-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-amber-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background" - title={`Warning (recommended: ${wLeaf.recommended}${unit})`} + title={`${t("settings.healthThresholds.warning")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${wLeaf.recommended}${unit})`} /> setVal(cKey, Number(e.target.value), wVal, false)} className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-moz-range-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-8 [&::-webkit-slider-thumb]:w-8 sm:[&::-webkit-slider-thumb]:h-4 sm:[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-red-500 [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:shadow [&::-moz-range-thumb]:h-8 [&::-moz-range-thumb]:w-8 sm:[&::-moz-range-thumb]:h-4 sm:[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-red-500 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background" - title={`Critical (recommended: ${cLeaf.recommended}${unit})`} + title={`${t("settings.healthThresholds.critical")} (${t("settings.healthThresholds.recommended").toLowerCase()}: ${cLeaf.recommended}${unit})`} />
@@ -660,9 +669,9 @@ export function HealthThresholds() { "warn" starts and ends without having to read the handles. */} {!options?.hideLabels && (
- OK < {wVal}{unit} - WARN {wVal}–{cVal}{unit} - CRIT > {cVal}{unit} + {t("settings.healthThresholds.ok")} < {wVal}{unit} + {t("settings.healthThresholds.warn")} {wVal}–{cVal}{unit} + {t("settings.healthThresholds.crit")} > {cVal}{unit}
)}
@@ -675,14 +684,14 @@ export function HealthThresholds() {
- Health Monitor Thresholds + {t("settings.healthThresholds.title")}
{!loading && (
{savedFlash && ( - Saved + {t("status.saved")} )} {editMode ? ( @@ -692,7 +701,7 @@ export function HealthThresholds() { onClick={handleCancel} disabled={saving} > - Cancel + {t("actions.cancel")} ) : ( @@ -712,17 +721,17 @@ export function HealthThresholds() { )} @@ -730,19 +739,23 @@ export function HealthThresholds() { )}
- The Health Monitor and notifications fire when these thresholds are crossed. - Drag the amber handle to set the warning level and the red handle to set the - critical level. Values that differ from the recommended default appear in blue — - hover a handle to see the recommendation, or use Reset to restore it. + {t("settings.healthThresholds.description")} + {/* Intentional exception to the global "edit mode contrast" + rule: the numeric inputs already carry semantic colored + backgrounds (red critical, amber warning, blue customised) + which are meaningful and more graphical than a plain form. + A sunken selector would either erase those tints or force + us to `!important` every one — cleaner to keep this card + untouched in edit mode. */} {loading ? (
) : !tree ? ( -
Failed to load thresholds.
+
{t("settings.healthThresholds.loadFailed")}
) : (
{error && ( @@ -767,13 +780,13 @@ export function HealthThresholds() {
-

{section.title}

+

{tFallback(`settings.healthThresholds.sections.${section.id}.title`, section.title)}

{editMode && ( @@ -781,7 +794,7 @@ export function HealthThresholds() {
{section.description && (

- {section.description} + {tFallback(`settings.healthThresholds.sections.${section.id}.description`, section.description)}

)}
@@ -800,20 +813,36 @@ export function HealthThresholds() { )) ) : section.id === "memory" ? ( // Memory & Swap is special: warn/crit pair for - // RAM, plus a single Swap threshold that has no - // companion (it's a "critical only" metric). - // Both use sliders so the section reads as one - // visual language end to end. + // RAM, plus the swap-pressure pair. Swap + // CRITICAL fires only when both conditions hold + // — swap file above `swap_high` AND available + // RAM below `available_min`. Rendering the two + // sliders one under the other under a shared + // header reads as "the two knobs of one signal". <>
- RAM + {t("settings.healthThresholds.ram")}
{renderThresholdRange(["memory"])} -
-
- Swap (critical only) +
+
+ {t("settings.healthThresholds.swapPressure")} +
+

+ {t("settings.healthThresholds.swapPressureHint")} +

+
+
+ {t("settings.healthThresholds.swapHighLabel")} +
+ {renderSingleThresholdSlider(["memory", "swap_high"], "critical")} +
+
+
+ {t("settings.healthThresholds.availableMinLabel")} +
+ {renderSingleThresholdSlider(["memory", "available_min"], "warning")}
- {renderSingleThresholdSlider(["memory", "swap_critical"], "critical")}
) : section.fields.length === 2 && diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 2759a837..1953ef99 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -51,6 +51,9 @@ import { fetchApi, getApiUrl } from "../lib/api-config" import { fetchTerminalTicket } from "../lib/terminal-ws" import { formatStorage, formatBytes } from "../lib/utils" import { getStorageUsageColor } from "../lib/storage-usage-color" +import { useI18n, useT } from "../lib/i18n/provider" + +type TFunction = (key: string, params?: Record) => string // ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ── @@ -234,36 +237,38 @@ const parseKeyfileError = (raw: string): KeyfileError | null => { } } -const KeyfileErrorBlock: React.FC<{ err: KeyfileError; className?: string }> = ({ err, className }) => ( -
-
- -
-
Encrypted backup — wrong keyfile on this host
-
- The keyfile installed here does not match the one used to create this backup, so PBS refuses to open it. -
- {err.manifestFp && ( -
-
Required (manifest)
-
{err.manifestFp}
+const KeyfileErrorBlock: React.FC<{ err: KeyfileError; className?: string }> = ({ err, className }) => { + const t = useT() + + return ( +
+
+ +
+
{t("backup.keyfileError.title")}
+
{t("backup.keyfileError.description")}
+ {err.manifestFp && ( +
+
{t("backup.keyfileError.requiredManifest")}
+
{err.manifestFp}
+
+ )} + {err.providedFp && ( +
+
{t("backup.keyfileError.currentlyInstalled")}
+
{err.providedFp}
+
+ )} +
+ {t("backup.keyfileError.importFrom")}{" "} + {t("backup.keyfileError.importPath")} + {" "}{t("backup.keyfileError.retryHint")}
- )} - {err.providedFp && ( -
-
Currently installed
-
{err.providedFp}
-
- )} -
- Import the correct keyfile from{" "} - Backup configuration → Destinations → PBS row → Upload - {" "}(you may need to Delete the current one first) and retry.
-
-) + ) +} const formatMtime = (mtime: number) => new Date(mtime * 1000).toLocaleString(undefined, { @@ -288,30 +293,30 @@ const formatNext = (iso: string | null) => { // operator at least knows what kind of string they're looking at. // Handles the patterns the host-backup wizard can emit ("hourly", // "daily", "weekly", "monthly", "*-*-* HH:MM:SS", "Mon..Sun *-*-* …"). -const humanizeOnCalendar = (raw: string | null | undefined): string => { +const humanizeOnCalendar = (raw: string | null | undefined, t: TFunction): string => { if (!raw) return "—" const s = raw.trim() if (!s) return "—" const lower = s.toLowerCase() - if (lower === "hourly") return "Every hour (at minute 0)" - if (lower === "daily") return "Every day at 00:00" - if (lower === "weekly") return "Every Monday at 00:00" - if (lower === "monthly") return "On the 1st of every month at 00:00" - if (lower === "yearly" || lower === "annually") return "On Jan 1st at 00:00" - if (lower === "minutely") return "Every minute" + if (lower === "hourly") return t("backup.schedule.everyHourAtMinuteZero") + if (lower === "daily") return t("backup.schedule.everyDayAtMidnight") + if (lower === "weekly") return t("backup.schedule.everyMondayAtMidnight") + if (lower === "monthly") return t("backup.schedule.firstDayMonthly") + if (lower === "yearly" || lower === "annually") return t("backup.schedule.janFirst") + if (lower === "minutely") return t("backup.schedule.everyMinute") // *-*-* HH:MM[:SS] → "Every day at HH:MM" let m = s.match(/^\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/) - if (m) return `Every day at ${m[1].padStart(2, "0")}:${m[2]}` + if (m) return t("backup.schedule.everyDayAt", { time: `${m[1].padStart(2, "0")}:${m[2]}` }) // *-*-* *:MM:SS → "Every hour at minute MM" m = s.match(/^\*-\*-\*\s+\*:(\d{2})(?::(\d{2}))?$/) - if (m) return `Every hour at minute ${m[1]}` + if (m) return t("backup.schedule.everyHourAtMinute", { minute: m[1] }) // Mon,Tue *-*-* HH:MM:SS → " at HH:MM" m = s.match(/^([A-Za-z,.\s]+)\s+\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/) if (m) { const expandWeekdays = (chunk: string): string => { const days: Record = { - mon: "Monday", tue: "Tuesday", wed: "Wednesday", - thu: "Thursday", fri: "Friday", sat: "Saturday", sun: "Sunday", + mon: t("backup.weekdays.monday"), tue: t("backup.weekdays.tuesday"), wed: t("backup.weekdays.wednesday"), + thu: t("backup.weekdays.thursday"), fri: t("backup.weekdays.friday"), sat: t("backup.weekdays.saturday"), sun: t("backup.weekdays.sunday"), } const rangeMatch = chunk.match(/^([A-Za-z]+)\.\.([A-Za-z]+)$/) if (rangeMatch) { @@ -327,9 +332,9 @@ const humanizeOnCalendar = (raw: string | null | undefined): string => { .map((d) => days[d.trim().slice(0, 3).toLowerCase()] || d.trim()) .join(", ") } - return `${expandWeekdays(m[1])} at ${m[2].padStart(2, "0")}:${m[3]}` + return t("backup.schedule.weekdaysAt", { days: expandWeekdays(m[1]), time: `${m[2].padStart(2, "0")}:${m[3]}` }) } - return `${s} (systemd OnCalendar)` + return t("backup.schedule.rawOnCalendar", { value: s }) } // A job is "running" when its .status file has RUN_AT (runner started) @@ -375,6 +380,23 @@ const methodBadgeCls = (m: string | undefined): string => { } } +const localizedBackendLabel = (source: string, t: (key: string) => string): string => + source === "pbs" ? "PBS" : source === "borg" ? "Borg" : t("backup.backends.local") + +const formatCalendarPreview = (value: string, language: string, t: (key: string) => string): string => { + if (language !== "sk") return value + const match = value.match(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(\d{4})-(\d{2})-(\d{2})\s+(.+)$/i) + if (!match) return value + const [, weekday, year, month, day, rest] = match + return `${t(`backup.weekdays.short.${weekday.toLowerCase()}`)} ${Number(day)}. ${Number(month)}. ${year} ${rest}` +} + +const formatCalendarDistance = (value: string, language: string): string => { + if (language !== "sk") return value + const distance = value.replace(/\s+left$/i, "").replace(/\bdays?\b/gi, "d") + return `zostáva ${distance}` +} + const formatRunAt = (iso: string | null) => { if (!iso) return null try { @@ -414,6 +436,7 @@ const formatRunAt = (iso: string | null) => { // the fuller management surface. // ────────────────────────────────────────────────────────────── function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { + const t = useT() const { data: info, mutate: mutateInfo } = useSWR<{ installed: boolean fingerprint?: string @@ -470,7 +493,7 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { const runUpload = async () => { if (!importFile && !importPath.trim()) { - setErr("Pick a keyfile file or enter an absolute path on this host.") + setErr(t("backup.errors.pickKeyfile")) return } setBusy(true) @@ -523,17 +546,17 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
- Encryption keyfile:{" "} + {t("backup.keyfileActions.keyfileLabel")}{" "} {installed ? ( - installed + {t("backup.keyfileActions.installed")} ) : ( - not installed on this host + {t("backup.keyfileActions.notInstalled")} )}
@@ -545,10 +568,10 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { variant="outline" className="h-7 text-[11px] !text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10" onClick={download} - title="Download the keyfile as pbs-key.conf" + title={t("backup.keyfileActions.downloadTitle")} > - Download + {t("backup.actions.download")} )} @@ -570,10 +593,10 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { variant="outline" className="h-7 text-[11px] !text-blue-400 border-blue-500/40 hover:bg-blue-500/10" onClick={() => setUploadOpen(true)} - title="Import a keyfile you already have" + title={t("backup.keyfileActions.uploadTitle")} > - Upload + {t("backup.actions.upload")} )}
@@ -582,9 +605,9 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { !v && closeUpload()}> - Upload PBS keyfile + {t("backup.keyfileActions.uploadDialogTitle")} - Import a keyfile you already have. It lands at /usr/local/share/proxmenux/pbs-key.conf and every subsequent encrypted backup reuses it. Recovery escrow stays off — use the setup wizard if you want to enable it. + {t("backup.keyfileActions.uploadDialogDescriptionBefore")} /usr/local/share/proxmenux/pbs-key.conf {t("backup.keyfileActions.uploadDialogDescriptionAfter")}
@@ -593,10 +616,10 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
-
PVE-managed keyfile detected for this PBS
+
{t("backup.keyfileActions.pveKeyDetected")}
- Proxmox already stores an encryption key for storage {pveMatch.name} at{" "} - {pveMatch.path}. Import it in one click. + {t("backup.keyfileActions.pveKeyDescriptionBefore")} {pveMatch.name} {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "} + {pveMatch.path}. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
@@ -610,14 +633,14 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { disabled={busy} className="!bg-emerald-600 hover:!bg-emerald-700 !text-white h-7 text-[11px]" > - Use this key + {t("backup.keyfileActions.useThisKey")}
)} - {pveMatch &&
— or upload your own —
} + {pveMatch &&
{t("backup.keyfileActions.orUploadOwn")}
}
- +
-
— or —
+
{t("backup.common.or")}
- + setImportPath(e.target.value)} disabled={busy || !!importFile} - placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" + placeholder={t("backup.placeholders.keyfilePath")} className="h-9 mt-1 font-mono text-xs" />
@@ -645,13 +668,13 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { )}
- + @@ -662,24 +685,24 @@ function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { - Delete keyfile + {t("backup.keyfileActions.deleteDialogTitle")} - Backups already stored on PBS were encrypted with the current keyfile. After this action: + {t("backup.keyfileActions.deleteDialogDescription")}
    -
  • New backups will use no encryption on this host until a new keyfile is set up.
  • -
  • Downloading pre-existing encrypted backups from this host will fail unless you kept a copy of the current key.
  • -
  • Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase.
  • +
  • {t("backup.keyfileActions.deleteWarningNewBackups")}
  • +
  • {t("backup.keyfileActions.deleteWarningDownloadsBefore")} {t("backup.keyfileActions.willFail")} {t("backup.keyfileActions.deleteWarningDownloadsAfter")}
  • +
  • {t("backup.keyfileActions.deleteWarningRecoveryBlobs")}
{err && (
{err}
)} - + @@ -695,6 +718,7 @@ function KeyfileActionsBar({ mutateStatus: () => Promise escrowMode?: "none" | "local" | "full" }) { + const t = useT() const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) const [pass1, setPass1] = useState("") @@ -709,11 +733,11 @@ function KeyfileActionsBar({ // Yes → passphrase required + match. No → passphrase ignored. if (pendingMode === "full") { if (!pass1) { - setErr("Recovery passphrase is required.") + setErr(t("backup.errors.recoveryPassphraseRequired")) return } if (pass1 !== pass2) { - setErr("Passphrases do not match.") + setErr(t("backup.errors.passphrasesDoNotMatch")) return } } @@ -743,12 +767,12 @@ function KeyfileActionsBar({ // Yes → Yes (new pw) → "Update passphrase" (rewraps envelope) const applyLabel = pendingMode === "full" && !currentIsFull - ? "Start uploading" + ? t("backup.keyfileManagement.startUploading") : pendingMode === "none" && currentIsFull - ? "Stop uploading" + ? t("backup.keyfileManagement.stopUploading") : pendingMode === "full" && currentIsFull - ? "Update passphrase" - : "Apply" + ? t("backup.keyfileManagement.updatePassphrase") + : t("backup.actions.apply") // Apply is enabled when there is a real change to commit. const canApply = ( @@ -767,20 +791,20 @@ function KeyfileActionsBar({ return (
-
Manage installed keyfile
+
{t("backup.keyfileManagement.title")}
{/* Current status — icon + colour by state, no truncated fp. */} {escrowMode !== undefined && ( -
- Upload to PBS: +
+ {t("backup.keyfileManagement.uploadToPbs")} {currentIsFull ? ( - Yes — envelope uploaded on every backup + {t("backup.keyfileManagement.uploadYes")} ) : ( - No — kept only on this host + {t("backup.keyfileManagement.uploadNo")} )}
@@ -791,7 +815,7 @@ function KeyfileActionsBar({ intent is Yes (both for a first-time upload and for a passphrase rotation while already in Yes). */}
-
Upload key to PBS?
+
{t("backup.keyfileManagement.uploadQuestion")}
@@ -824,29 +848,29 @@ function KeyfileActionsBar({
setPass1(e.target.value)} - placeholder={currentIsFull ? "Type a new passphrase to rotate" : "Long random string — write it down somewhere safe"} + placeholder={currentIsFull ? t("backup.placeholders.newRecoveryPassphrase") : t("backup.placeholders.recoveryPassphrase")} className="font-mono mt-1 h-8 text-xs" />
- + setPass2(e.target.value)} - placeholder="Type it again" + placeholder={t("backup.placeholders.typeItAgain")} className="font-mono mt-1 h-8 text-xs" /> {pass1 && pass2 && pass1 !== pass2 && ( -

Passphrases don't match.

+

{t("backup.errors.passphrasesDontMatch")}

)}
@@ -876,7 +900,7 @@ function KeyfileActionsBar({ onClick={download} > - Download keyfile + {t("backup.keyfileManagement.downloadKeyfile")}
@@ -887,6 +911,7 @@ function KeyfileActionsBar({ } export function HostBackup() { + const t = useT() const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>( "/api/host-backups/jobs", fetcher, @@ -921,7 +946,7 @@ export function HostBackup() { mutateJobs() setJobToDelete(null) } catch (e) { - setActionError(`Failed to delete "${id}": ${e instanceof Error ? e.message : String(e)}`) + setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` })) } finally { setBusyJobId(null) } @@ -1009,7 +1034,7 @@ export function HostBackup() {
- Scheduled Backup Jobs + {t("backup.jobs.scheduledTitle")} {jobsResp?.jobs?.filter((j) => !j.manual).length ?? 0} @@ -1020,16 +1045,16 @@ export function HostBackup() { onClick={() => setCreatingJob(true)} > - Create job + {t("backup.jobs.createJob")} {jobsErr ? ( -
Failed to load jobs
+
{t("backup.jobs.loadFailed")}
) : !jobsResp ? (
- Loading... + {t("backup.common.loading")}
) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : (
@@ -1046,7 +1071,7 @@ export function HostBackup() { // the row re-opens JobDetailModal which auto-detects // the in-progress state and resumes streaming. const statusBadge = running - ? { label: "running", cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" } + ? { label: t("backup.status.running"), cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" } : status?.result === "ok" ? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-400" } : status?.result @@ -1059,7 +1084,7 @@ export function HostBackup() { type="button" onClick={() => setViewingJobId(j.id)} className="w-full text-left flex items-start gap-3 p-3 rounded-md border border-border bg-card hover:bg-white/5 transition-colors group" - title="Click to open this job" + title={t("backup.jobs.openJobTitle")} >
{/* Title row */} @@ -1070,19 +1095,19 @@ export function HostBackup() { {j.manual && ( - manual + {t("backup.status.manual")} )} {j.attached && ( - attached + {t("backup.status.attached")} )} {j.encrypted && ( @@ -1097,19 +1122,19 @@ export function HostBackup() { className={`text-[10px] uppercase tracking-wide ${ j.profile_mode === "custom" ? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5" - : "border-border text-muted-foreground bg-background/40" + : "border-border text-muted-foreground bg-card" }`} title={ j.profile_mode === "custom" - ? "Custom path list — only the paths the operator picked" - : "Default path list — ProxMenux's recommended host config set" + ? t("backup.jobs.customProfileTitle") + : t("backup.jobs.defaultProfileTitle") } > - {j.profile_mode === "custom" ? "custom" : "default"} + {j.profile_mode === "custom" ? t("backup.profile.custom") : t("backup.profile.default")} {!j.enabled && !j.manual && ( - disabled + {t("status.disabled")} )}
@@ -1124,10 +1149,10 @@ export function HostBackup() {
- {humanizeOnCalendar(j.on_calendar)} + {humanizeOnCalendar(j.on_calendar, t)} {j.retention && ( - + {(() => { // Backend returns retention as "last=7, daily=7, …". @@ -1157,15 +1182,15 @@ export function HostBackup() { )} {!j.attached && j.next_run && ( - + - next: {formatNext(j.next_run)} + {t("backup.jobs.nextRun", { time: formatNext(j.next_run) })} )} {(statusBadge || lastRunWhen) && ( - last: + {t("backup.jobs.lastRunLabel")} {statusBadge && ( {running && } @@ -1178,7 +1203,7 @@ export function HostBackup() { {!status && ( - never run + {t("backup.jobs.neverRun")} )}
@@ -1204,19 +1229,19 @@ export function HostBackup() {
- Manual backups + {t("backup.manual.title")}

- Manual backups run once and stop — no schedule. + {t("backup.manual.description")}

{/* In-progress manual jobs. If the operator closed the ManualBackupDialog before the runner finished, this @@ -1231,13 +1256,13 @@ export function HostBackup() { type="button" onClick={() => setWatchingManualId(j.id)} className="w-full flex items-center gap-2 px-3 py-2 rounded-md border border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10 transition-colors text-left" - title="Click to re-open the live log" + title={t("backup.manual.reopenLogTitle")} > - Manual backup in progress — {j.id} + {t("backup.manual.inProgress")} — {j.id} - View progress + {t("backup.manual.viewProgress")} ))}
@@ -1248,17 +1273,17 @@ export function HostBackup() {
- Available Archives + {t("backup.archives.title")}
{unifiedArchives.length}

- All backups visible from this host — local .tar.zst files (PVE default dump dir, configured local target, USB mountpoints, scheduled jobs' destinations) and PBS backups from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS backups are extracted on-demand only when you request them. + {t("backup.archives.descriptionBefore")} .tar.zst {t("backup.archives.descriptionAfter")}

{remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && (
-
Some remote backends couldn't be queried:
+
{t("backup.archives.remoteQueryWarning")}
{remoteArchivesResp.errors.map((e, i) => (
{e.backend}/{e.repo_name}: {e.error} @@ -1267,15 +1292,15 @@ export function HostBackup() {
)} {archivesErr && remoteArchivesErr ? ( -
Failed to load archives
+
{t("backup.archives.loadFailed")}
) : !archivesResp && !remoteArchivesResp ? (
- Loading... + {t("backup.common.loading")}
) : unifiedArchives.length === 0 ? (
- No backups found yet. Use Run manual backup above, configure a scheduled job, or check that the configured PBS / Borg destinations have backups. + {t("backup.archives.emptyBefore")} {t("backup.manual.run")} {t("backup.archives.emptyAfter")}
) : (
@@ -1297,8 +1322,8 @@ export function HostBackup() { key={`${u.source}:${u.display_id}`} type="button" onClick={() => setInspectingArchive(u)} - className="w-full text-left flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-background/40 hover:bg-white/5 hover:border-blue-500/40 transition-colors group" - title="Click to inspect, restore or download this backup" + className="w-full text-left flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-card hover:bg-white/5 hover:border-blue-500/40 transition-colors group" + title={t("backup.archives.inspectTitle")} >
@@ -1306,12 +1331,12 @@ export function HostBackup() {
- {u.source} + {localizedBackendLabel(u.source, t)} {u.remote?.encrypted && ( @@ -1325,24 +1350,24 @@ export function HostBackup() { {formatBytes(u.size_bytes)} - at: {u.source_label} + {t("backup.archives.at")} {u.source_label} {u.source === "local" && localKind === "scheduled" && localJobId ? ( - job: {localJobId} + {t("backup.archives.job")} {localJobId} ) : u.source === "local" && localKind === "legacy" ? ( - legacy + {t("backup.status.legacy")} ) : u.source === "local" && localKind === "manual" ? ( - manual + {t("backup.status.manual")} ) : null} {(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && ( - {u.source === "pbs" ? "group" : "archive"}: {u.remote.backup_id} + {u.source === "pbs" ? t("backup.archives.group") : t("backup.archives.archive")}: {u.remote.backup_id} )} {u.source === "local" && localHost && ( - host: {localHost} + {t("backup.archives.host")} {localHost} )}
@@ -1454,33 +1479,31 @@ export function HostBackup() { - Delete backup job? + {t("backup.deleteJob.title")} - This action cannot be undone. + {t("backup.deleteJob.description")} {jobToDelete && (
-
Job ID
+
{t("backup.fields.jobId")}
{jobToDelete.id}
{jobToDelete.attached && jobToDelete.pve_storage && ( <> -
Type
-
attached to PVE storage {jobToDelete.pve_storage}
+
{t("backup.fields.type")}
+
{t("backup.deleteJob.attachedToStorage")} {jobToDelete.pve_storage}
)}
{jobToDelete.attached ? (

- Only the ProxMenux host backup hook is removed. - PVE vzdump jobs targeting this storage stay intact and keep running. + {t("backup.deleteJob.attachedWarning")}

) : (

- The systemd timer and service for this job will be stopped, disabled and removed. - Existing backup archives on disk are NOT deleted. + {t("backup.deleteJob.timerWarning")}

)}
@@ -1491,7 +1514,7 @@ export function HostBackup() { onClick={() => setJobToDelete(null)} disabled={busyJobId === jobToDelete?.id} > - Cancel + {t("actions.cancel")}
@@ -1526,13 +1549,14 @@ function InspectModal({ onClose: () => void onDeleted?: () => void }) { + const t = useT() const open = archive !== null // Aliases to the source-specific payloads — saves on `.local!` / // `.remote!` repetition later. PBS and Borg share the same shape. const localArc = archive?.source === "local" ? archive.local : undefined const remoteArc = archive && archive.source !== "local" ? archive.remote : undefined const isRemote = archive?.source === "pbs" || archive?.source === "borg" - const backendLabel = archive?.source === "pbs" ? "PBS" : archive?.source === "borg" ? "Borg" : "Local" + const backendLabel = archive?.source === "pbs" ? "PBS" : archive?.source === "borg" ? "Borg" : t("backup.backends.local") const [mode, setMode] = useState("full") const [report, setReport] = useState(null) const [running, setRunning] = useState(false) @@ -1647,7 +1671,7 @@ function InspectModal({ // both land at /usr/local/share/proxmenux/pbs-key.conf with // escrow_mode='none'. When both are provided the file wins. if (!importFile && !importPath.trim()) { - setImportError("Pick a keyfile file or enter an absolute path on this host.") + setImportError(t("backup.errors.pickKeyfile")) return } setImporting(true) @@ -1685,13 +1709,13 @@ function InspectModal({ setRestorePreparing(true) const body: Record = { source: archive.source } if (archive.source === "local") { - if (!localArc?.path) { setRestoreError("Local archive path missing"); setRestorePreparing(false); return } + if (!localArc?.path) { setRestoreError(t("backup.errors.localArchivePathMissing")); setRestorePreparing(false); return } body.path = localArc.path } else if (remoteArc) { body.repo_name = remoteArc.repo_name body.snapshot = remoteArc.snapshot } else { - setRestoreError("Snapshot info missing") + setRestoreError(t("backup.errors.snapshotInfoMissing")) setRestorePreparing(false) return } @@ -1701,7 +1725,7 @@ function InspectModal({ headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) - if (!r?.staging_path) throw new Error("backend did not return a staging path") + if (!r?.staging_path) throw new Error(t("backup.errors.backendNoStagingPath")) const ck = r.cross_kernel || {} const hyd = r.hydration || {} setRestoreOptions({ @@ -1753,7 +1777,7 @@ function InspectModal({ a.click() document.body.removeChild(a) } catch (e) { - setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) + setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) })) } finally { // The download itself runs in the browser's network stack; // we just initiated it. Clear the spinner immediately. @@ -1777,7 +1801,7 @@ function InspectModal({ repo_name: remoteArc.repo_name, snapshot: remoteArc.snapshot, state: "queued", - message: "Starting export…", + message: t("backup.archives.startingExport"), size_bytes: 0, output_path: null, error: null, @@ -1808,7 +1832,7 @@ function InspectModal({ if (task.state === "completed" || task.state === "failed") break } if (!task || task.state !== "completed") { - throw new Error(task?.error || "export did not complete") + throw new Error(task?.error || t("backup.errors.exportDidNotComplete")) } // Stream the resulting .tar.zst with a ticketed URL + . // Same rationale as downloadLocalArchive: bypass fetch+blob to @@ -1826,7 +1850,7 @@ function InspectModal({ document.body.removeChild(a) setExportTask(null) } catch (e) { - setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) + setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) })) } finally { setDownloading(false) } @@ -1874,7 +1898,7 @@ function InspectModal({ setShowDeleteArchiveConfirm(false) if (onDeleted) onDeleted(); else onClose() } catch (e) { - setError(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`) + setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) })) } finally { setDeletingArchive(false) } @@ -1933,7 +1957,7 @@ function InspectModal({ ) setReport(res) } catch (e: any) { - setError(e?.message || "Preflight failed") + setError(e?.message || t("backup.errors.preflightFailed")) } finally { setRunning(false) } @@ -1962,9 +1986,9 @@ function InspectModal({ rows mixed in only when they carry data. Backend + encryption badges live here (instead of the header, where they used to overlap the close button). */} -
+
-
Backup
+
{t("backup.archives.backup")}
{archive && ( - {archive.source} + {localizedBackendLabel(archive.source, t)} )} {remoteArc?.encrypted && ( @@ -1992,33 +2016,33 @@ function InspectModal({ {/* Time + size — present for every backend. */} {archive && (archive.source === "pbs" || archive.source === "borg") && remoteArc ? ( <> -
Backup time: {formatMtime(remoteArc.backup_time)}
- {remoteArc.size_bytes > 0 &&
Size: {formatBytes(remoteArc.size_bytes)}
} -
Repository: {remoteArc.repo_repository}
-
Repo name: {remoteArc.repo_name}
+
{t("backup.archives.backupTimeLabel")} {formatMtime(remoteArc.backup_time)}
+ {remoteArc.size_bytes > 0 &&
{t("backup.fields.sizeLabel")} {formatBytes(remoteArc.size_bytes)}
} +
{t("backup.fields.repositoryLabel")} {remoteArc.repo_repository}
+
{t("backup.fields.repoNameLabel")} {remoteArc.repo_name}
- {remoteArc.backend === "pbs" ? "Backup group:" : "Archive name:"}{" "} + {remoteArc.backend === "pbs" ? t("backup.fields.backupGroupLabel") : t("backup.fields.archiveNameLabel")}{" "} {remoteArc.backend === "pbs" ? `${remoteArc.backup_type}/${remoteArc.backup_id}` : remoteArc.backup_id}
- {remoteArc.owner &&
Owner: {remoteArc.owner}
} - {remoteArc.borg_id &&
Borg id: {remoteArc.borg_id}
} + {remoteArc.owner &&
{t("backup.fields.ownerLabel")} {remoteArc.owner}
} + {remoteArc.borg_id &&
{t("backup.fields.borgIdLabel")} {remoteArc.borg_id}
} ) : localArc ? ( <> -
Created: {formatMtime(localArc.mtime)}
-
Size: {formatBytes(localArc.size_bytes)}
-
Path: {localArc.path}
- {localArc.job_id &&
Job id: {localArc.job_id}
} - {localArc.profile &&
Profile: {localArc.profile}
} - {localArc.source_hostname &&
Source host: {localArc.source_hostname}
} -
Detected via: {localArc.detected_via}
+
{t("backup.fields.createdLabel")} {formatMtime(localArc.mtime)}
+
{t("backup.fields.sizeLabel")} {formatBytes(localArc.size_bytes)}
+
{t("backup.fields.pathLabel")} {localArc.path}
+ {localArc.job_id &&
{t("backup.fields.jobIdLabel")} {localArc.job_id}
} + {localArc.profile &&
{t("backup.fields.profileLabel")} {localArc.profile === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}
} + {localArc.source_hostname &&
{t("backup.fields.sourceHostLabel")} {localArc.source_hostname}
} +
{t("backup.fields.detectedViaLabel")} {localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : localArc.detected_via}
) : null}
{/* PBS pxar files list — only PBS exposes this. */} {remoteArc?.files && remoteArc.files.length > 0 && (
-
Files in this backup
+
{t("backup.archives.filesInBackup")}
    {remoteArc.files.map((f) => (
  • @@ -2035,7 +2059,7 @@ function InspectModal({ {archive?.source === "local" && archiveLog && archiveLog.log_path && archiveLog.tail.length > 0 && (

    - Run log + {t("backup.logs.runLog")}

    @@ -2043,12 +2067,12 @@ function InspectModal({
                       
    - tail · {formatBytes(archiveLog.size)} + {t("backup.logs.tail")} · {formatBytes(archiveLog.size)} {archiveLog.log_path}
    @@ -2057,17 +2081,17 @@ function InspectModal({ {/* In-flight export task feedback (Download for PBS/Borg). */} {exportTask && ( -
    +
    - {exportTask.state} + {t(`backup.taskStates.${exportTask.state}`)} — {exportTask.message}
    {exportTask.state === "failed" && exportTask.error && (
    {exportTask.error}
    )} {exportTask.state === "completed" && exportTask.size_bytes > 0 && ( -
    Packed size: {formatBytes(exportTask.size_bytes)}
    +
    {t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}
    )}
    )} @@ -2092,11 +2116,11 @@ function InspectModal({
    -
    Encrypted backup — keyfile required
    +
    {t("backup.keyfileGate.title")}
    - This snapshot is encrypted but no local keyfile is installed at + {t("backup.keyfileGate.descriptionBefore")} {" "}/usr/local/share/proxmenux/pbs-key.conf. - Import the keyfile that was used at backup time to continue. + {" "}{t("backup.keyfileGate.descriptionAfter")}
    @@ -2105,10 +2129,10 @@ function InspectModal({
    -
    PVE-managed keyfile detected for this PBS
    +
    {t("backup.keyfileActions.pveKeyDetected")}
    - Proxmox stores an encryption key for storage {pveMatchInspect.name} at{" "} - {pveMatchInspect.path}. Import it in one click. + {t("backup.keyfileActions.pveKeyDescriptionBefore")} {pveMatchInspect.name} {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "} + {pveMatchInspect.path}. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
    @@ -2122,14 +2146,14 @@ function InspectModal({ disabled={importing} className="!bg-emerald-600 hover:!bg-emerald-700 !text-white h-6 text-[10.5px] px-2" > - Use this key + {t("backup.keyfileActions.useThisKey")}
)}
-
— or —
+
{t("backup.common.or")}
- + setImportPath(e.target.value)} disabled={importing || !!importFile} - placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" + placeholder={t("backup.placeholders.keyfilePath")} className="h-8 text-[11px] font-mono" />
@@ -2171,7 +2195,7 @@ function InspectModal({ ) : ( )} - Import keyfile + {t("backup.actions.importKeyfile")}
@@ -2190,38 +2214,38 @@ function InspectModal({ onClick={beginRestore} disabled={restorePreparing || needsKeyfile} className="bg-green-600 hover:bg-green-700 text-white disabled:opacity-50" - title={needsKeyfile ? "Import the encryption keyfile above to enable Restore" : "Restore this snapshot to the current host (Complete or Custom by paths)"} + title={needsKeyfile ? t("backup.archives.importKeyToRestoreTitle") : t("backup.archives.restoreTitle")} > {restorePreparing ? ( ) : ( )} - Restore + {t("backup.actions.restore")}
@@ -2268,7 +2292,7 @@ function InspectModal({ - {kf ? "Restore blocked — encrypted backup" : "Restore preparation failed"} + {kf ? t("backup.restore.blockedEncryptedTitle") : t("backup.restore.preparationFailedTitle")} {!kf && ( @@ -2278,7 +2302,7 @@ function InspectModal({ {kf && }
- +
@@ -2346,11 +2370,11 @@ function InspectModal({ }} scriptPath="/usr/local/share/proxmenux/scripts/backup_restore/restore/monitor_apply.sh" scriptName="monitor_apply" - title={`Restore — ${restoreTerminal.mode === "full" ? "Complete" : "Custom by paths"}`} + title={t("backup.restore.terminalTitle", { mode: restoreTerminal.mode === "full" ? t("backup.restore.complete") : t("backup.restore.customByPaths") })} description={ restoreTerminal.mode === "custom" - ? `${restoreTerminal.paths.length} path(s) selected` - : "Complete restore — applies the whole backup" + ? t("backup.restore.pathsSelected", { count: restoreTerminal.paths.length }) + : t("backup.restore.completeDescription") } params={{ EXECUTION_MODE: "web", @@ -2370,22 +2394,22 @@ function InspectModal({ - Delete {backendLabel} backup + {t("backup.archives.deleteBackendTitle", { backend: backendLabel })} {archive?.source === "local" - ? "Removes the archive, its sidecar JSON and the matching run log. The action is permanent — restore needs an off-host copy." + ? t("backup.archives.deleteLocalDescription") : archive?.source === "pbs" - ? `Forgets this snapshot from the PBS repository "${remoteArc?.repo_name ?? ""}". The action is permanent — PBS GC may reclaim the underlying chunks at the next garbage-collection run.` - : `Deletes this archive from the Borg repository "${remoteArc?.repo_name ?? ""}". The action is permanent — Borg compacts the freed space at the next prune.`} + ? t("backup.archives.deletePbsDescription", { repo: remoteArc?.repo_name ?? "" }) + : t("backup.archives.deleteBorgDescription", { repo: remoteArc?.repo_name ?? "" })} -
+
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
@@ -2405,7 +2429,7 @@ function InspectModal({ - Run log + {t("backup.logs.runLog")} {archiveLog?.log_path} @@ -2415,7 +2439,7 @@ function InspectModal({ {archiveLog?.content ?? ""}
- +
@@ -2434,25 +2458,26 @@ function ManifestSummary({ storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } } } }) { + const t = useT() const sh = manifest.source_host const zfsCount = manifest.storage_inventory?.zfs_pools?.length ?? 0 const lvmCount = manifest.storage_inventory?.lvm?.vgs?.length ?? 0 return (
- } label="Source host" value={sh.hostname} /> - - - - - - - - + } label={t("backup.fields.sourceHost")} value={sh.hostname} /> + + + + + + + +
{manifest.proxmenux_installed_components.length > 0 && (
-
ProxMenux components at backup time:
+
{t("backup.manifest.componentsAtBackup")}
{manifest.proxmenux_installed_components.map((c) => ( @@ -2486,13 +2511,14 @@ function Field({ icon, label, value, mono, labelClassName }: { icon?: React.Reac // `key=val, key=val…` string which read like a config file. This view // drops zero-valued entries and presents what survives as ordered chips. function RetentionDisplay({ retention }: { retention: Record }) { + const t = useT() const order: Array<[string, string]> = [ - ["keep_last", "last"], - ["keep_hourly", "hourly"], - ["keep_daily", "daily"], - ["keep_weekly", "weekly"], - ["keep_monthly", "monthly"], - ["keep_yearly", "yearly"], + ["keep_last", "backup.retention.last"], + ["keep_hourly", "backup.retention.hourly"], + ["keep_daily", "backup.retention.daily"], + ["keep_weekly", "backup.retention.weekly"], + ["keep_monthly", "backup.retention.monthly"], + ["keep_yearly", "backup.retention.yearly"], ] const items = order .map(([k, lbl]) => { @@ -2506,10 +2532,10 @@ function RetentionDisplay({ retention }: { retention: Record
- retention + {t("backup.retention.title")}
{items.length === 0 ? ( -
No retention rules — backups will accumulate.
+
{t("backup.retention.noneAccumulate")}
) : (
{items.map((it) => ( @@ -2517,7 +2543,7 @@ function RetentionDisplay({ retention }: { retention: Record - {it.label} + {t(it.label)} {it.value} ))} @@ -2532,10 +2558,11 @@ function RetentionDisplay({ retention }: { retention: Record
- paths + {t("backup.paths.title")} ({paths.length})
@@ -2555,6 +2582,7 @@ function PathsDisplay({ paths }: { paths: string[] }) { // ── Preflight report view ──────────────────────────────────── function PreflightReportView({ report }: { report: PreflightReport }) { + const t = useT() const { summary, checks } = report.preflight const passColor = "text-emerald-500" const warnColor = "text-amber-500" @@ -2566,19 +2594,19 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
- {summary.pass} pass + {t("backup.preflight.passCount", { count: summary.pass })} - {summary.warn} warn + {t("backup.preflight.warnCount", { count: summary.warn })} - {summary.fail} fail + {t("backup.preflight.failCount", { count: summary.fail })} {summary.fail > 0 && ( - --apply would be refused + {t("backup.preflight.applyWouldBeRefused")} )}
@@ -2609,20 +2637,20 @@ function PreflightReportView({ report }: { report: PreflightReport }) { {/* Storage / network counts */}
-
Storage [in mode: {String(report.storage.in_selected_mode)}]
+
{t("backup.preflight.storageInMode", { mode: String(report.storage.in_selected_mode) })}
- {report.storage.zfs.length} ZFS pool(s) · - {" "}{report.storage.lvm.length} LVM VG(s) · - {" "}{report.storage.pve_storage.length} PVE storage(s) + {t("backup.preflight.zfsPoolsCount", { count: report.storage.zfs.length })} · + {" "}{t("backup.preflight.lvmVgsCount", { count: report.storage.lvm.length })} · + {" "}{t("backup.preflight.pveStorageCount", { count: report.storage.pve_storage.length })}
-
Network [in mode: {String(report.network.in_selected_mode)}]
+
{t("backup.preflight.networkInMode", { mode: String(report.network.in_selected_mode) })}
- {report.network.keep.length} keep · - {" "}{report.network.remap.length} remap · - {" "}{report.network.orphan.length} orphan · - {" "}{report.network.new.length} new + {t("backup.preflight.keepCount", { count: report.network.keep.length })} · + {" "}{t("backup.preflight.remapCount", { count: report.network.remap.length })} · + {" "}{t("backup.preflight.orphanCount", { count: report.network.orphan.length })} · + {" "}{t("backup.preflight.newCount", { count: report.network.new.length })}
@@ -2630,7 +2658,7 @@ function PreflightReportView({ report }: { report: PreflightReport }) { {/* Driver plan */} {report.driver_reinstall.plan.length > 0 && (
-
Driver reinstall plan ({report.driver_reinstall.plan.length})
+
{t("backup.preflight.driverReinstallPlan", { count: report.driver_reinstall.plan.length })}
{report.driver_reinstall.plan.map((p) => (
@@ -2768,6 +2796,7 @@ function CreateJobDialog({ onCreated: () => void editingJobId?: string | null }) { + const { t, language } = useI18n() const isEdit = !!editingJobId const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1) const [jobId, setJobId] = useState("") @@ -3168,12 +3197,12 @@ function CreateJobDialog({ // or a typed absolute path — mirrors the shell wizard. // Existing + PVE auto-detect uses source=pve-storage. if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) { - setError("Pick a keyfile file or enter an absolute path on this host.") + setError(t("backup.errors.pickKeyfile")) setSubmitting(false) return } if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) { - setError("Recovery passphrases don't match.") + setError(t("backup.errors.passphrasesDoNotMatch")) setSubmitting(false) return } @@ -3213,9 +3242,9 @@ function CreateJobDialog({ } catch (e) { const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } const detail = err.body?.tool_output - ? `${err.message}\n\nproxmox-backup-client output:\n${err.body.tool_output}` + ? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}` : err.message - setError(`Encryption setup failed: ${detail || String(e)}`) + setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) })) setPbsImportBusy(false) setSubmitting(false) return @@ -3289,7 +3318,7 @@ function CreateJobDialog({ return ( { if (!v) onClose() }}> - + {isEdit ? ( @@ -3297,10 +3326,10 @@ function CreateJobDialog({ ) : ( )} - {isEdit ? "Edit scheduled backup job" : "Create scheduled backup job"} + {isEdit ? t("backup.jobs.editScheduledJob") : t("backup.jobs.createScheduledJob")} - Step {step} of 5 · {mode === "attach" ? "Attached to PVE vzdump" : "Standalone scheduled job"} + {t("backup.jobs.stepOf", { step, total: 5 })} · {mode === "attach" ? t("backup.jobs.attachedToPveVzdump") : t("backup.jobs.standaloneScheduledJob")} @@ -3319,52 +3348,52 @@ function CreateJobDialog({ {step === 1 && (
- + setJobId(e.target.value)} disabled={isEdit} className="font-mono mt-1" - placeholder="my-host-backup" + placeholder="moja-zaloha-servera" />

{isEdit - ? "The job name can't be changed. Delete and recreate the job if you want to rename it." - : <>A short name to identify this job in the list, logs, and shell menu. Letters, digits, _ and - only (no spaces or accents).} + ? t("backup.jobs.jobNameLocked") + : <>{t("backup.jobs.jobNameHelpBefore")} _ {t("backup.jobs.jobNameHelpAnd")} -. {t("backup.jobs.jobNameHelpAfter")}}

{!idValid && jobId.length > 0 && !isEdit && ( -

Invalid characters. Use letters, digits, _ or -.

+

{t("backup.jobs.invalidJobName")}

)}
- + {isEdit && (

- You can change where the backup is sent. The destination of the new option is set on Step 5. + {t("backup.jobs.backendEditHelp")}

)}
{(["pbs", "local", "borg"] as const).map((b) => { const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive const desc = b === "pbs" - ? "Proxmox Backup Server. Incremental, encrypted, dedup." + ? t("backup.backends.pbsDescription") : b === "local" - ? "tar.zst archive into a local directory or mounted disk." - : "Borg repo over SSH or on a local/USB disk (timer only)." + ? t("backup.backends.localDescription") + : t("backup.backends.borgDescriptionTimerOnly") return ( @@ -3379,25 +3408,25 @@ function CreateJobDialog({ {step === 2 && (
- +

{backend === "borg" - ? "Borg backups only run on their own timer — they're not produced by PVE vzdump." - : "Either run on a schedule you define here, or hook into an existing PVE vzdump job and inherit its schedule + retention."} + ? t("backup.schedule.borgTimerOnly") + : t("backup.schedule.modeHelp")}

@@ -3431,19 +3460,19 @@ function CreateJobDialog({ {step === 3 && mode === "attach" && (
- +

- The host config backup will fire on every job-end of this job. + {t("backup.jobs.parentPveJobHelpBefore")} job-end {t("backup.jobs.parentPveJobHelpAfter")}

{compatibleJobs.length === 0 ? (
- No compatible PVE vzdump job + {t("backup.jobs.noCompatiblePveJob")}

- No PVE vzdump job currently uses a {backend} storage. Create one in Datacenter → Backup first, then come back here to attach. + {t("backup.jobs.noCompatiblePveJobDescriptionBefore")} {backend === "pbs" ? "PBS" : backend === "borg" ? "Borg" : t("backup.backends.local")} {t("backup.jobs.noCompatiblePveJobDescriptionMiddle")} Datacenter → Backup {t("backup.jobs.noCompatiblePveJobDescriptionAfter")}

) : ( @@ -3453,20 +3482,20 @@ function CreateJobDialog({ key={j.id} type="button" onClick={() => setPveJobId(j.id)} - className={`w-full text-left p-3 rounded-md border ${pveJobId === j.id ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`} + className={`w-full text-left p-3 rounded-md border ${pveJobId === j.id ? "border-blue-500 bg-blue-500/5" : "border-border bg-card"} hover:bg-white/5 transition-colors`} >
{j.id} {!j.enabled && ( - disabled + {t("status.disabled")} )}
- storage: {j.storage} - schedule: {j.schedule || "—"} - retention: {j.prune || "—"} + {t("backup.fields.storageLabel")} {j.storage} + {t("backup.fields.scheduleLabel")} {j.schedule || "—"} + {t("backup.fields.retentionLabel")} {j.prune || "—"}
))} @@ -3478,9 +3507,9 @@ function CreateJobDialog({ {step === 3 && mode === "new" && (
- +

- Pick how often this backup runs. The expression is built and validated for you. + {t("backup.schedule.pickFrequency")}

{scheduleType === "daily" && (
- + - +

- The job fires every hour at this minute. 0 = on the hour, 30 = half past, etc. + {t("backup.schedule.hourlyHelpBefore")} 0 {t("backup.schedule.hourlyHelpMiddle")} 30 {t("backup.schedule.hourlyHelpAfter")}

)} @@ -3533,7 +3562,7 @@ function CreateJobDialog({ {scheduleType === "weekly" && (
- +
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => { const active = scheduleWeekdays.has(d) @@ -3552,20 +3581,20 @@ function CreateJobDialog({ className={`px-3 py-1.5 rounded-md text-xs font-mono border transition-colors ${ active ? "border-blue-500 bg-blue-500/10 text-blue-400" - : "border-border bg-background/40 text-muted-foreground hover:bg-white/5" + : "border-border bg-card text-muted-foreground hover:bg-white/5" }`} > - {d} + {t(`backup.weekdays.short.${d.toLowerCase()}`)} ) })}
{scheduleWeekdays.size === 0 && ( -

Pick at least one day.

+

{t("backup.schedule.pickAtLeastOneDay")}

)}
- +
- +

- If the chosen day doesn't exist in a given month (e.g. 31 in February), systemd skips that month. + {t("backup.schedule.monthlySkipHelp")}

- + - + setScheduleAdvanced(e.target.value)} className="font-mono mt-1" - placeholder="*-*-* 02:00, Mon..Fri *-*-* 04:00, daily, ..." + placeholder={t("backup.placeholders.onCalendar")} />

- Any expression accepted by systemd-analyze calendar. See man systemd.time for the full grammar. + {t("backup.schedule.advancedHelpBefore")} systemd-analyze calendar. {t("backup.schedule.advancedHelpMiddle")} man systemd.time {t("backup.schedule.advancedHelpAfter")}

)} {/* Live preview from the backend */} -
-
Preview
+
+
{t("backup.schedule.preview")}
- Expression: + {t("backup.fields.expressionLabel")} {onCalendar}
{calendarPreview ? ( @@ -3635,41 +3664,41 @@ function CreateJobDialog({ <> {calendarPreview.normalized && calendarPreview.normalized !== onCalendar && (
- Normalized: + {t("backup.fields.normalizedLabel")} {calendarPreview.normalized}
)} {calendarPreview.next_elapse && (
- Next run: - {calendarPreview.next_elapse} + {t("backup.jobs.nextRunLabel")} + {formatCalendarPreview(calendarPreview.next_elapse, language, t)} {calendarPreview.from_now && ( - ({calendarPreview.from_now}) + ({formatCalendarDistance(calendarPreview.from_now, language)}) )}
)} ) : (
- Invalid: {calendarPreview.error} + {t("backup.validation.invalidLabel")} {calendarPreview.error}
) ) : ( -
checking…
+
{t("backup.common.checking")}
)}
- -

Zero disables that bucket.

+ +

{t("backup.retention.zeroDisables")}

{[ - { id: "keep-last", lbl: "keep-last", v: keepLast, set: setKeepLast }, - { id: "keep-hourly", lbl: "keep-hourly", v: keepHourly, set: setKeepHourly }, - { id: "keep-daily", lbl: "keep-daily", v: keepDaily, set: setKeepDaily }, - { id: "keep-weekly", lbl: "keep-weekly", v: keepWeekly, set: setKeepWeekly }, - { id: "keep-monthly", lbl: "keep-monthly", v: keepMonthly, set: setKeepMonthly }, - { id: "keep-yearly", lbl: "keep-yearly", v: keepYearly, set: setKeepYearly }, + { id: "keep-last", lbl: t("backup.retention.keepLast"), v: keepLast, set: setKeepLast }, + { id: "keep-hourly", lbl: t("backup.retention.keepHourly"), v: keepHourly, set: setKeepHourly }, + { id: "keep-daily", lbl: t("backup.retention.keepDaily"), v: keepDaily, set: setKeepDaily }, + { id: "keep-weekly", lbl: t("backup.retention.keepWeekly"), v: keepWeekly, set: setKeepWeekly }, + { id: "keep-monthly", lbl: t("backup.retention.keepMonthly"), v: keepMonthly, set: setKeepMonthly }, + { id: "keep-yearly", lbl: t("backup.retention.keepYearly"), v: keepYearly, set: setKeepYearly }, ].map((row) => (
@@ -3692,33 +3721,33 @@ function CreateJobDialog({ {step === 4 && (
- +
{profileMode === "custom" && (
- + {defaultPaths.map((p) => (