From 5113b51234bc26ffe8953565ec6070d9b87fec66 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sun, 19 Jul 2026 21:38:31 +0000 Subject: [PATCH] Surface plugin update availability on the plugins page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin manager page already showed each installed plugin's version and had an Update button, but nothing told users an update actually existed — they had to guess. The store manager already compares the installed manifest version against the registry's latest_version for its reinstall decision; this surfaces that same signal in the UI. - api_v3 /plugins/installed now returns `latest_version` (from the registry cache, no extra network call) and an `update_available` flag computed by a new semver-aware helper `_is_plugin_update_available`. A locally modified plugin whose version is ahead of the registry is not flagged. - The installed-plugin card shows "vX.Y.Z available" next to the installed version, and the Update button becomes emphasized ("Update to vX.Y.Z" with a gentle pulse) when a newer version is published — mirroring the app's own update banner styling. - Added tests for the helper and the endpoint fields. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012EW8yDbsk8EceDpq6Hjgqj --- test/test_web_api.py | 61 +++++++++++++++++++++- web_interface/blueprints/api_v3.py | 28 +++++++++- web_interface/static/v3/app.css | 15 ++++++ web_interface/static/v3/plugins_manager.js | 9 ++-- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/test/test_web_api.py b/test/test_web_api.py index 269a8525..91cdd3bd 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -372,11 +372,68 @@ class TestPluginsAPI: } response = client.get('/api/v3/plugins/installed') - + assert response.status_code == 200 data = json.loads(response.data) assert isinstance(data, (list, dict)) - + + def test_installed_plugins_report_update_available(self, client, mock_plugin_manager): + """Installed-plugin entries surface latest_version + update_available + by comparing the on-disk manifest version to the registry.""" + from web_interface.blueprints.api_v3 import api_v3 + api_v3.plugin_manager = mock_plugin_manager + # No on-disk manifest to merge — keep the version we hand in below. + mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir' + mock_plugin_manager.get_all_plugin_info.return_value = [ + {'id': 'weather', 'name': 'Weather', 'version': '1.0.0'} + ] + # Avoid touching plugin instances (Vegas hooks, enabled fallback). + mock_plugin_manager.get_plugin.return_value = None + # Registry advertises a newer version than the installed one. + api_v3.plugin_store_manager.get_registry_info.return_value = { + 'verified': True, 'latest_version': '1.2.0' + } + + response = client.get('/api/v3/plugins/installed') + + assert response.status_code == 200 + payload = json.loads(response.data) + entry = payload['data']['plugins'][0] + assert entry['version'] == '1.0.0' + assert entry['latest_version'] == '1.2.0' + assert entry['update_available'] is True + + def test_installed_plugins_no_update_when_current(self, client, mock_plugin_manager): + """No update is flagged when installed version matches the registry.""" + from web_interface.blueprints.api_v3 import api_v3 + api_v3.plugin_manager = mock_plugin_manager + mock_plugin_manager.plugins_dir = '/nonexistent-plugins-dir' + mock_plugin_manager.get_all_plugin_info.return_value = [ + {'id': 'weather', 'name': 'Weather', 'version': '1.2.0'} + ] + mock_plugin_manager.get_plugin.return_value = None + api_v3.plugin_store_manager.get_registry_info.return_value = { + 'verified': True, 'latest_version': '1.2.0' + } + + response = client.get('/api/v3/plugins/installed') + + assert response.status_code == 200 + entry = json.loads(response.data)['data']['plugins'][0] + assert entry['latest_version'] == '1.2.0' + assert entry['update_available'] is False + + def test_is_plugin_update_available_helper(self): + """Unit-level checks for the semver-aware update comparison.""" + from web_interface.blueprints.api_v3 import _is_plugin_update_available + assert _is_plugin_update_available('1.0.0', '1.0.1') is True + assert _is_plugin_update_available('1.0.1', '1.0.1') is False + # Local build ahead of the registry must not be flagged. + assert _is_plugin_update_available('2.0.0', '1.9.9') is False + # Missing either side yields no signal. + assert _is_plugin_update_available('', '1.0.0') is False + assert _is_plugin_update_available('1.0.0', '') is False + def test_get_plugin_health(self, client, mock_plugin_manager): """Test getting plugin health information.""" from web_interface.blueprints.api_v3 import api_v3 diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index ef47a341..73d16ade 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -120,6 +120,27 @@ def _get_plugin_version(plugin_id: str) -> str: logger.warning("[PluginVersion] Invalid JSON in manifest for %s at %s: %s", plugin_id, manifest_path, e) return '' +def _is_plugin_update_available(installed_version: str, latest_version: str) -> bool: + """Return True when the registry's ``latest_version`` is strictly newer + than the installed version. + + Uses PEP 440 / semver-aware comparison so a locally modified plugin whose + version is *ahead* of the published registry is not flagged as needing an + update. If either version string can't be parsed, falls back to a plain + inequality check (any difference is surfaced so the user can reconcile). + """ + if not installed_version or not latest_version: + return False + if installed_version == latest_version: + return False + try: + from packaging.version import parse as _parse_version + return _parse_version(latest_version) > _parse_version(installed_version) + except Exception: + # Unparseable version string: we can't tell direction, so surface the + # mismatch rather than silently hiding a potential update. + return True + def _ensure_cache_manager(): """Ensure cache manager is initialized.""" global cache_manager @@ -2242,9 +2263,12 @@ def get_installed_plugins(): if enabled is None: enabled = plugin_instance.enabled if plugin_instance else True - # Verified from registry (no network call) + # Verified + latest published version from registry (no network call) store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id) verified = store_info.get('verified', False) if store_info else False + latest_version = store_info.get('latest_version', '') if store_info else '' + installed_version = plugin_info.get('version', '') + update_available = _is_plugin_update_available(installed_version, latest_version) # Local git info (single subprocess on cache miss, zero on hit) plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id @@ -2291,6 +2315,8 @@ def get_installed_plugins(): 'id': plugin_id, 'name': plugin_info.get('name', plugin_id), 'version': plugin_info.get('version', ''), + 'latest_version': latest_version, + 'update_available': update_available, 'author': plugin_info.get('author', 'Unknown'), 'category': plugin_info.get('category', 'General'), 'description': plugin_info.get('description', 'No description available'), diff --git a/web_interface/static/v3/app.css b/web_interface/static/v3/app.css index 27416ede..993902dd 100644 --- a/web_interface/static/v3/app.css +++ b/web_interface/static/v3/app.css @@ -1334,6 +1334,21 @@ button.bg-white { color: #93c5fd; } +/* Installed-plugin "Update" button when a newer version is published. + A gentle pulsing ring draws the eye without being noisy. */ +.plugin-update-available { + box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.6); + animation: plugin-update-pulse 2s ease-out infinite; +} +@keyframes plugin-update-pulse { + 0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.55); } + 70% { box-shadow: 0 0 0 6px rgba(59, 130, 246, 0); } + 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); } +} +@media (prefers-reduced-motion: reduce) { + .plugin-update-available { animation: none; } +} + /* Under-voltage / throttling warning banner */ .power-warning-banner { background-color: #fef2f2; diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index cb158d95..86257d30 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -1454,7 +1454,7 @@ function renderInstalledPlugins(plugins) {

${escapeHtml(plugin.author || 'Unknown')}

- ${plugin.version ? `

v${escapeHtml(plugin.version)}

` : ''} + ${plugin.version ? `

v${escapeHtml(plugin.version)}${plugin.update_available && plugin.latest_version ? `v${escapeHtml(plugin.latest_version)} available` : ''}

` : ''}

${escapeHtml(plugin.category || 'General')}

${escapeHtml(plugin.description || 'No description available')}

@@ -1501,11 +1501,12 @@ function renderInstalledPlugins(plugins) { Configure
-