diff --git a/www/api/controllers/plugin.php b/www/api/controllers/plugin.php index a4dbe19b8..5c8e16b58 100644 --- a/www/api/controllers/plugin.php +++ b/www/api/controllers/plugin.php @@ -1206,6 +1206,12 @@ function InstallPluginFromInfo($pluginInfo, &$visited, $stream, $depth = 0, $dep // call, so each one is judged on where it actually came from rather than // inheriting anything from the plugin that pulled it in. RecordPluginInstallSource($repoName, $origSrcURL); + + // A plugin installed under a name that still has a stale aggregate + // updates-cache (reinstalled outside UninstallPlugin's own drop, or a + // dependency chain) should not inherit an old hasUpdate flag. + @unlink(PluginUpdatesCacheFile()); + // Freshly built on this OS: no longer waiting for a post-FPPOS reinstall. PluginReinstallPendingSync($repoName); return true; @@ -1899,6 +1905,11 @@ function UninstallPlugin() if ($return_val == 0) { + // Drop the aggregate cache outright, not just mark this plugin + // not-updatable: a plugin reinstalled under this same name later + // should start clean rather than inherit whatever was true before. + @unlink(PluginUpdatesCacheFile()); + MarkPluginPrivacyUninstalled($plugin); PluginReinstallPendingSync($plugin); if (isset($stream) && $stream != "false") { @@ -1966,6 +1977,11 @@ function CheckForPluginUpdates() $result['Status'] = 'OK'; $result['Message'] = ''; $result['updatesAvailable'] = PluginHasUpdates($plugin); + // Drop the aggregate cache so the navbar icon's next check recomputes + // from scratch rather than disagreeing with what this on-demand check + // just found (it already fetched this plugin, so the recompute won't + // need to spend its own fetch budget on it). + @unlink(PluginUpdatesCacheFile()); // The fetch above has just brought origin/ up to date, so // this reads the incoming declaration without a second fetch. // Not gated on updatesAvailable: no record is "changed" too. @@ -2292,7 +2308,11 @@ function UpgradePlugin() // not updated (pull and its reset fallback failed, or still behind // origin), 2 = the code was updated but the plugin's own // fpp_upgrade.sh / fpp_install.sh returned non-zero. The two need - // different next steps, so they get different messages. + // different next steps, so they get different messages -- but both mean + // the code itself is current, so both clear the aggregate cache. + if ($return_val != 1) { + @unlink(PluginUpdatesCacheFile()); + } if ($return_val == 0) { $result['Status'] = 'OK'; $result['Message'] = ''; @@ -3445,6 +3465,89 @@ function PluginHasUpdates($plugin) return 0; } +// One {updatesAvailable} flag for the whole box, via file_cache() -- the +// same TTL-cache helper GetPluginList() already uses in this file -- rather +// than a bespoke per-plugin cache with its own lockfile. There is nothing to +// keep in sync out of band this way: UpgradePlugin(), UninstallPlugin(), +// InstallPluginFromInfo() and CheckForPluginUpdates() (the Updates tab's own +// on-demand check) just @unlink() PluginUpdatesCacheFile() on any change that +// could affect the answer, and the next call recomputes from scratch. +// file_cache() itself provides the TTL, the non-blocking single-flighted +// refresh (concurrent callers get the stale answer instead of each spawning +// their own recompute), and torn-write-safe concurrent reads. +// +// Recomputing walks every installed plugin (InstalledPluginNames()). +// PluginHasUpdates() itself is cheap -- it only reads already-fetched +// remote-tracking refs, no network of its own, though it may also run the +// plugin's optional scripts/fpp_update_check.sh, which can do anything. +// Freshness otherwise comes from a live `git fetch`, gated behind the same +// 1s connectivity probe get_remote_git_version() uses (an offline box +// shouldn't hold a php-fpm worker on a DNS timeout), and capped at +// PLUGIN_UPDATES_MAX_REFRESH_PER_CALL per recompute -- which, since recompute +// itself only happens once per TTL window box-wide (not once per page load), +// still means a box with many plugins doesn't pay for all of their fetches +// in the one request that happens to trigger it. Which plugin gets that +// budget rotates across successive TTL windows (keyed off the window index, +// not any stored state) so coverage spreads out over time instead of always +// re-fetching the same (e.g. alphabetically first) plugin forever. +define('PLUGIN_UPDATES_CACHE_TTL', 6 * 60 * 60); // 6h, same horizon as PLUGIN_GITHUB_STATS_TTL +define('PLUGIN_UPDATES_CACHE_GRACE', 5 * 60); +define('PLUGIN_UPDATES_MAX_REFRESH_PER_CALL', 1); // at most one live `git fetch` per recompute + +function PluginUpdatesCacheFile() +{ + return '/tmp/cache_plugin_updates.cache'; +} + +/** + * Do any installed plugins have an update available? + * + * @route GET /api/plugin/updatesAvailable + * @response 200 Aggregate update-available flag + * ```json + * {"updatesAvailable": true} + * ``` + */ +function GetPluginUpdatesAvailable() +{ + $json = file_cache('plugin_updates', function () { + global $settings, $SUDO; + $pluginDir = $settings['pluginDirectory']; + $plugins = InstalledPluginNames(); + + $refreshesLeft = PLUGIN_UPDATES_MAX_REFRESH_PER_CALL; + $haveConnectivity = null; // computed at most once per call, only if actually needed + $startIdx = count($plugins) ? intdiv(time(), PLUGIN_UPDATES_CACHE_TTL) % count($plugins) : 0; + + $updatesAvailable = false; + foreach ($plugins as $i => $plugin) { + $dueForFetch = (($i - $startIdx + count($plugins)) % count($plugins)) < $refreshesLeft; + if ($dueForFetch) { + if ($haveConnectivity === null) { + // Same 1s-ping-before-network-op gate get_remote_git_version() + // uses (www/common.php). + exec('ping -q -c 1 -W 1 8.8.8.8 > /dev/null 2>&1', $pingOutput, $pingReturn); + unset($pingOutput); + $haveConnectivity = ($pingReturn == 0); + } + if ($haveConnectivity) { + exec('cd ' . escapeshellarg($pluginDir . '/' . $plugin) . ' && ' . $SUDO . ' timeout 20 git fetch >/dev/null 2>&1', $fetchOutput, $fetchReturn); + unset($fetchOutput); + } + } + if (PluginHasUpdates($plugin)) { + $updatesAvailable = true; + } + } + + return json_encode(array('updatesAvailable' => $updatesAvailable)); + }, PLUGIN_UPDATES_CACHE_TTL, PLUGIN_UPDATES_CACHE_GRACE); + + $decoded = json_decode($json, true); + $updatesAvailable = is_array($decoded) && !empty($decoded['updatesAvailable']); + return json(array('updatesAvailable' => $updatesAvailable)); +} + /** * Get setting from plugin * diff --git a/www/api/index.php b/www/api/index.php index 7e3d50ccd..04a913c82 100644 --- a/www/api/index.php +++ b/www/api/index.php @@ -242,6 +242,7 @@ dispatch_get('/plugin/popularity', 'GetPluginPopularity'); // keep above /plugin/:RepoName dispatch_get('/plugin/githubStats', 'GetPluginGitHubStats'); // keep above /plugin/:RepoName dispatch_get('/plugin/source', 'GetPluginSource'); // keep above /plugin/:RepoName +dispatch_get('/plugin/updatesAvailable', 'GetPluginUpdatesAvailable'); // keep above /plugin/:RepoName dispatch_get('/plugin/:RepoName', 'GetPluginInfo'); dispatch_get('/plugin/:RepoName/icon', 'PluginServeIcon'); dispatch_get('/plugin/:RepoName/page', 'GetPluginPageUrl'); diff --git a/www/api/openapi.json b/www/api/openapi.json index 1ad58af0f..fb83eaa32 100644 --- a/www/api/openapi.json +++ b/www/api/openapi.json @@ -6976,6 +6976,30 @@ } } }, + "/api/plugin/updatesAvailable": { + "get": { + "tags": [ + "plugin" + ], + "summary": "plugin/updatesAvailable", + "description": "Do any installed plugins have an update available?", + "responses": { + "200": { + "description": "Aggregate update-available flag", + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "updatesAvailable": true + } + } + } + } + } + } + }, "/api/plugin/{RepoName}": { "parameters": [ { diff --git a/www/css/fpp.css b/www/css/fpp.css index 76d2d14e2..3a17a9529 100644 --- a/www/css/fpp.css +++ b/www/css/fpp.css @@ -1092,6 +1092,16 @@ body.modal-open #scrollTopButton { font-size: 1.5em; } +#navbarPluginUpdateAvail { + display: none; + color: red; +} + +#navbarPluginUpdateAvail a { + color: rgb(245, 136, 34); + font-size: 1.5em; +} + #divLEDPanelMatrices a.nav-link.active { color: blue; } diff --git a/www/js/fpp.js b/www/js/fpp.js index fbe07da7c..08f60ed03 100644 --- a/www/js/fpp.js +++ b/www/js/fpp.js @@ -150,6 +150,15 @@ var FPP_UPDATE_STATE = { var _fppUpdateCheckInFlight = false; var FPP_UPDATE_CHECK_RETRY_MS = 5000; +// Global plugin-update state - used by the navbar plugin-update icon. +// Populated from api/plugin/updatesAvailable, which is itself cache-backed +// (see GetPluginUpdatesAvailable() server-side) so polling it here on every +// page load is cheap. +var FPP_PLUGIN_UPDATE_STATE = { + updatesAvailable: false, + checked: false +}; + // Build "http://host" + path. IPv6 literals (contain ':') must be bracketed; // IPv4 and hostnames never contain ':' so they pass through unchanged. // No zone-id ("%eth0") handling on purpose: a link-local address can't be @@ -14783,6 +14792,46 @@ function updateNavbarUpdateIndicator () { } } +/** + * Poll api/plugin/updatesAvailable for the navbar plugin-update icon. Cheap + * to call on every page load -- the endpoint is TTL-cached server-side (see + * GetPluginUpdatesAvailable()) and only occasionally pays for a real + * `git fetch`, never more than one per call. + */ +function checkForPluginUpdates () { + $.get('api/plugin/updatesAvailable') + .done(function (data) { + FPP_PLUGIN_UPDATE_STATE.updatesAvailable = !!(data && data.updatesAvailable); + FPP_PLUGIN_UPDATE_STATE.checked = true; + updateNavbarPluginUpdateIndicator(); + }) + .fail(function () { + console.log('Failed to check for plugin updates via API'); + }); +} + +/** + * StreamURL doneCallback/errorCallback for a single-plugin upgrade: runs the + * normal ProgressDialogDone, then re-polls the navbar icon immediately + * rather than leaving it showing whatever was true before the upgrade until + * the next full page load. + */ +function PluginUpgradeStreamDone (id) { + ProgressDialogDone(id); + checkForPluginUpdates(); +} + +/** + * Update the navbar plugin-update indicator based on FPP_PLUGIN_UPDATE_STATE + */ +function updateNavbarPluginUpdateIndicator () { + if (FPP_PLUGIN_UPDATE_STATE.updatesAvailable) { + $('#navbarPluginUpdateAvail').show(); + } else { + $('#navbarPluginUpdateAvail').hide(); + } +} + /** * Set by menuHead.inc from GPIOPlatformHasStablePinNumbers(): whether this platform's * gpiochip/line numbering is fixed enough to put in front of a user. On the diff --git a/www/menu.inc b/www/menu.inc index 63c2c90bd..d1ce1a1da 100644 --- a/www/menu.inc +++ b/www/menu.inc @@ -99,6 +99,11 @@ function list_plugin_entries($menu) + @@ -516,4 +521,19 @@ function list_plugin_entries($menu) checkForFppUpdate(); } }, 500); + + setTimeout(checkForPluginUpdates, 700); // staggered behind the FPP update check above + diff --git a/www/plugins.php b/www/plugins.php index 7b9aebe96..061b20046 100644 --- a/www/plugins.php +++ b/www/plugins.php @@ -983,6 +983,7 @@ function UpdateAllFinish() { $.jGrowl('All ' + ok + ' plugin(s) updated successfully', { themeState: 'success' }); FilterPlugins(); ProgressDialogDone('pluginsProgressPopupText'); + checkForPluginUpdates(); // refresh the navbar icon now, not on next page load }); } @@ -1020,10 +1021,10 @@ function RunUpgradePlugin(plugin, ack) { var url = 'api/plugin/' + plugin + '/upgrade?stream=true'; DisplayProgressDialog("pluginsProgressPopup", "Upgrade Plugin"); if (ack !== null) { - StreamURL(url, 'pluginsProgressPopupText', 'ProgressDialogDone', 'ProgressDialogDone', + StreamURL(url, 'pluginsProgressPopupText', 'PluginUpgradeStreamDone', 'PluginUpgradeStreamDone', 'POST', JSON.stringify(ack), 'application/json'); } else { - StreamURL(url, 'pluginsProgressPopupText', 'ProgressDialogDone', 'ProgressDialogDone'); + StreamURL(url, 'pluginsProgressPopupText', 'PluginUpgradeStreamDone', 'PluginUpgradeStreamDone'); } } @@ -3024,6 +3025,16 @@ function ShowTopTab(name) { // Re-select the tab the user was on before the last load. Called once the // plugin data is in so the Updates tab can run its update check. function RestoreTopTab() { + // Explicit deep link (e.g. plugins.php?tab=updates from the navbar + // plugin-update icon) wins over whatever tab the session was last + // left on, and becomes the new remembered tab going forward -- + // ShowTopTab() persists it to sessionStorage same as a manual click. + var requested = new URLSearchParams(window.location.search).get('tab'); + if (requested === 'installed' || requested === 'updates') { + ShowTopTab(requested); + return; + } + var saved = ''; try { saved = sessionStorage.getItem('pluginsTopTab') || ''; } catch (e) { } if (saved === 'installed' || saved === 'updates')