From 664ef981e57a79d9266fd7e4762b52227450cfa6 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:27:04 +0000 Subject: [PATCH] fix: separate LXC package checks from installation --- .../tests/test_command_descriptions.py | 9 +- AppImage/components/virtual-machines.tsx | 194 +++++++--- AppImage/lib/lxc-update-observation.ts | 26 ++ AppImage/lib/update-action-style.ts | 7 + AppImage/messages/en/common.json | 17 +- AppImage/messages/sk/common.json | 17 +- AppImage/scripts/build_appimage.sh | 1 + AppImage/scripts/flask_server.py | 48 ++- AppImage/scripts/lxc_check_job.py | 188 ++++++++++ scripts/lxc/apply_updates.sh | 14 + scripts/lxc/install_checked_os_updates.sh | 6 + tests/lxc_updates/test_apply_updates.sh | 19 + tests/lxc_updates/test_check_only_job.py | 296 +++++++++++++++ .../lxc_updates/test_docker_delegated_ui.cjs | 87 +++++ .../lxc_updates/test_download_update_guard.py | 1 + tests/lxc_updates/test_observed_state.py | 6 + tests/lxc_updates/test_observed_state_ui.cjs | 346 +++++++++++++++++- tests/lxc_updates/test_update_status_ui.cjs | 129 +++++++ 18 files changed, 1341 insertions(+), 70 deletions(-) create mode 100644 AppImage/lib/update-action-style.ts create mode 100644 AppImage/scripts/lxc_check_job.py create mode 100644 scripts/lxc/install_checked_os_updates.sh create mode 100644 tests/lxc_updates/test_check_only_job.py create mode 100644 tests/lxc_updates/test_update_status_ui.cjs diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index e7339492..554c4226 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -213,9 +213,16 @@ class CommandDescriptionsTests(unittest.TestCase): # Model the bot's future output in temporary catalogs, just # like the other pending keys above; never rewrite locales. for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown", - "checkStale", "checkStaleHelp"): + "checkStale", "checkStaleHelp", "checkBeforeUpdate", + "startBeforeCheck", "notCheckedYet", + "checkStillRunning", "refreshCheckHelp", + "checkInProgress", "checkSystemPackages", "runCustomCommand", + "installedVersionUnverified", "versionStatusUnknown"): messages["vmLxc"]["updates"].setdefault( key, english["vmLxc"]["updates"][key]) + for key in ("osDockerScope", "dockerImageMethod", "unverifiedTargetsHelp"): + messages["vmLxc"]["bulkUpdate"].setdefault( + key, english["vmLxc"]["bulkUpdate"][key]) path.write_text(json.dumps(messages, ensure_ascii=False)) before = {p: p.read_bytes() for p in root.glob("*/common.json")} argv = [str(SCRIPT), "--source", str(root / "en/common.json"), diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index a0660d18..9c8c8e2b 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -4,6 +4,7 @@ import type React from "react" import { useState, useMemo, useEffect, useRef } from "react" import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache" +import { getUpdateActionButtonClass } from "../lib/update-action-style" import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" @@ -28,7 +29,7 @@ import { formatStorage } from "../lib/utils" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search" -import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation" +import { refreshCheckLxcUpdates, getLxcOsUpdateState, isLxcUpdateStale } from "../lib/lxc-update-observation" import DOMPurify from "dompurify" import { marked } from "marked" import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider" @@ -917,6 +918,7 @@ export function VirtualMachines() { // (or after a 15 s safety timeout). const [updatesRefreshing, setUpdatesRefreshing] = useState(false) const [osCheckingVmid, setOsCheckingVmid] = useState(null) + const osCheckRequestRef = useRef(null) const [osCheckError, setOsCheckError] = useState(null) const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null) const [updatesBaselineCount, setUpdatesBaselineCount] = useState(null) @@ -1231,6 +1233,12 @@ export function VirtualMachines() { useEffect(() => { if (!updatesRefreshing) return if (!selectedVM) return + if (!selectedVM.update_check || selectedVM.update_check.error) { + setUpdatesResult(null) + setUpdatesRefreshing(false) + setUpdatesBaselineCount(null) + return + } const currentCount = selectedVM.update_check?.count ?? 0 if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) { const applied = Math.max(0, updatesBaselineCount - currentCount) @@ -1250,7 +1258,8 @@ export function VirtualMachines() { const safety = setTimeout(() => { const currentCount = selectedVMRef.current?.update_check?.count ?? 0 const baseline = updatesBaselineCountRef.current - setUpdatesResult({ + const observation = selectedVMRef.current?.update_check + setUpdatesResult(!observation || observation.error ? null : { pendingAfter: currentCount, appliedCount: Math.max(0, (baseline ?? 0) - currentCount), }) @@ -1983,6 +1992,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const [applyRestart, setApplyRestart] = useState(false) const [applyStartedAt, setApplyStartedAt] = useState(0) const [applyRunId, setApplyRunId] = useState("") + const [applyCheckedOs, setApplyCheckedOs] = useState(false) const [applyTargetIds, setApplyTargetIds] = useState([]) const [applyTargetLabels, setApplyTargetLabels] = useState([]) // Extra state carried alongside applyTarget when the App branch is @@ -2029,6 +2039,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // detected via the community-scripts pattern. All loaded once when // the user opens the Updates tab of a specific LXC. const [scheduleLoaded, setScheduleLoaded] = useState(null) + const scheduleLoadRequestRef = useRef(0) const [scheduleEnabled, setScheduleEnabled] = useState(false) const [scheduleCron, setScheduleCron] = useState("0 3 * * *") const [schedulePreset, setSchedulePreset] = useState("daily-3am") @@ -2073,6 +2084,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // with scheduled updates: changing a one-click manual selection must never // rewrite the cron automation configured in Options. const [bulkLoaded, setBulkLoaded] = useState(null) + const bulkLoadRequestRef = useRef(0) const [bulkConfigured, setBulkConfigured] = useState(false) const [bulkTargets, setBulkTargets] = useState(["os"]) const [bulkPersistedTargets, setBulkPersistedTargets] = useState(["os"]) @@ -2107,9 +2119,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ? s.targets.map((value: any) => String(value)) : ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])])) setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0) - if (s.backup !== undefined) setApplyBackup(!!s.backup) - if (s.backup_storage) setApplyBackupStorage(s.backup_storage) - if (s.restart !== undefined) setApplyRestart(!!s.restart) + setApplyBackup(!!s.backup) + setApplyBackupStorage(typeof s.backup_storage === "string" ? s.backup_storage : "") + setApplyRestart(!!s.restart) setScheduleLastRunAt(s.last_run_at || null) setScheduleLastRunStatus(s.last_run_status || null) setScheduleLastRunReason(s.last_run_reason || null) @@ -2122,30 +2134,46 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { } const loadSchedule = async (vmid: number) => { + if (selectedVMRef.current?.vmid !== vmid) return + const requestId = ++scheduleLoadRequestRef.current + const isCurrent = () => selectedVMRef.current?.vmid === vmid + && scheduleLoadRequestRef.current === requestId setScheduleError(null) // Seed from ref cache so tab-switching to Updates isn't a "Loading…" // moment when the payload was already fetched this session. const cachedSched = vmModalCacheRef.current.schedule.get(vmid) - if (cachedSched) applySchedulePayload(cachedSched) + // Missing options are this guest's defaults, never the previous guest's. + applySchedulePayload(cachedSched || {}) try { const s: any = await fetchApi(`/api/vms/${vmid}/schedule`) - if (s && typeof s === "object") { - vmModalCacheRef.current.schedule.set(vmid, s) - applySchedulePayload(s) - } + if (!isCurrent()) return + if (!s || typeof s !== "object" || Array.isArray(s)) + throw new Error(t("vmLxc.scheduled.loadFailed")) + vmModalCacheRef.current.schedule.set(vmid, s) + applySchedulePayload(s) } catch (e: any) { - setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed")) + if (isCurrent()) setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed")) } finally { - setScheduleLoaded(vmid) + if (isCurrent()) setScheduleLoaded(vmid) } } const loadBulkUpdate = async (vmid: number) => { + if (selectedVMRef.current?.vmid !== vmid) return + const requestId = ++bulkLoadRequestRef.current + const isCurrent = () => selectedVMRef.current?.vmid === vmid + && bulkLoadRequestRef.current === requestId setBulkError(null) setBulkLoaded(null) setBulkEditMode(false) + setBulkConfigured(false) + setBulkTargets(["os"]) + setBulkPersistedTargets(["os"]) try { const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`) + if (!isCurrent()) return + if (!config || typeof config !== "object" || Array.isArray(config)) + throw new Error(t("vmLxc.bulkUpdate.loadFailed")) const targets = Array.isArray(config?.targets) ? Array.from(new Set(config.targets.map((value: any) => String(value)))) as string[] : [] @@ -2155,12 +2183,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setBulkTargets(next) setBulkPersistedTargets(next) } catch (e: any) { + if (!isCurrent()) return setBulkConfigured(false) setBulkTargets(["os"]) setBulkPersistedTargets(["os"]) setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed")) } finally { - setBulkLoaded(vmid) + if (isCurrent()) setBulkLoaded(vmid) } } @@ -2560,8 +2589,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const openApplyTerminal = ( vmid: number, target: "os" | "app" | "both", - opts?: { updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] }, + opts?: { checkedOs?: boolean; updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] }, ) => { + const guest = selectedVMRef.current + if (target === "os" || target === "both") { + if (osCheckingVmid !== null || osCheckRequestRef.current !== null || updatesRefreshing || (opts?.checkedOs && osCheckError)) return + const state = guest?.vmid === vmid ? getLxcOsUpdateState(guest.update_check) : "unknown" + if (state === "unknown") { + setOsCheckError(t("vmLxc.updates.checkBeforeUpdate")) + return + } + // A combined plan can still update apps when OS packages are current. + if (target === "os" && state === "current") return + } + // The explicit update button is the user's confirmation. The runner + // owns the temporary start, backup and restoration of a stopped guest. const fallbackTargets = target === "os" ? ["os"] : target === "both" @@ -2584,6 +2626,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { setApplyVmid(vmid) setApplyTarget(target) setApplyRunId(runId) + setApplyCheckedOs(opts?.checkedOs === true) setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets) setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels) setApplyUpdateCommand(opts?.updateCommand || "") @@ -2680,10 +2723,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // false "N package pending" every time a registered app has an // upstream bump. const checkOsUpdates = async (vmid: number) => { + if (osCheckRequestRef.current !== null || applyOpen || controlLoading) return + osCheckRequestRef.current = vmid setOsCheckingVmid(vmid) setOsCheckError(null) try { - await checkLxcUpdates(vmid) + const observation = await refreshCheckLxcUpdates(vmid) + await mutate((current) => Array.isArray(current) + ? current.map(vm => vm.vmid === vmid ? { ...vm, update_check: observation } : vm) + : current, { revalidate: false }) + setSelectedVM(current => current?.vmid === vmid ? { ...current, update_check: observation } : current) const details = await fetchApi(`/api/vms/${vmid}`) vmModalCacheRef.current.details.set(vmid, details) if (selectedVMRef.current?.vmid === vmid) setVMDetails(details) @@ -2692,9 +2741,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { if (selectedVMRef.current?.vmid === vmid) { setOsCheckError(error.status === 429 ? t("vmLxc.updates.checkCooldown", { seconds: error.body?.retry_after ?? 60 }) - : error.message || t("vmLxc.updates.osStatusUnavailable")) + : error.message === "checkStillRunning" + ? t("vmLxc.updates.checkStillRunning") + : t("vmLxc.updates.osStatusUnavailable")) } } finally { + osCheckRequestRef.current = null setOsCheckingVmid(null) } } @@ -5707,7 +5759,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { !selectedVM.update_check?.is_oci_lxc && (() => { const uc = selectedVM.update_check const osUpdateStale = isLxcUpdateStale(uc) - const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale + const osUpdateStatusKnown = getLxcOsUpdateState(uc) !== "unknown" const hasOsUpdates = osUpdateStatusKnown && !!uc.available const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker") const dockerRegistered = !!dockerAppWatch @@ -5863,6 +5915,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { return { id: `app:${app.id}`, label: app.name || t("vmLxc.updates.applicationDefaultName"), + methodLabel: app.update_command?.trim() + ? t("vmLxc.updates.customCommandTitle") + : t("vmLxc.updates.helperMethod"), detail: "", logoUrl: webLinkLogo || app.logo_url?.trim() || "", } @@ -5877,6 +5932,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ? [{ id: "docker-engine", label: "Docker Engine", + methodLabel: t("vmLxc.scheduled.dockerEngineTarget"), detail: "", logoUrl: "", }] @@ -5884,6 +5940,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ...dockerUpdateUnits.map((unit) => ({ id: unit.id, label: unit.display_name || unit.primary_reference || unit.primary_service || unit.id, + methodLabel: unit.kind === "compose" + ? "Docker Compose" + : t("vmLxc.bulkUpdate.dockerImageMethod"), detail: (unit.dependent_services || []).length ? t("vmLxc.bulkUpdate.includesDependencies", { names: (unit.dependent_services || []).join(", "), @@ -5891,7 +5950,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { : "", logoUrl: unit.logo_url || "", })), - ] + ].map((choice, _index, choices) => ({ + ...choice, + // Keep distinct action IDs; names alone never prove + // that a custom updater and an image are one action. + label: choices.filter((other) => + other.label.trim().toLowerCase() === choice.label.trim().toLowerCase() + ).length > 1 ? `${choice.label} — ${choice.methodLabel}` : choice.label, + })) const bulkChoices = [{ id: "os", label: t("vmLxc.bulkUpdate.osTarget"), @@ -5935,7 +6001,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // has been checked and is current, otherwise unknown. const selectedBulkUpdateStates = bulkTargets.map((target) => { if (target === "os") { - if (!uc || uc.error) return null + if (!osUpdateStatusKnown) return null return hasOsUpdates } if (target === "docker-engine") { @@ -5960,6 +6026,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { return null }) const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true) + const bulkHasUnknownTargets = selectedBulkUpdateStates.some((state) => state === null) const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0 && selectedBulkUpdateStates.every((state) => state === false) const toggleBulkChoice = (id: string, checked: boolean) => { @@ -5968,9 +6035,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ? Array.from(new Set(["os", ...current.filter((value) => value !== "os"), id])) : current.filter((value) => value !== id)) } - const pendingBtnCls = "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200" - const upToDateBtnCls = "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300" - const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground" + const pendingBtnCls = getUpdateActionButtonClass(true, false) + const upToDateBtnCls = getUpdateActionButtonClass(false, true) return ( <> @@ -5982,12 +6048,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

