Skip to content

Add a navbar icon for plugin updates - #2929

Closed
focusedonsound wants to merge 6 commits into
FalconChristmas:masterfrom
focusedonsound:feat/plugin-update-navbar-icon
Closed

focusedonsound wants to merge 6 commits into
FalconChristmas:masterfrom
focusedonsound:feat/plugin-update-navbar-icon

Conversation

@focusedonsound

@focusedonsound focusedonsound commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds a navbar icon (puzzle-piece) next to the existing FPP-update icon, shown only when at least one installed plugin has an available update. Mirrors #navbarUpdateAvail's existing show/hide behavior and styling.
  • Clicking it opens the Plugin Manager directly on the Updates tab (plugins.php?tab=updates). plugins.php previously had no URL deep-link into that tab (only a sessionStorage-remembered "last tab" from a manual click), so this adds explicit ?tab= support to RestoreTopTab() that takes priority over — and then persists into — that same session memory.
  • New GET /api/plugin/updatesAvailable endpoint aggregates the existing PluginHasUpdates() check across every installed plugin. That check itself is cheap (local git log against already-fetched remote-tracking refs, no network), but is only as fresh as the last git fetch per plugin. This endpoint keeps those refs from going stale on their own via a small TTL cache (same convention/location pattern as the existing GitHub-stats cache), refreshing at most one stale plugin's remote per call so a page load never pays for N serial git fetches.

Test plan

  • Verified on a live FPP 10 (master) install: new endpoint returns well-formed {"updatesAvailable": bool}, existing /api/plugin and /api/plugin/:RepoName routes unaffected by the new fixed-path route.
  • Confirmed the per-call refresh throttle: repeated polls filled in one plugin's cache entry at a time rather than all at once.
  • Confirmed aggregation correctness: flipping a cached entry to hasUpdate: true correctly flips the aggregate updatesAvailable to true.
  • Confirmed plugins.php?tab=updates reliably opens the Updates tab regardless of prior session tab state.
  • Confirmed in-browser: icon shows/hides correctly via FPP_PLUGIN_UPDATE_STATE, correct tooltip, and click-through to the Updates tab.

Mirrors the existing #navbarUpdateAvail FPP-update icon: a puzzle-piece
icon that shows only when at least one installed plugin has an update,
and links to plugins.php?tab=updates.

Backend: a new GET /api/plugin/updatesAvailable endpoint aggregates
PluginHasUpdates() across every installed plugin. That check itself is
cheap (git log against already-fetched remote-tracking refs, no
network) but only as fresh as the last `git fetch` per plugin, so the
endpoint keeps a small TTL cache (same convention as the existing
GitHub-stats cache) and refreshes at most one stale plugin's remote
per call -- enough to stay fresh without a page load ever paying for
N serial git fetches.

Frontend: plugins.php didn't have a URL deep-link into its Updates
tab (only a sessionStorage-remembered "last tab"), so ?tab=updates is
added to RestoreTopTab() as an explicit override that also persists,
consistent with how a manual tab click already behaves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjuzBZvDeLNNaGX7j9mC8p
@focusedonsound

Copy link
Copy Markdown
Contributor Author

Sharing a screen grab of the added plugin update icon in the header
plugin_update_notification

Resolves two conflicts against upstream master, both additive (no
logic conflict, just two unrelated changes landing at the same line):

- www/js/fpp.js: kept both the new _fppUpdateCheckInFlight/
  FPP_UPDATE_CHECK_RETRY_MS dedup state from master and this branch's
  own FPP_PLUGIN_UPDATE_STATE.
- www/menu.inc: kept master's newer checkForFppUpdate() call (now
  guarded behind FPP_UPDATE_STATE.answered to avoid double-checking
  when about.php already answered it) and this branch's staggered
  checkForPluginUpdates() call right after it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NjuzBZvDeLNNaGX7j9mC8p
@darylc
darylc self-requested a review September 14, 2026 06:59
@darylc

darylc commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

@focusedonsound Claude suggests a bunch of things need to be considered before this could be merged:

Feedback for the PR author:

