fix(web-ui): Plugin Manager - enable aliased installs, Update All, on-demand modes, long installs, categories, GitHub-URL install (#746)

* fix(web-ui): Update All sends the live installed list and redraws the grid

updateAll() preferred PluginStateManager.installedPlugins over
window.installedPlugins. Only updateAll's own end-of-run refresh ever
fills PluginStateManager, so from the second run on it sent the first
run's plugins: one uninstalled since failed with "plugin not found" and
one installed since was never updated. That refresh also only replaced
window.installedPlugins, so the installed cards and the Updates badge
kept offering "Update to vX" for what had just been updated.

Read window.installedPlugins, the list plugins_manager.js republishes
after every install, uninstall and refresh, keeping PluginStateManager
as the fallback for a page without it, and refresh through
pluginManager.loadInstalledPlugins(true), which redraws the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): list each plugin's display modes in /plugins/installed

The on-demand modal fills its Display Mode select from
plugin.display_modes, but /plugins/installed never sent the field. Every
plugin offered one option, its own id, under "This plugin exposes a
single display mode"; the display resolved that id to the plugin's first
mode, so a multi-mode plugin could only be started, or pinned, there.

Add display_modes to each entry, read from the plugin catalog
(get_plugin_display_modes), the same declared list /display/modes and
on-demand/start use, keeping only strings. Single-mode plugins still get
one option and the same hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): enable a store install by its installed id, and not on reinstall

The store's Install button enabled the new plugin by the registry id it
installed. Weather, Music, Stocks and Leaderboard install under the id
their manifests declare (weather -> ledmatrix-weather); the plugin list,
the config section and /plugins/toggle know only that id, so the toggle
answered 404 "Plugin not found" and the plugin stayed disabled behind
"installed, but enabling it failed". The same button on an installed
plugin (Reinstall) enabled it too, switching a plugin the user had
turned off back on.

POST /plugins/install now names the installed plugin: plugin_id in the
direct answer and in the queued operation's result, read from the
installed manifest found the way the store's update and uninstall find
it (_find_plugin_path: id, aliases, plugin_path name), else the
requested id. The client reloads the list, then enables that id; from
an answer without it, the installed entry the store entry matches
(findInstalledStorePlugin, which isStorePluginInstalled now uses). A
reinstall, decided by the same match that labelled the button, reloads
the list and leaves the enabled state alone.

test/js/plugins_manager_sandbox.js runs the whole of
plugins_manager.js in a vm context against a fake DOM and API, for
suites that drive its real flows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): wait for long store installs; on timeout reload, not fail

pollOperationStatus gave a queued install 60 polls, a second apart,
then reported "Install operation timed out" as an error and stopped.
The server allows the plugin's dependency install 300 s on its own
(install_requirements_file in store_install.py), after a download that
fetches the plugin a file at a time, so installs that went on to
succeed were reported as failed, never enabled, and left out of the
installed list until the page was reloaded.

Give installs INSTALL_POLL_MAX_ATTEMPTS (600, ten minutes). When even
that runs out, reload the installed list and the store badges and warn
that the install may still be running; nothing is enabled without the
operation's answer. Uninstall keeps the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): build the store's category filter from the store's plugins

The #plugin-category select listed seven fixed categories while the
registry uses about twenty (productivity, utility, transit, finance,
...), so roughly a third of the store could not be filtered to, and
"Financial" missed the plugin filed under "finance".

The template now ships only "All Categories"; syncStoreCategoryOptions,
run by applyStoreFiltersAndSort, adds one option per category the cached
store plugins have (case folded, as the filter compares), keeps the
current choice, and rebuilds only when the set changes or the partial
was swapped in afresh -- the way the Starlark section builds its own.

The test sandbox gains window.addEventListener (initPluginsPage needs
it) and quiets the script's "element not found" warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-ui): one handler for the GitHub-URL Install button

#install-plugin-from-url had an inline onclick calling
window.handleGitHubPluginInstall, and attachInstallButtonHandler also
gave it a click listener that installs, so both ran on every click
(and on Enter, which clicks it). The inline handler threw a
ReferenceError -- it called isGithubUrl, which is local to the
plugin-manager IIFE, from outside it -- so only the listener's request
went out; correcting that scope alone would have sent every install
twice.

