From 8a5f7a4cb88ac33c4d2cd626ca06e1118654f7ab Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 09:08:52 +0200 Subject: [PATCH 1/2] i18n(lxc): localize update and application management flows --- AppImage/components/lxc-app-panel.tsx | 85 ++++--- AppImage/components/virtual-machines.tsx | 42 ++-- AppImage/messages/en/common.json | 55 ++++- AppImage/messages/sk/common.json | 277 ++++++++++++++++++++++- 4 files changed, 388 insertions(+), 71 deletions(-) diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index fd369eb8..37614303 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -275,7 +275,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { setSuggestions(s) } catch { /* non-fatal */ } } catch (e: any) { - setError(e?.message || "Could not load app configuration") + setError(e?.message || t("vmLxc.appEditor.loadFailed")) } finally { setLoading(false) } @@ -492,7 +492,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { setSidecar(r) onChange?.() } catch (e: any) { - setError(e?.message || "Check failed") + setError(e?.message || t("vmLxc.appEditor.checkFailed")) } finally { setBusyAppId(null) } @@ -508,7 +508,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { await load() onChange?.() } catch (e: any) { - setError(e?.message || "Delete failed") + setError(e?.message || t("vmLxc.appEditor.deleteFailed")) } finally { setBusyAppId(null) } @@ -532,7 +532,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { }) setSidecar(r) } catch (e: any) { - setError(e?.message || "Could not dismiss detection") + setError(e?.message || t("vmLxc.appEditor.dismissFailed")) await load() // resync on failure } } @@ -554,7 +554,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { }) setSidecar(r) } catch (e: any) { - setError(e?.message || "Could not restore detection") + setError(e?.message || t("vmLxc.appEditor.restoreFailed")) await load() } } @@ -571,7 +571,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { // in an Alpine CT). When we add more OCI apps we'll swap this to // a lookup keyed on managed_oci_app_id → catalog metadata. const isSecureGateway = managed.managed_oci_app_id === "secure-gateway" - const displayName = isSecureGateway ? "Secure Gateway" : (managed.name || "Managed app") + const displayName = isSecureGateway ? "Secure Gateway" : (managed.name || t("vmLxc.appEditor.managedApp")) const displaySubtitle = isSecureGateway ? "Tailscale VPN Gateway" : "" // Two variants — the selfh.st mark (dark logo on light bg) reads // better in light mode; the homarr-labs "-light" variant (light @@ -585,7 +585,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { : "" const upstreamName = isSecureGateway ? "Tailscale" : "" const repo = isSecureGateway ? "tailscale/tailscale" : "" - const methodLine = isSecureGateway ? "apk · tailscale · managed" : "managed" + const methodLine = isSecureGateway ? `apk · tailscale · ${t("vmLxc.appEditor.managedStatus")}` : t("vmLxc.appEditor.managedStatus") const hasUpdate = managed.update_available === true const upToDate = managed.update_available === false && !!managed.installed_version const showVersions = !!(managed.installed_version || managed.latest_version || repo) @@ -646,7 +646,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{methodLine}
{managed.checked_at && (
- Checked {new Date(managed.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" })} + {t("vmLxc.appEditor.checkedAt", { date: new Date(managed.checked_at).toLocaleString([], { dateStyle: "short", timeStyle: "short" }) })}
)} {repo && ( @@ -693,7 +693,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { {repo && (
- Latest upstream + {t("vmLxc.appEditor.latestUpstream")}
{managed.latest_version || {t("vmLxc.appEditor.checkingStatus")}} @@ -729,7 +729,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { return (
- Loading applications… + {t("vmLxc.appEditor.loadingApplications")}
) } @@ -798,7 +798,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { onChange={(e) => { setName(e.target.value); setPickerOpen(true) }} onFocus={() => setPickerOpen(true)} onBlur={() => setTimeout(() => setPickerOpen(false), 150)} - placeholder={suggestions?.name_suggestion || "Type to search 700+ apps, or type your own"} + placeholder={suggestions?.name_suggestion || t("vmLxc.appEditor.nameSearchPlaceholder")} maxLength={64} autoComplete="off" /> @@ -817,7 +817,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { return (
- {matches.length === 20 ? t("vmLxc.appEditor.top20Matches") : `${matches.length} match${matches.length === 1 ? "" : "es"}`} + {matches.length === 20 ? t("vmLxc.appEditor.top20Matches") : t("vmLxc.appEditor.matchCount", { count: matches.length })}
{matches.map((c) => (
{c.slug} - {c.default_port ? ` · port ${c.default_port}` : ""} - {c.has_tracking ? " · tracking available" : ""} + {c.default_port ? ` · ${t("vmLxc.appEditor.catalogPort", { port: c.default_port })}` : ""} + {c.has_tracking ? ` · ${t("vmLxc.appEditor.trackingAvailable")}` : ""}
@@ -894,7 +894,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { app card header. */}
@@ -928,7 +928,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { {suggestable.length > 0 && (
- Ports detected in the container — click to add: + {t("vmLxc.appEditor.detectedPorts")} {suggestable.map((p) => (
)} @@ -1025,15 +1024,15 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { ) : ( )} - Track upstream version (optional) + {t("vmLxc.appEditor.trackUpstream")} {!showAdvanced && !method && ( - Off — link only + {t("vmLxc.appEditor.trackOff")} )} {!showAdvanced && method && ( - On · {method} + {t("vmLxc.appEditor.trackOn", { method })} )} @@ -1041,11 +1040,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { {showAdvanced && (

- Optional. When enabled, ProxMenux checks the installed - version inside the container and compares it against - a GitHub repo — you'll see "update available" and get - an optional notification. Skip this if you only want - a clickable link. + {t("vmLxc.appEditor.trackHelp")}

@@ -1239,7 +1234,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { className="font-mono text-xs" />
- Runs argv-style inside the CT as root — never through a shell. You are responsible for what the command does. Use installed_regex below to extract the version from the output. + {t("vmLxc.appEditor.commandSafetyHelp")}
@@ -1266,7 +1261,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { className="font-mono text-xs" />
- Type the version you have installed. If repo is set below, ProxMenux still checks upstream and notifies on updates — after upgrading the app, come back here and update this string. + {t("vmLxc.appEditor.manualVersionHelp")}
)} @@ -1314,7 +1309,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
- How ProxMenux finds the latest version to compare against your installed one. + {t("vmLxc.appEditor.upstreamHelp")}
@@ -1329,7 +1324,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { placeholder={t("vmLxc.appEditor.githubRepoPlaceholder")} />
- Public GitHub repository where releases or tags are published. + {t("vmLxc.appEditor.githubRepoHelp")}
@@ -1362,7 +1357,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { maxLength={512} />
- Public JSON endpoint that returns a version somewhere in the payload. + {t("vmLxc.appEditor.httpJsonHelp")}
@@ -1376,7 +1371,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { maxLength={128} />
- Dotted path with optional [N] array indices. + {t("vmLxc.appEditor.jsonPathHelp")}
@@ -1478,7 +1473,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { className="w-full sm:w-auto" > - Restore + {t("vmLxc.appEditor.restoreButton")}
@@ -1518,7 +1513,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { className="flex-[3] sm:flex-none bg-blue-500 hover:bg-blue-600 text-white" > - Register + {t("vmLxc.appEditor.registerButton")}
{tracking && ( @@ -1809,7 +1804,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { {busyAppId === app.id ? : } - Check + {t("vmLxc.appEditor.checkButton")} )}
@@ -1836,7 +1831,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { {apps.length > 0 && unregisteredDetected.length > 0 && (
- Also detected on this container + {t("vmLxc.appEditor.alsoDetectedContainer")}
{unregisteredDetected.map(renderDetectionChip)}
@@ -1857,10 +1852,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { disabled={editMode} > - Add another application + {t("vmLxc.appEditor.addAnotherApplication")} {hiddenDetections.length > 0 && ( - · {hiddenDetections.length} hidden + · {t("vmLxc.appEditor.hiddenSuffix", { count: hiddenDetections.length })} )} @@ -1872,12 +1867,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { {editMode ? ( <> - Done + {t("vmLxc.appEditor.doneButton")} ) : ( <> - Edit + {t("vmLxc.appEditor.editButton")} )} diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 2d16f0d9..91b517f3 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -2410,12 +2410,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { > - App + {t("vmLxc.tabs.app")} {(selectedVM.app_watches || []).some( (a) => a.update_available && !a.managed_oci_app_id, ) && ( - + )} )} @@ -3438,7 +3438,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { managedEntry ? { managed_oci_app_id: managedEntry.managed_oci_app_id!, - name: managedEntry.name || "Managed app", + name: managedEntry.name || t("vmLxc.appEditor.managedApp"), installed_version: managedEntry.installed_version, latest_version: managedEntry.latest_version, update_available: managedEntry.update_available, @@ -3490,19 +3490,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

- {aw?.name || "Managed app"} + {aw?.name || t("vmLxc.updates.managedApp")}

- managed + {t("vmLxc.updates.managedBadge")} {hasUpdate ? ( <>
- Last checked: {lastChecked} ·{" "} + {t("vmLxc.updates.lastCheckedAt", { date: lastChecked })} ·{" "} - Tailscale v{aw?.latest_version} available + {t("vmLxc.updates.tailscaleAvailable", { version: aw?.latest_version || "" })}
@@ -3517,24 +3517,24 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" > - Update to v{aw?.latest_version} + {t("vmLxc.updates.updateToVersion", { version: aw?.latest_version || "" })}
{others > 0 && (
- +{others} other package{others === 1 ? "" : "s"} pending in the container + {t(others === 1 ? "vmLxc.updates.otherPackagePending" : "vmLxc.updates.otherPackagesPending", { count: others })}
)} ) : upToDate ? (
- Last checked: {lastChecked} + {t("vmLxc.updates.lastCheckedAt", { date: lastChecked })} {aw?.installed_version && <> · Tailscale v{aw.installed_version}} {" · "}{t("vmLxc.updates.noUpdatesAvailableChip")}
) : (
- No update information yet — check from Security → Secure Gateway. + {t("vmLxc.updates.noManagedUpdateInfo")}
)} @@ -3552,15 +3552,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

- OCI image container + {t("vmLxc.updates.ociTitle")}

- This container was created from an OCI (Docker) image. - Update management for OCI containers is coming with - the upcoming OCI install feature — updates will - rebuild the container from a newer image tag rather - than patching packages inside. + {t("vmLxc.updates.ociBody")}

@@ -3777,7 +3773,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
- upstream {matchApp!.latest_version} available + {t("vmLxc.updates.upstreamAvailable", { version: matchApp!.latest_version || "" })}
) : upToD ? ( @@ -3819,16 +3815,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {!helperExists && ( helperKnownNotUpdateable ? (

- The community-scripts registry marks this app as not updateable + {t("vmLxc.updates.helperNotUpdateable")} in place. Apply updates manually or reinstall with a newer helper-script.

) : (

- Detected a helper-scripts updater + {t("vmLxc.updates.helperDetectedTitle")} ({uc?.helper_slug}) but it isn't listed in the ProxMenux helpers catalogue. - Applying manually is safest. + {t("vmLxc.updates.helperDetectedBody")}

) )} @@ -3922,7 +3918,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
- upstream {aw.latest_version} available + {t("vmLxc.updates.upstreamAvailable", { version: aw.latest_version || "" })}
) : upToDate ? ( @@ -4103,7 +4099,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )} {applyBackup - ? <>{t("vmLxc.options.snapshotBefore")} — on {applyBackupStorage || selectedBackupStorage || t("vmLxc.options.storageAuto")} + ? <>{t("vmLxc.options.snapshotBefore")} {t("vmLxc.options.snapshotOn", { storage: applyBackupStorage || selectedBackupStorage || t("vmLxc.options.storageAuto") })} : {t("vmLxc.options.noSnapshot")}} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index c3a3d00a..64808876 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1244,7 +1244,20 @@ "applyOsAppsFooterPlural": "Apply OS + Apps updates", "osAppsFooter": "OS + {appName} updates", "osAppsFooterPlural": "OS + Apps updates", - "noUpdatesAvailableChip": "No updates available" + "noUpdatesAvailableChip": "No updates available", + "managedApp": "Managed app", + "managedBadge": "managed", + "lastCheckedAt": "Last checked: {date}", + "tailscaleAvailable": "Tailscale v{version} available", + "updateToVersion": "Update to v{version}", + "otherPackagePending": "+{count} other package pending in the container", + "otherPackagesPending": "+{count} other packages pending in the container", + "noManagedUpdateInfo": "No update information yet — check from Security → Secure Gateway.", + "ociTitle": "OCI image container", + "ociBody": "This container was created from an OCI (Docker) image. Update management for OCI containers is coming with the upcoming OCI install feature — updates will rebuild the container from a newer image tag rather than patching packages inside.", + "helperNotUpdateable": "The community-scripts registry marks this app as not updateable.", + "helperDetectedTitle": "Detected a helper-scripts updater", + "helperDetectedBody": "Applying manually is safest." }, "appEditor": { "closePanel": "Close panel", @@ -1351,7 +1364,45 @@ "versionRegexOptional": "Version regex (optional)", "top20Matches": "Top 20 matches — narrow the query for more", "binaryArgsHintPrefix": "Left empty defaults to", - "binaryArgsHintGrafana": "Grafana needs" + "binaryArgsHintGrafana": "Grafana needs", + "loadFailed": "Could not load app configuration", + "checkFailed": "Check failed", + "deleteFailed": "Delete failed", + "dismissFailed": "Could not dismiss detection", + "restoreFailed": "Could not restore detection", + "managedApp": "Managed app", + "managedStatus": "managed", + "checkedAt": "Checked {date}", + "latestUpstream": "Latest upstream", + "loadingApplications": "Loading applications…", + "nameSearchPlaceholder": "Type to search 700+ apps, or type your own", + "matchCount": "{count} matches", + "catalogPort": "port {port}", + "trackingAvailable": "tracking available", + "logoUrlLabel": "Logo URL", + "webLinks": "Web links", + "addPort": "Add port", + "detectedPorts": "Ports detected in the container — click to add:", + "noWebPorts": "No web ports listening. Use Add port if you still want to create a link manually.", + "trackUpstream": "Track upstream version (optional)", + "trackOff": "Off — link only", + "trackOn": "On · {method}", + "trackHelp": "Optional. When enabled, ProxMenux checks the installed version and compares it with the latest upstream release.", + "commandSafetyHelp": "Runs argv-style inside the CT as root — never through a shell. You are responsible for what the command does. Use installed_regex below to extract the version from the output.", + "manualVersionHelp": "Type the version you have installed. If an upstream source is set below, ProxMenux still checks for updates. After upgrading the app, return here and update this value.", + "upstreamHelp": "How ProxMenux finds the latest version to compare against your installed one.", + "githubRepoHelp": "Public GitHub repository where releases or tags are published.", + "httpJsonHelp": "Public JSON endpoint that returns a version somewhere in the payload.", + "jsonPathHelp": "Dotted path with optional [N] array indices.", + "restoreButton": "Restore", + "registerButton": "Register", + "removeButton": "Remove", + "checkButton": "Check", + "editFieldsButton": "Edit fields", + "alsoDetectedContainer": "Also detected on this container", + "addAnotherApplication": "Add another application", + "doneButton": "Done", + "editButton": "Edit" } }, "settings": { diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 8eae81cb..f85a5ccf 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1034,7 +1034,8 @@ "mounts": "Pripojenia", "backups": "Zálohy", "updates": "Aktualizácie", - "firewall": "Firewall" + "firewall": "Firewall", + "app": "Aplikácie" }, "actions": { "start": "Spustiť", @@ -1128,6 +1129,280 @@ "readFailed": "Firewall log sa nepodarilo načítať", "empty": "Zatiaľ tu nie sú žiadne záznamy.", "emptyHint": "Pravidlá s log: info alebo vyššie začnú tento prehľad napĺňať, keď príde sieťová prevádzka." + }, + "options": { + "title": "Možnosti", + "snapshotBefore": "Snapshot pred použitím zmien", + "snapshotOn": "— na úložisku {storage}", + "noSnapshot": "Bez snapshotu pred použitím zmien", + "restartAfter": "Po použití zmien reštartovať kontajner", + "noRestart": "Bez reštartu po použití zmien", + "snapshotLabel": "Pred použitím zmien vytvoriť snapshot kontajnera (vzdump)", + "appliesToBoth": "Platí pre ručné aj plánované spustenie.", + "backupStorage": "Úložisko zálohy", + "pickStorage": "Vyberte úložisko", + "editButton": "Upraviť", + "cancelButton": "Zrušiť", + "saveButton": "Uložiť", + "storageAuto": "automaticky" + }, + "scheduled": { + "title": "Plánované aktualizácie", + "enabledHelper": "Prebehnú na pozadí s použitím možností uvedených vyššie.", + "disabledHelper": "Zapnite automatické aktualizácie podľa cron plánu.", + "chipLabel": "Plán", + "disabledSuffix": "(vypnuté)", + "whatLabel": "Čo:", + "targetOs": "Balíky systému", + "targetApp": "Aplikácia", + "targetBoth": "Systém + aplikácia", + "lastRun": "Posledné spustenie: {date}", + "runSuccess": "✓ úspešné", + "runFailed": "✗ zlyhalo", + "notScheduled": "Bez plánu", + "frequency": "Frekvencia", + "cronExpression": "Cron výraz", + "cronPlaceholder": "napr. 0 3 * * *", + "whatToUpdate": "Čo aktualizovať", + "targetOptionOs": "Iba balíky systému", + "targetOptionApp": "Iba aplikáciu", + "targetOptionBoth": "Systém + aplikáciu", + "deleteButton": "Odstrániť plán", + "deleteConfirm": "Odstrániť plánované aktualizácie tohto kontajnera? Predvolené nastavenia (záloha a reštart) zostanú zachované." + }, + "cronChip": { + "detected": "zistený cron na serveri", + "variantTteck": "tteck (pôvodné)", + "variantCustom": "vlastné", + "variantCommunityScripts": "community-scripts", + "scopeAptApk": "apt/apk vo všetkých CT", + "scopeUnknown": "neznámy rozsah" + }, + "cronPresets": { + "hourly": "Každú hodinu", + "dailyAt3": "Denne o 3:00", + "dailyAtNoon": "Denne o 12:00", + "weeklySun3": "Týždenne (v nedeľu o 3:00)", + "monthly1st3": "Mesačne (1. deň o 3:00)", + "custom": "Vlastný cron…" + }, + "updates": { + "osPackagesTitle": "Balíky systému", + "lastCheckedPrefix": "Naposledy skontrolované:", + "familyLabel": "Systém:", + "applyOsUpdate": "Aktualizovať systém", + "osUpToDate": "Systém je aktuálny", + "installedByHelperPrefix": "Nainštalované cez", + "helperScriptsName": "Proxmox Helper-Scripts", + "helperUpdatesRun": "— aktualizácie spustia pomocný skript z community-scripts.", + "installedPrefix": "nainštalovaná verzia", + "upstreamAvailable": "dostupná verzia {version}", + "upToDateAt": "Aktuálna verzia", + "applyUpdate": "Aktualizovať", + "upToDate": "Aktuálne", + "runUpdater": "Spustiť aktualizáciu", + "applyOsPlusApp": "Aktualizovať systém + {appName}", + "applyOsPlusApps": "Aktualizovať systém + aplikácie", + "osPlusApp": "Aktualizácie systému + {appName}", + "osPlusApps": "Aktualizácie systému + aplikácií", + "noUpdateMethodTitle": "Aktualizácia nie je nastavená", + "noUpdateMethodBody": "Pre túto aplikáciu nie je nastavený spôsob aktualizácie. Aktualizujte ju priamo v aplikácii alebo nastavte vlastný príkaz na karte Aplikácia.", + "ociImmutableTitle": "Kontajner z OCI obrazu", + "ociImmutableBody": "Balíky systému sú súčasťou obrazu a nemožno ich aktualizovať priamo. Aktualizujte ich ručne alebo kontajner preinštalujte z novšieho obrazu.", + "hideNoticeButton": "Skryť toto upozornenie pre aplikáciu", + "noOsUpdatesPending": "Systém nečaká na žiadne aktualizácie", + "securityUpdateAria": "Bezpečnostná aktualizácia", + "packagePendingLabel": "čakajúci balík", + "packagesPendingLabel": "čakajúcich balíkov", + "securityUpdateLabel": "bezpečnostná aktualizácia", + "securityUpdatesLabel": "bezpečnostných aktualizácií", + "applicationDefaultName": "Aplikácia", + "installedLabel": "nainštalované", + "upToDateAtLabel": "Aktuálna verzia", + "versionTrackingPending": "Čaká sa na údaje o verzii — nainštalovaná a dostupná verzia sa doplnia pri najbližšej plánovanej kontrole.", + "customCommandTitle": "Vlastný príkaz na aktualizáciu", + "customCommandBody": "Spustí v kontajneri vlastný shell príkaz, ktorý aktualizuje túto aplikáciu.", + "noUpdaterConfiguredTitle": "Aktualizácia nie je nastavená", + "noUpdaterConfiguredBody": "Táto aplikácia nemá pomocný inštalátor ani vlastný príkaz na aktualizáciu.", + "hideNoticeAction": "Skryť upozornenie pre {appName}", + "detectedByPrefix": "Zistené cez", + "editApp": "Upraviť", + "alsoDetectedTitle": "V tomto kontajneri sa našli aj:", + "detectedInline": "· zistené cez {method}", + "customCommandLabel": "Vlastný príkaz na aktualizáciu", + "customCommandPlaceholder": "napr. cd /opt/ && git pull && systemctl restart ", + "removeButton": "Odstrániť", + "cancelButton": "Zrušiť", + "saveButton": "Uložiť", + "editCommandButton": "Upraviť príkaz", + "wireUpCommandButton": "Pridať vlastný príkaz na aktualizáciu", + "versionTrackingPendingShort": "Čaká sa na údaje o verzii.", + "noMethodBody": "Pre túto aplikáciu nie je nastavený spôsob aktualizácie. Aktualizujte ju priamo v aplikácii alebo pridajte vlastný príkaz, ktorý ProxMenux spustí v kontajneri.", + "noMethodHideBody": "Táto aplikácia nemá nastavený spôsob aktualizácie. Nastavte ho na karte Aplikácia alebo toto upozornenie skryte.", + "hideForApp": "Skryť upozornenie pre {appName}", + "applyOsAppsFooter": "Aktualizovať systém + {appName}", + "applyOsAppsFooterPlural": "Aktualizovať systém + aplikácie", + "osAppsFooter": "Aktualizácie systému + {appName}", + "osAppsFooterPlural": "Aktualizácie systému + aplikácií", + "noUpdatesAvailableChip": "Nie sú dostupné žiadne aktualizácie", + "managedApp": "Spravovaná aplikácia", + "managedBadge": "spravované", + "lastCheckedAt": "Naposledy skontrolované: {date}", + "tailscaleAvailable": "Dostupný Tailscale v{version}", + "updateToVersion": "Aktualizovať na v{version}", + "otherPackagePending": "+{count} ďalší čakajúci balík v kontajneri", + "otherPackagesPending": "+{count} ďalších čakajúcich balíkov v kontajneri", + "noManagedUpdateInfo": "Zatiaľ nie sú dostupné informácie o aktualizácii — skontrolujte ich v časti Bezpečnosť → Bezpečná brána.", + "ociTitle": "Kontajner z OCI obrazu", + "ociBody": "Tento kontajner bol vytvorený z OCI (Docker) obrazu. Pripravovaná podpora aktualizácií OCI kontajnerov vytvorí kontajner znova z novšieho tagu obrazu namiesto aktualizácie balíkov vo vnútri.", + "helperNotUpdateable": "Register community-scripts označuje túto aplikáciu ako neaktualizovateľnú.", + "helperDetectedTitle": "Našiel sa aktualizačný skript z helper-scripts", + "helperDetectedBody": "Najbezpečnejšie je spustiť aktualizáciu ručne." + }, + "appEditor": { + "closePanel": "Zavrieť panel", + "cancelButton": "Zrušiť", + "saveButton": "Uložiť", + "hideButton": "Skryť", + "hidePermanentlyTooltip": "Natrvalo skryť toto zistenie", + "hiddenBadge": "Momentálne skryté — po obnovení sa znova zobrazí v zozname nájdených aplikácií", + "registerDifferent": "Pridať inú aplikáciu", + "detectedInContainer": "Nájdené v tomto kontajneri", + "installedManaged": "Nainštalované a spravované cez ProxMenux", + "nameLabel": "Názov", + "installedViaLabel": "Spôsob inštalácie", + "installedVersionLabel": "Nainštalovaná verzia", + "installedVersionRegexLabel": "Regex nainštalovanej verzie (zachytávacia skupina)", + "installedRegexPlaceholder": "napr. v?(\\d+\\.\\d+\\.\\d+)", + "installedRegexAlt": "installed_regex", + "regexCaptureGroupLabel": "Regex (so zachytávacou skupinou)", + "regexPlaceholderVersion": "version:\\s*(\\d+\\.\\d+\\.\\d+)", + "optionalSuffix": "(voliteľné)", + "methodNone": "Žiadny (iba odkaz)", + "methodDpkg": "balík dpkg (Debian/Ubuntu)", + "methodApk": "balík apk (Alpine)", + "methodBinary": "Binárny súbor (úplná cesta alebo názov)", + "methodFile": "súbor + regex", + "methodDockerLabel": "docker inspect (OCI label)", + "methodDockerExec": "docker exec (binárny súbor v kontajneri)", + "methodPython": "Python distribúcia (importlib.metadata)", + "methodCommand": "príkaz (pokročilé — argv)", + "methodManual": "ručne (verziu zadávam sám)", + "packageIdentifierLabel": "Identifikátor balíka", + "binaryPathLabel": "Cesta k binárnemu súboru", + "binaryPathPlaceholder": "napr. /opt//binary", + "binaryPathBare": "Binárny súbor (úplná cesta alebo názov)", + "binaryPathBarePlaceholder": "napr. myapp (alebo úplná cesta)", + "binaryArgsLabel": "Argumenty binárneho súboru (oddelené čiarkou, voliteľné)", + "binaryArgsPlaceholder": "napr. --version (oddelené čiarkou)", + "filePathLabel": "Cesta k súboru", + "commandLabel": "Príkaz (argv oddelené čiarkou, bez shellu)", + "commandPlaceholder": "myapp, version, --json", + "commandServerExample": "server, -v", + "containerNameLabel": "Názov kontajnera", + "containerNamePlaceholder": "napr. ", + "ociLabelKeyLabel": "Kľúč OCI labelu", + "ociLabelPlaceholder": "org.opencontainers.image.version", + "pythonInterpreterLabel": "Cesta k Python interpreteru", + "pythonInterpreterPlaceholder": "napr. /opt//venv/bin/python", + "pipDistLabel": "Názov distribúcie (pip balík)", + "pipDistPlaceholder": "napr. ", + "upstreamSourceLabel": "Zdroj dostupnej verzie (voliteľné)", + "upstreamNone": "žiadny (nekontrolovať dostupnú verziu)", + "upstreamGithub": "GitHub repozitár", + "upstreamHttp": "HTTP JSON endpoint", + "upstreamDocker": "Obraz na Docker Hub", + "githubRepoLabel": "GitHub repozitár", + "githubRepoPlaceholder": "napr. vlastník/názov", + "githubRepoAlt": "vlastník/názov", + "githubSourceLabel": "Zdroj na GitHube", + "sourceReleases": "vydania", + "sourceTags": "tagy", + "endpointUrlLabel": "URL endpointu", + "endpointUrlPlaceholder": "napr. https://vendor.example.com/api/latest.json", + "jsonPathLabel": "JSON cesta", + "jsonPathPlaceholder": "napr. data.version alebo releases[0].tag_name", + "dockerImageLabel": "Obraz na Docker Hub", + "dockerImagePlaceholder": "napr. linuxserver/plex alebo nginx", + "tagRegexLabel": "Regex tagu (so zachytávacou skupinou)", + "tagRegexPlaceholder": "napr. v?(\\d+\\.\\d+\\.\\d+)", + "tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)", + "installedStatus": "Nainštalované", + "checkingStatus": "kontrolujem…", + "latestFromLabel": "Najnovšia verzia z", + "upToDateBadge": "Aktuálne", + "updateAvailableBadge": "Dostupná aktualizácia", + "portDescriptionPlaceholder": "Popis (napr. Web UI, go2rtc, admin)", + "portPortPlaceholder": "port", + "portHttp": "http", + "portHttps": "https", + "portLogoLabel": "URL loga pre tento odkaz (voliteľné)", + "portLogoPlaceholder": "napr. https://example.com/logo.webp", + "removePortTooltip": "Odstrániť port", + "detectMethodDpkg": "dpkg ·", + "detectMethodApk": "apk ·", + "detectMethodBinary": "binárny súbor ·", + "detectMethodFile": "súbor ·", + "detectMethodDockerLabel": "docker label", + "detectMethodDockerExec": "docker exec", + "detectMethodCommand": "príkaz", + "binaryVersionHint": "binary --version", + "systemctlHint": "systemctl show -p ExecStart", + "whichHint": "which ", + "apkInfoHint": "\" : \"apk info | grep", + "grafanaNote": ". Grafana potrebuje", + "hiddenDetectionsHelpSingular": "Máte {count} skrytú nájdenú aplikáciu. Obnovte ju alebo pridajte vlastnú aplikáciu ručne.", + "hiddenDetectionsHelpPlural": "Máte {count} skrytých nájdených aplikácií. Obnovte niektorú z nich alebo pridajte vlastnú aplikáciu ručne.", + "registerCustom": "Pridať vlastnú aplikáciu", + "noAppsTitle": "Nie sú pridané žiadne aplikácie", + "noAppsBody": "Pridajte aplikácie, ktoré bežia v tomto kontajneri. Získate odkazy na ich webové rozhranie a voliteľne aj sledovanie dostupných verzií s upozorneniami na nové vydania.", + "registerApplication": "Pridať aplikáciu", + "hiddenSuffix": "skryté: {count}", + "confirmHide": "Natrvalo skryť nájdenú aplikáciu „{name}“ v tomto kontajneri?\n\nAk ju budete chcieť neskôr vrátiť, kliknite na „Pridať inú aplikáciu“. V zozname skrytých aplikácií ju potom môžete obnoviť.", + "saveFailed": "Uloženie zlyhalo", + "tagFilterRegexOptional": "Regex na filtrovanie tagov (voliteľné)", + "versionRegexOptional": "Regex verzie (voliteľné)", + "top20Matches": "Prvých 20 výsledkov — spresnite hľadanie, ak chcete nájsť ďalšie", + "binaryArgsHintPrefix": "Ak pole necháte prázdne, použije sa", + "binaryArgsHintGrafana": "Grafana potrebuje", + "loadFailed": "Nastavenia aplikácií sa nepodarilo načítať", + "checkFailed": "Kontrola zlyhala", + "deleteFailed": "Odstránenie zlyhalo", + "dismissFailed": "Nájdenú aplikáciu sa nepodarilo skryť", + "restoreFailed": "Nájdenú aplikáciu sa nepodarilo obnoviť", + "managedApp": "Spravovaná aplikácia", + "managedStatus": "spravované", + "checkedAt": "Skontrolované {date}", + "latestUpstream": "Najnovšia dostupná", + "loadingApplications": "Načítavam aplikácie…", + "nameSearchPlaceholder": "Vyhľadajte medzi viac ako 700 aplikáciami alebo zadajte vlastný názov", + "matchCount": "Počet výsledkov: {count}", + "catalogPort": "port {port}", + "trackingAvailable": "možno sledovať verziu", + "logoUrlLabel": "URL loga", + "webLinks": "Webové odkazy", + "addPort": "Pridať port", + "detectedPorts": "Porty nájdené v kontajneri — kliknutím ich pridáte:", + "noWebPorts": "Nenašli sa žiadne aktívne webové porty. Odkaz môžete vytvoriť ručne cez Pridať port.", + "trackUpstream": "Sledovať dostupnú verziu (voliteľné)", + "trackOff": "Vypnuté — iba odkaz", + "trackOn": "Zapnuté · {method}", + "trackHelp": "Ak túto možnosť zapnete, ProxMenux porovná nainštalovanú verziu s najnovšou dostupnou verziou.", + "commandSafetyHelp": "Príkaz sa spustí priamo cez argv v CT ako root, nikdy nie cez shell. Za jeho obsah zodpovedáte vy. Verziu z výstupu získate pomocou installed_regex nižšie.", + "manualVersionHelp": "Zadajte nainštalovanú verziu. Ak nižšie nastavíte zdroj dostupnej verzie, ProxMenux bude ďalej kontrolovať aktualizácie. Po aktualizácii aplikácie sa sem vráťte a hodnotu upravte.", + "upstreamHelp": "Určuje, kde ProxMenux nájde najnovšiu verziu na porovnanie s nainštalovanou verziou.", + "githubRepoHelp": "Verejný GitHub repozitár, v ktorom sa zverejňujú vydania alebo tagy.", + "httpJsonHelp": "Verejný JSON endpoint, ktorý obsahuje číslo verzie.", + "jsonPathHelp": "Cesta oddelená bodkami s voliteľnými indexmi poľa [N].", + "restoreButton": "Obnoviť", + "registerButton": "Pridať", + "removeButton": "Odstrániť", + "checkButton": "Skontrolovať", + "editFieldsButton": "Upraviť údaje", + "alsoDetectedContainer": "Ďalšie aplikácie nájdené v kontajneri", + "addAnotherApplication": "Pridať ďalšiu aplikáciu", + "doneButton": "Hotovo", + "editButton": "Upraviť" } }, "settings": { From 547942cac845b1e7c4f91ec4b26a86f502fcc2f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 9 Aug 2026 09:09:10 +0200 Subject: [PATCH 2/2] fix(terminal): handle completed scripts without websocket error --- AppImage/components/script-terminal-modal.tsx | 92 +++++++++++++++++-- AppImage/components/virtual-machines.tsx | 16 ++-- AppImage/messages/en/common.json | 8 ++ AppImage/messages/sk/common.json | 8 ++ AppImage/scripts/flask_terminal_routes.py | 43 +++++++-- 5 files changed, 147 insertions(+), 20 deletions(-) diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index 32448c18..96b9b990 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -50,12 +50,14 @@ interface ScriptTerminalModalProps { description: string scriptName?: string params?: Record + completedSuccessfullyMessage?: string + completedWithErrorMessage?: (exitCode: number) => string // Optional callback fired when the script's WebSocket closes // (script_runner sends an exit code and then closes). Lets the // parent auto-dismiss the modal — used by host-backup's Restore // flow so "Press Enter to close" in the bash script actually // closes the modal without an extra click. Other callers ignore. - onComplete?: () => void + onComplete?: (exitCode?: number) => void } export function ScriptTerminalModal({ @@ -65,6 +67,8 @@ export function ScriptTerminalModal({ title, description, params = { EXECUTION_MODE: "web" }, + completedSuccessfullyMessage, + completedWithErrorMessage, onComplete, }: ScriptTerminalModalProps) { const t = useT() @@ -85,6 +89,7 @@ export function ScriptTerminalModal({ const reconnectTimeoutRef = useRef(null) const reconnectAttemptsRef = useRef(0) const keepAliveIntervalRef = useRef(null) + const completionReceivedRef = useRef(false) const [isMobile, setIsMobile] = useState(false) const [isTablet, setIsTablet] = useState(false) @@ -96,6 +101,14 @@ export function ScriptTerminalModal({ const resizeBarRef = useRef(null) const modalHeightRef = useRef(600) + const getCompletionMessage = useCallback( + (exitCode: number) => + exitCode === 0 + ? (completedSuccessfullyMessage ?? t("scriptTerminal.completedSuccessfully")) + : (completedWithErrorMessage?.(exitCode) ?? t("scriptTerminal.completedWithError", { code: exitCode })), + [completedSuccessfullyMessage, completedWithErrorMessage, t], + ) + const terminalContainerRef = useRef(null) const paramsRef = useRef(params) @@ -106,7 +119,7 @@ export function ScriptTerminalModal({ // Same trick for onComplete — we want the latest callback inside // the ws.onclose handler without re-running the connection effect. - const onCompleteRef = useRef<(() => void) | undefined>(undefined) + const onCompleteRef = useRef<((exitCode?: number) => void) | undefined>(undefined) useEffect(() => { onCompleteRef.current = onComplete }, [onComplete]) @@ -167,6 +180,23 @@ const initMessage = { if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') { return } + + // The PTY worker always emits this final line. Treat it as a + // completion fallback because some WebSocket servers tear down the + // connection before the following structured message is flushed. + const exitMatch = typeof event.data === "string" + ? event.data.match(/\[Script exited with code (-?\d+)\]/) + : null + if (exitMatch) { + const exitCode = Number(exitMatch[1]) + termRef.current?.write(event.data) + completionReceivedRef.current = true + setIsComplete(true) + termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) + onCompleteRef.current?.(exitCode) + if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") + return + } try { const msg = JSON.parse(event.data) @@ -189,6 +219,15 @@ const initMessage = { termRef.current?.writeln(`\x1b[31m${msg.message}\x1b[0m`) return } + if (msg.type === "script_complete") { + const exitCode = Number(msg.exit_code ?? 1) + completionReceivedRef.current = true + setIsComplete(true) + termRef.current?.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) + onCompleteRef.current?.(exitCode) + if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") + return + } } catch {} termRef.current?.write(event.data) setIsWaitingNextInteraction(false) @@ -199,6 +238,9 @@ const initMessage = { ws.onerror = () => { setConnectionStatus("offline") + if (!completionReceivedRef.current) { + termRef.current?.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`) + } } ws.onclose = (event) => { @@ -207,16 +249,19 @@ const initMessage = { clearInterval(keepAliveIntervalRef.current) keepAliveIntervalRef.current = null } + if (completionReceivedRef.current) { + return + } if (!isComplete && reconnectAttemptsRef.current < 3) { reconnectTimeoutRef.current = setTimeout(attemptReconnect, 2000) } else { setIsComplete(true) - onCompleteRef.current?.() + onCompleteRef.current?.(-1) } } } }, 1000) - }, [isOpen, isComplete, scriptPath]) + }, [isOpen, isComplete, scriptPath, getCompletionMessage, t]) const sendKey = useCallback((key: string) => { if (!termRef.current) return @@ -352,6 +397,23 @@ const initMessage = { if (event.data === '{"type": "pong"}' || event.data === '{"type":"pong"}') { return } + + // See the reconnect handler above. The exit line is guaranteed to be + // sent with the PTY output and is therefore a robust fallback when a + // final JSON frame is lost during server-side socket teardown. + const exitMatch = typeof event.data === "string" + ? event.data.match(/\[Script exited with code (-?\d+)\]/) + : null + if (exitMatch) { + const exitCode = Number(exitMatch[1]) + term.write(event.data) + completionReceivedRef.current = true + setIsComplete(true) + term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) + onCompleteRef.current?.(exitCode) + if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") + return + } try { const msg = JSON.parse(event.data) @@ -376,6 +438,15 @@ const initMessage = { term.writeln(`\x1b[31m${msg.message}\x1b[0m`) return } + if (msg.type === "script_complete") { + const exitCode = Number(msg.exit_code ?? 1) + completionReceivedRef.current = true + setIsComplete(true) + term.writeln(`\x1b[${exitCode === 0 ? "32" : "31"}m${getCompletionMessage(exitCode)}\x1b[0m`) + onCompleteRef.current?.(exitCode) + if (ws.readyState === WebSocket.OPEN) ws.close(1000, "script complete") + return + } } catch { // Not JSON, es output normal de terminal } @@ -390,21 +461,25 @@ const initMessage = { ws.onerror = (error) => { setConnectionStatus("offline") - term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`) + if (!completionReceivedRef.current) { + term.writeln(`\x1b[31m${t("scriptTerminal.websocketError")}\x1b[0m`) + } } ws.onclose = (event) => { setConnectionStatus("offline") - term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`) + if (!completionReceivedRef.current) { + term.writeln(`\x1b[33m${t("scriptTerminal.connectionClosed")}\x1b[0m`) + } if (keepAliveIntervalRef.current) { clearInterval(keepAliveIntervalRef.current) keepAliveIntervalRef.current = null } - if (!isComplete) { + if (!completionReceivedRef.current && !isComplete) { setIsComplete(true) - onCompleteRef.current?.() + onCompleteRef.current?.(-1) } } @@ -491,6 +566,7 @@ const initMessage = { sessionIdRef.current = Math.random().toString(36).substring(2, 8) reconnectAttemptsRef.current = 0 + completionReceivedRef.current = false setIsComplete(false) setInteractionInput("") setCurrentInteraction(null) diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 91b517f3..01d579b2 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -1464,7 +1464,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setApplyStartedAt(Date.now()) setApplyOpen(true) } - const handleApplyComplete = async () => { + const handleApplyComplete = async (exitCode = 0) => { if (applyVmid == null) return const duration = Math.max(0, Math.round((Date.now() - applyStartedAt) / 1000)) // The modal fires onComplete on any WS close (success or user cancel); @@ -1477,7 +1477,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - success: true, + success: exitCode === 0, target: applyTarget, duration_seconds: duration, ct_name: selectedVM?.name || `CT-${applyVmid}`, @@ -4967,13 +4967,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { onComplete={handleApplyComplete} scriptPath="/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh" scriptName="lxc_apply_updates" - title={`Apply updates — CT ${applyVmid}`} + title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })} + completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")} + completedWithErrorMessage={(exitCode) => + t("vmLxc.updates.terminalCompletedWithError", { code: exitCode }) + } description={ applyTarget === "os" - ? "Applying OS package updates inside the container..." + ? t("vmLxc.updates.terminalDescriptionOs") : applyTarget === "app" - ? "Running the application updater inside the container..." - : "Applying OS package + application updates inside the container..." + ? t("vmLxc.updates.terminalDescriptionApp") + : t("vmLxc.updates.terminalDescriptionBoth") } params={{ VMID: String(applyVmid), diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 64808876..4ff2e03a 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1201,6 +1201,12 @@ "applyUpdate": "Apply update", "upToDate": "Up to date", "runUpdater": "Run updater", + "terminalTitle": "Apply updates — CT {vmid}", + "terminalDescriptionOs": "Applying OS package updates inside the container...", + "terminalDescriptionApp": "Running the application updater inside the container...", + "terminalDescriptionBoth": "Applying OS package and application updates inside the container...", + "terminalCompletedSuccessfully": "Updates completed successfully", + "terminalCompletedWithError": "Updates finished with error code {code}", "applyOsPlusApp": "Apply OS + {appName} updates", "applyOsPlusApps": "Apply OS + Apps updates", "osPlusApp": "OS + {appName} updates", @@ -4649,6 +4655,8 @@ "offline": "Offline", "websocketError": "WebSocket error occurred", "connectionClosed": "Connection closed", + "completedSuccessfully": "Script completed successfully", + "completedWithError": "Script finished with error code {code}", "yes": "Yes", "yourInput": "Your input:", "submit": "Submit", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index f85a5ccf..de3c2cdb 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1201,6 +1201,12 @@ "applyUpdate": "Aktualizovať", "upToDate": "Aktuálne", "runUpdater": "Spustiť aktualizáciu", + "terminalTitle": "Aktualizácia — CT {vmid}", + "terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...", + "terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...", + "terminalDescriptionBoth": "Aktualizujem systém aj aplikácie v kontajneri...", + "terminalCompletedSuccessfully": "Aktualizácia bola úspešne dokončená", + "terminalCompletedWithError": "Aktualizácia skončila s chybovým kódom {code}", "applyOsPlusApp": "Aktualizovať systém + {appName}", "applyOsPlusApps": "Aktualizovať systém + aplikácie", "osPlusApp": "Aktualizácie systému + {appName}", @@ -4155,6 +4161,8 @@ "offline": "Offline", "websocketError": "Chyba WebSocket spojenia", "connectionClosed": "Spojenie bolo zatvorené", + "completedSuccessfully": "Skript sa úspešne dokončil", + "completedWithError": "Skript skončil s chybovým kódom {code}", "yes": "Áno", "yourInput": "Tvoj vstup:", "submit": "Odoslať", diff --git a/AppImage/scripts/flask_terminal_routes.py b/AppImage/scripts/flask_terminal_routes.py index 2ac08f47..72151873 100644 --- a/AppImage/scripts/flask_terminal_routes.py +++ b/AppImage/scripts/flask_terminal_routes.py @@ -478,6 +478,15 @@ def script_websocket(ws, session_id): preexec_fn=os.setsid, env=env ) + + # The child inherited the slave side of the PTY. Keeping the parent's + # duplicate open can prevent the reader from seeing EOF after the script + # exits, which in turn hides the final script_complete message. + try: + os.close(slave_fd) + slave_fd = None + except OSError: + pass # Set non-blocking mode for master_fd flags = fcntl.fcntl(master_fd, fcntl.F_GETFL) @@ -573,6 +582,13 @@ def script_websocket(ws, session_id): try: ws.send(f'\r\n[Script exited with code {exit_code}]\r\n') + # Send an explicit terminal result before the connection is + # closed. The browser previously saw the worker disappear as a + # generic WebSocket failure even when the script exited with 0. + ws.send(json.dumps({ + 'type': 'script_complete', + 'exit_code': exit_code, + })) except Exception as e: pass @@ -581,10 +597,16 @@ def script_websocket(ws, session_id): try: while True: - data = ws.receive(timeout=None) + data = ws.receive(timeout=0.25) if data is None: - break + if script_process.poll() is not None: + # The output worker owns the final PTY drain and emits + # both `[Script exited with code N]` and + # `script_complete`. Wait briefly for it before cleanup. + output_thread.join(timeout=2.0) + break + continue try: msg = json.loads(data) @@ -625,6 +647,14 @@ def script_websocket(ws, session_id): break if script_process.poll() is not None: + # The output worker owns the final PTY drain and emits both + # `[Script exited with code N]` and `script_complete`. A + # resize/ping arriving just after process exit used to make + # this receive loop enter cleanup immediately, closing the + # socket before those final frames were sent. Wait briefly + # for the worker so a normal script exit is delivered before + # teardown. + output_thread.join(timeout=2.0) break except Exception as e: @@ -644,10 +674,11 @@ def script_websocket(ws, session_id): except: pass - try: - os.close(slave_fd) - except: - pass + if slave_fd is not None: + try: + os.close(slave_fd) + except: + pass try: os.close(web_log_fd)