{t("vmLxc.updates.osPackagesTitle")}

@@ -5998,7 +6064,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )}
{osCheckError &&

{osCheckError}

} - {osUpdateStale && !uc?.error ? ( + {osCheckingVmid === selectedVM.vmid ? ( +
+ + {t("vmLxc.updates.checkInProgress")} +
+ ) : !uc?.last_check && !uc?.error ? ( +

{t("vmLxc.updates.notCheckedYet")}

+ ) : osUpdateStale && !uc?.error ? (

{t("vmLxc.updates.checkStaleHelp")}

) : !osUpdateStatusKnown ? (
{ {t("vmLxc.updates.noOsUpdatesPending")}
)} -
+ {osUpdateStatusKnown && osCheckingVmid !== selectedVM.vmid &&
+ {hasOsUpdates ? ( -
+ ) : ( + + )} +
} {/* Docker has three independent lifecycles: @@ -6200,11 +6278,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { dockerRefresh: true, }, )} - className={`${dockerEngineHasUpdate - ? pendingBtnCls - : dockerEngineUpToDate - ? upToDateBtnCls - : neutralBtnCls} flex-shrink-0`} + className={`${getUpdateActionButtonClass(dockerEngineHasUpdate, dockerEngineUpToDate)} flex-shrink-0`} > {dockerEngineHasUpdate && } {!dockerEngineHasUpdate && !dockerEngineUpToDate && } @@ -6480,7 +6554,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ) : helperTracksVersion ? (

- {t("vmLxc.updates.versionTrackingPending")} + {t("vmLxc.updates.versionStatusUnknown")}

) : helperExists ? (

@@ -6503,7 +6577,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // still force the helper's own // update script. const noState = !hasUpd && !upToD - const cls = hasUpd ? pendingBtnCls : upToD ? upToDateBtnCls : neutralBtnCls + const cls = getUpdateActionButtonClass(hasUpd, upToD) const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater") return (

@@ -6639,7 +6713,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : null ) : ( -
{t("vmLxc.updates.versionTrackingPendingShort")}
+
{t("vmLxc.updates.versionStatusUnknown")}
)} ) : tracksVersion ? ( @@ -6651,7 +6725,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {t("vmLxc.updates.dockerImagesSubheading")} - : t("vmLxc.updates.versionTrackingPendingShort")} + : aw.installed_via === "manual" + ? t("vmLxc.updates.installedVersionUnverified") + : t("vmLxc.updates.versionStatusUnknown")} ) : hasCmd ? (
@@ -6673,7 +6749,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { targetLabels: [aw.name || t("vmLxc.updates.applicationDefaultName")], }, )} - className={hasUpdate ? pendingBtnCls : upToDate ? upToDateBtnCls : neutralBtnCls} + className={getUpdateActionButtonClass(hasUpdate, upToDate)} > {hasUpdate && } {!hasUpdate && !upToDate && } @@ -6681,7 +6757,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ? t("vmLxc.updates.applyUpdate") : upToDate ? t("vmLxc.updates.upToDate") - : t("vmLxc.updates.runUpdater")} + : aw.update_method === "helper" + ? t("vmLxc.updates.runUpdater") + : t("vmLxc.updates.runCustomCommand")}
) : ( @@ -6719,6 +6797,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

{t("vmLxc.bulkUpdate.description")}

+ {dockerRegistered && ( +

+ {t("vmLxc.bulkUpdate.osDockerScope")} +

+ )} {!bulkEditMode && (