Blocking

  • Cache never invalidated: hasUpdate: true sticks for up to 6h after the user upgrades, so the icon stays lit. UpgradePlugin(),
    UninstallPlugin() and InstallPluginFromInfo() need to update/forget the entry; a plugin uninstalled and reinstalled under the same
    name inherits the old flag.
  • CheckForPluginUpdates() (the Updates tab's own check) does a real fetch but doesn't record the answer, so the icon and the tab can
    disagree.
  • git fetch runs with no timeout, no connectivity gate, and stderr unredirected — an offline show box holds a php-fpm worker on it.
    Compare timeout 60 git fetch at plugin.php:2016 and the 1s ping gate in get_remote_git_version().
  • No lock, and the cache is written only after the fetch, so N concurrent page loads (tabs, kiosks) at the TTL boundary each spawn a
    fetch of the same plugin. See pluginHeaders.php for the non-blocking flock + serve-stale pattern and the worker-starvation comment
    explaining why.
  • Cache write is a bare file_put_contents; a concurrent reader can see a torn file, which json_decode turns into an empty cache and a
    full re-seed. Use write-then-rename as WritePluginHeaderIndicatorCache() does.

Should fix

  • openapi.json is generated (www/api/tools/build_docs.sh); don't hand-edit it. Trim the docblock and regenerate — the current
    description is the whole internal comment verbatim, including "see CheckPluginsForUpdates()'s comment above".
  • Duplicates the installed-plugin directory scan; InstalledPluginNames() already does it.
  • Docblock says PluginHasUpdates() is "no network" — it also runs the plugin's scripts/fpp_update_check.sh, which may not be.
  • A failed fetch is stamped ts=now, so an outage costs 6h before retry; a shorter retry interval for failures is better.
  • Help page: CLAUDE.md requires a help update for new user-facing controls — add a sentence to www/help/plugins.php (what the icon
    means, how often FPP contacts plugin hosts).

Minor

  • Poll fires on every page even when no plugins are installed; gate the setTimeout in menu.inc on a */pluginInfo.json existing.
  • All entries get ts=now on first check and expire together; harmless with the one-per-call cap, just means the last plugin can be
    N×6h late.
  • PR was cut against an old base (hunk offsets are ~900 lines off) — worth a rebase.

focusedonsound and others added 2 commits September 15, 2026 14:36
- Invalidate the shared pluginUpdates cache on the three events that
  actually change a plugin's state: UpgradePlugin() (clears the flag
  instead of leaving it lit for up to 6h), UninstallPlugin() (drops the
  entry so a reinstall under the same name starts clean), and
  InstallPluginFromInfo() (defensive drop of any stale entry a fresh
  install might inherit).
