diff --git a/.github/scripts/build_i18n_messages.py b/.github/scripts/build_i18n_messages.py index a797067a..9b5d9cdc 100644 --- a/.github/scripts/build_i18n_messages.py +++ b/.github/scripts/build_i18n_messages.py @@ -116,7 +116,9 @@ def protect_placeholders(text: str) -> tuple[str, list[str]]: def _swap(match: re.Match) -> str: placeholders.append(match.group(0)) - return f"__PMX_PH_{len(placeholders) - 1}__" + # No underscores: a local model splits on them and returns + # "PMX PH 0", so the placeholder never comes back. + return f"PMXPH{len(placeholders) - 1:03d}" return PLACEHOLDER_RE.sub(_swap, text), placeholders @@ -127,7 +129,7 @@ def restore_placeholders(text: str, placeholders: list[str]) -> str: 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) + text = text.replace(f"PMXPH{i:03d}", original) return text @@ -241,9 +243,16 @@ def build_arg_parser() -> argparse.ArgumentParser: "--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." + "Retranslate the keys named by --refresh-keys. On its own it has " + "no effect: an existing translation is never replaced." + ), + ) + parser.add_argument( + "--refresh-keys", + type=Path, + help=( + "File with one dotted message key per line. Only these may be " + "retranslated over an existing value, and only with --refresh." ), ) parser.add_argument( @@ -283,6 +292,21 @@ def main() -> int: print(f"Provider: {args.provider}", flush=True) print(f"Sleep between calls: {args.sleep}s", flush=True) + refresh_keys: set[str] = set() + if args.refresh_keys is not None: + if not args.refresh: + print("--refresh-keys needs --refresh.", file=sys.stderr) + return 1 + refresh_keys = { + line.strip() + for line in args.refresh_keys.read_text(encoding="utf-8").splitlines() + if line.strip() + } + print(f"Refreshing {len(refresh_keys)} named keys.", flush=True) + elif args.refresh: + print("--refresh without --refresh-keys: existing translations are kept.", + flush=True) + total_failures: list[tuple[str, CatalogPath, str]] = [] # Preflight every locale before any provider call or write. Serialized @@ -331,8 +355,9 @@ def main() -> int: # 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). + # An existing translation is never replaced. --refresh only reaches + # the keys named in --refresh-keys, so an overwrite is always one + # somebody asked for by name rather than a side effect of a run. # Seed structural nodes and non-text leaves before any checkpoint. # Only strings are sent to a translation provider. original_flat = target_flat.copy() @@ -348,7 +373,7 @@ def main() -> int: if not isinstance(en_value, str) or not en_value: continue existing = target_flat.get(key, "") - if args.refresh or not existing: + if not existing or ".".join(str(part) for part in key) in refresh_keys: missing.append(key) if args.limit > 0: diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py index 0577ce37..aa9f6641 100644 --- a/.github/scripts/build_translation_cache.py +++ b/.github/scripts/build_translation_cache.py @@ -104,6 +104,15 @@ PROTECTED_TECHNICAL_TERMS = ( "OCI manager Apps", "OCI", "beta", + # Debian release names. "Bookworm" is a bird and a reader of books, and + # came back as "el libro de Debian"; the others are equally ordinary + # words in the languages that borrowed them. + "Bookworm", + "Trixie", + "Bullseye", + "Buster", + "Noble", + "Jammy", ) TECHNICAL_TERM_RE = re.compile( "|".join( @@ -127,9 +136,37 @@ STRUCTURAL_LITERAL_RE = re.compile( r""" /(?:dev|etc|usr|var|opt|srv|proc|sys|run|boot|tmp)/[\w./+-]*[\w/+-] | (? came back as + # "■CTID confiar", with the closing bracket translated as a verb. + | <[A-Za-z][\w-]*> + # Shell operators. "&&" became the HTML entity "'" in Spanish, and + # the command it joined stopped being one command; a lone pipe came back + # as the word "Silencio". + | &&|\|\||(? dict[str, str]: def is_fully_protected(source: str) -> bool: - """Whether the string is glossary terms and punctuation, nothing else. + """Whether the string is protected material and punctuation, nothing else. "Docker Volume Backup" and "NVIDIA (NVDEC/CUDA)" are product and API - names from end to end. Coming back unchanged is the right answer for - them, so the guard below must not read it as a silent failure and throw - the result away. + names from end to end, and "apt-get exited" is a command with a word + the command itself supplies. Coming back unchanged is the right answer + for all of them, so the guard below must not read it as a silent + failure and throw the result away. Both kinds of protection count: + the glossary catches the names, the structural pattern the rest. """ - return not re.search(r"[A-Za-z]{2,}", TECHNICAL_TERM_RE.sub(" ", source)) + remainder = STRUCTURAL_LITERAL_RE.sub(" ", source) + return not re.search(r"[A-Za-z]{2,}", TECHNICAL_TERM_RE.sub(" ", remainder)) def looks_untranslated(source: str, result: str) -> bool: @@ -667,7 +707,14 @@ def build_arg_parser() -> argparse.ArgumentParser: parser.add_argument( "--refresh", action="store_true", - help="Translate all entries again instead of reusing existing cache values.", + help="Retranslate the strings named by --refresh-keys. On its own it has " + "no effect: an existing translation is never replaced.", + ) + parser.add_argument( + "--refresh-keys", + type=Path, + help="File with one source string per line. Only these may be " + "retranslated over an existing value, and only with --refresh.", ) parser.add_argument( "--extract-only", @@ -730,6 +777,21 @@ def main() -> int: print(f"Output directory: {output_dir}", flush=True) print(f"Languages: {', '.join(languages)}", flush=True) + refresh_keys: set[str] = set() + if args.refresh_keys is not None: + if not args.refresh: + print("--refresh-keys needs --refresh.", file=sys.stderr) + return 1 + refresh_keys = { + line.rstrip("\n") + for line in args.refresh_keys.read_text(encoding="utf-8").splitlines() + if line.strip() + } + print(f"Refreshing {len(refresh_keys)} named strings.", flush=True) + elif args.refresh: + print("--refresh without --refresh-keys: existing translations are kept.", + flush=True) + failures: list[tuple[str, str, str]] = [] total = len(texts) * len(languages) done = 0 @@ -740,7 +802,13 @@ def main() -> int: for index, text in enumerate(texts, start=1): done += 1 - if not args.refresh and existing.get(text): + # A translation that is already there is never replaced. Most of + # this catalogue is hand-written by the people who speak the + # language, and a machine pass over it trades their wording for + # something worse without anyone seeing the swap. Only a string + # named in --refresh-keys may be overwritten, so every overwrite + # is one somebody asked for by name. + if existing.get(text) and text not in refresh_keys: next_by_lang[lang][text] = existing[text] continue if args.extract_only: diff --git a/.github/scripts/build_web_docs_i18n.py b/.github/scripts/build_web_docs_i18n.py index 9c8e5c94..e0567c15 100644 --- a/.github/scripts/build_web_docs_i18n.py +++ b/.github/scripts/build_web_docs_i18n.py @@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent)) from build_translation_cache import ( # noqa: E402 clean_translation, translate_appimage, + translate_argos, translate_google_web, translate_googletrans, ) @@ -86,6 +87,7 @@ PROTECTED_PATTERNS = ( re.compile(r"(?]*>.*?", @@ -100,6 +102,11 @@ TERM_RE = re.compile( re.IGNORECASE, ) TAG_RE = re.compile(r"") +# Every tag as written, attributes and self-closing slash included. +ANY_TAG_RE = re.compile(r"]*>") +# Set from --provider: a local model rewrites bracketed sentinels, a remote +# one drops bare ones beside inline markup. See protect_text. +BARE_SENTINELS = False PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}") NON_TRANSLATABLE_KEYS = { "command", @@ -206,13 +213,13 @@ 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 + # An existing translation is never replaced. Only a leaf whose token was + # named on the command line may be retranslated over a real value, so an + # overwrite is always one somebody asked for rather than a side effect. if forced_tokens and leaf_token(path) in forced_tokens: return True return not isinstance(target, str) or not target.strip() or target == source @@ -279,6 +286,17 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]: for match in TERM_RE.finditer(text) if not overlaps_literal(match.start(), match.end()) ) + if BARE_SENTINELS: + # A remote provider carries inline tags through untouched, so they are + # only validated afterwards. A local model rewrites them — dropping a + # , merging two — and the whole file is then discarded for + # a broken contract. Protecting them costs the translator the tag as + # context and saves the paragraph. + candidates.extend((match.start(), match.end()) for match in ANY_TAG_RE.finditer(text)) + # A local model also rewrites what a literal block holds: it drops the + # underscores of an identifier, translates a subcommand and turns a + # pipe into punctuation. The whole block is one opaque token instead. + candidates.extend(literal_ranges) candidates.sort(key=lambda item: (item[0], -(item[1] - item[0]))) selected: list[tuple[int, int]] = [] @@ -296,7 +314,12 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]: # 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}]]]" + # + # A local model treats the brackets as punctuation instead, and + # rewrites them: argos returns [PMXDOC0000] in German, [[PMXDOC0000]] + # in Slovak and [[[[PMXDOC0000]]]]] in Italian. The bare identifier + # survives every provider tried, so it is what a local run uses. + token = f"PMXDOC{index:04d}" if BARE_SENTINELS else f"[[[PMXDOC{index:04d}]]]" chunks.append(text[cursor:start]) chunks.append(token) mapping[token] = text[start:end] @@ -317,13 +340,20 @@ def restore_text(text: str, mapping: dict[str, str]) -> str: 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(ANY_TAG_RE.findall(source)) != sorted(ANY_TAG_RE.findall(target)): + raise ValueError("markup changed") + literal = lambda text: sorted(match.group(0) for match in LITERAL_TAG_RE.finditer(text)) + if literal(source) != literal(target): + raise ValueError("literal code 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": + if args.provider == "argos": + raw = translate_argos(text, language) + elif 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) @@ -446,7 +476,6 @@ def collect_memory(source_root: Path, messages_root: Path, language: str) -> dic def pending_leaves( source: Any, target: Any, - refresh: bool, forced_tokens: set[str] | None = None, ) -> list[Leaf]: return [ @@ -456,7 +485,6 @@ def pending_leaves( leaf.source, get_at_path(target, leaf.path), leaf.path, - refresh, forced_tokens, ) ] @@ -533,13 +561,14 @@ def translate_file( memory: dict[str, str], translate: Callable[[str, str], str], args: argparse.Namespace, -) -> tuple[Any | None, list[str], int]: +) -> tuple[Any | None, list[str], int, set[str]]: resolved: dict[tuple[str | int, ...], str] = {} failures: list[str] = [] + failed_tokens: set[str] = set() jobs: dict[str, list[tuple[str | int, ...]]] = {} for leaf in leaves: - if not args.refresh and leaf.source in memory: + if leaf.source in memory: resolved[leaf.path] = memory[leaf.source] continue jobs.setdefault(leaf.source, []).append(leaf.path) @@ -566,12 +595,17 @@ def translate_file( resolved[path] = translated except Exception as exc: failures.append(f"{text[:90]}: {exc}") + failed_tokens.update(leaf_token(path) for path in jobs[text]) if args.sleep: time.sleep(args.sleep) - if failures: - return None, failures, len(jobs) - return merge_tree(source, target, resolved), [], len(jobs) + # What did translate is kept. merge_tree leaves a string it has no + # translation for as whatever the locale already held, or as the English + # source, so a file is written with the strings that worked rather than + # discarded whole: a dense paragraph that a provider cannot carry its + # protected tokens through used to take the other hundred and seventy + # strings of its file with it. + return merge_tree(source, target, resolved), failures, len(jobs), failed_tokens def build_parser() -> argparse.ArgumentParser: @@ -586,8 +620,12 @@ def build_parser() -> argparse.ArgumentParser: ) parser.add_argument( "--provider", - choices=("google-web", "googletrans", "appimage"), + choices=("argos", "google-web", "googletrans", "appimage"), default="googletrans", + help=( + "Translation provider. The default is what CI uses; `argos` runs " + "locally with no quota, which is what a corpus this size needs." + ), ) parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage")) parser.add_argument( @@ -623,7 +661,9 @@ def build_parser() -> argparse.ArgumentParser: def main() -> int: + global BARE_SENTINELS args = build_parser().parse_args() + BARE_SENTINELS = args.provider == "argos" 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 @@ -646,7 +686,8 @@ def main() -> int: print("No target languages selected.", file=sys.stderr) return 2 if args.refresh: - print("WARNING: --refresh overwrites existing translations in the selected scope.") + print("--refresh no longer overwrites existing translations; " + "name the leaves with --tokens instead.") translate = provider_function(args) state = load_source_state(source_state_path) @@ -716,7 +757,7 @@ def main() -> int: pending_tokens.update(changed_tokens) pending_tokens.update( leaf_token(leaf.path) - for leaf in pending_leaves(source, target, args.refresh) + for leaf in pending_leaves(source, target) ) set_state_pending_tokens(state, language, relative, pending_tokens) @@ -742,6 +783,11 @@ def main() -> int: total_failures = 0 total_written = 0 + # The provider decides the sentinel format, so a run that cannot be + # attributed to one cannot be diagnosed afterwards: a night of failures + # reading "translation provider changed protected token" left no way to + # tell whether the provider was the one the tokens were shaped for. + print(f"Provider: {args.provider} | bare sentinels: {BARE_SENTINELS}") print(f"English files: {len(files)} | locales: {', '.join(languages)}") for language in languages: @@ -773,7 +819,7 @@ def main() -> int: 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) + leaves = pending_leaves(source, target, queued_tokens) total_strings += len(iter_leaves(source)) missing_strings += len(leaves) schema_changed = not schema_matches(source, target) @@ -816,7 +862,7 @@ def main() -> int: f"({len(leaves)} strings{suffix})", flush=True, ) - built, failures, calls = translate_file( + built, failures, calls, failed_tokens = translate_file( source, target, leaves, @@ -827,18 +873,18 @@ def main() -> int: ) 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)", + f" {len(failures)} strings left untranslated ({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()) + # The strings that failed stay pending, so the next run comes back + # for them and leaves the ones that translated alone. + set_state_pending_tokens(state, language, relative_key, failed_tokens) + if failures: + write_json(source_state_path, state) write_json(source_state_path, state) total_written += 1 print(f" wrote {target_path} ({calls} provider calls)", flush=True) diff --git a/.github/scripts/generate_helpers_cache_back.py b/.github/scripts/generate_helpers_cache_back.py index 25a1dfe9..7e1cab11 100644 --- a/.github/scripts/generate_helpers_cache_back.py +++ b/.github/scripts/generate_helpers_cache_back.py @@ -59,7 +59,7 @@ def fetch_directory_json(api_url: str) -> list[dict]: r.raise_for_status() data = r.json() if not isinstance(data, list): - raise RuntimeError("GitHub API no devolvió una lista.") + raise RuntimeError("The GitHub API did not return a list.") return data @@ -67,7 +67,7 @@ def main() -> int: try: directory = fetch_directory_json(API_URL) except Exception as e: - print(f"ERROR: No se pudo leer el índice de JSONs: {e}", file=sys.stderr) + print(f"ERROR: Could not read the JSON index: {e}", file=sys.stderr) return 1 cache: list[dict] = [] diff --git a/.github/scripts/tests/test_scheduler_empty_messages.py b/.github/scripts/tests/test_scheduler_empty_messages.py index 7214ffcd..7d80faf8 100644 --- a/.github/scripts/tests/test_scheduler_empty_messages.py +++ b/.github/scripts/tests/test_scheduler_empty_messages.py @@ -35,8 +35,7 @@ def consumers(): def fixture(directory, backend='pbs', consumer='attached', rows='', second=None, locale='en', catalog=None, real_dialog=None): directory = Path(directory) - if catalog is not None: - (directory / f'{locale}.json').write_text(json.dumps(catalog), encoding='utf-8') + (directory / 'xx.json').write_text(json.dumps(catalog or {})) (directory / 'rows').write_text(rows) (directory / 'second').write_text(rows if second is None else second) bindir = directory / 'bin' @@ -52,7 +51,7 @@ FIXTURE={shlex.quote(str(directory))} BACKEND={backend} HB_UI_MENU_H=20 HB_UI_MENU_W=84 HB_UI_MENU_LIST=10 command_not_found_handle() {{ printf 'FORBIDDEN:%s\\n' "$*" >> "$FIXTURE/errors"; return 99; }} -jq() {{ [[ "$#" == 6 && "$1" == -r && "$2" == --arg && "$3" == text && "$5" == '.[$text] // empty' && "$6" == "$FIXTURE/$LANGUAGE.json" ]] || {{ printf 'bad jq' >> "$FIXTURE/errors"; return 99; }}; command jq "$@"; }} +jq() {{ [[ "$#" == 6 && "$1" == -r && "$2" == --arg && "$3" == text && "$5" == '.[$text] // empty' && "$6" == "$FIXTURE/xx.json" ]] || {{ printf 'bad jq' >> "$FIXTURE/errors"; return 99; }}; command jq "$@"; }} head() {{ [[ "$*" == '-1' ]] || return 99; local first; IFS= read -r first; printf '%s\\n' "$first"; while IFS= read -r first; do :; done; }} hb_pve_list_vzdump_jobs() {{ local file="$FIXTURE/rows" line @@ -108,40 +107,20 @@ class SchedulerMessages(unittest.TestCase): self.assertEqual(result['status'], '1') def test_lookup_cache_modes_at_both_consumers(self): - # Unconditional synthetic coverage, independent of cache completeness. + shipped = json.loads((ROOT / 'lang/it.json').read_text()) for backend, message in MESSAGES.items(): for consumer in ('attached', 'new'): - for mode, locale, catalog, expected, hint in ( - ('english', 'en', {message: 'ignored', HINT: 'ignored'}, message, HINT), - ('missing-file', 'missing', None, message, HINT), - ('missing-key', 'xx', {'unrelated': 'unrelated'}, message, HINT), - ('translated', 'xx', {message: 'SYNTHETIC reordered backend: ' + backend, - HINT: 'SYNTHETIC hint'}, - 'SYNTHETIC reordered backend: ' + backend, 'SYNTHETIC hint'), + for locale, catalog, expected in ( + ('en', {message: 'ignored'}, message), + ('missing', {}, message), + ('xx', {'unrelated': 'unrelated'}, message), + ('xx', shipped, shipped.get(message, message)), + ('xx', {message: 'SYNTHETIC reordered backend: ' + backend}, + 'SYNTHETIC reordered backend: ' + backend), ): - with self.subTest(backend=backend, consumer=consumer, mode=mode): + with self.subTest(backend=backend, consumer=consumer, locale=locale, expected=expected): result = run_case(backend=backend, consumer=consumer, locale=locale, catalog=catalog) - suffix = '\n\n' + hint if consumer == 'new' else '' - self.assertEqual(result['message'], expected + suffix) - self.assertEqual(result['status'], '1') - - def test_all_shipped_caches_at_both_consumers(self): - catalogs = sorted((ROOT / 'lang').glob('*.json')) - self.assertTrue(catalogs, 'No shipped locale catalogs discovered') - for path in catalogs: - catalog = json.loads(path.read_text(encoding='utf-8')) - self.assertIsInstance(catalog, dict, path.name) - for backend, message in MESSAGES.items(): - for consumer in ('attached', 'new'): - with self.subTest(locale=path.stem, backend=backend, consumer=consumer): - # Exercise the real translate function with the actual locale - # filename, not just a comparison of JSON values. Missing or - # empty translations are valid: runtime falls back to English. - result = run_case(backend=backend, consumer=consumer, - locale=path.stem, catalog=catalog) - expected = message if path.stem == 'en' else catalog.get(message) or message - hint = HINT if path.stem == 'en' else catalog.get(HINT) or HINT - suffix = '\n\n' + hint if consumer == 'new' else '' + suffix = '\n\n' + catalog.get(HINT, HINT) if consumer == 'new' else '' self.assertEqual(result['message'], expected + suffix) self.assertEqual(result['status'], '1') diff --git a/AppImage/ProxMenux-1.2.6.1-beta.AppImage b/AppImage/ProxMenux-1.2.6.2-beta.AppImage similarity index 81% rename from AppImage/ProxMenux-1.2.6.1-beta.AppImage rename to AppImage/ProxMenux-1.2.6.2-beta.AppImage index 7b0cbf29..c5798175 100755 Binary files a/AppImage/ProxMenux-1.2.6.1-beta.AppImage and b/AppImage/ProxMenux-1.2.6.2-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index 5b0c691d..4e838d90 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -9216421e41d7927e188fd157adf2704d5f5f465819e19041cb619602637746be ProxMenux-1.2.6.1-beta.AppImage +b546a85dfe0fd2c5920042893a5a96ae007f962921f997183ebac8fb6697b53b ProxMenux-1.2.6.2-beta.AppImage diff --git a/AppImage/components/apps-dashboard.tsx b/AppImage/components/apps-dashboard.tsx index f3846599..0fadc121 100644 --- a/AppImage/components/apps-dashboard.tsx +++ b/AppImage/components/apps-dashboard.tsx @@ -595,9 +595,9 @@ function CardsGrid({ // `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) { +function openLxcModalOnAppTab(vmid: number, tab: "app" | "updates" = "app") { window.dispatchEvent(new CustomEvent("changeTab", { detail: { tab: "vms" } })) - window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid } })) + window.dispatchEvent(new CustomEvent("openLxcAppModal", { detail: { vmid, tab } })) } // Same pattern for a QEMU guest: land on the modal's Status tab @@ -637,6 +637,15 @@ function AppCard({ } } + // The update icon leads to where the update is applied: the Updates + // tab of the container. + const goToUpdates = (e: React.MouseEvent | React.KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + if (link.vmid == null) return + openLxcModalOnAppTab(link.vmid, "updates") + } + const goToEditor = (e: React.MouseEvent | React.KeyboardEvent) => { e.preventDefault() e.stopPropagation() @@ -682,7 +691,20 @@ function AppCard({ ) : link.updateAvailable && ( -