feat(docker): name containerised applications and the versions they run

An LXC running its workload in Docker could not answer two questions it
already had the data for: which application is in there, and whether a
newer version exists.

**Which version is available.** The Updates tab resolved that number only
for docker.io, and only when a version tag happened to share the digest of
the tag in use. On ghcr.io, lscr.io or quay.io the row said "New image
available" with no number at all. The image a pull would install carries
its own version label, so it is now read from the registry by digest —
over the same protocol and Bearer challenge the digest comparison already
uses, and through the same label lookup the installed version uses, now
shared as _docker_version_from_labels.

The question this answers is the one the tab asks: what do I get if I
re-pull this tag. Not "what is the newest upstream release", which is a
different number whenever a tag is pinned or the publisher tags releases
differently from images.

Docker Hub keeps priority on docker.io: its tag API is not a pull and does
not spend the anonymous pull-rate budget, and official images carry no
labels for the registry path to read. The digest still decides whether an
update exists; this only names it, and declines to name it when the answer
would be a guess — no build for this platform, no labels, an unreadable
manifest, a moving tag, a rebuild of the same version, or two sides whose
versions came from different label keys. Each refusal is recorded in
available_version_source.

Attestation manifests are skipped explicitly: they advertise
unknown/unknown and their config blob is a provenance document, not an
image. Every document is fetched by digest and verified against it, the
config read is bounded, and it is cached per digest, which never changes
content. The CDN redirect is followed by hand, dropping Authorization:
urllib re-sends it to the redirect target and signed-URL storage rejects a
second auth mechanism.

**Which application it is.** The probe already read "1.37.2" out of a
Vaultwarden container and get_suggestions discarded it, so the panel
answered "No new applications were detected" about an application whose
version it had just measured. Containerised applications are now offered
for registration like any other, with their name, logo, published ports
and installed version.

What they do not get is an update path of their own, because they do not
have one: updating Vaultwarden means pulling and recreating its image. A
new update_via=docker marker records that delegation, so one release stays
one badge, one notification and one button. The marker is validated rather
than inferred from installed_via, since docker_exec with an upstream is a
legitimate registration someone may already rely on; combining it with an
upstream is rejected instead of silently stripped, because registering an
app that checks GitHub behind a delegation promising it will not is worse
than an error message.

Three failure modes the delegation had to be defended against: detector
auto-healing would have migrated the app onto a leftover /root/.<app>
marker and quietly un-delegated it; saving replaces the whole record, so
the editor carries the marker explicitly rather than dropping it on the
first port edit; and the release-age hold gates on a publish date a
delegated app never has, which deferred the whole schedule forever.

Their version is resolved server-side through the container the detector
declares — not through the app's name or image, since Immich's compose
service and image are both immich-server while the application is immich.
The annotation happens on the way out of both endpoints rather than into
their caches: the App tab's cache is invalidated by events, not by time,
and the Docker inventory it reads is built asynchronously, so annotating
before storing froze a response taken before the first scan.

The rows carry that name too. display_name was already computed and
already used by the bulk-update section; the image row, the update
notification and the CT badge now use it as well. A delegated app's
pending update counts in the badge only while its image is not already
being counted, so registering just the application does not leave the
container looking up to date, and registering both does not count twice.

Catalog: four detectors verified on real containers, following the rules
in the file. vaultwarden and immich gain docker fallbacks for installs
where the native marker does not exist. netalertx is new — note its
repository is netalertx/NetAlertX; the Docker Hub namespace 404s.
technitiumdns is new and uses Technitium's own update endpoint rather than
GitHub releases: its marker reads 15.4 while the release tag is v15.4.0,
and _version_tuple compares (15,4,0) > (15,4) as an update that would
never clear.

