fix(web): use HTMX for Plugin Manager tab loading (#294)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-03-26 15:21:33 -04:00
committed by GitHub
parent 640a4c1706
commit 2c2fca2219
6 changed files with 88 additions and 592 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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, '&quot;');
};
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 ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : '';
const tags = (p.tags && p.tags.length > 0) ? '<div class="flex flex-wrap gap-1.5 mb-4">' + p.tags.map(function(tag) { return '<span class="badge badge-info">' + escapeHtml(tag) + '</span>'; }).join('') + '</div>' : '';
const escapedJsId = escapeJs(p.id);
return '<div class="plugin-card"><div class="flex items-start justify-between mb-4"><div class="flex-1 min-w-0"><div class="flex items-center flex-wrap gap-2 mb-2"><h4 class="font-semibold text-gray-900 text-base">' + name + '</h4>' + verified + '</div><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>' + author + '</p><p class="flex items-center"><i class="fas fa-code-branch mr-2 text-gray-400 w-4"></i>' + formatCommit(p.last_commit, p.branch) + '</p><p class="flex items-center"><i class="fas fa-calendar mr-2 text-gray-400 w-4"></i>' + formatDate(p.last_updated) + '</p><p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>' + category + '</p></div><p class="text-sm text-gray-700 leading-relaxed">' + desc + '</p></div><div class="flex-shrink-0 ml-4"><label class="relative inline-flex items-center cursor-pointer group"><input type="checkbox" class="sr-only peer" id="toggle-' + escapedId + '" ' + enabled + ' data-plugin-id="' + escapedId + '" data-action="toggle" onchange=\'if(window.togglePlugin){window.togglePlugin(' + escapedJsId + ', this.checked)}else{console.error("togglePlugin not available")}\'><div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all duration-200 ' + (enabledBool ? 'bg-green-50 border-green-500' : 'bg-gray-50 border-gray-300') + ' hover:shadow-md group-hover:scale-105"><div class="relative w-14 h-7 ' + (enabledBool ? 'bg-green-500' : 'bg-gray-300') + ' rounded-full peer peer-checked:bg-green-500 transition-colors duration-200 ease-in-out shadow-inner"><div class="absolute top-[3px] left-[3px] bg-white ' + (enabledBool ? 'translate-x-full' : '') + ' border-2 ' + (enabledBool ? 'border-green-500' : 'border-gray-400') + ' rounded-full h-5 w-5 transition-all duration-200 ease-in-out shadow-sm flex items-center justify-center">' + (enabledBool ? '<i class="fas fa-check text-green-600 text-xs"></i>' : '<i class="fas fa-times text-gray-400 text-xs"></i>') + '</div></div><span class="text-sm font-semibold ' + (enabledBool ? 'text-green-700' : 'text-gray-600') + ' flex items-center gap-1.5"><i class="fas ' + (enabledBool ? 'fa-toggle-on text-green-600' : 'fa-toggle-off text-gray-400') + '"></i><span>' + (enabledBool ? 'Enabled' : 'Disabled') + '</span></span></div></label></div></div>' + tags + '<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-200"><button onclick=\'if(window.configurePlugin){window.configurePlugin(' + escapedJsId + ')}else{console.error("configurePlugin not available")}\' class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold" data-plugin-id="' + escapedId + '" data-action="configure"><i class="fas fa-cog mr-2"></i>Configure</button><button onclick=\'if(window.updatePlugin){window.updatePlugin(' + escapedJsId + ')}else{console.error("updatePlugin not available")}\' class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold" data-plugin-id="' + escapedId + '" data-action="update"><i class="fas fa-sync mr-2"></i>Update</button><button onclick=\'if(window.uninstallPlugin){window.uninstallPlugin(' + escapedJsId + ')}else{console.error("uninstallPlugin not available")}\' class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold" data-plugin-id="' + escapedId + '" data-action="uninstall"><i class="fas fa-trash mr-2"></i>Uninstall</button></div></div>';
}).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 ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : '';
const escapedJsId = escapeJs(p.id);
return '<div class="plugin-card"><div class="flex items-start justify-between mb-4"><div class="flex-1 min-w-0"><div class="flex items-center flex-wrap gap-2 mb-2"><h4 class="font-semibold text-gray-900 text-base">' + name + '</h4>' + verified + '</div><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>' + author + '</p><p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>' + category + '</p>' + (stars > 0 ? '<p class="flex items-center"><i class="fas fa-star mr-2 text-gray-400 w-4"></i>' + stars + ' stars</p>' : '') + '</div><p class="text-sm text-gray-700 leading-relaxed">' + desc + '</p></div></div><div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-200"><button onclick=\'if(window.installPlugin){window.installPlugin(' + escapedJsId + ')}else{console.error("installPlugin not available")}\' class="btn bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"><i class="fas fa-download mr-2"></i>Install</button></div></div>';
}).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 = '<span class="text-red-600">Error loading store</span>';
}
}
})
.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));
}
};
})();
</script>
<!-- Fallback for loading plugins if HTMX fails -->
<script>
function loadPluginsDirect() {
const content = document.getElementById('plugins-content');
if (content && !content.hasAttribute('data-loaded')) {
content.setAttribute('data-loaded', 'true');
console.log('Loading plugins directly via fetch (HTMX fallback)...');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
fetch('/v3/partials/plugins', { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.text();
})
.then(html => {
clearTimeout(timeout);
content.innerHTML = html;
// Trigger full initialization chain
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
if (window.initPluginsPage) {
window.initPluginsPage();
}
})
.catch(err => {
clearTimeout(timeout);
console.error('Failed to load plugins:', err);
content.removeAttribute('data-loaded');
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Plugin Manager. Please refresh the page.</p></div>';
});
}
}
// Fallback if HTMX doesn't load within 5 seconds
setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab,
// so it's ready when the user navigates to it
loadPluginsDirect();
}
}, 5000);
</script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script>
// Helper function to get installed plugins with fallback
@@ -1491,7 +979,7 @@
<!-- Second row - Plugin tabs (populated dynamically) -->
<div id="plugin-tabs-row" class="border-b border-gray-200">
<nav class="-mb-px flex flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; $nextTick(() => { if (typeof htmx !== 'undefined' && !document.getElementById('plugins-content').hasAttribute('data-loaded')) { htmx.trigger('#plugins-content', 'load'); } })"
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug"></i>Plugin Manager
@@ -1683,10 +1171,9 @@
</div>
<!-- Plugins tab -->
<div x-show="activeTab === 'plugins'"
x-transition
x-effect="if (activeTab === 'plugins') { window.loadPluginsTab && window.loadPluginsTab(); }">
<div id="plugins-content">
<div x-show="activeTab === 'plugins'" x-transition>
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
hx-on::response-error="loadPluginsDirect()">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>