Remove the inline onclick and the window.handleGitHubPluginInstall it
called, which nothing else uses. The listener, which already sent the
only request, is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-03 22:18:54 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d18e4d3c9d
commit e32d177cbd
17 changed files with 1083 additions and 136 deletions
@@ -42,6 +42,29 @@ def _store_incompatibility(plugin: dict) -> Optional[str]:
return reason if isinstance(reason, str) and reason else None
def _installed_plugin_id(plugin_id: str) -> str:
"""The id the plugin installed for store entry ``plugin_id`` declares.
A registry entry can install under another id: ``weather`` installs a
directory whose manifest says ``ledmatrix-weather``, and that is the id
the plugin list, the config section and /plugins/toggle know it by. The
install is found the way the store's update and uninstall find it (the
entry's id, ``aliases`` and ``plugin_path`` name); ``plugin_id`` itself
when its manifest can't be read.
"""
try:
plugin_dir = api_v3.plugin_store_manager._find_plugin_path(plugin_id)
manifest_path = (resolve_under(plugin_dir, 'manifest.json')
if isinstance(plugin_dir, Path) else None)
if manifest_path is None or not manifest_path.is_file():
return plugin_id
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest_id = json.load(f).get('id')
except Exception: # noqa: BLE001 - only names the install for the client
return plugin_id
return manifest_id if isinstance(manifest_id, str) and safe_path_component(manifest_id) else plugin_id
def _listed_plugin_dir(base: Path, name: str) -> Optional[Path]:
"""The entry of ``base`` called ``name``, or None.
@@ -487,8 +510,10 @@ def install_plugin():
)
branch_msg = f" (branch: {branch})" if branch else ""
# plugin_id: the id to enable it by (see _installed_plugin_id).
return {'success': True,
'message': f'Plugin {plugin_id} installed successfully{branch_msg}',
'plugin_id': _installed_plugin_id(plugin_id),
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))}
else:
error_msg = f'Failed to install plugin {plugin_id}'
@@ -546,7 +571,8 @@ def install_plugin():
branch_msg = f" (branch: {branch})" if branch else ""
return success_response(
message=f'Plugin installed successfully{branch_msg}',
extra=_store_restart_fields('install', _plugin_enabled_in_config(plugin_id)))
extra={'plugin_id': _installed_plugin_id(plugin_id),
**_store_restart_fields('install', _plugin_enabled_in_config(plugin_id))})
else:
error_msg = f'Failed to install plugin {plugin_id}'
if branch:
@@ -145,6 +145,14 @@ def get_installed_plugins():
vegas_participation, vegas_participation_source = _vegas_participation(
plugin_id, plugin_config, plugin_info)
# The modes the manifest declares, from the catalog as /display/modes
# and on-demand/start read them. The on-demand modal offers these;
# without them it offered only the plugin id, which the display
# turns into the first mode. Strings only: a manifest is hand-edited.
declared_modes = api_v3.plugin_catalog.get_plugin_display_modes(plugin_id)
display_modes = ([m for m in declared_modes if isinstance(m, str)]
if isinstance(declared_modes, list) else [])
return {
'id': plugin_id,
'name': plugin_info.get('name', plugin_id),
@@ -158,6 +166,7 @@ def get_installed_plugins():
# The tab nav uses this as the <i> element's Font Awesome class
# (app-shell.js / app-early.js); only a string can be one.
'icon': plugin_info.get('icon') if isinstance(plugin_info.get('icon'), str) else None,
'display_modes': display_modes,
'enabled': enabled,
'verified': verified,
# loaded, state, error_info, loaded_version, loaded_at: the
@@ -48,12 +48,15 @@ const PluginInstallManager = {
* @returns {Promise<Array>} Update results, one per plugin sent
*/
async updateAll(onProgress, options = {}) {
// Prefer PluginStateManager if populated, fall back to window.installedPlugins
// (plugins_manager.js populates window.installedPlugins independently)
const stateManagerPlugins = window.PluginStateManager && window.PluginStateManager.installedPlugins;
const listed = (stateManagerPlugins && stateManagerPlugins.length > 0)
? stateManagerPlugins
: (window.installedPlugins || []);
// window.installedPlugins is the live list: plugins_manager.js
// republishes it after every install, uninstall and refresh.
// PluginStateManager's copy is written only by the refresh at the end
// of a run, so preferring it sent a second run the first run's
// plugins -- an uninstalled one failed, a new one was skipped. It is
// the fallback for a page without the plugin manager.
const listed = Array.isArray(window.installedPlugins)
? window.installedPlugins
: ((window.PluginStateManager && window.PluginStateManager.installedPlugins) || []);
// Snapshot: the list can be replaced while this loop is awaiting.
const plugins = this.updatablePlugins(listed);
@@ -102,10 +105,18 @@ const PluginInstallManager = {
}
// Reload plugin list once at the end. A failed refresh must not
// lose the results: they carry the restart flags.
if (window.PluginStateManager) {
// lose the results: they carry the restart flags. The plugin
// manager's load, forced past its caches, also redraws the installed
// grid and its Updates badge; PluginStateManager's only replaced
// window.installedPlugins, so the cards kept offering "Update to vX"
// for what had just been updated.
const pluginManager = window.pluginManager;
const refresh = (pluginManager && typeof pluginManager.loadInstalledPlugins === 'function')
? () => pluginManager.loadInstalledPlugins(true)
: (window.PluginStateManager ? () => window.PluginStateManager.loadInstalledPlugins() : null);
if (refresh) {
try {
await window.PluginStateManager.loadInstalledPlugins();
await refresh();
} catch (error) {
console.warn('Could not refresh the installed plugin list after updating:', error);
}
+109 -112
View File
@@ -34,8 +34,8 @@
*
* Layout: a few handlers defined up front, outside any IIFE, because the
* cards and other scripts call them through window (configurePlugin,
* togglePlugin, the GitHub token helpers, handleGitHubPluginInstall,
* checkGitHubAuthStatus); then the plugin-manager IIFE (private state:
* togglePlugin, the GitHub token helpers, checkGitHubAuthStatus); then the
* plugin-manager IIFE (private state:
* installedPlugins, the store cache, the on-demand poller); then the
* Starlark IIFE.
*
@@ -386,103 +386,6 @@ window.toggleGithubTokenContent = function(e) {
}
};
// Simple standalone handler for GitHub plugin installation
// Defined early and globally to ensure it's always available
debugLog('[DEFINE] Defining handleGitHubPluginInstall function...');
window.handleGitHubPluginInstall = function() {
debugLog('[handleGitHubPluginInstall] Function called!');
const urlInput = document.getElementById('github-plugin-url');
const statusDiv = document.getElementById('github-plugin-status');
const branchInput = document.getElementById('plugin-branch-input');
const installBtn = document.getElementById('install-plugin-from-url');
if (!urlInput) {
console.error('[handleGitHubPluginInstall] URL input not found');
alert('Error: Could not find URL input field');
return;
}
const repoUrl = urlInput.value.trim();
debugLog('[handleGitHubPluginInstall] Repo URL:', repoUrl);
if (!repoUrl) {
if (statusDiv) {
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
}
return;
}
if (!isGithubUrl(repoUrl)) {
if (statusDiv) {
statusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
}
return;
}
// Disable button and show loading
if (installBtn) {
installBtn.disabled = true;
installBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Installing...';
}
if (statusDiv) {
statusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Installing plugin...</span>';
}
const branch = branchInput?.value?.trim() || null;
const requestBody = { repo_url: repoUrl };
if (branch) {
requestBody.branch = branch;
}
debugLog('[handleGitHubPluginInstall] Sending request:', requestBody);
fetch('/api/v3/plugins/install-from-url', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
})
.then(response => {
debugLog('[handleGitHubPluginInstall] Response status:', response.status);
return response.json();
})
.then(data => {
debugLog('[handleGitHubPluginInstall] Response data:', data);
if (data.status === 'success') {
if (statusDiv) {
statusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${window.LEDEscape.html(data.plugin_id)}</span>`;
}
urlInput.value = '';
showNotification(`Plugin ${data.plugin_id} installed successfully`, 'success');
window.noteRestartRequired(data);
setTimeout(() => window.pluginManager.loadInstalledPlugins(true).catch(() => {}), 1000);
} else {
if (statusDiv) {
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${window.LEDEscape.html(data.message || 'Installation failed')}</span>`;
}
showNotification(data.message || 'Installation failed', 'error');
}
})
.catch(error => {
console.error('[handleGitHubPluginInstall] Error:', error);
if (statusDiv) {
statusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${window.LEDEscape.html(error.message)}</span>`;
}
showNotification('Error installing plugin: ' + error.message, 'error');
})
.finally(() => {
if (installBtn) {
installBtn.disabled = false;
installBtn.innerHTML = '<i class="fas fa-download mr-2"></i>Install';
}
});
};
debugLog('[DEFINE] handleGitHubPluginInstall defined and ready');
// GitHub Authentication Status - Define early so it's available in IIFE
// Shows warning banner only when token is missing or invalid
// The token itself is never exposed to the frontend for security
@@ -2083,6 +1986,13 @@ window.uninstallPlugin = function(pluginId) {
});
}
// How many times the store's Install polls a queued install, a second apart.
// The server allows the plugin's dependency install 300 s on its own
// (install_requirements_file in src/plugin_system/store_install.py), after a
// download that fetches the plugin a file at a time; the 60 the poller
// defaults to reported installs that then succeeded as timed out.
const INSTALL_POLL_MAX_ATTEMPTS = 600;
function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
const maxAttempts = options.maxAttempts || 60;
const attempt = options.attempt || 0;
@@ -2114,9 +2024,10 @@ function pollOperationStatus(operationId, pluginId, pluginName, options = {}) {
if (status === 'completed') {
// The operation's result says whether the display picks
// the change up by itself or needs a restart.
// the change up by itself or needs a restart, and for an
// install which id the plugin was installed as.
window.noteRestartRequired(operation.result);
onComplete();
onComplete(operation.result);
} else if (status === 'failed') {
onFailed(operation.error || operation.message);
} else {
@@ -2270,10 +2181,18 @@ function showStoreLoading(show) {
// ── Plugin Store: Client-Side Filter/Sort/Pagination ────────────────────────
function isStorePluginInstalled(pluginIdOrPlugin) {
return Boolean(findInstalledStorePlugin(pluginIdOrPlugin));
}
// The installed-list entry for a store plugin, or undefined. A registry entry
// can be installed under another id -- `weather` is listed as the
// `ledmatrix-weather` its manifest declares -- so its own id is tried first,
// then its plugin_path name, then its aliases.
function findInstalledStorePlugin(pluginIdOrPlugin) {
const installed = window.installedPlugins || installedPlugins || [];
// Accept either a plain ID string or a store plugin object (which may have plugin_path)
if (typeof pluginIdOrPlugin === 'string') {
return installed.some(p => p.id === pluginIdOrPlugin);
return installed.find(p => p.id === pluginIdOrPlugin);
}
const storeId = pluginIdOrPlugin.id;
// Derive the actual installed directory name from plugin_path (e.g. "plugins/ledmatrix-weather" → "ledmatrix-weather")
@@ -2281,8 +2200,9 @@ function isStorePluginInstalled(pluginIdOrPlugin) {
const pathDerivedId = pluginPath ? pluginPath.split('/').pop() : null;
// Newer registries also list the other ids outright (the manifest id).
const aliases = Array.isArray(pluginIdOrPlugin.aliases) ? pluginIdOrPlugin.aliases : [];
return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId)
|| aliases.includes(p.id));
return installed.find(p => p.id === storeId)
|| (pathDerivedId ? installed.find(p => p.id === pathDerivedId) : undefined)
|| installed.find(p => aliases.includes(p.id));
}
// ── Plugin Store: search / filter / sort ────────────────────────────────
@@ -2392,8 +2312,43 @@ function getStoreFilter() {
return _storeFilter;
}
// The category filter offers the categories the store's plugins have, as the
// Starlark section does. The template ships only "All Categories": a fixed
// list offered 7 of the registry's ~20 categories, so most plugins could not
// be filtered to, and "Financial" missed the plugin filed under "finance".
// One option per category whatever its case (the filter ignores case), and
// rebuilt only when the set changes, or for a select freshly swapped in.
function syncStoreCategoryOptions() {
const select = document.getElementById('plugin-category');
if (!select) return;
const ctl = getStoreFilter();
const selected = String((ctl ? ctl.state.filterCategory : select.value) || '');
const byKey = new Map();
(pluginStoreCache || []).forEach(plugin => {
const category = plugin && typeof plugin.category === 'string' ? plugin.category : '';
if (category.trim() && !byKey.has(category.toLowerCase())) {
byKey.set(category.toLowerCase(), category);
}
});
// The current choice stays selectable even if no plugin has it any more.
if (selected && !byKey.has(selected.toLowerCase())) byKey.set(selected.toLowerCase(), selected);
const categories = [...byKey.values()].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
const key = categories.join('\n');
if (select._storeCategories === key) return;
select._storeCategories = key;
select.innerHTML = '<option value="">All Categories</option>';
categories.forEach(category => {
const option = document.createElement('option');
option.value = category;
option.textContent = category.charAt(0).toUpperCase() + category.slice(1);
select.appendChild(option);
});
select.value = selected;
}
function applyStoreFiltersAndSort(skipPageReset) {
if (!pluginStoreCache) return;
syncStoreCategoryOptions();
const ctl = getStoreFilter();
if (ctl) {
ctl.apply(skipPageReset);
@@ -2510,11 +2465,45 @@ window.installPlugin = function(pluginId, branch = null) {
requestBody.branch = branch;
}
function enableAfterInstall() {
const storeEntry = (pluginStoreCache || []).find(p => p && p.id === pluginId) || { id: pluginId };
// Decided before the install changes the list, by the same match that
// labelled the button Install or Reinstall. A reinstall keeps the plugin
// as the user had it: enabling it here switched a deliberately disabled
// plugin back on.
const isReinstall = isStorePluginInstalled(storeEntry);
// The id the plugin was installed as, which can differ from the store's:
// `weather` installs as the `ledmatrix-weather` its manifest declares,
// and that is the id /plugins/toggle knows. The install answer names it
// (plugin_id); from one that doesn't, the installed entry the store
// entry matches, as for the Installed badge.
function installedPluginId(result) {
if (result && typeof result.plugin_id === 'string' && result.plugin_id) {
return result.plugin_id;
}
const match = findInstalledStorePlugin(storeEntry);
return match ? match.id : pluginId;
}
function afterInstall(result) {
// Reload first, so the new card exists (and, without plugin_id in the
// answer, so the installed id can be found), then redraw the store's
// badges from that list.
loadInstalledPlugins(true).catch(() => {}).then(() => {
applyStoreFiltersAndSort(true);
if (isReinstall) {
showNotification(`${pluginId} reinstalled`, 'success');
return;
}
enableAfterInstall(installedPluginId(result));
});
}
function enableAfterInstall(installedId) {
// Enable immediately so install -> enable is one step; only nudge
// for a restart once enablement actually succeeded (persistent
// toast; duration 0 = stays until dismissed).
Promise.resolve(window.togglePlugin(pluginId, true)).then(toggleResult => {
Promise.resolve(window.togglePlugin(installedId, true)).then(toggleResult => {
if (toggleResult && toggleResult.status === 'success') {
showNotification(
`${pluginId} installed and enabled — restart the display to show it`,
@@ -2532,9 +2521,6 @@ window.installPlugin = function(pluginId, branch = null) {
);
}
});
// Refresh installed plugins list, then re-render store to update badges
loadInstalledPlugins().catch(() => {});
setTimeout(() => applyStoreFiltersAndSort(true), 500);
}
fetch('/api/v3/plugins/install', {
@@ -2555,14 +2541,25 @@ window.installPlugin = function(pluginId, branch = null) {
// live: "installation queued" followed immediately by a failed
// enable). Wait for the operation to actually finish first.
pollOperationStatus(data.data.operation_id, pluginId, pluginId, {
onComplete: enableAfterInstall,
onComplete: afterInstall,
onFailed: (errorMsg) => showNotification(errorMsg || `Failed to install ${pluginId}`, 'error'),
onTimeout: () => showNotification(`Install operation timed out for ${pluginId}`, 'error')
maxAttempts: INSTALL_POLL_MAX_ATTEMPTS,
// Out of patience is not a failure: the server may still be
// installing. Show the list as it is now and say so; nothing
// is enabled without the operation's answer.
onTimeout: () => {
showNotification(
`${pluginId} is still installing — it will appear in the installed list when it finishes`,
'warning'
);
loadInstalledPlugins(true).catch(() => {})
.then(() => applyStoreFiltersAndSort(true));
}
});
} else {
// No operation queue configured - install already completed synchronously.
window.noteRestartRequired(data);
enableAfterInstall();
afterInstall(data);
}
})
.catch(error => {
@@ -250,13 +250,7 @@
<input type="text" id="plugin-search" placeholder="Search plugins by name, description, or tags..." aria-label="Search the Plugin Store" class="form-control text-sm flex-[3] min-w-0 px-4 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<select id="plugin-category" aria-label="Filter the Plugin Store by category" class="form-control text-sm flex-1 px-3 py-2.5 border border-gray-300 rounded-lg shadow-sm focus:shadow-md transition-shadow">
<option value="">All Categories</option>
<option value="sports">Sports</option>
<option value="content">Content</option>
<option value="time">Time</option>
<option value="weather">Weather</option>
<option value="financial">Financial</option>
<option value="media">Media</option>
<option value="demo">Demo</option>
<!-- The rest come from the store's plugins (plugins_manager.js, syncStoreCategoryOptions). -->
</select>
</div>
@@ -467,8 +461,9 @@
<input type="text" id="github-plugin-url" aria-label="Plugin GitHub repository URL"
placeholder="https://github.com/user/ledmatrix-plugin-name"
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<button type="button" id="install-plugin-from-url"
onclick="if(window.handleGitHubPluginInstall){window.handleGitHubPluginInstall()}else{alert('Function not loaded yet, please refresh the page')}"
<!-- Wired by attachInstallButtonHandler (plugins_manager.js); an inline
onclick here ran a second install handler on every click. -->
<button type="button" id="install-plugin-from-url"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-md whitespace-nowrap">
<i class="fas fa-download mr-2"></i>Install
</button>