Surface plugin update availability on the plugins page (#421)

* Surface plugin update availability on the plugins page

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EW8yDbsk8EceDpq6Hjgqj

* Catch InvalidVersion specifically in plugin update comparison

Address review feedback: the version comparison caught a blind `except
Exception` (ruff BLE001). Split the two failure modes and catch each
specifically — ImportError for a missing `packaging` (a core dependency)
and InvalidVersion for an unparseable version string — while preserving
the existing "surface the mismatch" fallback behavior. Added a test for
the unparseable-version path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EW8yDbsk8EceDpq6Hjgqj

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-07-21 08:06:36 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 989162d28f
commit 3872a68ff7
4 changed files with 115 additions and 7 deletions
+62 -2
View File
@@ -372,11 +372,71 @@ class TestPluginsAPI:
} }
response = client.get('/api/v3/plugins/installed') response = client.get('/api/v3/plugins/installed')
assert response.status_code == 200 assert response.status_code == 200
data = json.loads(response.data) data = json.loads(response.data)
assert isinstance(data, (list, dict)) 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
# Unparseable version differing from the installed one surfaces the
# mismatch rather than hiding a possible update.
assert _is_plugin_update_available('1.0.0', 'not-a-semver') is True
def test_get_plugin_health(self, client, mock_plugin_manager): def test_get_plugin_health(self, client, mock_plugin_manager):
"""Test getting plugin health information.""" """Test getting plugin health information."""
from web_interface.blueprints.api_v3 import api_v3 from web_interface.blueprints.api_v3 import api_v3
+33 -1
View File
@@ -120,6 +120,33 @@ 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) logger.warning("[PluginVersion] Invalid JSON in manifest for %s at %s: %s", plugin_id, manifest_path, e)
return '' 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, InvalidVersion
except ImportError:
# packaging is a core dependency, but if it's somehow unavailable we
# can't compare semantically — surface the mismatch we already know
# exists (the two strings differ).
return True
try:
return _parse_version(latest_version) > _parse_version(installed_version)
except InvalidVersion:
# 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(): def _ensure_cache_manager():
"""Ensure cache manager is initialized.""" """Ensure cache manager is initialized."""
global cache_manager global cache_manager
@@ -2242,9 +2269,12 @@ def get_installed_plugins():
if enabled is None: if enabled is None:
enabled = plugin_instance.enabled if plugin_instance else True 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) store_info = api_v3.plugin_store_manager.get_registry_info(plugin_id)
verified = store_info.get('verified', False) if store_info else False 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) # Local git info (single subprocess on cache miss, zero on hit)
plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id plugin_path = Path(api_v3.plugin_manager.plugins_dir) / plugin_id
@@ -2291,6 +2321,8 @@ def get_installed_plugins():
'id': plugin_id, 'id': plugin_id,
'name': plugin_info.get('name', plugin_id), 'name': plugin_info.get('name', plugin_id),
'version': plugin_info.get('version', ''), 'version': plugin_info.get('version', ''),
'latest_version': latest_version,
'update_available': update_available,
'author': plugin_info.get('author', 'Unknown'), 'author': plugin_info.get('author', 'Unknown'),
'category': plugin_info.get('category', 'General'), 'category': plugin_info.get('category', 'General'),
'description': plugin_info.get('description', 'No description available'), 'description': plugin_info.get('description', 'No description available'),
+15
View File
@@ -1334,6 +1334,21 @@ button.bg-white {
color: #93c5fd; 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 */ /* Under-voltage / throttling warning banner */
.power-warning-banner { .power-warning-banner {
background-color: #fef2f2; background-color: #fef2f2;
+5 -4
View File
@@ -1454,7 +1454,7 @@ function renderInstalledPlugins(plugins) {
</div> </div>
<div class="text-sm text-gray-600 space-y-1.5 mb-3"> <div class="text-sm text-gray-600 space-y-1.5 mb-3">
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p> <p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
${plugin.version ? `<p class="flex items-center"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}</p>` : ''} ${plugin.version ? `<p class="flex items-center flex-wrap gap-1.5"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>v${escapeHtml(plugin.version)}${plugin.update_available && plugin.latest_version ? `<span class="badge badge-info" title="Installed v${escapeAttr(plugin.version)} → latest v${escapeAttr(plugin.latest_version)}"><i class="fas fa-arrow-circle-up mr-1"></i>v${escapeHtml(plugin.latest_version)} available</span>` : ''}</p>` : ''}
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p> <p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
</div> </div>
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p> <p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
@@ -1501,11 +1501,12 @@ function renderInstalledPlugins(plugins) {
<i class="fas fa-cog mr-2"></i>Configure <i class="fas fa-cog mr-2"></i>Configure
</button> </button>
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<button class="btn bg-yellow-600 hover:bg-yellow-700 text-white px-4 py-2 rounded-md text-sm font-semibold" <button class="btn ${plugin.update_available ? 'bg-blue-600 hover:bg-blue-700 plugin-update-available' : 'bg-yellow-600 hover:bg-yellow-700'} text-white px-4 py-2 rounded-md text-sm font-semibold"
style="flex: 1;" style="flex: 1;"
data-plugin-id="${escapedPluginId}" data-plugin-id="${escapedPluginId}"
data-action="update"> data-action="update"
<i class="fas fa-sync mr-2"></i>Update title="${plugin.update_available && plugin.latest_version ? 'Update to v' + escapeAttr(plugin.latest_version) : 'Reinstall the latest published version'}">
<i class="fas ${plugin.update_available ? 'fa-arrow-circle-up' : 'fa-sync'} mr-2"></i>${plugin.update_available && plugin.latest_version ? 'Update to v' + escapeHtml(plugin.latest_version) : 'Update'}
</button> </button>
<button class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-semibold" <button class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-semibold"
style="flex: 1;" style="flex: 1;"