feat(updater): install window, honest Check-now, failed-auto surfacing

Four fixes that make the auto-updater a trustworthy background system:

* CFG_UPDATER_WINDOW (default 06:00-08:00 host time, right after the
  05:00 backup cron; HH:MM-HH:MM wraps midnight, 'always' = any time).
  Gates only the enqueue — scans keep running all day, so the Updates
  page stays current and pending updates visibly wait for the window.
  Malformed values fail closed and are rejected by the WebUI validator.

* "Check now" actually checks: an explicit `updater check` sets
  UPDATER_REGISTRY_FORCE=1. The flag existed but nothing ever set it,
  so the button silently reused the 6h digest cache and could not find
  a build the user knew had shipped. Force also overrides interval 0,
  which now means "manual-only" as documented in the roadmap.

* Registry stamp moved from /tmp to <system>/logs: the task processor
  runs under PrivateTmp, so daemon and CLI each kept a separate 6h
  clock and the daemon's reset on every service restart.

* A failed automatic attempt is no longer invisible: the scan emits
  auto_attempted_digest (the one-shot no-retry stamp), and when it
  matches the available build the UI stops promising an install that
  will never come — per-app detail explains, the fleet row gets an
  "auto failed" chip, and the Overview board counts it as needing you.

Also corrects the CFG_TIMEZONE label: it sets the containers' TZ only;
scheduled tasks follow the host clock (timedatectl), and the old
"Timezone for scheduled tasks" wording promised a knob that never
existed. The window + auto_window display state plainly WHEN updates
land, answering "how does the user know when the next update happens".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-11 21:06:27 +01:00
parent 1c3af5a533
commit 66c79f997e
12 changed files with 139 additions and 21 deletions

View File

@ -3,5 +3,5 @@
# @icon 🏷️
# ================================================================================
CFG_INSTALL_NAME=Change-Me # Installation Name - The name for your LibrePortal instance
CFG_TIMEZONE=Etc/UTC # System Timezone - Timezone for scheduled tasks and logging timestamps
CFG_TIMEZONE=Etc/UTC # Container Timezone - Timezone handed to app containers (their TZ). Scheduled tasks — backups, update checks — follow the HOST's own clock, set with timedatectl, not this value.
CFG_INSTALL_LEVEL=beginner # Experience Level - Beginner hides technical detail and skips advanced setup steps. Advanced reveals everything by default. Set during the first-run wizard; can be flipped any time via the Advanced toggle in the WebUI. [beginner:Beginner — simple|advanced:Advanced — show everything]

View File

@ -3,6 +3,7 @@
# @icon 🔄
# ================================================================================
CFG_UPDATER_SCAN_INTERVAL=30 # App Scan Interval - Minutes between automatic app update/CVE/improvement scans. 0 disables.
CFG_UPDATER_REGISTRY_INTERVAL=360 # Registry Check Interval - Minutes between registry lookups for new image builds (the expensive step; the local scan still refreshes every scan). 0 = never (local-only).
CFG_UPDATER_REGISTRY_INTERVAL=360 # Registry Check Interval - Minutes between registry lookups for new image builds (the expensive step; the local scan still refreshes every scan). 0 = only when you press Check now.
CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off]
CFG_UPDATER_AUTO=true # Automatic App Updates - Master switch for per-app automatic updates. Each app's own Update Type decides individually; turning this off makes every app manual. Every update snapshots the app first and rolls back on failure. [true:On|false:Off]
CFG_UPDATER_WINDOW=06:00-08:00 # Automatic Update Window - When automatic updates are allowed to install, in the host's local time (HH:MM-HH:MM; crosses midnight when start > end; 'always' = any time). Checks still run all day so the Updates page stays current — found updates simply wait for the window. Pressing Update yourself always works.

View File

