diff --git a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js index 4d479e4..392a526 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -599,13 +599,20 @@ class OverviewManager { : (failed ? `auto failed` : ''); + // A newer RELEASE LINE exists (v0.16 → v0.17). Deliberately not an action: + // no Update button applies it, because moving between versions can carry a + // data migration. It is a nudge to go read release notes and change the + // Version field, which is why it renders as a quiet informational chip. + const newer = a.newer_version + ? `${esc(a.newer_version)} available` + : ''; const updBtn = a.update_available ? `` : ''; return `
diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index cc09f05..f7837ea 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -550,9 +550,17 @@ class UpdaterPage { : (this.autoAttemptFailed(a) ? 'Set to automatic, but this build failed to apply and was rolled back — it won\'t be retried. Press Update to try again, or wait for the next build.' : 'Set to automatic — new builds install on their own, after a recovery snapshot.'); + // A newer release line, if one has been published. Separate from the + // update state above on purpose: "up to date" stays true — you ARE + // current on the version you track — while still saying a newer one + // exists and what to do about it. + const newerLine = a.newer_version + ? `
A newer release line is available: ${this.escape(a.newer_version)} (you track ${this.escape(a.channel || '—')}). Automatic updates keep you current within your line; to move, set Version on this app's Advanced config tab after checking the release notes.
` + : ''; versionSection = `

Version

${badge} ${cur}${avail ? ` ${avail}` : ''}
-
${policyLine}
`; +
${policyLine}
+ ${newerLine}
`; } const cves = a.cves || []; const appLabel = this.escape((window.getAppDisplayName ? window.getAppDisplayName(a.name) : null) || a.displayName || a.name || 'the app'); diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh index e52851a..24c1054 100644 --- a/scripts/webui/data/generators/updater/webui_updater_scan.sh +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -101,6 +101,80 @@ updaterRegistryDigest() { | tr -d '\r' | grep -oE 'sha256:[0-9a-f]{64}' | head -1 } +# --------------------------------------------------------------------------- +# Newer-VERSION discovery (distinct from newer-BUILD detection above). +# +# The digest compare answers "has the tag I track been rebuilt?". It can never +# answer "does a newer version exist?", because it only ever asks about the tag +# already pinned — so an app on v0.16 reports "up to date" forever while 0.17 +# ships, and nothing says so. That is the difference between an app that +# updates and an app that is current. +# +# Only meaningful for VERSIONED tags. Rolling tags (latest/stable) already move +# on their own, so there is nothing to discover. +# --------------------------------------------------------------------------- + +# Tag list for a repo. Docker Hub only, deliberately: every pinned app in the +# catalogue lives there (nextcloud, stalwartlabs, tootsuite), the endpoint needs +# no auth for public repos, and the generic OCI /v2/tags/list wants a per- +# registry token dance. Anything else returns nothing and the feature simply +# stays quiet for that app rather than guessing. 100 newest tags is plenty: +# they are returned newest-first and we only care about ones ABOVE the current. +updaterRegistryTags() { + local repo="${1%%:*}" # strip any :tag + case "$repo" in + *.*/*|localhost/*) return 0 ;; # a real registry host — not Hub + esac + case "$repo" in */*) : ;; *) repo="library/$repo" ;; esac # official images + command -v curl >/dev/null 2>&1 || return 0 + curl -fsSL --connect-timeout 5 --max-time 15 \ + "https://hub.docker.com/v2/repositories/${repo}/tags?page_size=100&ordering=last_updated" 2>/dev/null \ + | grep -oE '"name":"[^"]+"' | cut -d'"' -f4 +} + +# Split a tag into its SHAPE and its numbers, so only like-for-like is compared. +# "v0.16" -> shape "v#.#" numbers "0 16" +# "31-fpm-alpine" -> "#-fpm-alpine" "31" +# The shape must match exactly, which is what stops v0.16 being "upgraded" to +# v0.16-alpine, 31-fpm-alpine to 31-apache, or a date tag to a semver one. +updaterTagShape() { + printf '%s' "$1" | sed -E 's/[0-9]+/#/g' +} +updaterTagNums() { + printf '%s' "$1" | grep -oE '[0-9]+' | tr '\n' ' ' +} + +# Is tag $1 numerically greater than tag $2? Component-wise, left to right. +# Both must already share a shape, so the component counts line up. +updaterTagGreater() { + local a b; a="$(updaterTagNums "$1")"; b="$(updaterTagNums "$2")" + local -a A=($a) B=($b) + local i n=${#A[@]}; [ ${#B[@]} -lt "$n" ] && n=${#B[@]} + for ((i=0; i y )) && return 0 + (( x < y )) && return 1 + done + return 1 # identical -> not newer +} + +# The newest tag for $2 (repo) that shares $1's (current tag) shape and is +# numerically greater. Empty when there is nothing newer — the common case. +updaterNewerVersionTag() { + local cur="$1" repo="$2" + local shape; shape="$(updaterTagShape "$cur")" + local best="" t + while IFS= read -r t; do + [ -n "$t" ] || continue + [ "$(updaterTagShape "$t")" = "$shape" ] || continue + updaterTagGreater "$t" "$cur" || continue + if [ -z "$best" ] || updaterTagGreater "$t" "$best"; then best="$t"; fi + done < <(updaterRegistryTags "$repo") + printf '%s' "$best" +} + # Human-readable version for display: OCI label → tag (versioned) → tag·shortdigest. updaterDisplayVersion() { local oci="$1" channel="$2" vtype="$3" digest="$4" @@ -182,6 +256,21 @@ webuiUpdaterScan() { local update_available=false [ -n "$dig" ] && [ -n "$avail_dig" ] && [ "$dig" != "$avail_dig" ] && update_available=true + # Newer VERSION (a different tag), not just a newer build of this tag. + # Versioned tags only, and only inside the registry window — it is one + # more network call. Reuses the previous answer between windows so the + # field does not flicker off on a throttled scan. + local newer_ver="" + if [ "$vtype" = "versioned" ]; then + if [ "$do_registry" = "1" ]; then + newer_ver="$(updaterNewerVersionTag "$channel" "$(updaterRepoTag "$anchor")")" + newer_ver="${newer_ver%%:*}" + elif [ "$have_jq" = "1" ] && [ -f "$prev_json" ]; then + newer_ver="$(jq -r --arg n "$app" '(.apps[]?|select(.name==$n)|.newer_version)//""' "$prev_json" 2>/dev/null)" + [ "$newer_ver" = "null" ] && newer_ver="" + fi + fi + local cur_ver; cur_ver="$(updaterDisplayVersion "$oci" "$channel" "$vtype" "$dig")" local avail_ver="" if [ "$update_available" = "true" ]; then @@ -238,6 +327,7 @@ webuiUpdaterScan() { --arg available_digest "$avail_dig" --argjson update_available "$update_available" \ --arg last_checked "$now" --argjson services "$svcs" \ --arg update_type "$policy" --arg auto_attempted "$auto_attempted" \ + --arg newer_version "$newer_ver" \ '{name:$name,displayName:$displayName,type:$type,channel:$channel, current_image:$current_image,current_version:$current_version,current_digest:$current_digest, available_image:$available_image, @@ -245,6 +335,7 @@ webuiUpdaterScan() { available_digest:$available_digest,update_available:$update_available, update_type:$update_type, auto_attempted_digest:(if $auto_attempted=="" then null else $auto_attempted end), + newer_version:(if $newer_version=="" then null else $newer_version end), scanned:true,last_checked:$last_checked,services:$services}' \ >> "$objs" 2>/dev/null else