- CheckForPluginUpdates() (the Updates tab's own on-demand check) now
  writes its result back into the same cache, so the navbar icon and
  the tab can't disagree.
- Add the same non-blocking-lock + serve-stale pattern pluginHeaders.php
  already uses, plus write-then-rename cache writes, so concurrent page
  loads at a TTL boundary don't each spawn a git fetch of the same
  plugin and can't observe a torn cache file.
- Gate the git fetch behind the same 1s connectivity probe
  get_remote_git_version() uses (common.php), and wrap it in `timeout
  20` -- an offline box no longer holds a php-fpm worker on a hung
  fetch. A failed fetch now retries after 5 minutes instead of the
  full 6h TTL.
- Correct the docblock: PluginHasUpdates() also runs the plugin's own
  scripts/fpp_update_check.sh, which may do its own network I/O -- it
  isn't strictly "no network". Moved the implementation-rationale
  comment out of the /** */ docblock (it was leaking verbatim,
  including an internal cross-reference, into the public openapi.json)
  and regenerated openapi.json against current master.
- menu.inc: don't schedule the poll at all on a box with zero plugins
  installed.

No help/plugins.php exists yet for this page (pre-existing gap, not
introduced by this change) -- per .claude/HELP-PAGES.md's own carve-out,
noting that here rather than authoring a whole new help page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AE12rwWGhsJsyfENBXpBs5
Resolves conflicts against master's own recent plugin-privacy work
(PluginReinstallPendingSync, MarkPluginPrivacyUninstalled,
PluginRecordUpgradedPrivacy) by keeping both sides: this branch's
pluginUpdates-cache invalidation alongside master's newer
privacy-tracking calls at the same install/upgrade/uninstall success
points. Regenerated openapi.json against the merged state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AE12rwWGhsJsyfENBXpBs5
@focusedonsound

Copy link
Copy Markdown
Contributor Author

Thanks for running this through review - went through every item against the actual code before changing anything. Pushed fixes for what held up:

Fixed:

  • Cache invalidation: UpgradePlugin(), UninstallPlugin(), and InstallPluginFromInfo() now all update/drop the plugin's pluginUpdates cache entry on success, so the icon doesn't stay lit for up to 6h post-upgrade and a reinstall under the same name starts clean.
  • CheckForPluginUpdates() (the Updates tab's own on-demand check) now writes its result back into the same shared cache, so the tab and the navbar icon can't disagree.
  • Added the same non-blocking-lock + serve-stale pattern pluginHeaders.php's GetPluginHeaderIndicators() already uses, plus write-then-rename cache writes - concurrent page loads at a TTL boundary no longer each spawn a git fetch of the same plugin, and a reader can't see a torn cache file.
  • The git fetch is now gated behind the same 1s connectivity probe get_remote_git_version() uses in common.php (not plugin.php:2016 - that line/pattern doesn't exist; the real reference is in common.php, and it checks out as an accurate, directly-applicable pattern), and wrapped in timeout 20. A failed fetch now retries after 5 minutes instead of waiting out the full 6h TTL.
  • PluginHasUpdates()'s "no network" characterization was wrong (it runs the plugin's own scripts/fpp_update_check.sh, which can do anything) - corrected, and moved the implementation-rationale comment out of the /** */ docblock so it stops leaking verbatim (including an internal cross-reference) into the public openapi.json.
  • menu.inc no longer schedules the poll at all on a box with zero plugins installed.
  • Rebased onto current master and regenerated openapi.json against the merged state (70 commits behind, hence the stale hunk offsets).

Pushed back on:

  • InstalledPluginNames() doesn't exist anywhere in this codebase - nothing to dedupe against. There's a similar-but-different scan in pluginHeaders.php (glob-based, no pluginInfo.json check), so extracting a shared helper is a reasonable idea for later, but there's no existing function this duplicates today.
  • The help-page item: www/help/plugins.php (added to master since this branch was cut) documents the plugin-card privacy lights and buttons, but doesn't cover any header-level icon - and neither does the existing navbarUpdateAvail FPP-update icon this one is modeled after. That looks like a deliberate convention (global header chrome isn't page-specific F1 help), not a gap this PR introduces, so I left it alone rather than break the pattern.

Ready for another look.

@darylc

darylc commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Verified the round-1 fixes — they hold up, and the regenerated openapi.json matches. Two changes needed before merge, then some small stuff.

1. Switch the cache to file_cache() and drop the per-plugin bookkeeping.

The current design keeps a per-plugin entry that five call sites (install, uninstall, upgrade ×2, updates-tab check) update under a lock. That's a lot of moving parts for a boolean, and two bugs in the revised code come straight from it:

  • Icon stays lit after a partial upgrade. UpgradePlugin() only clears the entry on rc 0, but rc 2 means the code was pulled and only the post-script failed — the plugin is current, yet the icon stays lit for up to 6h. Both the streaming and JSON paths.
  • Writers block behind the poll's fetch. UpdatePluginUpdatesCacheEntry() takes a blocking LOCK_EX while GetPluginUpdatesAvailable() holds the lock across ping + git fetch + PluginHasUpdates() (~20s), so an upgrade finishing or an Updates-tab check stalls behind a header poll. It can't just be narrowed either — the whole-array write at the end would overwrite anything written during the fetch.

Both disappear if the cache is one {ts, updatesAvailable} per box, built by file_cache() from common.php — it's already what GetPluginList() uses in this same file, and TTL, single-flighting and serve-stale-on-failure are all handled there — and the mutation sites just @unlink() the cache file so the next page load recomputes. Nothing to update out-of-band, nothing to lock against, nothing to get wrong on rc 2, and no third bespoke JSON-cache-plus-lockfile in this controller. Keep the ping gate, the timeout 20, and the one-fetch-per-call cap inside the builder — those are the right calls. I'd expect the PHP side to shrink to about a third of its current size.

2. Use InstalledPluginNames() for the installed-plugin list instead of the copy in GetPluginUpdatesAvailable(). It's in master since your merge, and its sort() also makes the one-per-poll rotation deterministic.

Small, take or leave:

  • cd X && $SUDO timeout 20 git fetch (as PluginFetchBranch() does) rather than the bash -c wrapper.
  • menu.inc: foreach (glob(...)) warns on PHP 8 if glob() returns false; (bool) glob(...) is enough.
  • A sentence in www/help/plugins.php under "Updates and reinstalls" saying what the header icon means and that FPP fetches each plugin's repo about every 6h.
  • Call checkForPluginUpdates() from UpdateAllFinish() and RunUpgradePlugin()'s done callbacks so the icon clears on the page where the upgrade happened.

To merge: 1 and 2. Nothing under "Small" is blocking.

focusedonsound and others added 2 commits September 21, 2026 15:10
Per Daryl's second review on PR FalconChristmas#2929:

1. Replace the bespoke per-plugin cache (its own lockfile, five call
   sites doing read-modify-write) with a single {updatesAvailable}
   flag per box via file_cache() - the same TTL-cache helper
   GetPluginList() already uses in this file. Mutation sites
   (install/uninstall/upgrade/on-demand check) just @Unlink() the
   cache file instead of updating an entry, so there's nothing to
   keep in sync out of band and nothing to lock against.
   - Fixes: icon stayed lit after a partial upgrade (rc=2 - code
     pulled, only the post-script failed) because UpgradePlugin()
     only cleared the entry on rc 0. Both call sites now clear on
     rc != 1 (0 or 2 both mean the code itself is current).
   - Fixes: writers blocking behind the poll's own git-fetch, since
     mutation sites no longer take any lock at all.
2. Use InstalledPluginNames() instead of duplicating the
   installed-plugin directory scan.

Small items from the same review:
- git fetch now built the same way PluginFetchBranch() does (cd X &&
  $SUDO timeout 20 git fetch), not a bash -c wrapper.
- menu.inc: (bool) glob(...) instead of foreach-ing a result that can
  be false on a read error (PHP 8 warning).
- RunUpgradePlugin() and UpdateAllFinish() both now re-poll the
  navbar icon immediately when they finish, instead of leaving it
  showing pre-upgrade state until the next full page load.

Left alone (per round 1's already-given reasoning, which stood):
the help-page item - www/help/plugins.php documents page-level
controls, not global header chrome, matching the existing
navbarUpdateAvail icon this one is modeled after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	www/api/controllers/plugin.php
@focusedonsound

Copy link
Copy Markdown
Contributor Author

Thanks for the second pass — both blocking items done, plus the small stuff.

1. Cache switched to file_cache(). GetPluginUpdatesAvailable() is now one {updatesAvailable} flag for the whole box via file_cache() (the same helper GetPluginList() already uses in this file), not a per-plugin cache with its own lockfile. UpgradePlugin(), UninstallPlugin(), InstallPluginFromInfo(), and CheckForPluginUpdates() now just @unlink() the cache file on any change that could affect the answer — nothing to update out-of-band, nothing to lock against. This directly fixes both bugs you flagged:

  • Partial-upgrade bug: both UpgradePlugin() call sites now clear the cache on $return_val != 1 (0 or 2 — 2 means the code landed and only the post-script failed, so the plugin genuinely isn't stale anymore), not just on 0.
  • Writer-blocks-behind-poll bug: gone by construction — mutation sites don't take any lock at all now.

One addition beyond what was asked: since recompute now only happens once per 6h window box-wide (not once per page load), I kept the one-fetch-per-recompute cap but rotate which plugin gets it across successive TTL windows (intdiv(time(), TTL) % count($plugins), no stored state) rather than always picking the same one — otherwise a box with several plugins would only ever live-refresh whichever one happens to sort first.

2. InstalledPluginNames() used instead of the duplicated directory scan.

Small items:

  • git fetch now built cd X && $SUDO timeout 20 git fetch (matching PluginFetchBranch()'s style) instead of the bash -c wrapper.
  • menu.inc: (bool) glob(...) instead of foreach-ing a result that can be false.
  • RunUpgradePlugin() and UpdateAllFinish() now both re-poll the navbar icon immediately when they finish, instead of leaving it showing pre-upgrade state until the next full page load.
  • Left the help-page item alone, per round 1's reasoning (global header chrome isn't page-specific F1 help, matching the existing navbarUpdateAvail icon this one is modeled after) — you didn't dispute that when you repeated it as "take or leave," so I'm reading that as settled rather than re-litigating it.

Also rebased onto current master (was 70-some commits behind again) and force-pushed nothing — this was a merge, and git diff master...HEAD is back to a clean 7-file, ~330-line diff.

Ready for another look.

darylc added a commit that referenced this pull request Sep 24, 2026
Inspired by the work focusedonsound did on PR#2929

Header, every page:
- A puzzle-piece icon appears when an installed plugin has an update
  waiting. Hovering names the plugins, says how long ago they were
  checked and how many could not be; clicking opens the Plugins page
  on its Updates tab. It is hidden when nothing is known, never shown
  as a guess.
@darylc

darylc commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

@focusedonsound As discussed, I've added this feature outside of your PR. This turned out to be incredibly complex with a lot of moving parts to ensure consistency and safety. Thanks for working on this.

@darylc darylc closed this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants