mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 21:33:00 +00:00
Compare commits
5 Commits
main
...
8aab15d83c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aab15d83c | ||
|
|
6ff7fcba8d | ||
|
|
73109f73f7 | ||
|
|
3d96026e87 | ||
|
|
2619c0d893 |
@@ -14,6 +14,7 @@ import importlib.util
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import logging
|
||||
@@ -74,6 +75,10 @@ class PluginManager:
|
||||
self.state_manager = PluginStateManager(logger=self.logger)
|
||||
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, logger=self.logger)
|
||||
|
||||
# Lock protecting plugin_manifests and plugin_directories from
|
||||
# concurrent mutation (background reconciliation) and reads (requests).
|
||||
self._discovery_lock = threading.RLock()
|
||||
|
||||
# Active plugins
|
||||
self.plugins: Dict[str, Any] = {}
|
||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
@@ -106,6 +111,10 @@ class PluginManager:
|
||||
if not directory.exists():
|
||||
return plugin_ids
|
||||
|
||||
# Build new state locally before acquiring lock
|
||||
new_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
new_directories: Dict[str, Path] = {}
|
||||
|
||||
try:
|
||||
for item in directory.iterdir():
|
||||
if not item.is_dir():
|
||||
@@ -119,18 +128,21 @@ class PluginManager:
|
||||
plugin_id = manifest.get('id')
|
||||
if plugin_id:
|
||||
plugin_ids.append(plugin_id)
|
||||
self.plugin_manifests[plugin_id] = manifest
|
||||
|
||||
# Store directory mapping
|
||||
if not hasattr(self, 'plugin_directories'):
|
||||
self.plugin_directories = {}
|
||||
self.plugin_directories[plugin_id] = item
|
||||
new_manifests[plugin_id] = manifest
|
||||
new_directories[plugin_id] = item
|
||||
except (json.JSONDecodeError, PermissionError, OSError) as e:
|
||||
self.logger.warning("Error reading manifest from %s: %s", manifest_path, e, exc_info=True)
|
||||
continue
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.error("Error scanning directory %s: %s", directory, e, exc_info=True)
|
||||
|
||||
# Update shared state under lock
|
||||
with self._discovery_lock:
|
||||
self.plugin_manifests.update(new_manifests)
|
||||
if not hasattr(self, 'plugin_directories'):
|
||||
self.plugin_directories = {}
|
||||
self.plugin_directories.update(new_directories)
|
||||
|
||||
return plugin_ids
|
||||
|
||||
def discover_plugins(self) -> List[str]:
|
||||
@@ -459,7 +471,9 @@ class PluginManager:
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
self.plugin_manifests[plugin_id] = json.load(f)
|
||||
manifest = json.load(f)
|
||||
with self._discovery_lock:
|
||||
self.plugin_manifests[plugin_id] = manifest
|
||||
except Exception as e:
|
||||
self.logger.error("Error reading manifest: %s", e, exc_info=True)
|
||||
return False
|
||||
@@ -506,7 +520,8 @@ class PluginManager:
|
||||
Returns:
|
||||
Dict with plugin information or None if not found
|
||||
"""
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
with self._discovery_lock:
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
if not manifest:
|
||||
return None
|
||||
|
||||
@@ -533,7 +548,9 @@ class PluginManager:
|
||||
Returns:
|
||||
List of plugin info dictionaries
|
||||
"""
|
||||
return [info for info in [self.get_plugin_info(pid) for pid in self.plugin_manifests.keys()] if info]
|
||||
with self._discovery_lock:
|
||||
pids = list(self.plugin_manifests.keys())
|
||||
return [info for info in [self.get_plugin_info(pid) for pid in pids] if info]
|
||||
|
||||
def get_plugin_directory(self, plugin_id: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -545,8 +562,9 @@ class PluginManager:
|
||||
Returns:
|
||||
Directory path as string or None if not found
|
||||
"""
|
||||
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
|
||||
return str(self.plugin_directories[plugin_id])
|
||||
with self._discovery_lock:
|
||||
if hasattr(self, 'plugin_directories') and plugin_id in self.plugin_directories:
|
||||
return str(self.plugin_directories[plugin_id])
|
||||
|
||||
plugin_dir = self.plugins_dir / plugin_id
|
||||
if plugin_dir.exists():
|
||||
@@ -568,7 +586,8 @@ class PluginManager:
|
||||
Returns:
|
||||
List of display mode names
|
||||
"""
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
with self._discovery_lock:
|
||||
manifest = self.plugin_manifests.get(plugin_id)
|
||||
if not manifest:
|
||||
return []
|
||||
|
||||
@@ -588,7 +607,9 @@ class PluginManager:
|
||||
Plugin identifier or None if not found.
|
||||
"""
|
||||
normalized_mode = mode.strip().lower()
|
||||
for plugin_id, manifest in self.plugin_manifests.items():
|
||||
with self._discovery_lock:
|
||||
manifests_snapshot = dict(self.plugin_manifests)
|
||||
for plugin_id, manifest in manifests_snapshot.items():
|
||||
display_modes = manifest.get('display_modes')
|
||||
if isinstance(display_modes, list) and display_modes:
|
||||
if any(m.lower() == normalized_mode for m in display_modes):
|
||||
|
||||
@@ -67,7 +67,8 @@ class StateReconciliation:
|
||||
state_manager: PluginStateManager,
|
||||
config_manager,
|
||||
plugin_manager,
|
||||
plugins_dir: Path
|
||||
plugins_dir: Path,
|
||||
store_manager=None
|
||||
):
|
||||
"""
|
||||
Initialize reconciliation system.
|
||||
@@ -77,11 +78,13 @@ class StateReconciliation:
|
||||
config_manager: ConfigManager instance
|
||||
plugin_manager: PluginManager instance
|
||||
plugins_dir: Path to plugins directory
|
||||
store_manager: Optional PluginStoreManager for auto-repair
|
||||
"""
|
||||
self.state_manager = state_manager
|
||||
self.config_manager = config_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.store_manager = store_manager
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
def reconcile_state(self) -> ReconciliationResult:
|
||||
@@ -160,18 +163,32 @@ class StateReconciliation:
|
||||
message=f"Reconciliation failed: {str(e)}"
|
||||
)
|
||||
|
||||
# Top-level config keys that are NOT plugins
|
||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
||||
'web_display_autostart', 'timezone', 'location', 'display',
|
||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||
'dim_schedule', 'network', 'system', 'schedule',
|
||||
})
|
||||
|
||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get plugin state from config file."""
|
||||
state = {}
|
||||
try:
|
||||
config = self.config_manager.load_config()
|
||||
for plugin_id, plugin_config in config.items():
|
||||
if isinstance(plugin_config, dict):
|
||||
state[plugin_id] = {
|
||||
'enabled': plugin_config.get('enabled', False),
|
||||
'version': plugin_config.get('version'),
|
||||
'exists_in_config': True
|
||||
}
|
||||
if not isinstance(plugin_config, dict):
|
||||
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),
|
||||
'version': plugin_config.get('version'),
|
||||
'exists_in_config': True
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error reading config state: {e}")
|
||||
return state
|
||||
@@ -184,6 +201,8 @@ class StateReconciliation:
|
||||
for plugin_dir in self.plugins_dir.iterdir():
|
||||
if plugin_dir.is_dir():
|
||||
plugin_id = plugin_dir.name
|
||||
if '.standalone-backup-' in plugin_id:
|
||||
continue
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
if manifest_path.exists():
|
||||
import json
|
||||
@@ -263,14 +282,15 @@ class StateReconciliation:
|
||||
|
||||
# Check: Plugin in config but not on disk
|
||||
if config.get('exists_in_config') and not disk.get('exists_on_disk'):
|
||||
can_repair = self.store_manager is not None
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
|
||||
description=f"Plugin {plugin_id} in config but not on disk",
|
||||
fix_action=FixAction.MANUAL_FIX_REQUIRED,
|
||||
fix_action=FixAction.AUTO_FIX if can_repair else FixAction.MANUAL_FIX_REQUIRED,
|
||||
current_state={'exists_on_disk': False},
|
||||
expected_state={'exists_on_disk': True},
|
||||
can_auto_fix=False
|
||||
can_auto_fix=can_repair
|
||||
))
|
||||
|
||||
# Check: Enabled state mismatch
|
||||
@@ -303,6 +323,9 @@ class StateReconciliation:
|
||||
self.logger.info(f"Fixed: Added {inconsistency.plugin_id} to config")
|
||||
return True
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_ON_DISK:
|
||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||
# Sync enabled state from state manager to config
|
||||
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||
@@ -320,3 +343,31 @@ class StateReconciliation:
|
||||
|
||||
return False
|
||||
|
||||
def _auto_repair_missing_plugin(self, plugin_id: str) -> bool:
|
||||
"""Attempt to reinstall a missing plugin from the store."""
|
||||
if not self.store_manager:
|
||||
return False
|
||||
|
||||
# Try the plugin_id as-is, then without 'ledmatrix-' prefix
|
||||
candidates = [plugin_id]
|
||||
if plugin_id.startswith('ledmatrix-'):
|
||||
candidates.append(plugin_id[len('ledmatrix-'):])
|
||||
|
||||
for candidate_id in candidates:
|
||||
try:
|
||||
self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id)
|
||||
result = self.store_manager.install_plugin(candidate_id)
|
||||
if isinstance(result, dict):
|
||||
success = result.get('success', False)
|
||||
else:
|
||||
success = bool(result)
|
||||
|
||||
if success:
|
||||
self.logger.info("[AutoRepair] Successfully reinstalled plugin: %s (config key: %s)", candidate_id, plugin_id)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True)
|
||||
|
||||
self.logger.warning("[AutoRepair] Could not reinstall %s from store", plugin_id)
|
||||
return False
|
||||
|
||||
|
||||
@@ -1784,6 +1784,12 @@ class PluginStoreManager:
|
||||
# Try to get remote info from registry (optional)
|
||||
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)
|
||||
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}")
|
||||
remote_branch = None
|
||||
remote_sha = None
|
||||
|
||||
@@ -2059,6 +2065,15 @@ 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)
|
||||
|
||||
# If not found, try without 'ledmatrix-' prefix (monorepo migration)
|
||||
registry_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:
|
||||
registry_id = alt_id
|
||||
self.logger.info(f"Plugin {plugin_id} found in registry as {alt_id}")
|
||||
|
||||
# If not in registry but we have a repo URL, try reinstalling from that URL
|
||||
if not plugin_info_remote and repo_url:
|
||||
self.logger.info(f"Plugin {plugin_id} not in registry but has git remote URL. Reinstalling from {repo_url} to enable updates...")
|
||||
@@ -2111,13 +2126,13 @@ class PluginStoreManager:
|
||||
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
|
||||
|
||||
# Plugin is not a git repo but is in registry and has a newer version - reinstall
|
||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive")
|
||||
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
|
||||
|
||||
# Remove directory and reinstall fresh
|
||||
if not self._safe_remove_directory(plugin_path):
|
||||
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
|
||||
return False
|
||||
return self.install_plugin(plugin_id)
|
||||
return self.install_plugin(registry_id)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -651,12 +651,49 @@ def _initialize_health_monitor():
|
||||
|
||||
_health_monitor_initialized = True
|
||||
|
||||
# Initialize health monitor on first request (using before_request for compatibility)
|
||||
_reconciliation_done = False
|
||||
_reconciliation_started = False
|
||||
|
||||
def _run_startup_reconciliation():
|
||||
"""Run state reconciliation in background to auto-repair missing plugins."""
|
||||
global _reconciliation_done, _reconciliation_started
|
||||
from src.logging_config import get_logger
|
||||
_logger = get_logger('reconciliation')
|
||||
|
||||
try:
|
||||
from src.plugin_system.state_reconciliation import StateReconciliation
|
||||
reconciler = StateReconciliation(
|
||||
state_manager=plugin_state_manager,
|
||||
config_manager=config_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
plugins_dir=plugins_dir,
|
||||
store_manager=plugin_store_manager
|
||||
)
|
||||
result = reconciler.reconcile_state()
|
||||
if result.inconsistencies_found:
|
||||
_logger.info("[Reconciliation] %s", result.message)
|
||||
if result.reconciliation_successful:
|
||||
if result.inconsistencies_fixed:
|
||||
plugin_manager.discover_plugins()
|
||||
_reconciliation_done = True
|
||||
else:
|
||||
_logger.warning("[Reconciliation] Finished with unresolved issues, will retry")
|
||||
_reconciliation_started = False
|
||||
except Exception as e:
|
||||
_logger.error("[Reconciliation] Error: %s", e, exc_info=True)
|
||||
_reconciliation_started = False
|
||||
|
||||
# Initialize health monitor and run reconciliation on first request
|
||||
@app.before_request
|
||||
def check_health_monitor():
|
||||
"""Ensure health monitor is initialized on first request."""
|
||||
"""Ensure health monitor is initialized; launch reconciliation in background."""
|
||||
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()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
@@ -33,6 +33,29 @@ from src.web_interface.secret_helpers import (
|
||||
separate_secrets,
|
||||
)
|
||||
|
||||
_SECRET_KEY_PATTERN = re.compile(
|
||||
r'(api_key|api_secret|password|secret|token|auth_key|credential)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def _conservative_mask_config(config, _parent_key=None):
|
||||
"""Mask string values whose keys look like secrets (no schema available)."""
|
||||
if isinstance(config, list):
|
||||
return [
|
||||
_conservative_mask_config(item, _parent_key) if isinstance(item, (dict, list))
|
||||
else ('' if isinstance(item, str) and item and _parent_key and _SECRET_KEY_PATTERN.search(_parent_key) else item)
|
||||
for item in config
|
||||
]
|
||||
result = dict(config)
|
||||
for key, value in result.items():
|
||||
if isinstance(value, dict):
|
||||
result[key] = _conservative_mask_config(value)
|
||||
elif isinstance(value, list):
|
||||
result[key] = _conservative_mask_config(value, key)
|
||||
elif isinstance(value, str) and value and _SECRET_KEY_PATTERN.search(key):
|
||||
result[key] = ''
|
||||
return result
|
||||
|
||||
# Will be initialized when blueprint is registered
|
||||
config_manager = None
|
||||
plugin_manager = None
|
||||
@@ -2505,24 +2528,19 @@ def get_plugin_config():
|
||||
}
|
||||
|
||||
# Mask secret fields before returning to prevent exposing API keys
|
||||
# Fail closed — if schema unavailable, refuse to return unmasked config
|
||||
schema_mgr = api_v3.schema_manager
|
||||
if not schema_mgr:
|
||||
return error_response(
|
||||
ErrorCode.CONFIG_LOAD_FAILED,
|
||||
f"Cannot safely return config for {plugin_id}: schema manager unavailable",
|
||||
status_code=500
|
||||
)
|
||||
schema_for_mask = None
|
||||
if schema_mgr:
|
||||
try:
|
||||
schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True)
|
||||
except Exception as e:
|
||||
logger.error("[PluginConfig] Error loading schema for %s: %s", plugin_id, e, exc_info=True)
|
||||
|
||||
schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True)
|
||||
if not schema_for_mask or 'properties' not in schema_for_mask:
|
||||
return error_response(
|
||||
ErrorCode.CONFIG_LOAD_FAILED,
|
||||
f"Cannot safely return config for {plugin_id}: schema unavailable for secret masking",
|
||||
status_code=500
|
||||
)
|
||||
|
||||
plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties'])
|
||||
if schema_for_mask and 'properties' in schema_for_mask:
|
||||
plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties'])
|
||||
else:
|
||||
logger.warning("[PluginConfig] Schema unavailable for %s, applying conservative masking", plugin_id)
|
||||
plugin_config = _conservative_mask_config(plugin_config)
|
||||
|
||||
return success_response(data=plugin_config)
|
||||
except Exception as e:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -391,566 +391,51 @@
|
||||
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 ? '<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)...');
|
||||
fetch('/v3/partials/plugins')
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
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 => {
|
||||
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');
|
||||
const appElement = document.querySelector('[x-data="app()"]');
|
||||
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
||||
const activeTab = appElement._x_dataStack[0].activeTab;
|
||||
if (activeTab === 'plugins') {
|
||||
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 +976,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'"
|
||||
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
|
||||
class="nav-tab">
|
||||
<i class="fas fa-plug"></i>Plugin Manager
|
||||
@@ -1683,10 +1168,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::htmx: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>
|
||||
|
||||
Reference in New Issue
Block a user