From 2c2fca2219c82a6a36e53a6716cdd0b30f290d53 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:21:33 -0400 Subject: [PATCH] fix(web): use HTMX for Plugin Manager tab loading (#294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: auto-repair missing plugins and graceful config fallback Plugins whose directories are missing (failed update, migration, etc.) now get automatically reinstalled from the store on startup. The config endpoint no longer returns a hard 500 when a schema is unavailable — it falls back to conservative key-name-based masking so the settings page stays functional. Co-Authored-By: Claude Opus 4.6 * fix: handle ledmatrix- prefix in plugin updates and reconciliation The store registry uses unprefixed IDs (e.g., 'weather') while older installs used prefixed config keys (e.g., 'ledmatrix-weather'). Both update_plugin() and auto-repair now try the unprefixed ID as a fallback when the prefixed one isn't found in the registry. Also filters system config keys (schedule, display, etc.) from reconciliation to avoid false positives. Co-Authored-By: Claude Opus 4.6 * fix: address code review findings for plugin auto-repair - Move backup-folder filter from _get_config_state to _get_disk_state where the artifact actually lives - Run startup reconciliation in a background thread so requests aren't blocked by plugin reinstallation - Set _reconciliation_done only after success so failures allow retries - Replace print() with proper logger in reconciliation - Wrap load_schema in try/except so exceptions fall through to conservative masking instead of 500 - Handle list values in _conservative_mask_config for nested secrets - Remove duplicate import re Co-Authored-By: Claude Opus 4.6 * fix: add thread-safe locking to PluginManager and fix reconciliation retry PluginManager thread safety: - Add RLock protecting plugin_manifests and plugin_directories - Build scan results locally in _scan_directory_for_plugins, then update shared state under lock - Protect reads in get_plugin_info, get_all_plugin_info, get_plugin_directory, get_plugin_display_modes, find_plugin_for_mode - Protect manifest mutation in reload_plugin - Prevents races between background reconciliation thread and request handlers reading plugin state Reconciliation retry: - Clear _reconciliation_started on exception so next request retries - Check result.reconciliation_successful before marking done - Reset _reconciliation_started on non-success results to allow retry Co-Authored-By: Claude Opus 4.6 * fix(web): use HTMX for Plugin Manager tab loading instead of custom fetch The Plugin Manager tab was the only tab using a custom window.loadPluginsTab() function with plain fetch() instead of HTMX. This caused a race condition where plugins_manager.js listened for htmx:afterSwap to initialize, but that event never fired for the custom fetch. Users had to navigate to a plugin config tab and back to trigger initialization. Changes: - Switch plugins tab to hx-get/hx-trigger="revealed" matching all other tabs - Remove ~560 lines of dead code (script extraction for a partial with no scripts, nested retry intervals, inline HTML card rendering fallbacks) - Add simple loadPluginsDirect() fallback for when HTMX fails to load - Remove typeof htmx guard on afterSwap listener so it registers unconditionally - Tighten afterSwap target check to avoid spurious re-init from other tab swaps Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address CodeRabbit review findings across plugin system - plugin_manager.py: clear plugin_manifests/plugin_directories before update to prevent ghost entries for uninstalled plugins persisting across scans - state_reconciliation.py: remove 'enabled' key check that skipped legacy plugin configs, default to enabled=True matching PluginManager.load_plugin - app.py: add threading.Lock around reconciliation start guard to prevent race condition spawning duplicate threads; add -> None return annotation - store_manager.py: use resolved registry ID (alt_id) instead of original plugin_id when reinstalling during monorepo migration - base.html: check Response.ok in loadPluginsDirect fallback; trigger fallback on tab click when HTMX unavailable; remove active-tab check from 5-second timeout so content preloads regardless Skipped: api_v3.py secret redaction suggestion — the caller at line 2539 already tries schema-based mask_secret_fields() before falling back to _conservative_mask_config, making the suggested change redundant. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: skip backup dirs in plugin discovery and fix HTMX event syntax - plugin_manager.py: skip directories containing '.standalone-backup-' during discovery scan, matching state_reconciliation.py behavior and preventing backup manifests from overwriting live plugin entries - base.html: fix hx-on::htmx:response-error → hx-on::response-error (the :: shorthand already adds the htmx: prefix, so the original syntax resolved to htmx:htmx:response-error making the handler dead) Skipped findings: - web-ui-info in _SYSTEM_CONFIG_KEYS: it's a real plugin with manifest.json and config entry, not a system key - store_manager config key migration: valid feature request for handling ledmatrix- prefix rename, but new functionality outside this PR scope Co-Authored-By: Claude Opus 4.6 (1M context) * fix(web): add fetch timeout to loadPluginsDirect fallback Add AbortController with 10s timeout so a hanging fetch doesn't leave data-loaded set and block retries. Timer is cleared in both success and error paths. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 --- src/plugin_system/plugin_manager.py | 8 +- src/plugin_system/state_reconciliation.py | 4 +- src/plugin_system/store_manager.py | 10 +- web_interface/app.py | 18 +- web_interface/static/v3/plugins_manager.js | 29 +- web_interface/templates/v3/base.html | 611 ++------------------- 6 files changed, 88 insertions(+), 592 deletions(-) diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index dc767eae..406f8d69 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -119,6 +119,9 @@ class PluginManager: for item in directory.iterdir(): if not item.is_dir(): continue + # Skip backup directories so they don't overwrite live entries + if '.standalone-backup-' in item.name: + continue manifest_path = item / "manifest.json" if manifest_path.exists(): @@ -136,11 +139,14 @@ class PluginManager: except (OSError, PermissionError) as e: self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True) - # Update shared state under lock + # Replace shared state under lock so uninstalled plugins don't linger with self._discovery_lock: + self.plugin_manifests.clear() self.plugin_manifests.update(new_manifests) if not hasattr(self, 'plugin_directories'): self.plugin_directories = {} + else: + self.plugin_directories.clear() self.plugin_directories.update(new_directories) return plugin_ids diff --git a/src/plugin_system/state_reconciliation.py b/src/plugin_system/state_reconciliation.py index e549090a..ca381cfd 100644 --- a/src/plugin_system/state_reconciliation.py +++ b/src/plugin_system/state_reconciliation.py @@ -182,10 +182,8 @@ class StateReconciliation: continue if plugin_id in self._SYSTEM_CONFIG_KEYS: continue - if 'enabled' not in plugin_config: - continue state[plugin_id] = { - 'enabled': plugin_config.get('enabled', False), + 'enabled': plugin_config.get('enabled', True), 'version': plugin_config.get('version'), 'exists_in_config': True } diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index e6a65d80..fba7dc36 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -1785,11 +1785,13 @@ class PluginStoreManager: self.fetch_registry(force_refresh=True) plugin_info_remote = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True) # Try without 'ledmatrix-' prefix (monorepo migration) + resolved_id = plugin_id if not plugin_info_remote and plugin_id.startswith('ledmatrix-'): alt_id = plugin_id[len('ledmatrix-'):] plugin_info_remote = self.get_plugin_info(alt_id, fetch_latest_from_github=True, force_refresh=True) if plugin_info_remote: - self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}") + resolved_id = alt_id + self.logger.info(f"Plugin {plugin_id} found in registry as {resolved_id}") remote_branch = None remote_sha = None @@ -1804,13 +1806,13 @@ class PluginStoreManager: local_remote = git_info.get('remote_url', '') if local_remote and registry_repo and self._normalize_repo_url(local_remote) != self._normalize_repo_url(registry_repo): self.logger.info( - f"Plugin {plugin_id} git remote ({local_remote}) differs from registry ({registry_repo}). " + f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). " f"Reinstalling from registry to migrate to new source." ) if not self._safe_remove_directory(plugin_path): - self.logger.error(f"Failed to remove old plugin directory for {plugin_id}") + self.logger.error(f"Failed to remove old plugin directory for {resolved_id}") return False - return self.install_plugin(plugin_id) + return self.install_plugin(resolved_id) # Check if already up to date if remote_sha and local_sha and remote_sha.startswith(local_sha): diff --git a/web_interface/app.py b/web_interface/app.py index 4782cbfe..4263dcc8 100644 --- a/web_interface/app.py +++ b/web_interface/app.py @@ -653,8 +653,10 @@ def _initialize_health_monitor(): _reconciliation_done = False _reconciliation_started = False +import threading as _threading +_reconciliation_lock = _threading.Lock() -def _run_startup_reconciliation(): +def _run_startup_reconciliation() -> None: """Run state reconciliation in background to auto-repair missing plugins.""" global _reconciliation_done, _reconciliation_started from src.logging_config import get_logger @@ -678,10 +680,12 @@ def _run_startup_reconciliation(): _reconciliation_done = True else: _logger.warning("[Reconciliation] Finished with unresolved issues, will retry") - _reconciliation_started = False + with _reconciliation_lock: + _reconciliation_started = False except Exception as e: _logger.error("[Reconciliation] Error: %s", e, exc_info=True) - _reconciliation_started = False + with _reconciliation_lock: + _reconciliation_started = False # Initialize health monitor and run reconciliation on first request @app.before_request @@ -690,10 +694,10 @@ def check_health_monitor(): global _reconciliation_started if not _health_monitor_initialized: _initialize_health_monitor() - if not _reconciliation_started: - _reconciliation_started = True - import threading - threading.Thread(target=_run_startup_reconciliation, daemon=True).start() + with _reconciliation_lock: + if not _reconciliation_started: + _reconciliation_started = True + _threading.Thread(target=_run_startup_reconciliation, daemon=True).start() if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 549d58ce..8a27f43f 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -1157,21 +1157,20 @@ function initializePluginPageWhenReady() { // Strategy 3: HTMX afterSwap event (for HTMX-loaded content) // This is the primary way plugins content is loaded - if (typeof htmx !== 'undefined') { - document.body.addEventListener('htmx:afterSwap', function(event) { - const target = event.detail.target; - // Check if plugins content was swapped in - if (target.id === 'plugins-content' || - target.querySelector('#installed-plugins-grid') || - document.getElementById('installed-plugins-grid')) { - console.log('HTMX swap detected for plugins, initializing...'); - // Reset initialization flag to allow re-initialization after HTMX swap - window.pluginManager.initialized = false; - window.pluginManager.initializing = false; - initTimer = setTimeout(attemptInit, 100); - } - }, { once: false }); // Allow multiple swaps - } + // Register unconditionally — HTMX may load after this script (loaded dynamically from CDN) + // CustomEvent listeners work even before HTMX is available + document.body.addEventListener('htmx:afterSwap', function(event) { + const target = event.detail.target; + // Check if plugins content was swapped in (only match direct plugins content targets) + if (target.id === 'plugins-content' || + target.querySelector('#installed-plugins-grid')) { + console.log('HTMX swap detected for plugins, initializing...'); + // Reset initialization flag to allow re-initialization after HTMX swap + window.pluginManager.initialized = false; + window.pluginManager.initializing = false; + initTimer = setTimeout(attemptInit, 100); + } + }, { once: false }); // Allow multiple swaps })(); // Initialization guard to prevent multiple initializations diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index c1a5139e..c0975c10 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -391,566 +391,54 @@ icon.classList.add('fa-chevron-right'); } }; - - // Function to load plugins tab - window.loadPluginsTab = function() { - const content = document.getElementById('plugins-content'); - if (content && !content.hasAttribute('data-loaded')) { - content.setAttribute('data-loaded', 'true'); - console.log('Loading plugins directly via fetch...'); - - fetch('/v3/partials/plugins') - .then(r => r.text()) - .then(html => { - // Parse HTML into a temporary container to extract scripts - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; - - // Extract scripts BEFORE inserting into DOM (browser may remove them) - const scripts = Array.from(tempDiv.querySelectorAll('script')); - console.log('Found', scripts.length, 'scripts to execute'); - - // Insert content WITHOUT scripts first - const scriptsToExecute = []; - scripts.forEach(script => { - scriptsToExecute.push({ - content: script.textContent || script.innerHTML, - src: script.src, - type: script.type - }); - script.remove(); // Remove from temp div - }); - - // Now insert the HTML (without scripts) - content.innerHTML = tempDiv.innerHTML; - console.log('Plugins HTML loaded, executing', scriptsToExecute.length, 'scripts...'); - - // Execute scripts manually - ensure they run properly - if (scriptsToExecute.length > 0) { - try { - scriptsToExecute.forEach((scriptData, index) => { - try { - // Skip if script has no content and no src - const scriptContent = scriptData.content ? scriptData.content.trim() : ''; - if (!scriptContent && !scriptData.src) { - return; - } - - // Log script info for debugging - if (scriptContent) { - const preview = scriptContent.substring(0, 100).replace(/\n/g, ' '); - console.log(`[SCRIPT ${index + 1}] Content preview: ${preview}... (${scriptContent.length} chars)`); - - // Check if this script defines our critical functions - if (scriptContent.includes('window.configurePlugin') || scriptContent.includes('window.togglePlugin')) { - console.log(`[SCRIPT ${index + 1}] ⚠️ This script should define configurePlugin/togglePlugin!`); - } - } - - // Only execute if we have valid content - if (scriptContent || scriptData.src) { - // For inline scripts, use appendChild for reliable execution - if (scriptContent && !scriptData.src) { - // For very large scripts (>100KB), try fallback methods first - // as appendChild can sometimes have issues with large scripts - const isLargeScript = scriptContent.length > 100000; - - if (isLargeScript) { - console.log(`[SCRIPT ${index + 1}] Large script detected (${scriptContent.length} chars), trying fallback methods first...`); - - // Try Function constructor first for large scripts - let executed = false; - try { - const func = new Function('window', scriptContent); - func(window); - console.log(`[SCRIPT ${index + 1}] ✓ Executed large script via Function constructor`); - executed = true; - } catch (funcError) { - console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError.message); - } - - // If Function constructor failed, try indirect eval - if (!executed) { - try { - (0, eval)(scriptContent); - console.log(`[SCRIPT ${index + 1}] ✓ Executed large script via indirect eval`); - executed = true; - } catch (evalError) { - console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError.message); - } - } - - // If both fallbacks worked, skip appendChild - if (executed) { - // Verify functions were defined - setTimeout(() => { - console.log(`[SCRIPT ${index + 1}] After fallback execution:`, { - configurePlugin: typeof window.configurePlugin, - togglePlugin: typeof window.togglePlugin - }); - }, 50); - return; // Skip to next script (use return, not continue, in forEach) - } - } - - try { - // Create new script element and append to head/body - // This ensures proper execution context and window attachment - const newScript = document.createElement('script'); - if (scriptData.type) { - newScript.type = scriptData.type; - } - - // Wrap in a promise to wait for execution - const scriptPromise = new Promise((resolve, reject) => { - // Set up error handler - newScript.onerror = (error) => { - reject(error); - }; - - // For inline scripts, execution happens synchronously when appended - // But we'll use a small delay to ensure it completes - try { - // Set textContent (not innerHTML) to avoid execution issues - // Note: We can't wrap in try-catch here as it would interfere with the script - // Instead, we rely on the script's own error handling - newScript.textContent = scriptContent; - - // Append to head for better execution context - const target = document.head || document.body; - if (target) { - // Set up error handler to catch execution errors - newScript.onerror = (error) => { - console.error(`[SCRIPT ${index + 1}] Execution error:`, error); - reject(error); - }; - - // Check before execution - const beforeConfigurePlugin = typeof window.configurePlugin === 'function'; - const beforeTogglePlugin = typeof window.togglePlugin === 'function'; - - // Declare variables in outer scope so setTimeout can access them - let afterConfigurePlugin = beforeConfigurePlugin; - let afterTogglePlugin = beforeTogglePlugin; - - // Append and execute (execution is synchronous for inline scripts) - // Wrap in try-catch to catch any execution errors - try { - target.appendChild(newScript); - - // Check immediately after append (inline scripts execute synchronously) - afterConfigurePlugin = typeof window.configurePlugin === 'function'; - afterTogglePlugin = typeof window.togglePlugin === 'function'; - - console.log(`[SCRIPT ${index + 1}] Immediate check after appendChild:`, { - configurePlugin: { before: beforeConfigurePlugin, after: afterConfigurePlugin }, - togglePlugin: { before: beforeTogglePlugin, after: afterTogglePlugin } - }); - } catch (appendError) { - console.error(`[SCRIPT ${index + 1}] Error during appendChild:`, appendError); - console.error(`[SCRIPT ${index + 1}] Error message:`, appendError.message); - console.error(`[SCRIPT ${index + 1}] Error stack:`, appendError.stack); - - // Try fallback execution methods immediately - console.warn(`[SCRIPT ${index + 1}] Attempting fallback execution methods...`); - let executed = false; - - // Method 1: Function constructor - try { - const func = new Function('window', scriptContent); - func(window); - console.log(`[SCRIPT ${index + 1}] ✓ Executed via Function constructor (fallback)`); - executed = true; - } catch (funcError) { - console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError.message); - if (funcError.stack) { - console.warn(`[SCRIPT ${index + 1}] Function constructor stack:`, funcError.stack); - } - // Try to find the line number if available - if (funcError.message.includes('line')) { - const lineMatch = funcError.message.match(/line (\d+)/); - if (lineMatch) { - const lineNum = parseInt(lineMatch[1]); - const lines = scriptContent.split('\n'); - const start = Math.max(0, lineNum - 5); - const end = Math.min(lines.length, lineNum + 5); - console.warn(`[SCRIPT ${index + 1}] Context around error (lines ${start}-${end}):`, - lines.slice(start, end).join('\n')); - } - } - } - - // Method 2: Indirect eval - if (!executed) { - try { - (0, eval)(scriptContent); - console.log(`[SCRIPT ${index + 1}] ✓ Executed via indirect eval (fallback)`); - executed = true; - } catch (evalError) { - console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError.message); - if (evalError.stack) { - console.warn(`[SCRIPT ${index + 1}] Indirect eval stack:`, evalError.stack); - } - } - } - - // Check if functions are now defined - const fallbackConfigurePlugin = typeof window.configurePlugin === 'function'; - const fallbackTogglePlugin = typeof window.togglePlugin === 'function'; - - console.log(`[SCRIPT ${index + 1}] After fallback attempts:`, { - configurePlugin: fallbackConfigurePlugin, - togglePlugin: fallbackTogglePlugin, - executed: executed - }); - - if (!executed) { - reject(appendError); - } else { - resolve(); - } - } - - // Also check after a small delay to catch any async definitions - setTimeout(() => { - const delayedConfigurePlugin = typeof window.configurePlugin === 'function'; - const delayedTogglePlugin = typeof window.togglePlugin === 'function'; - - // Use the variables from the outer scope - if (delayedConfigurePlugin !== afterConfigurePlugin || delayedTogglePlugin !== afterTogglePlugin) { - console.log(`[SCRIPT ${index + 1}] Functions appeared after delay:`, { - configurePlugin: { immediate: afterConfigurePlugin, delayed: delayedConfigurePlugin }, - togglePlugin: { immediate: afterTogglePlugin, delayed: delayedTogglePlugin } - }); - } - - resolve(); - }, 100); // Small delay to catch any async definitions - } else { - reject(new Error('No target found for script execution')); - } - } catch (appendError) { - reject(appendError); - } - }); - - // Wait for script to execute (with timeout) - Promise.race([ - scriptPromise, - new Promise((_, reject) => setTimeout(() => reject(new Error('Script execution timeout')), 1000)) - ]).catch(error => { - console.warn(`[SCRIPT ${index + 1}] Script execution issue, trying fallback:`, error); - // Fallback: try multiple execution methods - let executed = false; - - // Method 1: Function constructor with window in scope - try { - const func = new Function('window', scriptContent); - func(window); - console.log(`[SCRIPT ${index + 1}] Executed via Function constructor (fallback method 1)`); - executed = true; - } catch (funcError) { - console.warn(`[SCRIPT ${index + 1}] Function constructor failed:`, funcError); - } - - // Method 2: Direct eval in global scope (if method 1 failed) - if (!executed) { - try { - // Use indirect eval to execute in global scope - (0, eval)(scriptContent); - console.log(`[SCRIPT ${index + 1}] Executed via indirect eval (fallback method 2)`); - executed = true; - } catch (evalError) { - console.warn(`[SCRIPT ${index + 1}] Indirect eval failed:`, evalError); - } - } - - // Verify functions after fallback - setTimeout(() => { - console.log(`[SCRIPT ${index + 1}] After fallback execution:`, { - configurePlugin: typeof window.configurePlugin, - togglePlugin: typeof window.togglePlugin, - executed: executed - }); - }, 10); - - if (!executed) { - console.error(`[SCRIPT ${index + 1}] All script execution methods failed`); - console.error(`[SCRIPT ${index + 1}] Script content (first 500 chars):`, scriptContent.substring(0, 500)); - } - }); - } catch (appendError) { - console.error('Failed to execute script:', appendError); - } - } else if (scriptData.src) { - // For external scripts, use appendChild - const newScript = document.createElement('script'); - newScript.src = scriptData.src; - if (scriptData.type) { - newScript.type = scriptData.type; - } - const target = document.head || document.body; - if (target) { - target.appendChild(newScript); - } - console.log('Loaded external script', index + 1, 'of', scriptsToExecute.length); - } - } - } catch (scriptError) { - console.warn('Error executing script', index + 1, ':', scriptError); - } - }); - - // Wait a moment for scripts to execute, then verify functions are available - // Use multiple checks to ensure scripts have time to execute - let checkCount = 0; - const maxChecks = 10; - const checkInterval = setInterval(() => { - checkCount++; - const funcs = { - configurePlugin: typeof window.configurePlugin, - togglePlugin: typeof window.togglePlugin, - updatePlugin: typeof window.updatePlugin, - uninstallPlugin: typeof window.uninstallPlugin, - initializePlugins: typeof window.initializePlugins, - loadInstalledPlugins: typeof window.loadInstalledPlugins, - renderInstalledPlugins: typeof window.renderInstalledPlugins - }; - - if (checkCount === 1 || checkCount === maxChecks) { - console.log('Verifying plugin functions after script execution (check', checkCount, '):', funcs); - } - - // Stop checking once critical functions are available or max checks reached - if ((funcs.configurePlugin === 'function' && funcs.togglePlugin === 'function') || checkCount >= maxChecks) { - clearInterval(checkInterval); - if (funcs.configurePlugin !== 'function' || funcs.togglePlugin !== 'function') { - console.error('Critical plugin functions not available after', checkCount, 'checks'); - } - } - }, 100); - } catch (executionError) { - console.error('Script execution error:', executionError); - } - } else { - console.log('No scripts found in loaded HTML'); - } - - // Wait for scripts to execute, then load plugins - // CRITICAL: Wait for configurePlugin and togglePlugin to be defined before proceeding - let attempts = 0; - const maxAttempts = 20; // Increased to give more time - const checkInterval = setInterval(() => { - attempts++; - - // First, ensure critical functions are available - const criticalFunctionsReady = - window.configurePlugin && typeof window.configurePlugin === 'function' && - window.togglePlugin && typeof window.togglePlugin === 'function'; - - if (!criticalFunctionsReady && attempts < maxAttempts) { - if (attempts % 5 === 0) { // Log every 5th attempt - console.log(`Waiting for critical functions... (attempt ${attempts}/${maxAttempts})`, { - configurePlugin: typeof window.configurePlugin, - togglePlugin: typeof window.togglePlugin - }); - } - return; // Keep waiting - } - - if (!criticalFunctionsReady) { - console.error('Critical functions (configurePlugin, togglePlugin) not available after', maxAttempts, 'attempts'); - clearInterval(checkInterval); - return; - } - - console.log('Critical functions ready, proceeding with plugin initialization...'); - clearInterval(checkInterval); - - // Now try to call initializePlugins first (loads both installed and store) - if (window.initializePlugins && typeof window.initializePlugins === 'function') { - console.log('Found initializePlugins, calling it...'); - window.initializePlugins(); - } else if (window.loadInstalledPlugins && typeof window.loadInstalledPlugins === 'function') { - console.log('Found loadInstalledPlugins, calling it...'); - window.loadInstalledPlugins(); - // Also try to load plugin store - if (window.searchPluginStore && typeof window.searchPluginStore === 'function') { - setTimeout(() => window.searchPluginStore(true), 500); - } - } else if (window.pluginManager && window.pluginManager.loadInstalledPlugins) { - console.log('Found pluginManager.loadInstalledPlugins, calling it...'); - window.pluginManager.loadInstalledPlugins(); - // Also try to load plugin store - setTimeout(() => { - const searchFn = window.searchPluginStore || - (window.pluginManager && window.pluginManager.searchPluginStore); - if (searchFn && typeof searchFn === 'function') { - console.log('Loading plugin store...'); - searchFn(true); - } else { - console.warn('searchPluginStore not available'); - } - }, 500); - } else if (attempts >= maxAttempts) { - console.log('loadInstalledPlugins not found after', maxAttempts, 'attempts, fetching and rendering directly...'); - clearInterval(checkInterval); - - // Load both installed plugins and plugin store - Promise.all([ - // Use batched API requests for better performance - window.PluginAPI && window.PluginAPI.batch ? - window.PluginAPI.batch([ - {endpoint: '/plugins/installed', method: 'GET'}, - {endpoint: '/plugins/store/list?fetch_commit_info=true', method: 'GET'} - ]).then(([installedRes, storeRes]) => { - return [installedRes, storeRes]; - }) : - Promise.all([ - getInstalledPluginsSafe(), - fetch('/api/v3/plugins/store/list?fetch_commit_info=true').then(r => r.json()) - ]) - ]).then(([installedData, storeData]) => { - console.log('Fetched plugins:', installedData); - console.log('Fetched store:', storeData); - - // Render installed plugins - if (installedData.status === 'success') { - const plugins = installedData.data.plugins || []; - const container = document.getElementById('installed-plugins-grid'); - const countEl = document.getElementById('installed-count'); - - // Try renderInstalledPlugins one more time - if (window.renderInstalledPlugins && typeof window.renderInstalledPlugins === 'function') { - console.log('Using renderInstalledPlugins...'); - window.renderInstalledPlugins(plugins); - } else if (container) { - console.log('renderInstalledPlugins not available, rendering full plugin cards manually...'); - // Render full plugin cards with all information - const escapeHtml = function(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - }; - const escapeAttr = function(text) { - return (text || '').replace(/'/g, "\\'").replace(/"/g, '"'); - }; - const escapeJs = function(text) { - return JSON.stringify(text || ''); - }; - const formatCommit = function(commit, branch) { - if (!commit && !branch) return 'Unknown'; - const shortCommit = commit ? String(commit).substring(0, 7) : ''; - const branchText = branch ? String(branch) : ''; - if (branchText && shortCommit) return branchText + ' · ' + shortCommit; - if (branchText) return branchText; - if (shortCommit) return shortCommit; - return 'Unknown'; - }; - const formatDate = function(dateString) { - if (!dateString) return 'Unknown'; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) return 'Unknown'; - const now = new Date(); - const diffDays = Math.ceil(Math.abs(now - date) / (1000 * 60 * 60 * 24)); - if (diffDays < 1) return 'Today'; - if (diffDays < 2) return 'Yesterday'; - if (diffDays < 7) return diffDays + ' days ago'; - if (diffDays < 30) { - const weeks = Math.floor(diffDays / 7); - return weeks + (weeks === 1 ? ' week' : ' weeks') + ' ago'; - } - return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - } catch (e) { - return 'Unknown'; - } - }; - container.innerHTML = plugins.map(function(p) { - const name = escapeHtml(p.name || p.id); - const desc = escapeHtml(p.description || 'No description available'); - const author = escapeHtml(p.author || 'Unknown'); - const category = escapeHtml(p.category || 'General'); - const enabled = p.enabled ? 'checked' : ''; - const enabledBool = Boolean(p.enabled); - const escapedId = escapeAttr(p.id); - const verified = p.verified ? 'Verified' : ''; - const tags = (p.tags && p.tags.length > 0) ? '
' + p.tags.map(function(tag) { return '' + escapeHtml(tag) + ''; }).join('') + '
' : ''; - const escapedJsId = escapeJs(p.id); - return '

' + name + '

' + verified + '

' + author + '

' + formatCommit(p.last_commit, p.branch) + '

' + formatDate(p.last_updated) + '

' + category + '

' + desc + '

' + tags + '
'; - }).join(''); - if (countEl) countEl.textContent = plugins.length + ' installed'; - window.installedPlugins = plugins; - console.log('Rendered', plugins.length, 'plugins with full cards'); - } else { - console.error('installed-plugins-grid container not found'); - } - } - - // Render plugin store - if (storeData.status === 'success') { - const storePlugins = storeData.data.plugins || []; - const storeContainer = document.getElementById('plugin-store-grid'); - const storeCountEl = document.getElementById('store-count'); - - if (storeContainer) { - // Try renderPluginStore if available - if (window.renderPluginStore && typeof window.renderPluginStore === 'function') { - console.log('Using renderPluginStore...'); - window.renderPluginStore(storePlugins); - } else { - // Manual rendering fallback - console.log('renderPluginStore not available, rendering manually...'); - const escapeHtml = function(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - }; - const escapeJs = function(text) { - return JSON.stringify(text || ''); - }; - storeContainer.innerHTML = storePlugins.map(function(p) { - const name = escapeHtml(p.name || p.id); - const desc = escapeHtml(p.description || 'No description available'); - const author = escapeHtml(p.author || 'Unknown'); - const category = escapeHtml(p.category || 'General'); - const stars = p.stars || 0; - const verified = p.verified ? 'Verified' : ''; - const escapedJsId = escapeJs(p.id); - return '

' + name + '

' + verified + '

' + author + '

' + category + '

' + (stars > 0 ? '

' + stars + ' stars

' : '') + '

' + desc + '

'; - }).join(''); - } - - if (storeCountEl) { - storeCountEl.innerHTML = storePlugins.length + ' available'; - } - console.log('Rendered', storePlugins.length, 'store plugins'); - } else { - console.error('plugin-store-grid container not found'); - } - } else { - console.error('Failed to load plugin store:', storeData.message); - const storeCountEl = document.getElementById('store-count'); - if (storeCountEl) { - storeCountEl.innerHTML = 'Error loading store'; - } - } - }) - .catch(err => { - console.error('Error fetching plugins/store:', err); - // Still try to render installed plugins if store fails - }); - } - }, 100); // Reduced from 200ms to 100ms for faster retries - }) - .catch(err => console.error('Error loading plugins:', err)); - } - }; })(); + +