Verified live against ghcr.io (Immich 3.1.0), docker.io (Vaultwarden
1.37.2) and lscr.io (Radarr 6.3.0.10514-ls314), plus postgres:16, which
correctly reports no version because official images carry no labels.
Exercised end to end on Proxmox VE 9.2.4 with NetAlertX reporting
26.6.3 -> 26.9.0. 31 new unit tests cover the resolution rules, every
refusal, the delegation contract and the container-to-image pairing.
This commit is contained in:
byGarcia
2026-09-04 13:16:28 +02:00
parent 5e60feab84
commit de72e18a77
8 changed files with 1390 additions and 66 deletions
+150 -6
View File
@@ -92,6 +92,9 @@ interface AppConfig {
health_path?: string
logo_url?: string
helper_slug?: string
// Set when the application runs inside Docker: registered for its identity
// and installed version, updated by the image it comes from.
update_via?: string
// Preserved here even though this editor does not execute updates.
// The backend uses full-record replacement, so omitting these when
// editing ports/tracking would silently erase the Updates-tab setup.
@@ -130,6 +133,11 @@ interface AppEntry extends AppConfig {
id: string
state?: AppState
created_at?: string
// Resolved server-side from the image a delegated app updates with. It is
// not the app's own upstream — the app deliberately has none — so it is
// kept in its own field rather than folded into state.latest_version.
docker_available_version?: string | null
docker_update_available?: boolean | null
}
interface SidecarResponse {
@@ -220,9 +228,25 @@ interface Suggestions {
logo_url?: string | null
category?: string | null
extras?: DetectedApp[]
docker_workloads?: DockerWorkload[]
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.
interface DockerWorkload {
slug: string
name: string
logo_url?: string | null
container_name?: string
installed_version?: string | null
installed_via?: string | null
tracking_suggestion?: TrackingSuggestion | null
default_ports?: number[]
category?: string | null
}
// Compact catalog entry — one row for every registerable app the
// picker can offer. Fetched once from /api/apps/catalog on panel
// mount, filtered client-side while the user types.
@@ -594,6 +618,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
)
// Alias for pre-existing consumers (post-registration chip strip).
const unregisteredDetected = visibleDetected
// Applications running inside Docker. They are filtered the same way, and
// deliberately NOT nested inside the Docker chip: that chip disappears the
// moment Docker is registered, which is exactly when these become
// registrable.
const visibleWorkloads = (suggestions?.docker_workloads || []).filter(
(w) => !registeredSlugs.has(w.slug) && !dismissedSlugs.has(w.slug),
)
// 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))
@@ -624,6 +655,16 @@ 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,
@@ -680,6 +721,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
health_path: existing.health_path || "",
logo_url: existing.logo_url || "",
helper_slug: existing.helper_slug || "",
// Preserved explicitly: saving replaces the whole record, so dropping
// it here would silently un-delegate the app the first time someone
// edits one of its ports.
update_via: (existing as { update_via?: string }).update_via || "",
update_command: existing.update_command || "",
hide_no_updater_notice: existing.hide_no_updater_notice === true,
notifications_enabled: existing.notifications_enabled !== false,
@@ -745,6 +790,20 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
docker_image: (t as any).docker_image || "",
tag_regex: t.tag_regex || "v?(\\d+\\.\\d+\\.\\d+)",
}
// A containerised workload delegates its update to its image, and
// the backend refuses a delegation that also carries an upstream —
// so the upstream fields seeded above are cleared, not just ignored.
if ((t as { update_via?: string }).update_via === "docker") {
seed = {
...seed,
update_via: "docker",
upstream_type: "",
repo: "",
upstream_url: "",
upstream_json_path: "",
docker_image: "",
}
}
setShowAdvanced(true)
} else {
setShowAdvanced(false)
@@ -1825,6 +1884,22 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
if (!t) patch.tag_regex = ""
setField(patch)
}
// A delegated app has no upstream of its own on purpose:
// the image it runs on resolves the available version.
// Showing an empty "none" dropdown reads as a setting the
// user forgot, so say what is happening instead.
if (editing.draft.update_via === "docker") {
return (
<div className="rounded-md border border-border/60 bg-background/40 p-3">
<div className="text-xs font-medium text-foreground">
{t("vmLxc.appEditor.upstreamDelegatedTitle")}
</div>
<div className="text-[10px] text-muted-foreground mt-1 leading-relaxed">
{t("vmLxc.appEditor.upstreamDelegatedHelp")}
</div>
</div>
)
}
return (
<>
<div>
@@ -2227,6 +2302,67 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
)
// A containerised application. Same chip language as a native detection,
// with the update path spelled out: it is registered for what it is, not
// for an updater it does not have.
const renderWorkloadChip = (w: DockerWorkload) => (
<div key={`workload-${w.slug}`} className="p-3 rounded-md border border-border/60 bg-background/40">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{w.logo_url && (
<ThemeAwareLogo
src={w.logo_url}
className="h-14 w-14 flex-shrink-0 rounded-md object-contain"
/>
)}
<div className="min-w-0">
<div className="text-sm font-semibold text-foreground truncate">{w.name}</div>
<div className="text-xs text-emerald-400/90">
{w.installed_version
? t("vmLxc.appEditor.versionDetected", { version: w.installed_version })
: t("vmLxc.appEditor.detectedInContainer")}
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 truncate">
{t("vmLxc.appEditor.runsInsideDocker")}
{w.container_name ? ` · ${w.container_name}` : ""}
</div>
</div>
</div>
<div className="flex flex-row gap-2 flex-shrink-0 sm:justify-end w-full sm:w-auto">
<Button
size="sm"
onClick={() => openEditor(undefined, {
withTracking: !!w.tracking_suggestion,
preset: {
slug: w.slug,
name: w.name,
logo_url: w.logo_url || "",
default_ports: w.default_ports || [],
category: w.category || null,
tracking_suggestion: w.tracking_suggestion || null,
} as unknown as DetectedApp,
})}
className="flex-[3] sm:flex-none bg-blue-500 hover:bg-blue-600 text-white"
>
<PlusCircle className="h-3.5 w-3.5 mr-1" />
{t("vmLxc.appEditor.registerButton")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => dismissDetection(w.slug, w.name)}
aria-label={`Hide ${w.name} detection`}
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"
>
<EyeOff className="h-3.5 w-3.5 sm:mr-1" />
<span className="hidden sm:inline">{t("vmLxc.appEditor.hideButton")}</span>
</Button>
</div>
</div>
</div>
)
return (
<div className="space-y-4">
{/* Browse panel — surfaces hidden detections with Restore
@@ -2288,9 +2424,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
{t("vmLxc.appEditor.noAppsBody")}
</p>
{visibleDetected.length > 0 && (
{(visibleDetected.length > 0 || visibleWorkloads.length > 0) && (
<div className="space-y-2 pt-2">
{visibleDetected.map(renderDetectionChip)}
{visibleWorkloads.map(renderWorkloadChip)}
</div>
)}
<div className="pt-1 flex flex-wrap justify-center gap-2">
@@ -2394,8 +2531,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
</div>
{(() => {
const hasUpstream = !!(app.repo || app.upstream_type)
const hasUpdate = st?.update_available === true
// A delegated app has no upstream of its own; the version to
// compare against comes from the image it updates with.
const delegated = app.update_via === "docker"
const hasUpstream = !!(app.repo || app.upstream_type) || (delegated && !!app.docker_available_version)
const hasUpdate = delegated
? app.docker_update_available === true
: st?.update_available === true
const latestVersion = delegated ? app.docker_available_version : st?.latest_version
if (!tracking || !(st?.installed_version || hasUpstream)) return null
return (
<div className={"mb-3 grid gap-3 " + (st?.installed_version && hasUpstream ? "grid-cols-2" : "grid-cols-1")}>
@@ -2413,8 +2556,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
{t("vmLxc.appEditor.latestUpstream")}
</div>
<div className={"text-lg font-semibold font-mono flex items-center gap-2 " + (hasUpdate ? "text-purple-400" : "text-foreground")}>
{st?.latest_version || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && st?.latest_version && (
{latestVersion || <span className="text-muted-foreground text-base font-normal">{t("vmLxc.appEditor.checkingStatus")}</span>}
{hasUpdate && latestVersion && (
<ArrowUpCircle className="h-5 w-5 text-purple-400 flex-shrink-0" aria-label={t("vmLxc.appEditor.updateAvailableBadge")} />
)}
</div>
@@ -2561,12 +2704,13 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
hasn't been registered yet is shown as a chip with a
one-click Register button. Filtered against the sidecar's
`helper_slug` field so a registered app never re-appears. */}
{apps.length > 0 && unregisteredDetected.length > 0 && (
{apps.length > 0 && (unregisteredDetected.length > 0 || visibleWorkloads.length > 0) && (
<div className="space-y-2">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
{t("vmLxc.appEditor.alsoDetectedContainer")}
</div>
{unregisteredDetected.map(renderDetectionChip)}
{visibleWorkloads.map(renderWorkloadChip)}
</div>
)}
+115 -12
View File
@@ -90,6 +90,15 @@ interface LxcAppWatch {
id: string
name: string | null
installed_via?: string | null
// Set when the app runs inside Docker: its update is the image's, so it
// reports identity and installed version and nothing else.
update_via?: string | null
container_name?: string | null
// Resolved server-side from the image this app delegates to. Kept apart
// from latest_version/update_available so the CT badge counts the release
// once, through the image.
docker_available_version?: string | null
docker_update_available?: boolean | null
ports?: LxcAppPort[]
logo_url?: string | null
health_path?: string | null
@@ -246,7 +255,16 @@ function hasLxcPendingUpdates(vm: VMData): boolean {
).length
const dockerRegistered = (vm.app_watches || []).some((app) => app.helper_slug === "docker")
const dockerUpdates = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0
return osUpdates + appUpdates + dockerUpdates > 0
// An app that delegates to Docker has no update flag of its own, so its
// pending release reaches this count through the image — but only while
// Docker is registered. Registering just the application is now a
// 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(
(app) => app.update_via === "docker" && app.docker_update_available === true && !app.exclude_from_badge,
).length
return osUpdates + appUpdates + dockerUpdates + delegatedUpdates > 0
}
function buildRegisteredAppUrl(vm: VMData, port?: LxcAppPort): string | null {
@@ -2532,14 +2550,47 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
).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(
(a) => a.update_via === "docker" && a.docker_update_available === true && !a.exclude_from_badge,
).length
const osCount = uc?.count ?? 0
const total = osCount + appCount + dockerCount
if (!uc && appCount === 0 && dockerCount === 0) return undefined
const total = osCount + appCount + dockerCount + delegatedCount
if (!uc && appCount === 0 && dockerCount === 0 && delegatedCount === 0) return undefined
if (total === 0) return uc
// The badge names what is pending, and until now it could only name OS
// packages — so a container whose only pending update was an application
// showed a number that explained nothing. Applications and images join
// that list under the same names they carry everywhere else.
const pendingNames: LxcPackageUpdate[] = []
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_available !== true && !isDelegatedPending) continue
pendingNames.push({
name: a.name || "",
current: a.installed_version || "",
latest: (isDelegatedPending ? a.docker_available_version : a.latest_version) || "",
security: false,
})
}
if (dockerRegistered) {
for (const image of vm.docker_inventory?.images || []) {
if (image.update_available !== true) continue
pendingNames.push({
name: image.display_name || image.reference,
current: image.installed_version || "",
latest: image.available_version || "",
security: false,
})
}
}
return {
...(uc || {}),
count: total,
available: true,
packages: [...(uc?.packages || []), ...pendingNames],
last_check: uc?.last_check ?? new Date().toISOString(),
} as LxcUpdateCheck
}
@@ -5159,7 +5210,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
}).map((app) => ({ id: `app:${app.id}`, label: app.name }))
const versionTrackedScheduleAppIds = new Set(
registeredApps
.filter((app) => !!app.installed_via && app.helper_slug !== "docker")
.filter((app) => (
!!app.installed_via
&& app.helper_slug !== "docker"
// A delegated app never resolves a release date;
// holding the schedule on it would defer forever.
&& app.update_via !== "docker"
))
.map((app) => `app:${app.id}`),
)
const scheduleHasVersionTrackedApps = scheduleTargets.includes("apps")
@@ -5265,6 +5322,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const bulkActionAppIds = new Set(bulkAppChoices.map((choice) => choice.id))
const bulkUnavailableApps = registeredApps.filter((app) => (
app.helper_slug !== "docker"
// Not "unavailable": it updates through the Docker
// unit listed right above in this same section.
&& app.update_via !== "docker"
&& !bulkActionAppIds.has(`app:${app.id}`)
))
const pendingDockerBulkTargets = bulkTargets.filter((target) => (
@@ -5613,9 +5673,23 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)}
</div>
<div className="min-w-0">
<span className="font-mono text-foreground/90 block truncate" title={image.reference}>
{image.reference}
</span>
{image.display_name ? (
<>
<span className="text-foreground block truncate" title={image.display_name}>
{image.display_name}
</span>
<span
className="font-mono text-xs text-muted-foreground block truncate"
title={image.reference}
>
{image.reference}
</span>
</>
) : (
<span className="font-mono text-foreground/90 block truncate" title={image.reference}>
{image.reference}
</span>
)}
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-1.5">
<Package className="h-3.5 w-3.5 flex-shrink-0" />
{image.installed_version ? (
@@ -5977,11 +6051,38 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<Package className="h-4 w-4 flex-shrink-0" />
<span>{t("vmLxc.updates.installedLabel")} <code className="text-foreground/80">{aw.installed_version}</code></span>
</div>
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div>
{/* "Pending" is a promise that a number is on its way. For a
delegated app it never is, by design: the image row carries
the available version. Saying nothing here is the honest
option the delegation notice below explains where to look. */}
{aw.update_via === "docker" ? (
aw.docker_update_available === true && aw.docker_available_version ? (
<div className="flex items-center gap-2 text-purple-400">
<ArrowUpCircle className="h-4 w-4 flex-shrink-0" />
<span>
{t("vmLxc.updates.upstreamAvailable", { version: aw.docker_available_version })}
</span>
</div>
) : aw.docker_update_available === true ? (
<div className="flex items-center gap-2 text-purple-400">
<ArrowUpCircle className="h-4 w-4 flex-shrink-0" />
<span>{t("vmLxc.updates.imageUpdateAvailable")}</span>
</div>
) : aw.docker_update_available === false ? (
<div className="flex items-center gap-2 text-green-500">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span>{t("vmLxc.updates.imageUpToDate")}</span>
</div>
) : null
) : (
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div>
)}
</div>
) : tracksVersion ? (
<div className="text-sm text-muted-foreground">
{t("vmLxc.updates.versionTrackingPendingShort")}
{aw.update_via === "docker"
? t("vmLxc.updates.updatedWithDockerImage")
: t("vmLxc.updates.versionTrackingPendingShort")}
</div>
) : hasCmd ? (
<div className="text-sm text-muted-foreground">
@@ -6016,9 +6117,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
) : (
<p className={`text-xs text-muted-foreground leading-relaxed min-w-0 ${tracksVersion ? "mt-3" : ""}`}>
{managedByOs
? t("vmLxc.updates.managedByOsPackages")
: t("vmLxc.updates.noUpdateMethodBody")}
{aw.update_via === "docker"
? t("vmLxc.updates.updatedWithDockerImage")
: managedByOs
? t("vmLxc.updates.managedByOsPackages")
: t("vmLxc.updates.noUpdateMethodBody")}
</p>
)}
</>