@ -407,9 +407,12 @@ class OverviewManager {
// wolf. They still get a quiet line so nothing is hidden.
// With automatic scanning off nothing is found unattended, so an "auto"
// app's update sits there until someone presses Check — that IS waiting
// for the user, whatever the policy says.
// for the user, whatever the policy says. Likewise a build the auto-
// updater already attempted and rolled back: it will not be retried, so
// it waits for a person too (updater-page's autoAttemptFailed).
const autoScan = !(up.updates && Number(up.updates.scan_interval_minutes) === 0);
const waiting = pending.filter((a) => !autoScan || a.update_type === 'manual');
const waiting = pending.filter((a) =>
!autoScan || a.update_type === 'manual' || (up.autoAttemptFailed && up.autoAttemptFailed(a)));
const selfing = pending.length - waiting.length;
const listOf = (arr) => {
const names = arr.map((a) => a.displayName || a.name);
@ -425,9 +428,14 @@ class OverviewManager {
+ `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`,
});
} else {
// Say WHEN, not just that it happens — "installing automatically"
// with no time reads as "any second now", which a 06:00-08:00 window
// makes untrue for most of the day.
const win = up.updates && up.updates.auto_window;
const winBit = (win && win !== 'always') ? ` during the ${esc(win).replace('-', '')} window` : '';
rows.push({
hue: 'updates', icon: '⬆️', kind: 'ok',
text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically`,
text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically${winBit}`,
sub: `${esc(listOf(pending))} — each is snapshotted first, and rolled back if it fails`,
actions: goto('updates', 'Review', 'updates'),
});
@ -583,9 +591,14 @@ class OverviewManager {
// Automatic is the default, so only the apps that opt OUT carry a marker —
// 30-odd "auto" chips would be wallpaper, one "manual" chip is information.
// The fleet-level statement lives in the auto-check line above the list.
// A failed automatic attempt is the other marker that earns a chip: that
// update will NOT retry itself, so the row must say a person is needed.
const failed = this.updater && this.updater.autoAttemptFailed && this.updater.autoAttemptFailed(a);
const pol = a.update_type === 'manual'
? `<span class="updater-badge updater-badge-unknown" title="This app updates only when you press Update">manual</span>`
: '';
: (failed
? `<span class="updater-badge sev-high" title="The automatic attempt failed and was rolled back — press Update to retry">auto failed</span>`
: '');
const updBtn = a.update_available
? `<button class="updater-btn updater-btn-primary" data-updater-action="update" data-app="${slug}">Update</button>`
: '';

View File

@ -541,10 +541,15 @@ class UpdaterPage {
: (a.scanned ? `<span class="updater-badge updater-badge-ok">up to date</span>` : `<span class="updater-badge updater-badge-unknown">unscanned</span>`);
// What happens next, in the app's own words — the Updates setting on this
// app's Configure page decides, so say which way it is set rather than
// leaving "update available" to imply someone must act.
// leaving "update available" to imply someone must act. One honest
// exception: an auto app whose available build was already attempted and
// rolled back will NOT retry (by design — one shot per build), so saying
// "installs on its own" there would promise an install that never comes.
const policyLine = a.update_type === 'manual'
? 'Set to <strong>manual</strong> — this app updates only when you press Update.'
: 'Set to <strong>automatic</strong> — new builds install on their own, after a recovery snapshot.';
: (this.autoAttemptFailed(a)
? 'Set to <strong>automatic</strong>, 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 <strong>automatic</strong> — new builds install on their own, after a recovery snapshot.');
versionSection = `<div class="updater-detail-section"><h4>Version</h4>
<div class="updater-detail-row">${badge} <span class="updater-row-ver">${cur}${avail ? ` <span class="updater-arrow">→</span> <strong>${avail}</strong>` : ''}</span></div>
<div class="updater-detail-row"><span class="updater-detail-meta">${policyLine}</span></div></div>`;
@ -594,6 +599,15 @@ class UpdaterPage {
return `<div class="updater-detail">${versionSection}${security}${recovery}${history}</div>`;
}
// True when this app's AVAILABLE build is the one the auto-updater already
// attempted and rolled back (the one-shot no-retry stamp). Such an update is
// effectively manual now: it sits until a person retries or a newer build
// ships. Both this page and the Overview board branch on it.
autoAttemptFailed(a) {
return !!(a && a.update_available && a.update_type !== 'manual'
&& a.auto_attempted_digest && a.auto_attempted_digest === a.available_digest);
}
// "automatic" marker for a History entry. Only automatic runs are labelled —
// a hand-pressed update needs no explanation, and entries written before the
// trigger field existed carry none, so silence is also the honest default.
@ -657,11 +671,14 @@ class UpdaterPage {
// restating a config value: all / some / none on automatic.
// Nothing installs itself while the scan that finds updates is off, so with
// iv === 0 the policy is moot and claiming otherwise would be a lie.
// The window says WHEN: checks run all day, installs land inside it.
const win = this.updates && this.updates.auto_window;
const winBit = (win && win !== 'always') ? ` during ${this.escape(win).replace('-', '')}` : '';
const auto = this.apps.filter((a) => a.update_type !== 'manual').length;
let autoBit = '';
if (iv === 0 || !this.apps.length) autoBit = '';
else if (auto === this.apps.length) autoBit = ' · updates install automatically';
else if (auto) autoBit = ` · ${auto} of ${this.apps.length} apps install updates automatically`;
else if (auto === this.apps.length) autoBit = ` · updates install automatically${winBit}`;
else if (auto) autoBit = ` · ${auto} of ${this.apps.length} apps install updates automatically${winBit}`;
else autoBit = ' · updates wait for you';
return `<div class="updater-autocheck${off}"><span class="updater-autocheck-dot"></span>` +
`<span class="updater-autocheck-text">Checked automatically · last checked <strong>${last}</strong>${nextBit}${autoBit}</span>` +

View File

@ -143,7 +143,14 @@ Shipped a shape the original sketch didn't consider: the policy is **per app**,
Why not the sketched `off|security|all` severity split: severity is a property of the *CVE data*, which is only as complete as Trivy's per-image scan, whereas "do I let this app move on its own?" is a property of the *app* — nextcloud and a DNS blocker are not the same risk. Per-app also composes with the CVE work instead of competing with it: a future `security` value can be added per app without changing the master switch or the enqueuer's shape.
**Still to soak:** the honest gap below is unchanged — apply/revert has been exercised in helper-level tests, not end-to-end on a live install with a real pending update. Auto-update inherits exactly that risk, which is why every attempt still snapshots first and why the digest stamp makes a bad build a one-time event rather than a loop.
**Soaked 2026-08-11:** the first live end-to-end auto-update (trivy 0.72.0→0.73.0) ran on a real install. Its first attempt exposed and fixed a latent dynamic-scoping bug (a callee's undeclared `while read app` loop blanked the updater's `app` local mid-update — pre-existing, had also corrupted a manual update's History on 08-10); the retry completed cleanly with `trigger:auto` and exact digest refs.
**Refinements (2026-08-11), after the "hands-off background" review:**
- **Install window**`CFG_UPDATER_WINDOW` (HH:MM-HH:MM host local time, wraps midnight, `always`; default **06:00-08:00**, right after the 05:00 backup cron). Gates only the *enqueue* in `updaterApplyAuto`; scans keep their all-day cadence so detection stays current and pending updates visibly wait. Malformed values fail closed (hold updates) and are rejected by the WebUI validator; the raw value is surfaced in `updates.json` (`auto_window`) so the UI states when installs land. Ordering rationale: backups nightly at 05:00 → updates 06:00-08:00, and every update still takes its own pre-update snapshot regardless (fail-closed), so the window is about *quiet hours*, not safety.
- **Check now forces a live registry lookup**`updater check` (non-`auto`) sets `UPDATER_REGISTRY_FORCE=1`; previously the flag existed but nothing set it, so the button silently reused the 6h digest cache. Force also overrides `CFG_UPDATER_REGISTRY_INTERVAL=0`, making 0 mean "manual-only" as originally intended.
- **Registry stamp out of /tmp** — the task-processor unit runs with systemd `PrivateTmp`, so the /tmp stamp existed once per namespace (daemon vs CLI, daemon's resetting every restart). Now at `<system>/logs/updater_registry_checked`.
- **Failed-auto honesty** — the scan emits each app's `auto_attempted_digest` (the one-shot no-retry stamp). When it matches `available_digest`, the UI stops claiming "installs automatically": the per-app detail says the build failed and won't retry, the fleet row gets an `auto failed` chip, and the Overview board counts it as *waiting for you* (warn) instead of quietly-installing (ok).
- **`CFG_TIMEZONE` label corrected** — it only sets containers' TZ; scheduled tasks follow the host clock. The old "System Timezone - Timezone for scheduled tasks" label promised a knob that never existed.
## 7. Build phases (each independently shippable)

View File

@ -6,9 +6,12 @@
# build available?", `updaterApplyApp` answers "how do I install it safely?",
# and this file answers "should I install it without being asked?".
#
# Policy is per app, with a fleet-wide master switch:
# Policy is per app, with a fleet-wide master switch and a daily install window:
# CFG_<APP>_UPDATE_TYPE = auto | manual (per app, default auto)
# CFG_UPDATER_AUTO = true | false (master switch, default true)
# CFG_UPDATER_WINDOW = HH:MM-HH:MM (host local time, default 06:00-08:00
# — right after the 05:00 backups;
# 'always' = any time)
# Precedence mirrors the established backup-strategy shape (per-app override on
# top of a global default): the master switch can only ever make things MORE
# manual, so "turn auto-updates off" is one flip, not 33 config edits.
@ -31,6 +34,32 @@ _updaterAutoGenDir() { echo "${containers_dir%/}/libreportal/frontend/data/updat
_updaterAutoDir() { echo "$(_updaterAutoGenDir)/auto"; }
_updaterAutoStamp() { echo "$(_updaterAutoDir)/$1.digest"; } # $1=app
# Is the automatic-update window open right now? CFG_UPDATER_WINDOW is
# HH:MM-HH:MM in the HOST's local time (the same clock cron and the daemon run
# on — deliberately NOT CFG_TIMEZONE, which only sets the containers' TZ), or
# 'always'. start > end crosses midnight (22:00-02:00); start == end means the
# window never closes. Scans are not gated by this — only the enqueue is, so
# detection stays current all day and pending updates simply wait.
# Malformed values FAIL CLOSED (hold updates): the user typed something trying
# to restrict when updates land, and ignoring it would violate that intent —
# the Updates page shows the raw window value, so a typo is visible, and the
# WebUI validator rejects bad values before they ever land in the config.
updaterInWindow()
{
local win="${CFG_UPDATER_WINDOW:-always}"
[[ "$win" == "always" ]] && return 0
[[ "$win" =~ ^([01][0-9]|2[0-3]):([0-5][0-9])-([01][0-9]|2[0-3]):([0-5][0-9])$ ]] || return 1
local now s e
now=$(( 10#$(date +%H) * 60 + 10#$(date +%M) ))
s=$(( 10#${BASH_REMATCH[1]} * 60 + 10#${BASH_REMATCH[2]} ))
e=$(( 10#${BASH_REMATCH[3]} * 60 + 10#${BASH_REMATCH[4]} ))
if (( s < e )); then
(( now >= s && now < e ))
else
(( now >= s || now < e )) # wraps midnight; s == e -> whole day
fi
}
# Effective update policy for one app -> "auto" | "manual".
# 1. master switch off -> manual (everything, no exceptions)
# 2. per-app CFG_<APP>_UPDATE_TYPE
@ -85,6 +114,17 @@ updaterApplyAuto()
local upd; upd="$(_updaterAutoGenDir)/updates.json"
[[ -f "$upd" ]] || return 0
# The window gates the ENQUEUE only — the scan that got us here already ran.
# Quiet unless something is actually waiting, so the daemon's half-hourly
# no-op scans don't fill the log with window notices.
if ! updaterInWindow; then
local n_waiting
n_waiting="$(jq -r '[.apps[]? | select(.update_available == true)] | length' "$upd" 2>/dev/null)"
[[ "${n_waiting:-0}" -gt 0 ]] && \
isNotice "$n_waiting update(s) are waiting for the automatic update window (${CFG_UPDATER_WINDOW:-always})."
return 0
fi
local auto_dir; auto_dir="$(_updaterAutoDir)"
[[ -d "$auto_dir" ]] || runFileOp mkdir -p "$auto_dir" 2>/dev/null

View File

@ -41,6 +41,12 @@ cliHandleUpdaterCommands()
(( _now - _last < scan_interval * 60 )) && return 0
fi
fi
# An explicit check (the Check-now button, or `updater check` by
# hand) forces a live registry lookup. Without this the button
# silently reused the 6-hour digest cache, so "Check now" could not
# find a build the user knows just shipped. The daemon's background
# `check auto` stays throttled.
[[ "$app" != "auto" ]] && export UPDATER_REGISTRY_FORCE=1
# Quick + safe — just regenerates the read-only data files. Source
# the generator explicitly if the lazy loader hasn't mapped it yet
# (new file; the array regen self-heals it on deploy, this covers

View File

@ -21,8 +21,10 @@ cliShowUpdaterHelp()
echo ""
echo "Automatic updates: each check enqueues the update for every app set to"
echo "CFG_<APP>_UPDATE_TYPE=auto (the default) — the same snapshot-first apply"
echo "as pressing Update, recorded in History as automatic. Set an app to"
echo "'manual' on its Configure page to hold it back, or CFG_UPDATER_AUTO=false"
echo "to hold back every app. A build that fails is never auto-retried."
echo "as pressing Update, recorded in History as automatic. They install only"
echo "inside CFG_UPDATER_WINDOW (default 06:00-08:00 host time, right after"
echo "the nightly backups); checks still run all day. Set an app to 'manual'"
echo "on its Configure page to hold it back, or CFG_UPDATER_AUTO=false to"
echo "hold back every app. A build that fails is never auto-retried."
echo ""
}

View File

@ -927,6 +927,7 @@ declare -gA LP_FN_MAP=(
[updaterComposePull]="cli/commands/updater/cli_updater_commands.sh"
[updaterDisplayVersion]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterInspectLocal]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterInWindow]="cli/commands/updater/cli_updater_auto.sh"
[updaterLastUpdateFrom]="cli/commands/updater/cli_updater_commands.sh"
[updaterPrimaryImage]="webui/data/generators/updater/webui_updater_scan.sh"
[updaterRecordHistory]="cli/commands/updater/cli_updater_commands.sh"
@ -1940,6 +1941,7 @@ declare -gA LP_FN_ROOT=(
[updaterComposePull]="scripts"
[updaterDisplayVersion]="scripts"
[updaterInspectLocal]="scripts"
[updaterInWindow]="scripts"
[updaterLastUpdateFrom]="scripts"
[updaterPrimaryImage]="scripts"
[updaterRecordHistory]="scripts"
@ -2986,6 +2988,7 @@ _updaterCleanImageRef() { unset -f _updaterCleanImageRef; __lpAutoload "${instal
updaterComposePull() { unset -f updaterComposePull; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterComposePull "$@"; }
updaterDisplayVersion() { unset -f updaterDisplayVersion; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterDisplayVersion "$@"; }
updaterInspectLocal() { unset -f updaterInspectLocal; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterInspectLocal "$@"; }
updaterInWindow() { unset -f updaterInWindow; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_auto.sh"; updaterInWindow "$@"; }
updaterLastUpdateFrom() { unset -f updaterLastUpdateFrom; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterLastUpdateFrom "$@"; }
updaterPrimaryImage() { unset -f updaterPrimaryImage; __lpAutoload "${install_scripts_dir}webui/data/generators/updater/webui_updater_scan.sh"; updaterPrimaryImage "$@"; }
updaterRecordHistory() { unset -f updaterRecordHistory; __lpAutoload "${install_scripts_dir}cli/commands/updater/cli_updater_commands.sh"; updaterRecordHistory "$@"; }

View File

@ -177,7 +177,7 @@ PORTEOF
"category": "general",
"label": "Updates",
"type": "select",
"tooltip": "Automatic installs a new image build as soon as one is published — the app is snapshotted first and rolled back automatically if the update fails. Manual leaves it listed under Updates until you press Update.",
"tooltip": "Automatic installs new image builds during the daily update window (set in Settings → Updater) — the app is snapshotted first and rolled back automatically if the update fails. Manual leaves it listed under Updates until you press Update.",
"options": [
{"value": "auto", "label": "Automatic (recommended)"},
{"value": "manual", "label": "Manual — I'll press Update"}

View File

@ -84,6 +84,14 @@ webuiValidateConfigValue() {
isError " Invalid timezone format for $var_name"
fi
;;
CFG_UPDATER_WINDOW)
# 'always' or HH:MM-HH:MM (24h; start > end wraps midnight). The
# enqueuer fails CLOSED on a malformed value, so reject bad input
# here before it can silently hold every automatic update.
if ! echo "$var_value" | grep -qE '^always$|^([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]$'; then
isError " $var_name must be 'always' or HH:MM-HH:MM (e.g. 06:00-08:00)"
fi
;;
CFG_NETWORK_SUBNET)
# Validate CIDR format
if ! echo "$var_value" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$'; then

View File

@ -141,9 +141,16 @@ webuiUpdaterScan() {
# (the "Check now" button) forces a live pull.
local prev_json="$out_dir/updates.json"
local reg_interval="${CFG_UPDATER_REGISTRY_INTERVAL:-360}"
local reg_stamp="/tmp/libreportal_updater_registry_checked"
# Manager-owned persistent path, NOT /tmp: the task-processor service runs
# with systemd PrivateTmp, so a /tmp stamp existed once per namespace —
# the daemon and the CLI each kept their own 6h clock, and the daemon's
# reset on every service restart. logs/ is manager-writable in both.
local reg_stamp="${docker_dir:-/libreportal-system}/logs/updater_registry_checked"
local do_registry=1
[ "$reg_interval" = "0" ] && do_registry=0
# Forced (an explicit Check now): always a live lookup, even at interval 0
# — that setting means "only when I press the button", not "never".
[ -n "${UPDATER_REGISTRY_FORCE:-}" ] && do_registry=1
if [ -z "${UPDATER_REGISTRY_FORCE:-}" ] && [ "$do_registry" = "1" ] && [ -f "$reg_stamp" ]; then
local _rn _rl; _rn=$(date +%s); _rl=$(stat -c '%Y' "$reg_stamp" 2>/dev/null || echo 0)
(( _rn - _rl < reg_interval * 60 )) && do_registry=0
@ -214,6 +221,14 @@ webuiUpdaterScan() {
declare -F updaterAppPolicy >/dev/null 2>&1 && policy="$(updaterAppPolicy "$app")"
fi
# The digest the auto-updater last ATTEMPTED for this app (its one-shot
# no-retry stamp). Exposed so the WebUI can tell "installing automatically"
# from "the automatic attempt failed and was rolled back — this one now
# waits for you". Without it a failed auto-update looked identical to a
# pending one, and the page promised an install that would never come.
local auto_attempted=""
[ -f "$out_dir/auto/$app.digest" ] && auto_attempted="$(cat "$out_dir/auto/$app.digest" 2>/dev/null)"
if [ "$have_jq" = "1" ]; then
jq -cn \
--arg name "$app" --arg displayName "$app" --arg type "$vtype" \
@ -222,13 +237,14 @@ webuiUpdaterScan() {
--arg available_image "$anchor" --arg available_version "$avail_ver" \
--arg available_digest "$avail_dig" --argjson update_available "$update_available" \
--arg last_checked "$now" --argjson services "$svcs" \
--arg update_type "$policy" \
--arg update_type "$policy" --arg auto_attempted "$auto_attempted" \
'{name:$name,displayName:$displayName,type:$type,channel:$channel,
current_image:$current_image,current_version:$current_version,current_digest:$current_digest,
available_image:$available_image,
available_version:(if $available_version=="" then null else $available_version end),
available_digest:$available_digest,update_available:$update_available,
update_type:$update_type,
auto_attempted_digest:(if $auto_attempted=="" then null else $auto_attempted end),
scanned:true,last_checked:$last_checked,services:$services}' \
>> "$objs" 2>/dev/null
else
@ -244,13 +260,18 @@ webuiUpdaterScan() {
# the auto-scan cadence, so the display needs no separate config fetch.
local scan_interval="${CFG_UPDATER_SCAN_INTERVAL:-30}"
[[ "$scan_interval" =~ ^[0-9]+$ ]] || scan_interval=30
# The install window rides along so the WebUI can say WHEN automatic
# updates land, not just that they will. Raw value on purpose: a malformed
# window (which fails closed in the enqueuer) is then visible on the page.
local auto_window="${CFG_UPDATER_WINDOW:-always}"
local tmp; tmp="$(mktemp)"
if [ "$have_jq" = "1" ]; then
jq -s --arg now "$now" --argjson iv "$scan_interval" '{generated_at:$now, scan_interval_minutes:$iv, apps:.}' "$objs" > "$tmp" 2>/dev/null \
|| printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "apps": [] }\n' "$now" "$scan_interval" > "$tmp"
jq -s --arg now "$now" --argjson iv "$scan_interval" --arg win "$auto_window" \
'{generated_at:$now, scan_interval_minutes:$iv, auto_window:$win, apps:.}' "$objs" > "$tmp" 2>/dev/null \
|| printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "auto_window": "%s", "apps": [] }\n' "$now" "$scan_interval" "$auto_window" > "$tmp"
else
{ printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "apps": [' "$now" "$scan_interval"
{ printf '{ "generated_at": "%s", "scan_interval_minutes": %s, "auto_window": "%s", "apps": [' "$now" "$scan_interval" "$auto_window"
paste -sd, "$objs"; printf '] }\n'; } > "$tmp"
fi
rm -f "$objs"