Fix Docker app version tracking, cache consistency and delegated updates

This commit is contained in:
MacRimi
2026-09-05 17:01:02 +02:00
parent 3a49648f0d
commit 337cb188c3
17 changed files with 668 additions and 92 deletions
+7 -16
View File
@@ -233,9 +233,8 @@ interface Suggestions {
docker_web_links?: DockerWebLinkSuggestion[]
}
// An application proven to run inside Docker. Shown beneath the Docker
// detection, never as a registrable app: its update path is the Docker
// image it comes from, and offering a second one would contradict it.
// A registrable application inside Docker. Its image remains the updater;
// registration adds identity and version tracking, not a second update path.
interface DockerWorkload {
slug: string
name: string
@@ -636,10 +635,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)
// Detections the user hid and could restore from the Register-a-
// different-app panel. Not affected by registration state.
const hiddenDetections = detectedList.filter((d) => dismissedSlugs.has(d.slug))
const hiddenDetections = [...detectedList, ...(suggestions?.docker_workloads || [])]
.filter((d, index, items) => dismissedSlugs.has(d.slug) && items.findIndex(item => item.slug === d.slug) === index)
const searchInstalledApplications = async () => {
const before = new Set(visibleDetected.map((item) => item.slug))
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
setSearchingApplications(true)
setDetectionNotice(null)
setError(null)
@@ -653,6 +653,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
const detected = new Set<string>()
if (result.helper_slug) detected.add(result.helper_slug)
for (const item of result.extras || []) detected.add(item.slug)
for (const item of result.docker_workloads || []) detected.add(item.slug)
const visible = [...detected].filter(
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
)
@@ -664,16 +665,6 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
found: true,
text: t("vmLxc.appEditor.newApplicationsDetected", { count: newCount }),
})
} else if ((result.docker_workloads || []).length > 0) {
// Saying "nothing found" while the panel is listing containerised
// applications it just read versions from is the one answer that is
// certainly wrong.
setDetectionNotice({
found: true,
text: t("vmLxc.appEditor.dockerDetectedWithWorkloads", {
count: (result.docker_workloads || []).length,
}),
})
} else {
setDetectionNotice({
found: false,
@@ -2361,7 +2352,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
size="sm"
variant="ghost"
onClick={() => dismissDetection(w.slug, w.name)}
aria-label={`Hide ${w.name} detection`}
aria-label={`${t("vmLxc.appEditor.hideButton")}: ${w.name}`}
title={t("vmLxc.appEditor.hidePermanentlyTooltip")}
className="flex-1 sm:flex-none bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 text-red-400 hover:text-red-300"
>
+38 -14
View File
@@ -100,6 +100,8 @@ interface LxcAppWatch {
// once, through the image.
docker_available_version?: string | null
docker_update_available?: boolean | null
docker_image_reference?: string | null
docker_binding_error?: string | null
ports?: LxcAppPort[]
logo_url?: string | null
health_path?: string | null
@@ -263,7 +265,7 @@ function hasLxcPendingUpdates(vm: VMData): boolean {
if (vm.type !== "lxc") return false
const osUpdates = vm.update_check?.count ?? 0
const appUpdates = (vm.app_watches || []).filter(
(app) => app.update_available === true && !app.exclude_from_badge,
(app) => app.update_via !== "docker" && app.update_available === true && !app.exclude_from_badge,
).length
const dockerRegistered = (vm.app_watches || []).some((app) => app.helper_slug === "docker")
const dockerUpdates = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0
@@ -273,9 +275,9 @@ function hasLxcPendingUpdates(vm: VMData): boolean {
// supported choice, and it must not leave the CT looking up to date.
// Counted only when the image is not already counted, so one release
// stays one number.
const delegatedUpdates = dockerRegistered ? 0 : (vm.app_watches || []).filter(
const delegatedUpdates = dockerRegistered ? 0 : new Set((vm.app_watches || []).filter(
(app) => app.update_via === "docker" && app.docker_update_available === true && !app.exclude_from_badge,
).length
).map(app => app.docker_image_reference || app.id)).size
return osUpdates + appUpdates + dockerUpdates + delegatedUpdates > 0
}
@@ -2572,15 +2574,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const getAggregateUpdateCheck = (vm: VMData): LxcUpdateCheck | undefined => {
const uc = vm.update_check
const appCount = (vm.app_watches || []).filter(
(a) => a.update_available === true && !a.exclude_from_badge,
(a) => a.update_via !== "docker" && a.update_available === true && !a.exclude_from_badge,
).length
const dockerRegistered = (vm.app_watches || []).some((a) => a.helper_slug === "docker")
const dockerCount = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0
// See hasLxcPendingUpdates: a delegated app counts only while its image
// is not already being counted through the Docker section.
const delegatedCount = dockerRegistered ? 0 : (vm.app_watches || []).filter(
const delegatedCount = dockerRegistered ? 0 : new Set((vm.app_watches || []).filter(
(a) => a.update_via === "docker" && a.docker_update_available === true && !a.exclude_from_badge,
).length
).map(app => app.docker_image_reference || app.id)).size
const osCount = uc?.count ?? 0
const total = osCount + appCount + dockerCount + delegatedCount
if (!uc && appCount === 0 && dockerCount === 0 && delegatedCount === 0) return undefined
@@ -2590,10 +2592,17 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// showed a number that explained nothing. Applications and images join
// that list under the same names they carry everywhere else.
const pendingNames: LxcPackageUpdate[] = []
const namedImages = new Set<string>()
for (const a of vm.app_watches || []) {
if (a.exclude_from_badge) continue
const isDelegatedPending = a.update_via === "docker" && a.docker_update_available === true && !dockerRegistered
if (a.update_via === "docker" && !isDelegatedPending) continue
if (a.update_available !== true && !isDelegatedPending) continue
if (isDelegatedPending) {
const reference = a.docker_image_reference || a.id
if (namedImages.has(reference)) continue
namedImages.add(reference)
}
pendingNames.push({
name: a.name || "",
current: a.installed_version || "",
@@ -5171,6 +5180,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const hasOsUpdates = osUpdateStatusKnown && !!uc.available
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
const dockerRegistered = !!dockerAppWatch
const dockerWorkloadsRegistered = dockerRegistered || (selectedVM.app_watches || []).some(a => a.update_via === "docker")
const dockerEngineInstalledVersion = selectedVM.docker_inventory?.engine_version
|| dockerAppWatch?.installed_version
|| ""
@@ -5187,7 +5197,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|| canonicalDockerEngineUpdateCommand
const dockerInventoryRefreshing = selectedVM.docker_inventory?.refreshing === true
const dockerInventoryAvailable = selectedVM.docker_inventory?.available === true
const dockerImages = dockerRegistered ? (selectedVM.docker_inventory?.images || []) : []
const delegatedContainers = new Set((selectedVM.app_watches || [])
.filter(a => a.update_via === "docker").map(a => a.container_name))
const dockerImages = (selectedVM.docker_inventory?.images || []).filter(image =>
dockerRegistered || (image.used_by || []).some(name => delegatedContainers.has(name)))
const followedImageReferences = new Set(dockerImages.map(image => image.reference))
const dockerPending = dockerImages.filter((image) => image.update_available === true)
const helperExists = !!uc?.app_updater_present && uc?.helper_slug_source === "update_wrapper"
const helperName = uc?.helper_app_name || null
@@ -5250,7 +5264,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
: scheduleTargets.some((target) => versionTrackedScheduleAppIds.has(target))
const composeProjects = new Map<string, LxcDockerComposeTarget>()
for (const target of selectedVM.docker_inventory?.compose_projects || []) {
composeProjects.set(`docker-compose:${target.project}`, target)
if (dockerRegistered || dockerImages.some(image => (image.update_targets || []).some(item => item.project === target.project))) {
composeProjects.set(`docker-compose:${target.project}`, target)
}
}
const standaloneContainers = new Set<string>()
for (const image of dockerImages) {
@@ -5315,8 +5331,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
logoUrl: webLinkLogo || app.logo_url?.trim() || "",
}
})
const dockerUpdateUnits = dockerRegistered
? (selectedVM.docker_inventory?.update_units || [])
const dockerUpdateUnits = dockerWorkloadsRegistered
? (selectedVM.docker_inventory?.update_units || []).filter(unit =>
dockerRegistered || (unit.references || []).some(ref => followedImageReferences.has(ref)))
: []
const bulkActionChoices = [
...bulkAppChoices,
@@ -5513,8 +5530,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
standalone containers. Keep all three in
one registered-app section while exposing
a concrete action for each target. */}
{dockerRegistered && dockerAppWatch && (
{dockerWorkloadsRegistered && (
<div className={dockerEditing ? "py-4 -mx-4 px-4 bg-accent [&_textarea]:bg-background" : "py-4"}>
{dockerAppWatch && (<>
<div className="flex items-center justify-between gap-3 mb-3 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<Container className="h-4 w-4 text-muted-foreground flex-shrink-0" />
@@ -5649,8 +5667,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
</div>
)}
</>)}
{selectedVM.docker_inventory && (<>
<div className="border-t border-border/50 pt-4 mb-1 flex items-center justify-between gap-3">
<div id={`docker-images-${selectedVM.vmid}`} className="border-t border-border/50 pt-4 mb-1 flex items-center justify-between gap-3">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
{t("vmLxc.updates.dockerImagesSubheading")}
</div>
@@ -5985,7 +6004,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{aw.name}
</h3>
</div>
{!editing && (
{!editing && aw.update_via !== "docker" && (
<button
type="button"
onClick={() => openCustomCmdEditor(aw)}
@@ -6074,7 +6093,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : tracksVersion ? (
<div className="text-sm text-muted-foreground">
{aw.update_via === "docker"
? t("vmLxc.updates.updatedWithDockerImage")
? <>{aw.docker_binding_error
? t("vmLxc.updates.dockerBindingUnavailable")
: t("vmLxc.updates.updatedWithDockerImage")}{" "}
<a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}>
{t("vmLxc.updates.dockerImagesSubheading")}
</a></>
: t("vmLxc.updates.versionTrackingPendingShort")}
</div>
) : hasCmd ? (