diff --git a/configs/general/general_terminal b/configs/general/general_terminal index b7181f4..4520c05 100755 --- a/configs/general/general_terminal +++ b/configs/general/general_terminal @@ -3,6 +3,7 @@ # ================================================================================ CFG_UPDATER_CHECK=60 # Update Check Interval - Hours between system update checks CFG_HOTFIX_AUTO=security-breakage # Hotfix Auto-Apply - Which signed hotfix severities apply automatically on the update check [security-breakage|all|off] +CFG_UPDATER_SCAN_INTERVAL=30 # App Scan Interval - Minutes between automatic app update/CVE/improvement scans (0 disables) CFG_SWAPFILE_SIZE=2G # Swap File Size - Size of swap file for memory management CFG_GENERATED_PASS_LENGTH=14 # Password Length - Length for auto generated passwords CFG_GENERATED_USER_LENGTH=8 # Username Length - Length for auto generated usernames diff --git a/containers/libreportal/frontend/components/apps/index.js b/containers/libreportal/frontend/components/apps/index.js index 0adb413..45d0be6 100644 --- a/containers/libreportal/frontend/components/apps/index.js +++ b/containers/libreportal/frontend/components/apps/index.js @@ -123,6 +123,9 @@ LP.features.register({ // overviewManager singleton + its DOM persist with the layout; its run() // self-guards, but unregistering is the clean release. try { ctx && ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('overview'); } catch (_) {} + // Same for the headless updater's auto-refresh timer (re-armed on the next + // Overview mount). + try { window.overviewManager && window.overviewManager.updater && window.overviewManager.updater.dispose(); } catch (_) {} // The Backups tab embeds a BackupPage; release its document listeners + // task-refresh registration on the way out (the "stacks on revisit" bug). try { window.overviewBackupPage && window.overviewBackupPage.dispose && window.overviewBackupPage.dispose(); } catch (_) {} 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 9786e7e..7089935 100644 --- a/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js +++ b/containers/libreportal/frontend/components/apps/overview/js/overview-manager.js @@ -53,6 +53,20 @@ class OverviewManager { const initial = this.parseTabFromUrl() || 'overview'; await this.refreshAll(); this._applyTab(initial); // active + header + render, no history push + // Keep an open Overview in step with the host-side auto-scan (no task event + // fires for it). The headless updater owns the timer + change detection; + // re-armed here on every mount, released by the apps feature's unmount. + // isActive: the pane element persists hidden in the shared layout, so gate + // on it actually being shown (offsetParent is null while display:none). + if (this.updater && this.updater.startAutoRefresh) { + this.updater.startAutoRefresh( + () => this._applyTab(this.current || 'overview'), + () => { + const pane = window.overviewManager === this && document.getElementById('overview-view'); + return !!(pane && pane.offsetParent); + } + ); + } } bindEvents(root) { @@ -327,7 +341,7 @@ class OverviewManager { ``)} ${card('system', u.apps, 'Apps tracked', `last scan: ${checked}`)} - ${(this.updater && this.updater.updates) ? '' : `
No scan data yet — run Check now to fetch versions, CVEs & improvements.
`}`; + ${(this.updater && this.updater.updates) ? '' : `
No scan data yet — the first automatic scan runs within a couple of minutes, or hit Check now.
`}`; } backupSummary() { diff --git a/containers/libreportal/frontend/components/updater/index.js b/containers/libreportal/frontend/components/updater/index.js index d174b76..b142d98 100644 --- a/containers/libreportal/frontend/components/updater/index.js +++ b/containers/libreportal/frontend/components/updater/index.js @@ -27,6 +27,9 @@ LP.features.register({ // doesn't repaint a torn-down page. The page self-guards via // (window.updaterPage === this); nulling it neutralises any pending work. try { ctx.services.tasks.refresh && ctx.services.tasks.refresh.unregister('updater'); } catch (_) {} + // Release the auto-refresh timer that keeps the page in step with the + // host-side auto-scan. + try { window.updaterPage && window.updaterPage.dispose && window.updaterPage.dispose(); } catch (_) {} window.updaterPage = null; }, }); diff --git a/containers/libreportal/frontend/components/updater/js/updater-page.js b/containers/libreportal/frontend/components/updater/js/updater-page.js index 82be798..f625788 100644 --- a/containers/libreportal/frontend/components/updater/js/updater-page.js +++ b/containers/libreportal/frontend/components/updater/js/updater-page.js @@ -18,6 +18,7 @@ class UpdaterPage { this.apps = []; // merged per-app view rendered in the table this._pushedAnyTab = false; this._eventBound = false; + this._poll = null; // auto-refresh timer (startAutoRefresh/dispose) } // ---- lifecycle ----------------------------------------------------------- @@ -29,6 +30,42 @@ class UpdaterPage { await this.refreshAll(); this.render(); this.updateHeader(); + // Keep the open page in step with the host-side auto-scan (the task + // processor refreshes the generated JSON on its own schedule, with no task + // event to hook). Repaints only when the data actually changed. + this.startAutoRefresh( + () => this.render(), + () => window.updaterPage === this && !!document.getElementById('updater-page') + ); + } + + // The host auto-scan (`libreportal updater check auto`, run from the task + // processor's idle poll) rewrites the generated JSON without a task event, + // so an open page re-reads it on a slow timer: a few static-file GETs per + // minute, and onChange() only when a generated_at stamp moved. isActive lets + // the hosting surface skip ticks while it isn't visible; dispose() releases + // the timer (also called by the feature unmount). + startAutoRefresh(onChange, isActive) { + if (this._poll) return; + this._poll = setInterval(() => { + if (document.hidden || (isActive && !isActive())) return; + const before = this._dataStamp(); + this.refreshAll().then(() => { + if (this._poll && this._dataStamp() !== before && onChange) onChange(); + }); + }, 60000); + } + + dispose() { + if (this._poll) { clearInterval(this._poll); this._poll = null; } + } + + // Cheap change detector for the auto-refresh: the generators stamp each file + // with generated_at; history has no stamp, so its newest entry stands in. + _dataStamp() { + const g = (d) => (d && d.generated_at) || ''; + const h = this.history && this.history.entries && this.history.entries[0]; + return [g(this.updates), g(this.cves), g(this.artifacts), (h && h.ts) || ''].join('|'); } parseTabFromUrl() { @@ -272,7 +309,7 @@ class UpdaterPage { ${card('system', c.apps, 'Apps tracked', `last scan: ${checked}`, ``)} - ${this.updates ? '' : `
No scan data yet — showing your installed apps. Run Check now to fetch versions & vulnerabilities.
`}`; + ${this.updates ? '' : `
No scan data yet — showing your installed apps. The first automatic scan runs within a couple of minutes, or hit Check now.
`}`; } renderUpdates() { @@ -305,7 +342,7 @@ class UpdaterPage { // withToolbar=false lets an embedding surface (the fleet Overview tab) skip // the inline Check button because it provides one in its own header. renderImprovements(withToolbar = true) { - if (!this.artifacts) return this.empty('No hotfix data yet. Run a check to fetch the signed improvements index.', true); + if (!this.artifacts) return this.empty('No hotfix data yet — the automatic scan fetches the signed improvements index within a couple of minutes.', true); const list = Array.isArray(this.artifacts.artifacts) ? this.artifacts.artifacts : []; const signed = !!this.artifacts.signed; if (!list.length) return this.empty('No improvements available right now — you are all caught up. 🎉'); @@ -339,7 +376,7 @@ class UpdaterPage { renderSecurity() { const withCves = this.apps.filter(a => (a.cves || []).length); - if (!this.cves) return this.empty('No vulnerability scan yet. Run a check to scan your app images for known CVEs.', true); + if (!this.cves) return this.empty('No vulnerability scan yet — one runs automatically within a couple of minutes.', true); if (!withCves.length) return this.empty('No known vulnerabilities in your installed apps. 🎉'); const blocks = withCves.map(a => { const items = (a.cves || []).map(c => ` diff --git a/scripts/cli/commands/updater/cli_updater_commands.sh b/scripts/cli/commands/updater/cli_updater_commands.sh index 33b4875..31dc7e5 100644 --- a/scripts/cli/commands/updater/cli_updater_commands.sh +++ b/scripts/cli/commands/updater/cli_updater_commands.sh @@ -22,6 +22,22 @@ cliHandleUpdaterCommands() case "$sub" in ""|"check") + # `check auto` — the task processor's idle poll calls this every + # ~60s; self-throttle on the age of the generated updates.json so a + # full scan only happens once per CFG_UPDATER_SCAN_INTERVAL minutes + # (0 disables automatic scans). A manual check / post-update rescan + # rewrites updates.json, which resets this clock for free. A missing + # file means never scanned -> run now (first scan needs no click). + if [[ "$app" == "auto" ]]; then + local scan_interval="${CFG_UPDATER_SCAN_INTERVAL:-30}" + [[ "$scan_interval" =~ ^[0-9]+$ ]] || scan_interval=30 + (( scan_interval == 0 )) && return 0 + local scan_file="${containers_dir%/}/libreportal/frontend/data/updater/generated/updates.json" + if [[ -f "$scan_file" ]]; then + local _now _last; _now=$(date +%s); _last=$(stat -c '%Y' "$scan_file" 2>/dev/null || echo 0) + (( _now - _last < scan_interval * 60 )) && return 0 + fi + fi # 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 diff --git a/scripts/cli/commands/updater/cli_updater_header.sh b/scripts/cli/commands/updater/cli_updater_header.sh index f7946e9..59d5bdd 100644 --- a/scripts/cli/commands/updater/cli_updater_header.sh +++ b/scripts/cli/commands/updater/cli_updater_header.sh @@ -8,7 +8,9 @@ cliShowUpdaterHelp() echo "" echo "Available App Updater Commands:" echo "" - echo " libreportal updater check - Refresh per-app version & vulnerability data" + echo " libreportal updater check [auto] - Refresh per-app version & vulnerability data" + echo " (auto = background mode: only scans when the data is" + echo " older than CFG_UPDATER_SCAN_INTERVAL minutes)" echo " libreportal updater apply - Update one app (snapshots it first; auto-rollback on failure)" echo " libreportal updater apply-all [a,b] - Update a comma-list of apps (each snapshotted first)" echo " libreportal updater rollback - Restore an app's most recent pre-update snapshot" diff --git a/scripts/task/crontab_task_processor.sh b/scripts/task/crontab_task_processor.sh index 711eea2..ee7992b 100755 --- a/scripts/task/crontab_task_processor.sh +++ b/scripts/task/crontab_task_processor.sh @@ -459,6 +459,12 @@ maybeRegenPoll() { # config file — so it runs every poll, self-throttled to its own interval # (cheap no-op most ticks), writing network_status.json when a scan is due. command -v libreportal >/dev/null 2>&1 && libreportal system network check >/dev/null 2>&1 || true + # App updater auto-scan (versions / CVEs / signed improvements). Same shape as + # the drift check: runs every poll, self-throttled inside the CLI to + # CFG_UPDATER_SCAN_INTERVAL minutes via the age of the generated updates.json, + # so almost every tick is a single stat() and an early return. Eligible signed + # hotfixes are enqueued as ordinary tasks by the scan, never applied inline. + command -v libreportal >/dev/null 2>&1 && libreportal updater check auto >/dev/null 2>&1 || true } # ============================================================================ diff --git a/scripts/webui/data/generators/config/webui_update_config.sh b/scripts/webui/data/generators/config/webui_update_config.sh index 25e6487..d33ff8c 100755 --- a/scripts/webui/data/generators/config/webui_update_config.sh +++ b/scripts/webui/data/generators/config/webui_update_config.sh @@ -72,7 +72,7 @@ webuiValidateConfigValue() { isError " Invalid crontab format for $var_name" fi ;; - CFG_BACKUP_KEEP_LAST|CFG_BACKUP_KEEP_DAILY|CFG_BACKUP_KEEP_WEEKLY|CFG_BACKUP_KEEP_MONTHLY|CFG_BACKUP_KEEP_YEARLY|CFG_BACKUP_VERIFY_DATA_PERCENT|CFG_UPDATER_CHECK|CFG_SWAPFILE_SIZE|CFG_GENERATED_PASS_LENGTH|CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES|CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES|CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC) + CFG_BACKUP_KEEP_LAST|CFG_BACKUP_KEEP_DAILY|CFG_BACKUP_KEEP_WEEKLY|CFG_BACKUP_KEEP_MONTHLY|CFG_BACKUP_KEEP_YEARLY|CFG_BACKUP_VERIFY_DATA_PERCENT|CFG_UPDATER_CHECK|CFG_UPDATER_SCAN_INTERVAL|CFG_SWAPFILE_SIZE|CFG_GENERATED_PASS_LENGTH|CFG_WEBUI_LOG_STREAM_IDLE_TIMEOUT_MINUTES|CFG_WEBUI_LOG_STREAM_MAX_DURATION_MINUTES|CFG_WEBUI_LOG_STREAM_MAX_LINES_PER_SEC) # Validate numeric values if ! echo "$var_value" | grep -qE '^[0-9]+$'; then isError " $var_name must be a positive integer" diff --git a/scripts/webui/data/generators/updater/webui_updater_scan.sh b/scripts/webui/data/generators/updater/webui_updater_scan.sh index b7af812..4d46633 100644 --- a/scripts/webui/data/generators/updater/webui_updater_scan.sh +++ b/scripts/webui/data/generators/updater/webui_updater_scan.sh @@ -7,10 +7,13 @@ # frontend/data/updater/generated/cves.json (per-app known CVEs) # frontend/data/updater/generated/history.json (created/owned by `apply`) # -# Run on demand by `libreportal updater check` (NOT on every regen) so a slow -# registry/scanner call never stalls the periodic WebUI refresh. The WebUI -# degrades gracefully when these files are absent (it derives the app list from -# the installed-apps data), so this generator is a bonus, never a dependency. +# Run by `libreportal updater check` — manually, or automatically from the task +# processor's idle poll via `updater check auto`, which only falls through to a +# real scan when updates.json is older than CFG_UPDATER_SCAN_INTERVAL minutes. +# Kept OFF the regen path so a slow registry/scanner call never stalls the +# periodic WebUI refresh. The WebUI degrades gracefully when these files are +# absent (it derives the app list from the installed-apps data), so this +# generator is a bonus, never a dependency. # # Version + CVE discovery is intentionally pluggable: the loop below records # each installed app's CURRENT image (from its compose file) and leaves clearly