mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
* fix(plugins): Resolve plugin ID determination error in action buttons
- Fix server-side template parameter order for executePluginAction
- Add data-plugin-id attributes to action buttons in all templates
- Enhance executePluginAction with comprehensive fallback logic
- Support retrieving pluginId from DOM, Alpine context, and config state
- Fixes 'Unable to determine plugin ID' error for Spotify/YouTube auth
* fix(plugins): Add missing button IDs and status divs in server-side action template
- Add action-{id}-{index} IDs to action buttons
- Add action-status-{id}-{index} status divs for each action
- Match client-side template structure for consistency
- Fixes 'Action elements not found' error
* fix(api): Fix indentation error in execute_plugin_action function
- Fix incorrect else block indentation that caused 500 errors
- Correct indentation for OAuth flow and simple script execution paths
- Resolves syntax error preventing plugin actions from executing
* fix(api): Improve error handling for plugin actions and config saves
- Add better JSON parsing error handling with request details
- Add detailed permission error messages for secrets file saves
- Include file path and permission status in error responses
- Helps diagnose 400 errors on action execution and 500 errors on config saves
* fix(api): Add detailed permission error handling for secrets config saves
- Add PermissionError-specific handling with permission checks
- Include directory and file permission status in error logs
- Provide more helpful error messages with file paths
- Helps diagnose permission issues when saving config_secrets.json
* fix(config): Add permission check and actionable error message for config saves
- Check file writability before attempting write
- Show file owner and current permissions in error message
- Provide exact command to fix permissions (chown + chmod)
- Helps diagnose and resolve permission issues with config_secrets.json
* fix(config): Preserve detailed permission error messages
- Handle PermissionError separately to preserve detailed error messages
- Ensure actionable permission fix commands are included in error response
- Prevents detailed error messages from being lost in exception chain
* fix(config): Remove overly strict pre-write permission check
- Remove pre-write file existence/writability check that was blocking valid writes
- Let actual file write operation determine success/failure
- Provide detailed error messages only when write actually fails
- Fixes regression where config_secrets.json saves were blocked unnecessarily
* fix(config): Use atomic writes for config_secrets.json to handle permission issues
- Write to temp file first, then atomically move to final location
- Works even when existing file isn't writable (as long as directory is writable)
- Matches pattern used elsewhere in codebase (disk_cache, atomic_manager)
- Fixes permission errors when saving secrets configuration
* chore: Update music plugin submodule to include live_priority fix
* fix(plugins): Improve plugin ID determination in dynamic button generation
- Update generateFormFromSchema to pass currentPluginConfig?.pluginId and add data attributes
- Update generateSimpleConfigForm to pass currentPluginConfig?.pluginId and add data attributes
- Scope fallback 6 DOM lookup to button context instead of document-wide search
- Ensures correct plugin tab selection when multiple plugins are present
- Maintains existing try/catch error handling and logging
* chore: Update music plugin submodule to fix has_live_priority enabled attribute
* chore: Update music plugin submodule - remove redundant music_priority_mode
* fix(web-ui): Fix file upload widget detection for nested plugin properties
- Added helper function to get schema properties by full key path
- Enhanced x-widget detection to check both property object and schema directly
- Improved upload config retrieval with fallback to schema
- Added debug logging for file-upload widget detection
- Fixes issue where static-image plugin file upload widget was not rendering
The file upload widget was not being detected for nested properties like
image_config.images because the x-widget attribute wasn't being checked
in the schema directly. This fix ensures the widget is properly detected
and rendered even when nested deep in the configuration structure.
* fix(web-ui): Improve file upload widget detection with direct schema fallback
- Fixed getSchemaProperty helper function to correctly navigate nested paths
- Added direct schema lookup fallback for image_config.images path
- Enhanced debug logging to diagnose widget detection issues
- Simplified widget detection logic while maintaining robustness
* fix(web-ui): Add aggressive schema lookup for file-upload widget detection
- Always try direct schema navigation for image_config.images
- Added general direct lookup fallback if getSchemaProperty fails
- Enhanced debug logging with schema existence checks
- Prioritize schema lookup over prop object for x-widget detection
* fix(web-ui): Add direct check for top-level images field in file upload detection
- Added specific check for top-level 'images' field (flattened schema)
- Enhanced debug logging to show all x-widget detection attempts
- Improved widget detection to check prop object more thoroughly
* fix(web-ui): Prioritize prop object for x-widget detection
- Check prop object first (should have x-widget from schema)
- Then fall back to schema lookup
- Enhanced debug logging to show all detection attempts
* fix(web-ui): Add aggressive direct detection for images file upload widget
- Added direct check for 'images' field in schema.properties.images
- Multiple fallback detection methods (direct, prop object, schema lookup)
- Simplified logic to explicitly check for file-upload widget
- Enhanced debug logging to show detection path
* fix(web-ui): Add file upload widget support to server-side Jinja2 template
- Added check for x-widget: file-upload in array field rendering
- Renders file upload drop zone with drag-and-drop support
- Displays uploaded images list with delete and schedule buttons
- Falls back to comma-separated text input for regular arrays
- Fixes file upload widget not appearing in static-image plugin
* feat(web-ui): Add route to serve plugin asset files from assets directory
- Added /assets/plugins/<plugin_id>/uploads/<filename> route
- Serves uploaded images and other assets with proper content types
- Includes security checks to prevent directory traversal
- Fixes 404 errors when displaying uploaded plugin images
* fix(web-ui): Fix import for send_from_directory in plugin assets route
* feat(web-ui): Load uploaded images from metadata file when rendering config form
- Populates images field from .metadata.json if not in config
- Ensures uploaded images appear in form even before config is saved
- Merges metadata images with existing config images to avoid duplicates
* fix(web-ui): Fix PROJECT_ROOT reference in image metadata loading
* docs(web-ui): Add reminder to save configuration after file upload
- Added informational note below upload widget
- Reminds users to save config after uploading files
- Uses amber color and info icon for visibility
* fix(web-ui): Move plugin asset serving route to main app
- Moved /assets/plugins/... route from api_v3 blueprint to main app
- Blueprint has /api/v3 prefix, but route needs to be at /assets/...
- Fixes 404 errors when trying to display uploaded images
- Route must be on main app for correct URL path
* security(web-ui): Fix path containment check in plugin asset serving
- Replace string startswith() with proper path resolution using os.path.commonpath()
- Prevents prefix-based directory traversal bypasses
- Uses resolved absolute paths to ensure true path containment
- Handles ValueError for cross-drive paths (Windows compatibility)
* security(web-ui): Remove traceback exposure from plugin asset serving errors
- Return generic error message instead of full traceback in production
- Log exceptions server-side using app.logger.exception()
- Only include detailed error information when app.debug is True
- Prevents leaking internal implementation details to clients
* fix(web-ui): Assign currentPluginConfig to window for template access
- Assign currentPluginConfig to window.currentPluginConfig when building the object
- Fixes empty pluginId in template interpolation for plugin action buttons
- Ensures window.currentPluginConfig?.pluginId is available in onclick handlers
- Prevents executePluginAction from receiving empty pluginId parameter
* chore: Update music plugin submodule to include .gitignore
---------
Co-authored-by: Chuck <chuck@example.com>
4821 lines
286 KiB
HTML
4821 lines
286 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>LED Matrix Control Panel - v3</title>
|
|
|
|
<!-- Resource hints for CDN resources -->
|
|
<link rel="preconnect" href="https://unpkg.com" crossorigin>
|
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
|
<link rel="dns-prefetch" href="https://unpkg.com">
|
|
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
|
|
|
<!-- HTMX for dynamic content loading -->
|
|
<!-- Use local files when in AP mode (192.168.4.x) to avoid CDN dependency -->
|
|
<script>
|
|
(function() {
|
|
// Detect AP mode by IP address
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
|
|
// In AP mode, use local files; otherwise use CDN
|
|
const htmxSrc = isAPMode ? '/static/v3/js/htmx.min.js' : 'https://unpkg.com/htmx.org@1.9.10';
|
|
const sseSrc = isAPMode ? '/static/v3/js/htmx-sse.js' : 'https://unpkg.com/htmx.org/dist/ext/sse.js';
|
|
const jsonEncSrc = isAPMode ? '/static/v3/js/htmx-json-enc.js' : 'https://unpkg.com/htmx.org/dist/ext/json-enc.js';
|
|
|
|
// Load HTMX with fallback
|
|
function loadScript(src, fallback, onLoad) {
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.onload = onLoad || (() => {});
|
|
script.onerror = function() {
|
|
if (fallback && src !== fallback) {
|
|
console.warn(`Failed to load ${src}, trying fallback ${fallback}`);
|
|
const fallbackScript = document.createElement('script');
|
|
fallbackScript.src = fallback;
|
|
fallbackScript.onload = onLoad || (() => {});
|
|
document.head.appendChild(fallbackScript);
|
|
} else {
|
|
console.error(`Failed to load script: ${src}`);
|
|
}
|
|
};
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
// Load HTMX core
|
|
loadScript(htmxSrc, isAPMode ? 'https://unpkg.com/htmx.org@1.9.10' : '/static/v3/js/htmx.min.js', function() {
|
|
// Wait a moment for HTMX to initialize, then verify
|
|
setTimeout(function() {
|
|
// Verify HTMX loaded
|
|
if (typeof htmx === 'undefined') {
|
|
console.error('HTMX failed to load, trying fallback...');
|
|
const fallbackSrc = isAPMode ? 'https://unpkg.com/htmx.org@1.9.10' : '/static/v3/js/htmx.min.js';
|
|
if (fallbackSrc !== htmxSrc) {
|
|
loadScript(fallbackSrc, null, function() {
|
|
setTimeout(function() {
|
|
if (typeof htmx !== 'undefined') {
|
|
console.log('HTMX loaded from fallback');
|
|
// Load extensions after core loads
|
|
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
|
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
|
} else {
|
|
console.error('HTMX failed to load from both primary and fallback sources');
|
|
// Trigger fallback content loading
|
|
window.dispatchEvent(new Event('htmx-load-failed'));
|
|
}
|
|
}, 100);
|
|
});
|
|
} else {
|
|
console.error('HTMX failed to load and no fallback available');
|
|
window.dispatchEvent(new Event('htmx-load-failed'));
|
|
}
|
|
} else {
|
|
console.log('HTMX loaded successfully');
|
|
// Load extensions after core loads
|
|
loadScript(sseSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/sse.js' : '/static/v3/js/htmx-sse.js');
|
|
loadScript(jsonEncSrc, isAPMode ? 'https://unpkg.com/htmx.org/dist/ext/json-enc.js' : '/static/v3/js/htmx-json-enc.js');
|
|
}
|
|
}, 100);
|
|
});
|
|
})();
|
|
</script>
|
|
<script>
|
|
// Configure HTMX to evaluate scripts in swapped content and fix insertBefore errors
|
|
(function() {
|
|
function setupScriptExecution() {
|
|
if (document.body) {
|
|
// Fix HTMX insertBefore errors by validating targets before swap
|
|
document.body.addEventListener('htmx:beforeSwap', function(event) {
|
|
try {
|
|
const target = event.detail.target;
|
|
if (!target) {
|
|
console.warn('[HTMX] Target is null, skipping swap');
|
|
event.detail.shouldSwap = false;
|
|
return false;
|
|
}
|
|
|
|
// Check if target is a valid DOM element
|
|
if (!(target instanceof Element)) {
|
|
console.warn('[HTMX] Target is not a valid Element, skipping swap');
|
|
event.detail.shouldSwap = false;
|
|
return false;
|
|
}
|
|
|
|
// Check if target has a parent node (required for insertBefore)
|
|
if (!target.parentNode) {
|
|
console.warn('[HTMX] Target has no parent node, skipping swap');
|
|
event.detail.shouldSwap = false;
|
|
return false;
|
|
}
|
|
|
|
// Ensure target is in the DOM
|
|
if (!document.body.contains(target) && !document.head.contains(target)) {
|
|
console.warn('[HTMX] Target is not in DOM, skipping swap');
|
|
event.detail.shouldSwap = false;
|
|
return false;
|
|
}
|
|
|
|
// Additional check: ensure parent is also in DOM
|
|
if (target.parentNode && !document.body.contains(target.parentNode) && !document.head.contains(target.parentNode)) {
|
|
console.warn('[HTMX] Target parent is not in DOM, skipping swap');
|
|
event.detail.shouldSwap = false;
|
|
return false;
|
|
}
|
|
|
|
// All checks passed, allow swap
|
|
return true;
|
|
} catch (e) {
|
|
// If validation fails, cancel swap
|
|
console.warn('[HTMX] Error validating target:', e);
|
|
event.detail.shouldSwap = false;
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// Suppress HTMX insertBefore errors and other noisy errors - they're harmless but noisy
|
|
const originalError = console.error;
|
|
const originalWarn = console.warn;
|
|
|
|
console.error = function(...args) {
|
|
const errorStr = args.join(' ');
|
|
const errorStack = args.find(arg => arg && typeof arg === 'string' && arg.includes('htmx')) || '';
|
|
|
|
// Suppress HTMX insertBefore errors (comprehensive check)
|
|
// These occur when HTMX tries to swap content but the target element is null
|
|
// Usually happens due to timing/race conditions and is harmless
|
|
if (errorStr.includes("insertBefore") ||
|
|
errorStr.includes("Cannot read properties of null") ||
|
|
errorStr.includes("reading 'insertBefore'")) {
|
|
// Check if it's from HTMX by looking at stack trace or error string
|
|
// Also check the call stack if available
|
|
const isHtmxError = errorStr.includes('htmx.org') ||
|
|
errorStr.includes('htmx') ||
|
|
errorStack.includes('htmx') ||
|
|
args.some(arg => {
|
|
if (typeof arg === 'string') {
|
|
return arg.includes('htmx.org') || arg.includes('htmx');
|
|
}
|
|
// Check error objects for stack traces
|
|
if (arg && typeof arg === 'object' && arg.stack) {
|
|
return arg.stack.includes('htmx');
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (isHtmxError) {
|
|
return; // Suppress - this is a harmless HTMX timing/race condition issue
|
|
}
|
|
}
|
|
|
|
// Suppress script execution errors from malformed HTML
|
|
if (errorStr.includes("Failed to execute 'appendChild' on 'Node'") ||
|
|
errorStr.includes("Failed to execute 'insertBefore' on 'Node'")) {
|
|
if (errorStr.includes('Unexpected token')) {
|
|
return; // Suppress malformed HTML errors
|
|
}
|
|
}
|
|
originalError.apply(console, args);
|
|
};
|
|
|
|
console.warn = function(...args) {
|
|
const warnStr = args.join(' ');
|
|
// Suppress Permissions-Policy warnings (harmless browser warnings)
|
|
if (warnStr.includes('Permissions-Policy header') ||
|
|
warnStr.includes('Unrecognized feature') ||
|
|
warnStr.includes('Origin trial controlled feature') ||
|
|
warnStr.includes('browsing-topics') ||
|
|
warnStr.includes('run-ad-auction') ||
|
|
warnStr.includes('join-ad-interest-group') ||
|
|
warnStr.includes('private-state-token') ||
|
|
warnStr.includes('private-aggregation') ||
|
|
warnStr.includes('attribution-reporting')) {
|
|
return; // Suppress - these are harmless browser feature warnings
|
|
}
|
|
originalWarn.apply(console, args);
|
|
};
|
|
|
|
// Handle HTMX errors gracefully with detailed logging
|
|
document.body.addEventListener('htmx:responseError', function(event) {
|
|
const detail = event.detail;
|
|
const xhr = detail.xhr;
|
|
const target = detail.target;
|
|
|
|
// Enhanced error logging
|
|
console.error('HTMX response error:', {
|
|
status: xhr?.status,
|
|
statusText: xhr?.statusText,
|
|
url: xhr?.responseURL,
|
|
target: target?.id || target?.tagName,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
// For form submissions, log the form data
|
|
if (target && target.tagName === 'FORM') {
|
|
const formData = new FormData(target);
|
|
const formPayload = {};
|
|
for (const [key, value] of formData.entries()) {
|
|
formPayload[key] = value;
|
|
}
|
|
console.error('Form payload:', formPayload);
|
|
|
|
// Try to parse error response for validation details
|
|
if (xhr?.responseText) {
|
|
try {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
console.error('Error details:', {
|
|
message: errorData.message,
|
|
details: errorData.details,
|
|
validation_errors: errorData.validation_errors,
|
|
context: errorData.context
|
|
});
|
|
} catch (e) {
|
|
console.error('Error response (non-JSON):', xhr.responseText.substring(0, 500));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
document.body.addEventListener('htmx:swapError', function(event) {
|
|
// Log but don't break the app
|
|
console.warn('HTMX swap error:', event.detail);
|
|
});
|
|
|
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
|
if (event.detail && event.detail.target) {
|
|
try {
|
|
const scripts = event.detail.target.querySelectorAll('script');
|
|
scripts.forEach(function(oldScript) {
|
|
try {
|
|
if (oldScript.innerHTML.trim() || oldScript.src) {
|
|
const newScript = document.createElement('script');
|
|
if (oldScript.src) newScript.src = oldScript.src;
|
|
if (oldScript.type) newScript.type = oldScript.type;
|
|
if (oldScript.innerHTML) newScript.textContent = oldScript.innerHTML;
|
|
if (oldScript.parentNode) {
|
|
oldScript.parentNode.insertBefore(newScript, oldScript);
|
|
oldScript.parentNode.removeChild(oldScript);
|
|
} else {
|
|
// If no parent, append to head or body
|
|
(document.head || document.body).appendChild(newScript);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Silently ignore script execution errors
|
|
}
|
|
});
|
|
} catch (e) {
|
|
// Silently ignore errors in script processing
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', setupScriptExecution);
|
|
} else {
|
|
setTimeout(setupScriptExecution, 100);
|
|
}
|
|
}
|
|
}
|
|
setupScriptExecution();
|
|
|
|
// Section toggle function - define early so it's available for HTMX-loaded content
|
|
window.toggleSection = function(sectionId) {
|
|
const section = document.getElementById(sectionId);
|
|
const icon = document.getElementById(sectionId + '-icon');
|
|
if (!section) {
|
|
console.warn('toggleSection: Could not find section for', sectionId);
|
|
return;
|
|
}
|
|
if (!icon) {
|
|
console.warn('toggleSection: Could not find icon for', sectionId);
|
|
return;
|
|
}
|
|
|
|
// Check if currently hidden by checking both class and computed display
|
|
const hasHiddenClass = section.classList.contains('hidden');
|
|
const computedDisplay = window.getComputedStyle(section).display;
|
|
const isHidden = hasHiddenClass || computedDisplay === 'none';
|
|
|
|
if (isHidden) {
|
|
// Show the section - remove hidden class and explicitly set display to block
|
|
section.classList.remove('hidden');
|
|
section.style.display = 'block';
|
|
icon.classList.remove('fa-chevron-right');
|
|
icon.classList.add('fa-chevron-down');
|
|
} else {
|
|
// Hide the section - add hidden class and set display to none
|
|
section.classList.add('hidden');
|
|
section.style.display = 'none';
|
|
icon.classList.remove('fa-chevron-down');
|
|
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>
|
|
|
|
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
|
|
<script>
|
|
// Helper function to get installed plugins with fallback
|
|
// Must be defined before app() function that uses it
|
|
async function getInstalledPluginsSafe() {
|
|
if (window.PluginAPI && window.PluginAPI.getInstalledPlugins) {
|
|
try {
|
|
const plugins = await window.PluginAPI.getInstalledPlugins();
|
|
// Ensure plugins is always an array
|
|
const pluginsArray = Array.isArray(plugins) ? plugins : [];
|
|
return { status: 'success', data: { plugins: pluginsArray } };
|
|
} catch (error) {
|
|
console.error('Error using PluginAPI.getInstalledPlugins, falling back to direct fetch:', error);
|
|
// Fall through to direct fetch
|
|
}
|
|
}
|
|
// Fallback to direct fetch if PluginAPI not loaded
|
|
const response = await fetch('/api/v3/plugins/installed');
|
|
return await response.json();
|
|
}
|
|
|
|
// Global event listener for pluginsUpdated - works even if Alpine isn't ready yet
|
|
// This ensures tabs update when plugins_manager.js loads plugins
|
|
document.addEventListener('pluginsUpdated', function(event) {
|
|
console.log('[GLOBAL] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
|
const plugins = event.detail?.plugins || [];
|
|
|
|
// Update window.installedPlugins
|
|
window.installedPlugins = plugins;
|
|
|
|
// Try to update Alpine component if it exists (only if using full implementation)
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
const appComponent = appElement._x_dataStack[0];
|
|
appComponent.installedPlugins = plugins;
|
|
// Only call updatePluginTabs if it's the full implementation (has _doUpdatePluginTabs)
|
|
if (typeof appComponent.updatePluginTabs === 'function' &&
|
|
appComponent.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
|
console.log('[GLOBAL] Updating plugin tabs via Alpine component (full implementation)');
|
|
appComponent.updatePluginTabs();
|
|
return; // Full implementation handles it, don't do direct update
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only do direct DOM update if full implementation isn't available yet
|
|
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
|
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
|
if (pluginTabsRow && pluginTabsNav && plugins.length > 0) {
|
|
// Clear existing plugin tabs (except Plugin Manager)
|
|
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
existingTabs.forEach(tab => tab.remove());
|
|
|
|
// Add tabs for each installed plugin
|
|
plugins.forEach(plugin => {
|
|
const tabButton = document.createElement('button');
|
|
tabButton.type = 'button';
|
|
tabButton.setAttribute('data-plugin-id', plugin.id);
|
|
tabButton.className = `plugin-tab nav-tab`;
|
|
tabButton.onclick = function() {
|
|
// Try to set activeTab via Alpine if available
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
appElement._x_dataStack[0].activeTab = plugin.id;
|
|
appElement._x_dataStack[0].updatePluginTabStates();
|
|
}
|
|
}
|
|
};
|
|
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${(plugin.name || plugin.id).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}`;
|
|
pluginTabsNav.appendChild(tabButton);
|
|
});
|
|
console.log('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
|
|
}
|
|
});
|
|
|
|
// Define app() function early so Alpine can find it when it initializes
|
|
// This is a complete implementation that will work immediately
|
|
(function() {
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
|
|
// Create the app function - will be enhanced by full implementation later
|
|
window.app = function() {
|
|
return {
|
|
activeTab: isAPMode ? 'wifi' : 'overview',
|
|
installedPlugins: [],
|
|
|
|
init() {
|
|
// Try to enhance immediately with full implementation
|
|
const tryEnhance = () => {
|
|
if (typeof window.app === 'function') {
|
|
const fullApp = window.app();
|
|
// Check if this is the full implementation (has updatePluginTabs with proper implementation)
|
|
if (fullApp && typeof fullApp.updatePluginTabs === 'function' && fullApp.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
|
// Full implementation is available, copy all methods
|
|
// But preserve _initialized flag to prevent double init
|
|
const wasInitialized = this._initialized;
|
|
Object.assign(this, fullApp);
|
|
// Restore _initialized flag if it was set
|
|
if (wasInitialized) {
|
|
this._initialized = wasInitialized;
|
|
}
|
|
// Only call init if not already initialized
|
|
if (typeof this.init === 'function' && !this._initialized) {
|
|
this.init();
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Set up event listener for pluginsUpdated in stub (only if not already enhanced)
|
|
// The full implementation will have its own listener, so we only need this for the stub
|
|
if (!this._pluginsUpdatedListenerSet) {
|
|
const handlePluginsUpdated = (event) => {
|
|
console.log('[STUB] Received pluginsUpdated event:', event.detail?.plugins?.length || 0, 'plugins');
|
|
const plugins = event.detail?.plugins || [];
|
|
// Only update if we're still in stub mode (not enhanced yet)
|
|
if (typeof this.updatePluginTabs === 'function' && !this.updatePluginTabs.toString().includes('_doUpdatePluginTabs')) {
|
|
this.installedPlugins = plugins;
|
|
if (this.$nextTick && typeof this.$nextTick === 'function') {
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
} else {
|
|
setTimeout(() => {
|
|
this.updatePluginTabs();
|
|
}, 100);
|
|
}
|
|
}
|
|
};
|
|
document.addEventListener('pluginsUpdated', handlePluginsUpdated);
|
|
this._pluginsUpdatedListenerSet = true;
|
|
console.log('[STUB] init: Set up pluginsUpdated event listener');
|
|
}
|
|
|
|
// Try immediately - if full implementation is already loaded, use it right away
|
|
if (!tryEnhance()) {
|
|
// Full implementation not ready yet, load plugins directly while waiting
|
|
this.loadInstalledPluginsDirectly();
|
|
// Try again very soon to enhance with full implementation
|
|
setTimeout(tryEnhance, 10);
|
|
|
|
// Also set up a periodic check to update tabs if plugins get loaded by plugins_manager.js
|
|
let retryCount = 0;
|
|
const maxRetries = 20; // Check for 2 seconds (20 * 100ms)
|
|
const checkAndUpdateTabs = () => {
|
|
if (retryCount >= maxRetries) {
|
|
// Fallback: if plugins_manager.js hasn't loaded after 2 seconds, fetch directly
|
|
if (!window.installedPlugins || window.installedPlugins.length === 0) {
|
|
console.log('[STUB] checkAndUpdateTabs: Fallback - fetching plugins directly after timeout');
|
|
this.loadInstalledPluginsDirectly();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Check if plugins are available (either from window or component)
|
|
const plugins = window.installedPlugins || this.installedPlugins || [];
|
|
if (plugins.length > 0) {
|
|
console.log('[STUB] checkAndUpdateTabs: Found', plugins.length, 'plugins, updating tabs');
|
|
this.installedPlugins = plugins;
|
|
if (typeof this.updatePluginTabs === 'function') {
|
|
this.updatePluginTabs();
|
|
}
|
|
} else {
|
|
retryCount++;
|
|
setTimeout(checkAndUpdateTabs, 100);
|
|
}
|
|
};
|
|
// Start checking after a short delay
|
|
setTimeout(checkAndUpdateTabs, 200);
|
|
} else {
|
|
// Full implementation loaded, but still set up fallback timer
|
|
setTimeout(() => {
|
|
if (!window.installedPlugins || window.installedPlugins.length === 0) {
|
|
console.log('[STUB] init: Fallback timer - fetching plugins directly');
|
|
this.loadInstalledPluginsDirectly();
|
|
}
|
|
}, 2000);
|
|
}
|
|
},
|
|
|
|
// Direct plugin loading for stub (before full implementation loads)
|
|
async loadInstalledPluginsDirectly() {
|
|
try {
|
|
console.log('[STUB] loadInstalledPluginsDirectly: Starting...');
|
|
// Ensure DOM is ready
|
|
const ensureDOMReady = () => {
|
|
return new Promise((resolve) => {
|
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
|
// Use requestAnimationFrame to ensure DOM is painted
|
|
requestAnimationFrame(() => {
|
|
setTimeout(resolve, 50); // Small delay to ensure rendering
|
|
});
|
|
} else {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
requestAnimationFrame(() => {
|
|
setTimeout(resolve, 50);
|
|
});
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
await ensureDOMReady();
|
|
|
|
const data = await getInstalledPluginsSafe();
|
|
if (data.status === 'success') {
|
|
const plugins = data.data.plugins || [];
|
|
console.log('[STUB] loadInstalledPluginsDirectly: Loaded', plugins.length, 'plugins');
|
|
|
|
// Update both component and window
|
|
this.installedPlugins = plugins;
|
|
window.installedPlugins = plugins;
|
|
|
|
// Dispatch event so global listener can update tabs
|
|
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
|
detail: { plugins: plugins }
|
|
}));
|
|
console.log('[STUB] loadInstalledPluginsDirectly: Dispatched pluginsUpdated event');
|
|
|
|
// Update tabs if we have the method - use $nextTick if available
|
|
if (typeof this.updatePluginTabs === 'function') {
|
|
if (this.$nextTick && typeof this.$nextTick === 'function') {
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
} else {
|
|
// Fallback: wait a bit for DOM
|
|
setTimeout(() => {
|
|
this.updatePluginTabs();
|
|
}, 100);
|
|
}
|
|
}
|
|
} else {
|
|
console.warn('[STUB] loadInstalledPluginsDirectly: Failed to load plugins:', data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('[STUB] loadInstalledPluginsDirectly: Error loading plugins:', error);
|
|
}
|
|
},
|
|
|
|
// Stub methods that will be replaced by full implementation
|
|
loadTabContent: function(tab) {},
|
|
loadInstalledPlugins: async function() {
|
|
// Try to use global function if available, otherwise use direct loading
|
|
if (typeof window.loadInstalledPlugins === 'function') {
|
|
await window.loadInstalledPlugins();
|
|
// Update tabs after loading (window.installedPlugins should be set by the global function)
|
|
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
|
this.installedPlugins = window.installedPlugins;
|
|
this.updatePluginTabs();
|
|
}
|
|
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
|
await window.pluginManager.loadInstalledPlugins();
|
|
// Update tabs after loading
|
|
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
|
this.installedPlugins = window.installedPlugins;
|
|
this.updatePluginTabs();
|
|
}
|
|
} else {
|
|
// Fallback to direct loading (which already calls updatePluginTabs)
|
|
await this.loadInstalledPluginsDirectly();
|
|
}
|
|
},
|
|
updatePluginTabs: function() {
|
|
// Basic implementation for stub - will be replaced by full implementation
|
|
// Debounce to prevent multiple rapid calls
|
|
if (this._updatePluginTabsTimeout) {
|
|
clearTimeout(this._updatePluginTabsTimeout);
|
|
}
|
|
|
|
this._updatePluginTabsTimeout = setTimeout(() => {
|
|
console.log('[STUB] updatePluginTabs: Executing with', this.installedPlugins?.length || 0, 'plugins');
|
|
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
|
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
|
if (!pluginTabsRow || !pluginTabsNav) {
|
|
console.warn('[STUB] updatePluginTabs: Plugin tabs container not found');
|
|
return;
|
|
}
|
|
if (!this.installedPlugins || this.installedPlugins.length === 0) {
|
|
console.log('[STUB] updatePluginTabs: No plugins to display');
|
|
return;
|
|
}
|
|
|
|
// Check if tabs are already correct by comparing plugin IDs
|
|
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
const existingIds = Array.from(existingTabs).map(tab => tab.getAttribute('data-plugin-id')).sort().join(',');
|
|
const currentIds = this.installedPlugins.map(p => p.id).sort().join(',');
|
|
|
|
if (existingIds === currentIds && existingTabs.length === this.installedPlugins.length) {
|
|
console.log('[STUB] updatePluginTabs: Tabs already match, skipping update');
|
|
return;
|
|
}
|
|
|
|
// Clear existing plugin tabs (except Plugin Manager)
|
|
existingTabs.forEach(tab => tab.remove());
|
|
console.log('[STUB] updatePluginTabs: Cleared', existingTabs.length, 'existing tabs');
|
|
|
|
// Add tabs for each installed plugin
|
|
this.installedPlugins.forEach(plugin => {
|
|
const tabButton = document.createElement('button');
|
|
tabButton.type = 'button';
|
|
tabButton.setAttribute('data-plugin-id', plugin.id);
|
|
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
|
|
tabButton.onclick = () => {
|
|
this.activeTab = plugin.id;
|
|
if (typeof this.updatePluginTabStates === 'function') {
|
|
this.updatePluginTabStates();
|
|
}
|
|
};
|
|
const div = document.createElement('div');
|
|
div.textContent = plugin.name || plugin.id;
|
|
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${div.innerHTML}`;
|
|
pluginTabsNav.appendChild(tabButton);
|
|
});
|
|
console.log('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
|
|
}, 100);
|
|
},
|
|
showNotification: function(message, type) {},
|
|
escapeHtml: function(text) { return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
};
|
|
};
|
|
})();
|
|
</script>
|
|
|
|
<!-- Alpine.js for reactive components -->
|
|
<!-- Use local file when in AP mode (192.168.4.x) to avoid CDN dependency -->
|
|
<script>
|
|
(function() {
|
|
// Prevent Alpine from auto-initializing by setting deferLoadingAlpine before it loads
|
|
window.deferLoadingAlpine = function(callback) {
|
|
// Wait for DOM to be ready
|
|
function waitForReady() {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', waitForReady);
|
|
return;
|
|
}
|
|
|
|
// app() is already defined in head, so we can initialize Alpine
|
|
if (callback && typeof callback === 'function') {
|
|
callback();
|
|
} else if (window.Alpine && typeof window.Alpine.start === 'function') {
|
|
// If callback not provided but Alpine is available, start it
|
|
try {
|
|
window.Alpine.start();
|
|
} catch (e) {
|
|
// Alpine may already be initialized, ignore
|
|
console.warn('Alpine start error (may already be initialized):', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
waitForReady();
|
|
};
|
|
|
|
// Detect AP mode by IP address
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
|
|
const alpineSrc = isAPMode ? '/static/v3/js/alpinejs.min.js' : 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js';
|
|
const alpineFallback = isAPMode ? 'https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js' : '/static/v3/js/alpinejs.min.js';
|
|
|
|
const script = document.createElement('script');
|
|
script.defer = true;
|
|
script.src = alpineSrc;
|
|
script.onerror = function() {
|
|
if (alpineSrc !== alpineFallback) {
|
|
const fallback = document.createElement('script');
|
|
fallback.defer = true;
|
|
fallback.src = alpineFallback;
|
|
document.head.appendChild(fallback);
|
|
}
|
|
};
|
|
document.head.appendChild(script);
|
|
})();
|
|
</script>
|
|
|
|
<!-- CodeMirror for JSON editing - lazy loaded when needed -->
|
|
<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.css"></noscript>
|
|
<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/monokai.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
|
<noscript><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/theme/monokai.min.css"></noscript>
|
|
<!-- CodeMirror scripts loaded on demand when JSON editor is opened -->
|
|
<script>
|
|
// Lazy load CodeMirror when needed
|
|
window.loadCodeMirror = function() {
|
|
if (window.CodeMirror) return Promise.resolve();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const scripts = [
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/codemirror.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/javascript/javascript.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/mode/json/json.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/closebrackets.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.2/addon/edit/matchbrackets.min.js'
|
|
];
|
|
|
|
let loaded = 0;
|
|
scripts.forEach((src, index) => {
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.defer = true;
|
|
script.onload = () => {
|
|
loaded++;
|
|
if (loaded === scripts.length) resolve();
|
|
};
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
});
|
|
};
|
|
</script>
|
|
|
|
<!-- Font Awesome icons -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
|
|
<!-- Custom v3 styles -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='v3/app.css') }}">
|
|
</head>
|
|
<body x-data="app()" class="bg-gray-50 min-h-screen">
|
|
<!-- Header -->
|
|
<header class="bg-white shadow-md border-b border-gray-200">
|
|
<div class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16" style="max-width: 100%;">
|
|
<div class="flex justify-between items-center h-16">
|
|
<div class="flex items-center">
|
|
<h1 class="text-xl font-bold text-gray-900">
|
|
<i class="fas fa-tv text-blue-600 mr-2"></i>
|
|
LED Matrix Control - v3
|
|
</h1>
|
|
</div>
|
|
|
|
<!-- Connection status -->
|
|
<div class="flex items-center space-x-4">
|
|
<div id="connection-status" class="flex items-center space-x-2 text-sm">
|
|
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
<span class="text-gray-600">Disconnected</span>
|
|
</div>
|
|
|
|
<!-- System stats (populated via SSE) -->
|
|
<div class="hidden lg:flex items-center space-x-4 text-sm text-gray-600 xl:space-x-6 2xl:space-x-8">
|
|
<span id="cpu-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-microchip"></i>
|
|
<span>--%</span>
|
|
</span>
|
|
<span id="memory-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-memory"></i>
|
|
<span>--%</span>
|
|
</span>
|
|
<span id="temp-stat" class="flex items-center space-x-1">
|
|
<i class="fas fa-thermometer-half"></i>
|
|
<span>--°C</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Main content -->
|
|
<main class="mx-auto px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8" style="max-width: 100%;">
|
|
<!-- Navigation tabs -->
|
|
<nav class="mb-8">
|
|
<!-- First row - System tabs -->
|
|
<div class="border-b border-gray-200 mb-4">
|
|
<nav class="-mb-px flex space-x-4 lg:space-x-6 xl:space-x-8 overflow-x-auto">
|
|
<button @click="activeTab = 'overview'"
|
|
:class="activeTab === 'overview' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-tachometer-alt"></i>Overview
|
|
</button>
|
|
<button @click="activeTab = 'general'"
|
|
:class="activeTab === 'general' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-sliders-h"></i>General
|
|
</button>
|
|
<button @click="activeTab = 'wifi'"
|
|
:class="activeTab === 'wifi' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-wifi"></i>WiFi
|
|
</button>
|
|
<button @click="activeTab = 'schedule'"
|
|
:class="activeTab === 'schedule' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-clock"></i>Schedule
|
|
</button>
|
|
<button @click="activeTab = 'display'"
|
|
:class="activeTab === 'display' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-desktop"></i>Display
|
|
</button>
|
|
<button @click="activeTab = 'config-editor'"
|
|
:class="activeTab === 'config-editor' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-file-code"></i>Config Editor
|
|
</button>
|
|
<button @click="activeTab = 'fonts'"
|
|
:class="activeTab === 'fonts' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-font"></i>Fonts
|
|
</button>
|
|
<button @click="activeTab = 'logs'"
|
|
:class="activeTab === 'logs' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-file-alt"></i>Logs
|
|
</button>
|
|
<button @click="activeTab = 'cache'"
|
|
:class="activeTab === 'cache' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-database"></i>Cache
|
|
</button>
|
|
<button @click="activeTab = 'operation-history'"
|
|
:class="activeTab === 'operation-history' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-history"></i>Operation History
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<!-- Second row - Plugin tabs (populated dynamically) -->
|
|
<div id="plugin-tabs-row" class="border-b border-gray-200">
|
|
<nav class="-mb-px flex space-x-4 lg:space-x-6 xl:space-x-8 overflow-x-auto">
|
|
<button @click="activeTab = 'plugins'; $nextTick(() => { if (typeof htmx !== 'undefined' && !document.getElementById('plugins-content').hasAttribute('data-loaded')) { htmx.trigger('#plugins-content', 'load'); } })"
|
|
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
|
|
class="nav-tab">
|
|
<i class="fas fa-plug"></i>Plugin Manager
|
|
</button>
|
|
<!-- Installed plugin tabs will be added here dynamically -->
|
|
</nav>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Tab content -->
|
|
<div id="tab-content" class="space-y-6">
|
|
<!-- Overview tab -->
|
|
<div x-show="activeTab === 'overview'" x-transition>
|
|
<div id="overview-content" hx-get="/v3/partials/overview" hx-trigger="revealed" hx-swap="innerHTML" hx-on::htmx:response-error="loadOverviewDirect()">
|
|
<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>
|
|
<div class="h-32 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Fallback: Load overview content directly if HTMX fails
|
|
function loadOverviewDirect() {
|
|
const overviewContent = document.getElementById('overview-content');
|
|
if (overviewContent && !overviewContent.hasAttribute('data-loaded')) {
|
|
fetch('/v3/partials/overview')
|
|
.then(response => response.text())
|
|
.then(html => {
|
|
overviewContent.innerHTML = html;
|
|
overviewContent.setAttribute('data-loaded', 'true');
|
|
// Re-initialize Alpine.js for the new content
|
|
if (window.Alpine) {
|
|
window.Alpine.initTree(overviewContent);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load overview content:', err);
|
|
overviewContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load overview page. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Listen for HTMX load failure
|
|
window.addEventListener('htmx-load-failed', function() {
|
|
console.warn('HTMX failed to load, setting up direct content loading fallbacks');
|
|
// Try to load content directly after a delay
|
|
setTimeout(() => {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement.__x) {
|
|
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
if (activeTab === 'overview') {
|
|
loadOverviewDirect();
|
|
}
|
|
}
|
|
}, 2000);
|
|
});
|
|
|
|
// Also try direct load 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 content');
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement.__x) {
|
|
const activeTab = appElement.__x.$data.activeTab || 'overview';
|
|
if (activeTab === 'overview') {
|
|
loadOverviewDirect();
|
|
}
|
|
}
|
|
}
|
|
}, 5000);
|
|
</script>
|
|
|
|
<!-- General tab -->
|
|
<div x-show="activeTab === 'general'" x-transition>
|
|
<div id="general-content" hx-get="/v3/partials/general" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<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>
|
|
<div class="space-y-4">
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WiFi tab -->
|
|
<div x-show="activeTab === 'wifi'" x-transition>
|
|
<div id="wifi-content"
|
|
hx-get="/v3/partials/wifi"
|
|
hx-trigger="revealed"
|
|
hx-swap="innerHTML"
|
|
hx-on::htmx:response-error="loadWifiDirect()">
|
|
<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>
|
|
<div class="space-y-4">
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Fallback: Load WiFi content directly if HTMX fails
|
|
function loadWifiDirect() {
|
|
const wifiContent = document.getElementById('wifi-content');
|
|
if (wifiContent && !wifiContent.hasAttribute('data-loaded')) {
|
|
fetch('/v3/partials/wifi')
|
|
.then(response => response.text())
|
|
.then(html => {
|
|
wifiContent.innerHTML = html;
|
|
wifiContent.setAttribute('data-loaded', 'true');
|
|
// Re-initialize Alpine.js for the new content
|
|
if (window.Alpine) {
|
|
window.Alpine.initTree(wifiContent);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Failed to load WiFi content:', err);
|
|
wifiContent.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load WiFi setup page. Please refresh the page.</p></div>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Also try direct load if HTMX doesn't load within 3 seconds (AP mode detection)
|
|
setTimeout(() => {
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
if (isAPMode && typeof htmx === 'undefined') {
|
|
console.warn('HTMX not loaded, using direct fetch for WiFi content');
|
|
loadWifiDirect();
|
|
}
|
|
}, 3000);
|
|
</script>
|
|
|
|
<!-- Schedule tab -->
|
|
<div x-show="activeTab === 'schedule'" x-transition>
|
|
<div id="schedule-content" hx-get="/v3/partials/schedule" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<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>
|
|
<div class="space-y-4">
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
<div class="h-10 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Display tab -->
|
|
<div x-show="activeTab === 'display'" x-transition>
|
|
<div id="display-content" hx-get="/v3/partials/display" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<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>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div class="h-32 bg-gray-200 rounded"></div>
|
|
<div class="h-32 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Config Editor tab -->
|
|
<div x-show="activeTab === 'config-editor'" x-transition>
|
|
<div id="config-editor-content" hx-get="/v3/partials/raw-json" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<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>
|
|
<div class="space-y-4">
|
|
<div class="h-64 bg-gray-200 rounded"></div>
|
|
<div class="h-64 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plugins tab -->
|
|
<div x-show="activeTab === 'plugins'"
|
|
x-transition
|
|
x-effect="if (activeTab === 'plugins') { window.loadPluginsTab && window.loadPluginsTab(); }">
|
|
<div id="plugins-content">
|
|
<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>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
<div class="h-24 bg-gray-200 rounded"></div>
|
|
<div class="h-24 bg-gray-200 rounded"></div>
|
|
<div class="h-24 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fonts tab -->
|
|
<div x-show="activeTab === 'fonts'" x-transition>
|
|
<div id="fonts-content" hx-get="/v3/partials/fonts" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<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>
|
|
<div class="space-y-4">
|
|
<div class="h-20 bg-gray-200 rounded"></div>
|
|
<div class="h-20 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs tab -->
|
|
<div x-show="activeTab === 'logs'" x-transition>
|
|
<div id="logs-content" hx-get="/v3/partials/logs" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<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>
|
|
<div class="h-96 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cache tab -->
|
|
<div x-show="activeTab === 'cache'" x-transition>
|
|
<div id="cache-content" hx-get="/v3/partials/cache" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<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>
|
|
<div class="h-64 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Operation History tab -->
|
|
<div x-show="activeTab === 'operation-history'" x-transition>
|
|
<div id="operation-history-content" hx-get="/v3/partials/operation-history" hx-trigger="revealed" hx-swap="innerHTML">
|
|
<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>
|
|
<div class="h-64 bg-gray-200 rounded"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dynamic Plugin Tabs - HTMX Lazy Loading -->
|
|
<!--
|
|
Architecture: Server-side rendered plugin configuration forms
|
|
- Each plugin tab loads its config via HTMX when first viewed
|
|
- Forms are generated server-side using Jinja2 macros
|
|
- Reduces client-side complexity and improves performance
|
|
- Uses x-init to trigger HTMX after Alpine renders the element
|
|
-->
|
|
<template x-for="plugin in installedPlugins" :key="plugin.id">
|
|
<div x-show="activeTab === plugin.id" x-transition>
|
|
<!-- Only load content when tab is active (lazy loading) -->
|
|
<template x-if="activeTab === plugin.id">
|
|
<div class="bg-white rounded-lg shadow p-6 plugin-config-tab"
|
|
:id="'plugin-config-' + plugin.id"
|
|
x-init="$nextTick(() => {
|
|
if (window.htmx && !$el.dataset.htmxLoaded) {
|
|
$el.dataset.htmxLoaded = 'true';
|
|
htmx.ajax('GET', '/v3/partials/plugin-config/' + plugin.id, {target: $el, swap: 'innerHTML'});
|
|
}
|
|
})">
|
|
<!-- Loading skeleton shown until HTMX loads server-rendered content -->
|
|
<div class="animate-pulse space-y-6">
|
|
<div class="border-b border-gray-200 pb-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="space-y-2">
|
|
<div class="h-6 bg-gray-200 rounded w-48"></div>
|
|
<div class="h-4 bg-gray-200 rounded w-96"></div>
|
|
</div>
|
|
<div class="h-6 bg-gray-200 rounded w-24"></div>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
|
|
<div class="h-5 bg-gray-200 rounded w-32"></div>
|
|
<div class="space-y-3">
|
|
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
<div class="bg-gray-50 rounded-lg p-4 space-y-4">
|
|
<div class="h-5 bg-gray-200 rounded w-32"></div>
|
|
<div class="space-y-3">
|
|
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
|
<div class="h-10 bg-gray-200 rounded w-full"></div>
|
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
<div class="h-10 bg-gray-200 rounded w-full"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Notifications -->
|
|
<div id="notifications" class="fixed top-4 right-4 z-50 space-y-2"></div>
|
|
|
|
<!-- SSE connection for real-time updates -->
|
|
<script>
|
|
// Connect to SSE streams
|
|
const statsSource = new EventSource('/api/v3/stream/stats');
|
|
const displaySource = new EventSource('/api/v3/stream/display');
|
|
|
|
statsSource.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
updateSystemStats(data);
|
|
};
|
|
|
|
displaySource.onmessage = function(event) {
|
|
const data = JSON.parse(event.data);
|
|
updateDisplayPreview(data);
|
|
};
|
|
|
|
// Connection status
|
|
statsSource.addEventListener('open', function() {
|
|
document.getElementById('connection-status').innerHTML = `
|
|
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
<span class="text-gray-600">Connected</span>
|
|
`;
|
|
});
|
|
|
|
statsSource.addEventListener('error', function() {
|
|
document.getElementById('connection-status').innerHTML = `
|
|
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
<span class="text-gray-600">Disconnected</span>
|
|
`;
|
|
});
|
|
|
|
function updateSystemStats(data) {
|
|
// Update CPU in header
|
|
const cpuEl = document.getElementById('cpu-stat');
|
|
if (cpuEl && data.cpu_percent !== undefined) {
|
|
const spans = cpuEl.querySelectorAll('span');
|
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_percent + '%';
|
|
}
|
|
|
|
// Update Memory in header
|
|
const memEl = document.getElementById('memory-stat');
|
|
if (memEl && data.memory_used_percent !== undefined) {
|
|
const spans = memEl.querySelectorAll('span');
|
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.memory_used_percent + '%';
|
|
}
|
|
|
|
// Update Temperature in header
|
|
const tempEl = document.getElementById('temp-stat');
|
|
if (tempEl && data.cpu_temp !== undefined) {
|
|
const spans = tempEl.querySelectorAll('span');
|
|
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C';
|
|
}
|
|
|
|
// Update Overview tab stats (if visible)
|
|
const cpuUsageEl = document.getElementById('cpu-usage');
|
|
if (cpuUsageEl && data.cpu_percent !== undefined) {
|
|
cpuUsageEl.textContent = data.cpu_percent + '%';
|
|
}
|
|
|
|
const memUsageEl = document.getElementById('memory-usage');
|
|
if (memUsageEl && data.memory_used_percent !== undefined) {
|
|
memUsageEl.textContent = data.memory_used_percent + '%';
|
|
}
|
|
|
|
const cpuTempEl = document.getElementById('cpu-temp');
|
|
if (cpuTempEl && data.cpu_temp !== undefined) {
|
|
cpuTempEl.textContent = data.cpu_temp + '°C';
|
|
}
|
|
|
|
const displayStatusEl = document.getElementById('display-status');
|
|
if (displayStatusEl) {
|
|
displayStatusEl.textContent = data.service_active ? 'Active' : 'Inactive';
|
|
displayStatusEl.className = data.service_active ?
|
|
'text-lg font-medium text-green-600' :
|
|
'text-lg font-medium text-red-600';
|
|
}
|
|
}
|
|
|
|
window.__onDemandStore = window.__onDemandStore || {
|
|
loading: true,
|
|
state: {},
|
|
service: {},
|
|
error: null,
|
|
lastUpdated: null
|
|
};
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
// On-Demand state store
|
|
if (window.Alpine && !window.Alpine.store('onDemand')) {
|
|
window.Alpine.store('onDemand', {
|
|
loading: window.__onDemandStore.loading,
|
|
state: window.__onDemandStore.state,
|
|
service: window.__onDemandStore.service,
|
|
error: window.__onDemandStore.error,
|
|
lastUpdated: window.__onDemandStore.lastUpdated
|
|
});
|
|
}
|
|
if (window.Alpine) {
|
|
window.__onDemandStore = window.Alpine.store('onDemand');
|
|
}
|
|
|
|
// Plugin state store - centralized state management for plugins
|
|
// Used primarily by HTMX-loaded plugin config partials
|
|
if (window.Alpine && !window.Alpine.store('plugins')) {
|
|
window.Alpine.store('plugins', {
|
|
// Track which plugin configs have been loaded
|
|
loadedConfigs: {},
|
|
|
|
// Mark a plugin config as loaded
|
|
markLoaded(pluginId) {
|
|
this.loadedConfigs[pluginId] = true;
|
|
},
|
|
|
|
// Check if a plugin config is loaded
|
|
isLoaded(pluginId) {
|
|
return !!this.loadedConfigs[pluginId];
|
|
},
|
|
|
|
// Refresh a plugin config tab via HTMX
|
|
refreshConfig(pluginId) {
|
|
const container = document.querySelector(`#plugin-config-${pluginId}`);
|
|
if (container && window.htmx) {
|
|
htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
|
|
target: container,
|
|
swap: 'innerHTML'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// ===== DEPRECATED: pluginConfigData =====
|
|
// This function is no longer used - plugin configuration forms are now
|
|
// rendered server-side and loaded via HTMX. Kept for backwards compatibility.
|
|
// See: /v3/partials/plugin-config/<plugin_id> for the new implementation.
|
|
function pluginConfigData(plugin) {
|
|
if (!plugin) {
|
|
console.error('pluginConfigData called with undefined plugin');
|
|
return {
|
|
plugin: { id: 'unknown', name: 'Unknown Plugin', enabled: false },
|
|
loading: false,
|
|
config: {},
|
|
schema: {},
|
|
webUiActions: [],
|
|
onDemandRefreshing: false,
|
|
onDemandStopping: false
|
|
};
|
|
}
|
|
return {
|
|
plugin: plugin,
|
|
loading: true,
|
|
config: {},
|
|
schema: {},
|
|
webUiActions: [],
|
|
onDemandRefreshing: false,
|
|
onDemandStopping: false,
|
|
get onDemandStore() {
|
|
if (window.Alpine && typeof Alpine.store === 'function' && Alpine.store('onDemand')) {
|
|
return Alpine.store('onDemand');
|
|
}
|
|
return window.__onDemandStore || { loading: true, state: {}, service: {}, error: null, lastUpdated: null };
|
|
},
|
|
get isOnDemandLoading() {
|
|
const store = this.onDemandStore || {};
|
|
return !!store.loading;
|
|
},
|
|
get onDemandState() {
|
|
const store = this.onDemandStore || {};
|
|
return store.state || {};
|
|
},
|
|
get onDemandService() {
|
|
const store = this.onDemandStore || {};
|
|
return store.service || {};
|
|
},
|
|
get onDemandError() {
|
|
const store = this.onDemandStore || {};
|
|
return store.error || null;
|
|
},
|
|
get onDemandActive() {
|
|
const state = this.onDemandState;
|
|
return !!(state.active && state.plugin_id === plugin.id);
|
|
},
|
|
resolvePluginName() {
|
|
return plugin.name || plugin.id;
|
|
},
|
|
resolvePluginDisplayName(id) {
|
|
if (!id) {
|
|
return 'Another plugin';
|
|
}
|
|
const list = window.installedPlugins || [];
|
|
const match = Array.isArray(list) ? list.find(p => p.id === id) : null;
|
|
return match ? (match.name || match.id) : id;
|
|
},
|
|
formatDuration(value) {
|
|
if (value === undefined || value === null) {
|
|
return '';
|
|
}
|
|
const total = Number(value);
|
|
if (Number.isNaN(total)) {
|
|
return '';
|
|
}
|
|
const seconds = Math.max(0, Math.round(total));
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
if (minutes > 0) {
|
|
return `${minutes}m${remainingSeconds > 0 ? ` ${remainingSeconds}s` : ''}`;
|
|
}
|
|
return `${remainingSeconds}s`;
|
|
},
|
|
get onDemandStatusText() {
|
|
if (this.isOnDemandLoading) {
|
|
return 'Loading on-demand status...';
|
|
}
|
|
if (this.onDemandError) {
|
|
return `On-demand error: ${this.onDemandError}`;
|
|
}
|
|
const state = this.onDemandState;
|
|
if (state.active) {
|
|
const activeName = this.resolvePluginDisplayName(state.plugin_id);
|
|
if (state.plugin_id !== plugin.id) {
|
|
return `${activeName} is running on-demand.`;
|
|
}
|
|
const modeLabel = state.mode ? ` (${state.mode})` : '';
|
|
const remaining = this.formatDuration(state.remaining);
|
|
const duration = this.formatDuration(state.duration);
|
|
let message = `${this.resolvePluginName()}${modeLabel} is running on-demand`;
|
|
if (remaining) {
|
|
message += ` — ${remaining} remaining`;
|
|
} else if (duration) {
|
|
message += ` — duration ${duration}`;
|
|
} else {
|
|
message += ' — until stopped';
|
|
}
|
|
return message;
|
|
}
|
|
const lastEvent = state.last_event ? state.last_event.replace(/-/g, ' ') : null;
|
|
if (lastEvent && lastEvent !== 'cleared') {
|
|
return `No on-demand session active (last event: ${lastEvent})`;
|
|
}
|
|
return 'No on-demand session active.';
|
|
},
|
|
get onDemandStatusClass() {
|
|
if (this.isOnDemandLoading) return 'text-blue-600';
|
|
if (this.onDemandError) return 'text-red-600';
|
|
if (this.onDemandActive) return 'text-green-600';
|
|
return 'text-blue-600';
|
|
},
|
|
get onDemandServiceText() {
|
|
if (this.isOnDemandLoading) {
|
|
return 'Checking display service status...';
|
|
}
|
|
if (this.onDemandError) {
|
|
return 'Display service status unavailable.';
|
|
}
|
|
if (this.onDemandService.active) {
|
|
return 'Display service is running.';
|
|
}
|
|
const serviceError = this.onDemandService.stderr || this.onDemandService.error;
|
|
return serviceError ? `Display service inactive (${serviceError})` : 'Display service is not running.';
|
|
},
|
|
get onDemandServiceClass() {
|
|
if (this.isOnDemandLoading) return 'text-blue-500';
|
|
if (this.onDemandError) return 'text-red-500';
|
|
return this.onDemandService.active ? 'text-blue-500' : 'text-red-500';
|
|
},
|
|
get onDemandLastUpdated() {
|
|
const store = this.onDemandStore || {};
|
|
if (!store.lastUpdated) {
|
|
return '';
|
|
}
|
|
const deltaSeconds = Math.round((Date.now() - store.lastUpdated) / 1000);
|
|
if (deltaSeconds < 5) return 'Just now';
|
|
if (deltaSeconds < 60) return `${deltaSeconds}s ago`;
|
|
const minutes = Math.round(deltaSeconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
return `${hours}h ago`;
|
|
},
|
|
get canStopOnDemand() {
|
|
if (this.isOnDemandLoading) return false;
|
|
if (this.onDemandError) return true;
|
|
return this.onDemandActive;
|
|
},
|
|
get disableRunButton() {
|
|
return !plugin.enabled;
|
|
},
|
|
get showEnableHint() {
|
|
return !plugin.enabled;
|
|
},
|
|
notify(message, type = 'info') {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(message, type);
|
|
}
|
|
},
|
|
refreshOnDemandStatus() {
|
|
if (typeof window.loadOnDemandStatus !== 'function') {
|
|
this.notify('On-demand status controls unavailable. Refresh the Plugin Manager tab.', 'error');
|
|
return;
|
|
}
|
|
this.onDemandRefreshing = true;
|
|
Promise.resolve(window.loadOnDemandStatus(true))
|
|
.finally(() => {
|
|
this.onDemandRefreshing = false;
|
|
});
|
|
},
|
|
runOnDemand() {
|
|
if (!plugin.enabled) {
|
|
this.notify('Enable the plugin before running it on-demand.', 'warning');
|
|
return;
|
|
}
|
|
if (typeof window.openOnDemandModal === 'function') {
|
|
window.openOnDemandModal(plugin.id);
|
|
} else {
|
|
this.notify('On-demand modal unavailable. Refresh the Plugin Manager tab.', 'error');
|
|
}
|
|
},
|
|
stopOnDemandWithEvent(stopService = false) {
|
|
if (typeof window.requestOnDemandStop !== 'function') {
|
|
this.notify('Unable to stop on-demand mode. Refresh the Plugin Manager tab.', 'error');
|
|
return;
|
|
}
|
|
this.onDemandStopping = true;
|
|
Promise.resolve(window.requestOnDemandStop({ stopService }))
|
|
.finally(() => {
|
|
this.onDemandStopping = false;
|
|
});
|
|
},
|
|
async loadPluginConfig(pluginId) {
|
|
// Use PluginConfigHelpers to load config directly into this component
|
|
if (window.PluginConfigHelpers) {
|
|
await window.PluginConfigHelpers.loadPluginConfig(pluginId, this);
|
|
this.loading = false;
|
|
return;
|
|
}
|
|
console.error('loadPluginConfig not available');
|
|
this.loading = false;
|
|
}
|
|
// Note: generateConfigForm and savePluginConfig are now called via window.PluginConfigHelpers
|
|
// to avoid delegation recursion and ensure proper access to app component.
|
|
// See template usage:
|
|
// x-html="window.PluginConfigHelpers.generateConfigForm(...)" and
|
|
// x-on:submit.prevent="window.PluginConfigHelpers.savePluginConfig(...)"
|
|
};
|
|
}
|
|
|
|
// Alpine.js app function - full implementation
|
|
function app() {
|
|
// If Alpine is already initialized, get the current component and enhance it
|
|
let baseComponent = {};
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
baseComponent = appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
|
|
const fullImplementation = {
|
|
activeTab: (function() {
|
|
// Auto-open WiFi tab when in AP mode (192.168.4.x)
|
|
const isAPMode = window.location.hostname === '192.168.4.1' ||
|
|
window.location.hostname.startsWith('192.168.4.');
|
|
return isAPMode ? 'wifi' : 'overview';
|
|
})(),
|
|
installedPlugins: [],
|
|
|
|
init() {
|
|
// Prevent multiple initializations
|
|
if (this._initialized) {
|
|
return;
|
|
}
|
|
this._initialized = true;
|
|
|
|
// Load plugins on page load so tabs are available on any page, regardless of active tab
|
|
// First check if plugins are already in window.installedPlugins (from plugins_manager.js)
|
|
if (typeof window.installedPlugins !== 'undefined' && Array.isArray(window.installedPlugins) && window.installedPlugins.length > 0) {
|
|
this.installedPlugins = window.installedPlugins;
|
|
console.log('Initialized installedPlugins from global:', this.installedPlugins.length);
|
|
// Ensure tabs are updated immediately
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
} else if (!this.installedPlugins || this.installedPlugins.length === 0) {
|
|
// Load plugins asynchronously, but ensure tabs update when done
|
|
this.loadInstalledPlugins().then(() => {
|
|
// Ensure tabs are updated after loading
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
}).catch(err => {
|
|
console.error('Error loading plugins in init:', err);
|
|
// Still try to update tabs in case some plugins are available
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
});
|
|
} else {
|
|
// Plugins already loaded, just update tabs
|
|
this.$nextTick(() => {
|
|
this.updatePluginTabs();
|
|
});
|
|
}
|
|
|
|
// Ensure content loads for the active tab
|
|
this.$watch('activeTab', (newTab, oldTab) => {
|
|
// Update plugin tab states when activeTab changes
|
|
this.updatePluginTabStates();
|
|
// Trigger content load when tab changes
|
|
this.$nextTick(() => {
|
|
this.loadTabContent(newTab);
|
|
});
|
|
});
|
|
|
|
// Load initial tab content
|
|
this.$nextTick(() => {
|
|
this.loadTabContent(this.activeTab);
|
|
});
|
|
|
|
// Listen for plugin updates from pluginManager
|
|
document.addEventListener('pluginsUpdated', (event) => {
|
|
console.log('Received pluginsUpdated event:', event.detail.plugins.length, 'plugins');
|
|
this.installedPlugins = event.detail.plugins;
|
|
this.updatePluginTabs();
|
|
});
|
|
|
|
// Also listen for direct window.installedPlugins changes
|
|
// Store the actual value in a private property to avoid infinite loops
|
|
let _installedPluginsValue = this.installedPlugins || [];
|
|
|
|
// Only define the property if it doesn't already exist or if it's configurable
|
|
const existingDescriptor = Object.getOwnPropertyDescriptor(window, 'installedPlugins');
|
|
if (!existingDescriptor || existingDescriptor.configurable) {
|
|
// Delete existing property if it exists and is configurable
|
|
if (existingDescriptor) {
|
|
delete window.installedPlugins;
|
|
}
|
|
|
|
Object.defineProperty(window, 'installedPlugins', {
|
|
set: (value) => {
|
|
const newPlugins = value || [];
|
|
const oldIds = (_installedPluginsValue || []).map(p => p.id).sort().join(',');
|
|
const newIds = newPlugins.map(p => p.id).sort().join(',');
|
|
|
|
// Only update if plugin list actually changed
|
|
if (oldIds !== newIds) {
|
|
console.log('window.installedPlugins changed:', newPlugins.length, 'plugins');
|
|
_installedPluginsValue = newPlugins;
|
|
this.installedPlugins = newPlugins;
|
|
this.updatePluginTabs();
|
|
}
|
|
},
|
|
get: () => _installedPluginsValue,
|
|
configurable: true // Allow redefinition if needed
|
|
});
|
|
} else {
|
|
// Property already exists and is not configurable, just update the value
|
|
if (typeof window.installedPlugins !== 'undefined') {
|
|
_installedPluginsValue = window.installedPlugins;
|
|
}
|
|
}
|
|
|
|
},
|
|
|
|
loadTabContent(tab) {
|
|
// Try to load content for the active tab
|
|
if (typeof htmx !== 'undefined') {
|
|
const contentId = tab + '-content';
|
|
const contentEl = document.getElementById(contentId);
|
|
if (contentEl && !contentEl.hasAttribute('data-loaded')) {
|
|
// Trigger HTMX load
|
|
htmx.trigger(contentEl, 'revealed');
|
|
}
|
|
} else {
|
|
// HTMX not available, use direct fetch
|
|
console.warn('HTMX not available, using direct fetch for tab:', tab);
|
|
if (tab === 'overview' && typeof loadOverviewDirect === 'function') {
|
|
loadOverviewDirect();
|
|
} else if (tab === 'wifi' && typeof loadWifiDirect === 'function') {
|
|
loadWifiDirect();
|
|
}
|
|
}
|
|
},
|
|
|
|
async loadInstalledPlugins() {
|
|
// If pluginManager exists (plugins.html is loaded), delegate to it
|
|
if (window.pluginManager) {
|
|
console.log('[FULL] Delegating plugin loading to pluginManager...');
|
|
await window.pluginManager.loadInstalledPlugins();
|
|
// pluginManager should set window.installedPlugins, so update our component
|
|
if (window.installedPlugins && Array.isArray(window.installedPlugins)) {
|
|
this.installedPlugins = window.installedPlugins;
|
|
console.log('[FULL] Updated component plugins from window.installedPlugins:', this.installedPlugins.length);
|
|
}
|
|
this.updatePluginTabs();
|
|
return;
|
|
}
|
|
|
|
// Otherwise, load plugins directly (fallback for when plugins.html isn't loaded)
|
|
try {
|
|
console.log('[FULL] Loading installed plugins directly...');
|
|
const data = await getInstalledPluginsSafe();
|
|
|
|
if (data.status === 'success') {
|
|
this.installedPlugins = data.data.plugins || [];
|
|
// Also update window.installedPlugins for consistency
|
|
window.installedPlugins = this.installedPlugins;
|
|
console.log(`[FULL] Loaded ${this.installedPlugins.length} plugins:`, this.installedPlugins.map(p => p.id));
|
|
|
|
// Debug: Log enabled status for each plugin
|
|
this.installedPlugins.forEach(plugin => {
|
|
console.log(`[DEBUG Alpine] Plugin ${plugin.id}: enabled=${plugin.enabled} (type: ${typeof plugin.enabled})`);
|
|
});
|
|
|
|
this.updatePluginTabs();
|
|
} else {
|
|
console.error('[FULL] Failed to load plugins:', data.message);
|
|
}
|
|
} catch (error) {
|
|
console.error('[FULL] Error loading installed plugins:', error);
|
|
}
|
|
},
|
|
|
|
updatePluginTabs(retryCount = 0) {
|
|
console.log('[FULL] updatePluginTabs called (retryCount:', retryCount, ')');
|
|
const maxRetries = 5;
|
|
|
|
// Debounce: Clear any pending update
|
|
if (this._updatePluginTabsTimeout) {
|
|
clearTimeout(this._updatePluginTabsTimeout);
|
|
}
|
|
|
|
// For first call or retries, execute immediately to ensure tabs appear quickly
|
|
if (retryCount === 0) {
|
|
// First call - execute immediately, then debounce subsequent calls
|
|
this._doUpdatePluginTabs(retryCount);
|
|
} else {
|
|
// Retry - execute immediately
|
|
this._doUpdatePluginTabs(retryCount);
|
|
}
|
|
},
|
|
|
|
_doUpdatePluginTabs(retryCount = 0) {
|
|
const maxRetries = 5;
|
|
|
|
// Use component's installedPlugins first (most up-to-date), then global, then empty array
|
|
const pluginsToShow = (this.installedPlugins && this.installedPlugins.length > 0)
|
|
? this.installedPlugins
|
|
: (window.installedPlugins || []);
|
|
|
|
console.log('[FULL] _doUpdatePluginTabs called with:', pluginsToShow.length, 'plugins (attempt', retryCount + 1, ')');
|
|
console.log('[FULL] Plugin sources:', {
|
|
componentPlugins: this.installedPlugins?.length || 0,
|
|
windowPlugins: window.installedPlugins?.length || 0,
|
|
using: pluginsToShow.length > 0 ? (this.installedPlugins?.length > 0 ? 'component' : 'window') : 'none'
|
|
});
|
|
|
|
// Check if plugin list actually changed by comparing IDs
|
|
const currentPluginIds = pluginsToShow.map(p => p.id).sort().join(',');
|
|
const lastRenderedIds = (this._lastRenderedPluginIds || '');
|
|
|
|
// Only skip if we have plugins and they match (don't skip if both are empty)
|
|
if (currentPluginIds === lastRenderedIds && retryCount === 0 && currentPluginIds.length > 0) {
|
|
// Plugin list hasn't changed, skip update
|
|
console.log('[FULL] Plugin list unchanged, skipping update');
|
|
return;
|
|
}
|
|
|
|
// If we have no plugins and haven't rendered anything yet, still try to render (might be first load)
|
|
if (pluginsToShow.length === 0 && retryCount === 0) {
|
|
console.log('[FULL] No plugins to show, but will retry in case they load...');
|
|
if (retryCount < maxRetries) {
|
|
setTimeout(() => {
|
|
this._doUpdatePluginTabs(retryCount + 1);
|
|
}, 500);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Store the current plugin IDs for next comparison
|
|
this._lastRenderedPluginIds = currentPluginIds;
|
|
|
|
const pluginTabsRow = document.getElementById('plugin-tabs-row');
|
|
const pluginTabsNav = pluginTabsRow?.querySelector('nav');
|
|
|
|
console.log('[FULL] Plugin tabs elements:', {
|
|
pluginTabsRow: !!pluginTabsRow,
|
|
pluginTabsNav: !!pluginTabsNav,
|
|
bodyExists: !!document.body,
|
|
installedPlugins: pluginsToShow.length,
|
|
pluginIds: pluginsToShow.map(p => p.id)
|
|
});
|
|
|
|
if (!pluginTabsRow || !pluginTabsNav) {
|
|
if (retryCount < maxRetries) {
|
|
console.warn('[FULL] Plugin tabs container not found, retrying in 500ms... (attempt', retryCount + 1, 'of', maxRetries, ')');
|
|
setTimeout(() => {
|
|
this._doUpdatePluginTabs(retryCount + 1);
|
|
}, 500);
|
|
} else {
|
|
console.error('[FULL] Plugin tabs container not found after maximum retries. Elements:', {
|
|
pluginTabsRow: document.getElementById('plugin-tabs-row'),
|
|
pluginTabsNav: document.getElementById('plugin-tabs-row')?.querySelector('nav'),
|
|
allNavs: document.querySelectorAll('nav').length
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log(`[FULL] Updating plugin tabs for ${pluginsToShow.length} plugins`);
|
|
|
|
// Always show the plugin tabs row (Plugin Manager should always be available)
|
|
console.log('[FULL] Ensuring plugin tabs row is visible');
|
|
pluginTabsRow.style.display = 'block';
|
|
|
|
// Clear existing plugin tabs (except the Plugin Manager tab)
|
|
const existingTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
console.log(`[FULL] Removing ${existingTabs.length} existing plugin tabs`);
|
|
existingTabs.forEach(tab => tab.remove());
|
|
|
|
// Add tabs for each installed plugin
|
|
console.log('[FULL] Adding tabs for plugins:', pluginsToShow.map(p => p.id));
|
|
pluginsToShow.forEach(plugin => {
|
|
const tabButton = document.createElement('button');
|
|
tabButton.type = 'button';
|
|
tabButton.setAttribute('data-plugin-id', plugin.id);
|
|
tabButton.className = `plugin-tab nav-tab ${this.activeTab === plugin.id ? 'nav-tab-active' : ''}`;
|
|
tabButton.onclick = () => {
|
|
this.activeTab = plugin.id;
|
|
this.updatePluginTabStates();
|
|
};
|
|
tabButton.innerHTML = `
|
|
<i class="fas fa-puzzle-piece"></i>${this.escapeHtml(plugin.name || plugin.id)}
|
|
`;
|
|
|
|
// Insert before the closing </nav> tag
|
|
pluginTabsNav.appendChild(tabButton);
|
|
console.log('[FULL] Added tab for plugin:', plugin.id);
|
|
});
|
|
|
|
console.log('[FULL] Plugin tabs update completed. Total tabs:', pluginTabsNav.querySelectorAll('.plugin-tab').length);
|
|
},
|
|
|
|
updatePluginTabStates() {
|
|
// Update active state of all plugin tabs when activeTab changes
|
|
const pluginTabsNav = document.getElementById('plugin-tabs-row')?.querySelector('nav');
|
|
if (!pluginTabsNav) return;
|
|
|
|
const pluginTabs = pluginTabsNav.querySelectorAll('.plugin-tab');
|
|
pluginTabs.forEach(tab => {
|
|
const pluginId = tab.getAttribute('data-plugin-id');
|
|
if (pluginId && this.activeTab === pluginId) {
|
|
tab.classList.add('nav-tab-active');
|
|
} else {
|
|
tab.classList.remove('nav-tab-active');
|
|
}
|
|
});
|
|
},
|
|
|
|
showNotification(message, type = 'info') {
|
|
const notifications = document.getElementById('notifications');
|
|
const notification = document.createElement('div');
|
|
|
|
const colors = {
|
|
success: 'bg-green-500',
|
|
error: 'bg-red-500',
|
|
warning: 'bg-yellow-500',
|
|
info: 'bg-blue-500'
|
|
};
|
|
|
|
notification.className = `px-4 py-3 rounded-md text-white text-sm ${colors[type] || colors.info}`;
|
|
notification.textContent = message;
|
|
|
|
notifications.appendChild(notification);
|
|
|
|
setTimeout(() => {
|
|
notification.remove();
|
|
}, 5000);
|
|
},
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
},
|
|
|
|
async refreshPlugins() {
|
|
await this.loadInstalledPlugins();
|
|
await this.searchPluginStore();
|
|
this.showNotification('Plugin list refreshed', 'success');
|
|
},
|
|
|
|
|
|
|
|
async loadPluginConfig(pluginId) {
|
|
console.log('Loading config for plugin:', pluginId);
|
|
this.loading = true;
|
|
|
|
try {
|
|
// Load config, schema, and installed plugins (for web_ui_actions) in parallel
|
|
// Use batched API if available for better performance
|
|
let configData, schemaData, pluginsData;
|
|
|
|
if (window.PluginAPI && window.PluginAPI.batch) {
|
|
// PluginAPI.batch returns already-parsed JSON objects
|
|
try {
|
|
const results = await window.PluginAPI.batch([
|
|
{endpoint: `/plugins/config?plugin_id=${pluginId}`, method: 'GET'},
|
|
{endpoint: `/plugins/schema?plugin_id=${pluginId}`, method: 'GET'},
|
|
{endpoint: '/plugins/installed', method: 'GET'}
|
|
]);
|
|
[configData, schemaData, pluginsData] = results;
|
|
} catch (batchError) {
|
|
console.error('Batch API request failed, falling back to individual requests:', batchError);
|
|
// Fall back to individual requests
|
|
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
|
|
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
|
|
]);
|
|
configData = configResponse;
|
|
schemaData = schemaResponse;
|
|
pluginsData = pluginsResponse;
|
|
}
|
|
} else {
|
|
// Direct fetch returns Response objects that need parsing
|
|
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
|
|
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
|
|
]);
|
|
configData = configResponse;
|
|
schemaData = schemaResponse;
|
|
pluginsData = pluginsResponse;
|
|
}
|
|
|
|
if (configData && configData.status === 'success') {
|
|
this.config = configData.data;
|
|
} else {
|
|
console.warn('Config API returned non-success status:', configData);
|
|
// Set defaults if config failed to load
|
|
this.config = { enabled: true, display_duration: 30 };
|
|
}
|
|
|
|
if (schemaData && schemaData.status === 'success') {
|
|
this.schema = schemaData.data.schema || {};
|
|
} else {
|
|
console.warn('Schema API returned non-success status:', schemaData);
|
|
// Set empty schema as fallback
|
|
this.schema = {};
|
|
}
|
|
|
|
// Extract web_ui_actions from installed plugins and update plugin data
|
|
if (pluginsData && pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
|
|
// Update window.installedPlugins with fresh data (includes commit info)
|
|
// The setter will check if data actually changed before updating tabs
|
|
window.installedPlugins = pluginsData.data.plugins;
|
|
// Update Alpine.js app data
|
|
this.installedPlugins = pluginsData.data.plugins;
|
|
|
|
const pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
|
|
this.webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
|
|
console.log('[DEBUG] Loaded web_ui_actions for', pluginId, ':', this.webUiActions.length, 'actions');
|
|
console.log('[DEBUG] Updated plugin data with commit info:', pluginInfo ? {
|
|
last_commit: pluginInfo.last_commit,
|
|
branch: pluginInfo.branch,
|
|
last_updated: pluginInfo.last_updated
|
|
} : 'plugin not found');
|
|
} else {
|
|
console.warn('Plugins API returned non-success status:', pluginsData);
|
|
this.webUiActions = [];
|
|
}
|
|
|
|
console.log('Loaded config, schema, and actions for', pluginId);
|
|
} catch (error) {
|
|
console.error('Error loading plugin config:', error);
|
|
this.config = { enabled: true, display_duration: 30 };
|
|
this.schema = {};
|
|
this.webUiActions = [];
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
generateConfigForm(pluginId, config, schema, webUiActions = []) {
|
|
// Safety check - if schema/config not ready, return empty
|
|
if (!pluginId || !config) {
|
|
return '<div class="text-gray-500">Loading configuration...</div>';
|
|
}
|
|
|
|
// Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering)
|
|
if (!this._configFormLogged || this._configFormLogged !== pluginId) {
|
|
console.log('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions');
|
|
// Debug: Check if image_config.images has x-widget in schema
|
|
if (schema && schema.properties && schema.properties.image_config) {
|
|
const imgConfig = schema.properties.image_config;
|
|
if (imgConfig.properties && imgConfig.properties.images) {
|
|
const imagesProp = imgConfig.properties.images;
|
|
console.log('[DEBUG] Schema check - image_config.images:', {
|
|
type: imagesProp.type,
|
|
'x-widget': imagesProp['x-widget'],
|
|
'has x-widget': 'x-widget' in imagesProp,
|
|
keys: Object.keys(imagesProp)
|
|
});
|
|
}
|
|
}
|
|
this._configFormLogged = pluginId;
|
|
}
|
|
if (!schema || !schema.properties) {
|
|
return this.generateSimpleConfigForm(config, webUiActions, pluginId);
|
|
}
|
|
|
|
// Helper function to get schema property by full key path
|
|
const getSchemaProperty = (schemaObj, keyPath) => {
|
|
if (!schemaObj || !schemaObj.properties) return null;
|
|
const keys = keyPath.split('.');
|
|
let current = schemaObj.properties;
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const k = keys[i];
|
|
if (!current || !current[k]) {
|
|
return null;
|
|
}
|
|
|
|
const prop = current[k];
|
|
// If this is the last key, return the property
|
|
if (i === keys.length - 1) {
|
|
return prop;
|
|
}
|
|
|
|
// If this property has nested properties, navigate deeper
|
|
if (prop && typeof prop === 'object' && prop.properties) {
|
|
current = prop.properties;
|
|
} else {
|
|
// Can't navigate deeper
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const generateFieldHtml = (key, prop, value, prefix = '') => {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
const description = prop.description || '';
|
|
let html = '';
|
|
|
|
// Debug: Log property structure for arrays to help diagnose file-upload widget issues
|
|
if (prop.type === 'array') {
|
|
// Also check schema directly as fallback
|
|
const schemaProp = getSchemaProperty(schema, fullKey);
|
|
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
|
|
|
|
console.log('[DEBUG generateFieldHtml] Array property:', fullKey, {
|
|
'prop.x-widget': prop['x-widget'],
|
|
'prop.x_widget': prop['x_widget'],
|
|
'schema.x-widget': xWidgetFromSchema,
|
|
'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'),
|
|
'x-widget in prop': 'x-widget' in prop,
|
|
'all prop keys': Object.keys(prop),
|
|
'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null'
|
|
});
|
|
}
|
|
|
|
// Handle nested objects
|
|
if (prop.type === 'object' && prop.properties) {
|
|
const sectionId = `section-${fullKey.replace(/\./g, '-')}`;
|
|
const nestedConfig = value || {};
|
|
const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
// Calculate nesting depth for better spacing
|
|
const nestingDepth = (fullKey.match(/\./g) || []).length;
|
|
const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4';
|
|
|
|
html += `
|
|
<div class="nested-section border border-gray-300 rounded-lg ${marginClass}">
|
|
<button type="button"
|
|
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors"
|
|
onclick="toggleNestedSection('${sectionId}', event); return false;">
|
|
<div class="flex-1">
|
|
<h4 class="font-semibold text-gray-900">${sectionLabel}</h4>
|
|
${description ? `<p class="text-sm text-gray-600 mt-1">${description}</p>` : ''}
|
|
</div>
|
|
<i id="${sectionId}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
|
</button>
|
|
<div id="${sectionId}" class="nested-content collapsed bg-gray-50 px-4 py-4 space-y-3" style="max-height: 0; display: none;">
|
|
`;
|
|
|
|
// Recursively generate fields for nested properties
|
|
// Get ordered properties if x-propertyOrder is defined
|
|
let nestedPropertyEntries = Object.entries(prop.properties);
|
|
if (prop['x-propertyOrder'] && Array.isArray(prop['x-propertyOrder'])) {
|
|
const order = prop['x-propertyOrder'];
|
|
const orderedEntries = [];
|
|
const unorderedEntries = [];
|
|
|
|
// Separate ordered and unordered properties
|
|
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
|
const index = order.indexOf(nestedKey);
|
|
if (index !== -1) {
|
|
orderedEntries[index] = [nestedKey, nestedProp];
|
|
} else {
|
|
unorderedEntries.push([nestedKey, nestedProp]);
|
|
}
|
|
});
|
|
|
|
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
|
nestedPropertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
|
}
|
|
|
|
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
|
// Use config value if it exists and is not null (including false), otherwise use schema default
|
|
// Check if key exists in config and value is not null/undefined
|
|
const hasValue = nestedKey in nestedConfig && nestedConfig[nestedKey] !== null && nestedConfig[nestedKey] !== undefined;
|
|
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
|
const isNestedObject = nestedProp.type === 'object' && nestedProp.properties;
|
|
const nestedValue = hasValue ? nestedConfig[nestedKey] :
|
|
(nestedProp.default !== undefined ? nestedProp.default :
|
|
(isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : ''))));
|
|
|
|
// Debug logging for file-upload widgets
|
|
if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) {
|
|
console.log('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
|
|
}
|
|
|
|
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add extra spacing after nested sections to prevent overlap with next section
|
|
if (nestingDepth > 0) {
|
|
html += `<div class="mb-2"></div>`;
|
|
}
|
|
|
|
return html;
|
|
}
|
|
|
|
// Regular (non-nested) field
|
|
html += `<div class="form-group">`;
|
|
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${label}</label>`;
|
|
|
|
if (description) {
|
|
html += `<p class="text-sm text-gray-600 mb-2">${description}</p>`;
|
|
}
|
|
|
|
// Generate appropriate input based on type
|
|
if (prop.type === 'boolean') {
|
|
html += `<label class="flex items-center">`;
|
|
html += `<input type="checkbox" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">`;
|
|
html += `<span class="ml-2 text-sm">Enabled</span>`;
|
|
html += `</label>`;
|
|
} else if (prop.type === 'number' || prop.type === 'integer' ||
|
|
(Array.isArray(prop.type) && (prop.type.includes('number') || prop.type.includes('integer')))) {
|
|
// Handle union types like ["integer", "null"]
|
|
const isUnionType = Array.isArray(prop.type);
|
|
const allowsNull = isUnionType && prop.type.includes('null');
|
|
const isInteger = prop.type === 'integer' || (isUnionType && prop.type.includes('integer'));
|
|
const isNumber = prop.type === 'number' || (isUnionType && prop.type.includes('number'));
|
|
const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : '';
|
|
const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : '';
|
|
const step = isInteger ? 'step="1"' : 'step="any"';
|
|
|
|
// For union types with null, don't show default if value is null (leave empty)
|
|
// This allows users to explicitly set null by leaving it empty
|
|
let fieldValue = '';
|
|
if (value !== undefined && value !== null) {
|
|
fieldValue = value;
|
|
} else if (!allowsNull && prop.default !== undefined) {
|
|
// Only use default if null is not allowed
|
|
fieldValue = prop.default;
|
|
}
|
|
|
|
// Ensure value respects min/max constraints
|
|
if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) {
|
|
const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
|
|
if (!isNaN(numValue)) {
|
|
// Clamp value to min/max if constraints exist
|
|
if (prop.minimum !== undefined && numValue < prop.minimum) {
|
|
fieldValue = prop.minimum;
|
|
} else if (prop.maximum !== undefined && numValue > prop.maximum) {
|
|
fieldValue = prop.maximum;
|
|
} else {
|
|
fieldValue = numValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add placeholder/help text for null-able fields
|
|
const placeholder = allowsNull ? 'Leave empty to use current time (random)' : '';
|
|
const helpText = allowsNull && description && description.includes('null') ?
|
|
`<p class="text-xs text-gray-500 mt-1">${description}</p>` : '';
|
|
|
|
html += `<input type="number" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} placeholder="${placeholder}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
if (helpText) {
|
|
html += helpText;
|
|
}
|
|
} else if (prop.type === 'array') {
|
|
// AGGRESSIVE file upload widget detection
|
|
// For 'images' field in static-image plugin, always check schema directly
|
|
let isFileUpload = false;
|
|
let uploadConfig = {};
|
|
|
|
// Direct check: if this is the 'images' field and schema has it with x-widget
|
|
if (fullKey === 'images' && schema && schema.properties && schema.properties.images) {
|
|
const imagesSchema = schema.properties.images;
|
|
if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') {
|
|
isFileUpload = true;
|
|
uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {};
|
|
console.log('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig);
|
|
}
|
|
}
|
|
|
|
// Fallback: check prop object (should have x-widget if schema loaded correctly)
|
|
if (!isFileUpload) {
|
|
const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget;
|
|
if (xWidgetFromProp === 'file-upload') {
|
|
isFileUpload = true;
|
|
uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {};
|
|
console.log('[DEBUG] ✅ Detection via prop object');
|
|
}
|
|
}
|
|
|
|
// Fallback: schema property lookup
|
|
if (!isFileUpload) {
|
|
let schemaProp = getSchemaProperty(schema, fullKey);
|
|
if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) {
|
|
schemaProp = schema.properties.images;
|
|
}
|
|
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
|
|
if (xWidgetFromSchema === 'file-upload') {
|
|
isFileUpload = true;
|
|
uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {};
|
|
console.log('[DEBUG] ✅ Detection via schema lookup');
|
|
}
|
|
}
|
|
|
|
// Debug logging for ALL array fields to diagnose
|
|
console.log('[DEBUG] Array field check:', fullKey, {
|
|
'isFileUpload': isFileUpload,
|
|
'prop keys': Object.keys(prop),
|
|
'prop.x-widget': prop['x-widget'],
|
|
'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images),
|
|
'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null,
|
|
'uploadConfig': uploadConfig
|
|
});
|
|
|
|
if (isFileUpload) {
|
|
console.log('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig);
|
|
// Use the file upload widget from plugins.html
|
|
// We'll need to call a function that exists in the global scope
|
|
const maxFiles = uploadConfig.max_files || 10;
|
|
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
|
|
const currentImages = Array.isArray(value) ? value : [];
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const safePluginId = (uploadConfig.plugin_id || pluginId || 'static-image').toString().replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
|
|
html += `
|
|
<div id="${fieldId}_upload_widget" class="mt-1">
|
|
<!-- File Upload Drop Zone -->
|
|
<div id="${fieldId}_drop_zone"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
|
ondragover="event.preventDefault()"
|
|
data-field-id="${fieldId}"
|
|
onclick="document.getElementById(this.dataset.fieldId + '_file_input').click()">
|
|
<input type="file"
|
|
id="${fieldId}_file_input"
|
|
multiple
|
|
accept="${allowedTypes.join(',')}"
|
|
style="display: none;"
|
|
data-field-id="${fieldId}"
|
|
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
|
|
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
|
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)</p>
|
|
</div>
|
|
|
|
<!-- Uploaded Images List -->
|
|
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
|
|
${currentImages.map((img, idx) => {
|
|
const imgSchedule = img.schedule || {};
|
|
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
|
|
let scheduleSummary = 'Always shown';
|
|
if (hasSchedule && window.getScheduleSummary) {
|
|
try {
|
|
scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled';
|
|
} catch (e) {
|
|
scheduleSummary = 'Scheduled';
|
|
}
|
|
} else if (hasSchedule) {
|
|
scheduleSummary = 'Scheduled';
|
|
}
|
|
// Escape the summary for HTML
|
|
scheduleSummary = String(scheduleSummary).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
return `
|
|
<div id="img_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center space-x-3 flex-1">
|
|
<img src="/${(img.path || '').replace(/&/g, '&').replace(/"/g, '"')}"
|
|
alt="${(img.filename || '').replace(/"/g, '"')}"
|
|
class="w-16 h-16 object-cover rounded"
|
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
|
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
|
<i class="fas fa-image text-gray-400"></i>
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-medium text-gray-900 truncate">${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</p>
|
|
<p class="text-xs text-gray-500">${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&')}</p>
|
|
<p class="text-xs text-blue-600 mt-1">
|
|
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2 ml-4">
|
|
<button type="button"
|
|
data-field-id="${fieldId}"
|
|
data-image-id="${img.id || ''}"
|
|
data-image-idx="${idx}"
|
|
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
|
|
class="text-blue-600 hover:text-blue-800 p-2"
|
|
title="Schedule this image">
|
|
<i class="fas fa-calendar-alt"></i>
|
|
</button>
|
|
<button type="button"
|
|
data-field-id="${fieldId}"
|
|
data-image-id="${img.id || ''}"
|
|
data-plugin-id="${safePluginId}"
|
|
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
|
|
class="text-red-600 hover:text-red-800 p-2"
|
|
title="Delete image">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- Schedule widget will be inserted here when opened -->
|
|
<div id="schedule_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
|
|
<!-- Hidden input to store image data -->
|
|
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentImages).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')}">
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Regular array input
|
|
const arrayValue = Array.isArray(value) ? value.join(', ') : '';
|
|
html += `<input type="text" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
html += `<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>`;
|
|
}
|
|
} else if (prop.enum) {
|
|
html += `<select name="${fullKey}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
prop.enum.forEach(option => {
|
|
const selected = value === option ? 'selected' : '';
|
|
html += `<option value="${option}" ${selected}>${option}</option>`;
|
|
});
|
|
html += `</select>`;
|
|
} else if (prop.type === 'string' && prop['x-widget'] === 'file-upload') {
|
|
// File upload widget for string fields (e.g., credentials.json)
|
|
const uploadConfig = prop['x-upload-config'] || {};
|
|
const uploadEndpoint = uploadConfig.upload_endpoint || '/api/v3/plugins/assets/upload';
|
|
const maxSizeMB = uploadConfig.max_size_mb || 1;
|
|
const allowedExtensions = uploadConfig.allowed_extensions || ['.json'];
|
|
const targetFilename = uploadConfig.target_filename || 'file.json';
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const hasFile = value && value !== '';
|
|
|
|
html += `
|
|
<div id="${fieldId}_upload_widget" class="mt-1">
|
|
<div id="${fieldId}_file_upload"
|
|
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
|
onclick="document.getElementById('${fieldId}_file_input').click()">
|
|
<input type="file"
|
|
id="${fieldId}_file_input"
|
|
accept="${allowedExtensions.join(',')}"
|
|
style="display: none;"
|
|
data-field-id="${fieldId}"
|
|
data-upload-endpoint="${uploadEndpoint}"
|
|
data-target-filename="${targetFilename}"
|
|
onchange="window.handleCredentialsUpload(event, this.dataset.fieldId, this.dataset.uploadEndpoint, this.dataset.targetFilename)">
|
|
<i class="fas fa-file-upload text-2xl text-gray-400 mb-2"></i>
|
|
<p class="text-sm text-gray-600" id="${fieldId}_status">
|
|
${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename}
|
|
</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})</p>
|
|
</div>
|
|
<input type="hidden" name="${fullKey}" value="${value || ''}" id="${fieldId}_hidden">
|
|
</div>
|
|
`;
|
|
} else {
|
|
// Default to text input
|
|
const maxLength = prop.maxLength || '';
|
|
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
|
|
html += `<input type="text" name="${fullKey}" value="${value !== undefined ? value : ''}" ${maxLengthAttr} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
return html;
|
|
};
|
|
|
|
let formHtml = '';
|
|
// Get ordered properties if x-propertyOrder is defined
|
|
let propertyEntries = Object.entries(schema.properties);
|
|
if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) {
|
|
const order = schema['x-propertyOrder'];
|
|
const orderedEntries = [];
|
|
const unorderedEntries = [];
|
|
|
|
// Separate ordered and unordered properties
|
|
propertyEntries.forEach(([key, prop]) => {
|
|
const index = order.indexOf(key);
|
|
if (index !== -1) {
|
|
orderedEntries[index] = [key, prop];
|
|
} else {
|
|
unorderedEntries.push([key, prop]);
|
|
}
|
|
});
|
|
|
|
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
|
propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
|
}
|
|
|
|
propertyEntries.forEach(([key, prop]) => {
|
|
// Skip the 'enabled' property - it's managed separately via the header toggle
|
|
if (key === 'enabled') return;
|
|
// Use config value if key exists and is not null/undefined, otherwise use schema default
|
|
// Check if key exists in config and value is not null/undefined
|
|
const hasValue = key in config && config[key] !== null && config[key] !== undefined;
|
|
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
|
const isNestedObject = prop.type === 'object' && prop.properties;
|
|
const value = hasValue ? config[key] :
|
|
(prop.default !== undefined ? prop.default :
|
|
(isNestedObject ? {} : (prop.type === 'array' ? [] : (prop.type === 'boolean' ? false : ''))));
|
|
formHtml += generateFieldHtml(key, prop, value);
|
|
});
|
|
|
|
// Add web UI actions section if plugin defines any
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
console.log('[DEBUG] Rendering', webUiActions.length, 'actions in tab form');
|
|
|
|
// Map color names to explicit Tailwind classes
|
|
const colorMap = {
|
|
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
|
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
|
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
|
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
|
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
|
};
|
|
|
|
formHtml += `
|
|
<div class="border-t border-gray-200 pt-4 mt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
|
<p class="text-sm text-gray-600 mb-4">${webUiActions[0].section_description || 'Perform actions for this plugin'}</p>
|
|
|
|
<div class="space-y-3">
|
|
`;
|
|
|
|
webUiActions.forEach((action, index) => {
|
|
const actionId = `action-${action.id}-${index}`;
|
|
const statusId = `action-status-${action.id}-${index}`;
|
|
const bgColor = action.color || 'blue';
|
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
|
// Ensure pluginId is valid for template interpolation
|
|
const safePluginId = pluginId || '';
|
|
|
|
formHtml += `
|
|
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<h4 class="font-medium ${colors.text} mb-1">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
|
</h4>
|
|
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
|
</div>
|
|
<button type="button"
|
|
id="${actionId}"
|
|
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
|
data-plugin-id="${safePluginId}"
|
|
data-action-id="${action.id}"
|
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
|
</button>
|
|
</div>
|
|
<div id="${statusId}" class="mt-3 hidden"></div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
formHtml += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return formHtml;
|
|
},
|
|
|
|
generateSimpleConfigForm(config, webUiActions = [], pluginId = '') {
|
|
let actionsHtml = '';
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
const colorMap = {
|
|
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
|
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
|
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
|
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
|
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
|
};
|
|
|
|
actionsHtml = `
|
|
<div class="border-t border-gray-200 pt-4 mt-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
|
<div class="space-y-3">
|
|
`;
|
|
webUiActions.forEach((action, index) => {
|
|
const actionId = `action-${action.id}-${index}`;
|
|
const statusId = `action-status-${action.id}-${index}`;
|
|
const bgColor = action.color || 'blue';
|
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
|
// Ensure pluginId is valid for template interpolation
|
|
const safePluginId = pluginId || '';
|
|
actionsHtml += `
|
|
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex-1">
|
|
<h4 class="font-medium ${colors.text} mb-1">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
|
</h4>
|
|
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
|
</div>
|
|
<button type="button"
|
|
id="${actionId}"
|
|
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
|
data-plugin-id="${safePluginId}"
|
|
data-action-id="${action.id}"
|
|
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
|
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
|
</button>
|
|
</div>
|
|
<div id="${statusId}" class="mt-3 hidden"></div>
|
|
</div>
|
|
`;
|
|
});
|
|
actionsHtml += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="form-group">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
|
|
<input type="number" name="display_duration" value="${Math.max(5, Math.min(300, config.display_duration || 30))}" min="5" max="300" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
|
<p class="text-sm text-gray-600 mt-1">How long to show this plugin's content</p>
|
|
</div>
|
|
${actionsHtml}
|
|
`;
|
|
},
|
|
|
|
// Helper function to get schema property type for a field path
|
|
getSchemaPropertyType(schema, path) {
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
const parts = path.split('.');
|
|
let current = schema.properties;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (current && current[part]) {
|
|
if (i === parts.length - 1) {
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
async savePluginConfig(pluginId, event) {
|
|
try {
|
|
// Get the form element for this plugin
|
|
const form = event ? event.target : null;
|
|
if (!form) {
|
|
throw new Error('Form element not found');
|
|
}
|
|
const formData = new FormData(form);
|
|
const schema = this.schema || {};
|
|
|
|
// First, collect all checkbox states (including unchecked ones)
|
|
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
|
|
const flatConfig = {};
|
|
|
|
// Process all form elements to capture all field states
|
|
for (let i = 0; i < form.elements.length; i++) {
|
|
const element = form.elements[i];
|
|
const name = element.name;
|
|
|
|
// Skip elements without names or submit buttons
|
|
if (!name || element.type === 'submit' || element.type === 'button') {
|
|
continue;
|
|
}
|
|
|
|
// Handle checkboxes explicitly (both checked and unchecked)
|
|
if (element.type === 'checkbox') {
|
|
// Check if this is a checkbox group (name ends with [])
|
|
if (name.endsWith('[]')) {
|
|
const baseName = name.slice(0, -2); // Remove '[]' suffix
|
|
if (!flatConfig[baseName]) {
|
|
flatConfig[baseName] = [];
|
|
}
|
|
if (element.checked) {
|
|
flatConfig[baseName].push(element.value);
|
|
}
|
|
} else {
|
|
// Regular checkbox (boolean)
|
|
flatConfig[name] = element.checked;
|
|
}
|
|
}
|
|
// Handle radio buttons
|
|
else if (element.type === 'radio') {
|
|
if (element.checked) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
// Handle select elements (including multi-select)
|
|
else if (element.tagName === 'SELECT') {
|
|
if (element.multiple) {
|
|
// Multi-select: get all selected options
|
|
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
|
|
flatConfig[name] = selectedValues;
|
|
} else {
|
|
// Single select: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
// Handle textarea
|
|
else if (element.tagName === 'TEXTAREA') {
|
|
// Textarea: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now process FormData for other field types
|
|
for (const [key, value] of formData.entries()) {
|
|
// Skip checkboxes - we already handled them above
|
|
const element = form.elements[key];
|
|
if (element && element.type === 'checkbox') {
|
|
// Also skip checkbox groups (name ends with [])
|
|
if (key.endsWith('[]')) {
|
|
continue; // Already processed
|
|
}
|
|
continue; // Already processed
|
|
}
|
|
// Skip multi-select - we already handled them above
|
|
if (element && element.tagName === 'SELECT' && element.multiple) {
|
|
continue; // Already processed
|
|
}
|
|
|
|
// Get schema property type if available
|
|
const propSchema = this.getSchemaPropertyType(schema, key);
|
|
const propType = propSchema ? propSchema.type : null;
|
|
|
|
// Handle based on schema type or field name patterns
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array in hidden input)
|
|
if (propSchema && propSchema['x-widget'] === 'file-upload') {
|
|
try {
|
|
// Unescape HTML entities that were escaped when setting the value
|
|
let unescapedValue = value;
|
|
if (typeof value === 'string') {
|
|
// Reverse the HTML escaping: " -> ", ' -> ', & -> &
|
|
unescapedValue = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
// Try to parse as JSON
|
|
const jsonValue = JSON.parse(unescapedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
console.log(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
|
|
// Not valid JSON, use empty array or try comma-separated
|
|
if (value && value.trim()) {
|
|
// Try to unescape and parse again
|
|
try {
|
|
const unescaped = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&');
|
|
const jsonValue = JSON.parse(unescaped);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e2) {
|
|
// If still fails, try comma-separated or empty array
|
|
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
|
|
flatConfig[key] = arrayValue.length > 0 ? arrayValue : [];
|
|
}
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[key] = arrayValue;
|
|
}
|
|
} else if (propType === 'integer' || (Array.isArray(propType) && propType.includes('integer'))) {
|
|
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
|
|
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
|
|
flatConfig[key] = ''; // Send empty string, backend will normalize to null
|
|
} else {
|
|
const numValue = parseInt(value, 10);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
}
|
|
} else if (propType === 'number' || (Array.isArray(propType) && propType.includes('number'))) {
|
|
// Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null)
|
|
if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) {
|
|
flatConfig[key] = ''; // Send empty string, backend will normalize to null
|
|
} else {
|
|
const numValue = parseFloat(value);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
}
|
|
} else if (propType === 'boolean') {
|
|
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
|
|
flatConfig[key] = value === 'on' || value === 'true' || value === true;
|
|
} else {
|
|
// String or other types
|
|
// Check if it's a number field by name pattern (fallback if no schema)
|
|
if (!propType && (key.includes('duration') || key.includes('interval') ||
|
|
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
|
|
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
|
|
const numValue = parseFloat(value);
|
|
if (!isNaN(numValue)) {
|
|
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes using schema (if available)
|
|
if (schema && schema.properties) {
|
|
const collectBooleanFields = (props, prefix = '') => {
|
|
const boolFields = [];
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
|
|
}
|
|
}
|
|
return boolFields;
|
|
};
|
|
|
|
const allBoolFields = collectBooleanFields(schema.properties);
|
|
allBoolFields.forEach(key => {
|
|
// Only set to false if the field is completely missing from flatConfig
|
|
// Don't override existing false values - they're explicitly set by the user
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const dotToNested = (obj) => {
|
|
const result = {};
|
|
for (const key in obj) {
|
|
const parts = key.split('.');
|
|
let current = result;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) {
|
|
current[parts[i]] = {};
|
|
}
|
|
current = current[parts[i]];
|
|
}
|
|
current[parts[parts.length - 1]] = obj[key];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const config = dotToNested(flatConfig);
|
|
|
|
// Save to backend
|
|
const response = await fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
config: config
|
|
})
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (e) {
|
|
console.error('Failed to parse JSON response:', e);
|
|
console.error('Response status:', response.status, response.statusText);
|
|
console.error('Response text:', await response.text());
|
|
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
console.log('Response status:', response.status, 'Response OK:', response.ok);
|
|
console.log('Response data:', JSON.stringify(data, null, 2));
|
|
|
|
if (!response.ok || data.status !== 'success') {
|
|
let errorMessage = data.message || 'Failed to save configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
console.error('Validation errors:', data.validation_errors);
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
if (data.config_keys && data.schema_keys) {
|
|
console.error('Config keys sent:', data.config_keys);
|
|
console.error('Schema keys expected:', data.schema_keys);
|
|
const extraKeys = data.config_keys.filter(k => !data.schema_keys.includes(k));
|
|
const missingKeys = data.schema_keys.filter(k => !data.config_keys.includes(k));
|
|
if (extraKeys.length > 0) {
|
|
errorMessage += '\n\nExtra keys (not in schema): ' + extraKeys.join(', ');
|
|
}
|
|
if (missingKeys.length > 0) {
|
|
errorMessage += '\n\nMissing keys (in schema): ' + missingKeys.join(', ');
|
|
}
|
|
}
|
|
this.showNotification(errorMessage, 'error');
|
|
console.error('Config save failed - Full error response:', JSON.stringify(data, null, 2));
|
|
} else {
|
|
this.showNotification('Configuration saved successfully', 'success');
|
|
// Reload plugin config to reflect changes
|
|
await this.loadPluginConfig(pluginId);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving plugin config:', error);
|
|
this.showNotification('Error saving configuration: ' + error.message, 'error');
|
|
}
|
|
},
|
|
|
|
formatCommitInfo(commit, branch) {
|
|
// Handle null, undefined, or empty string
|
|
const commitStr = (commit && String(commit).trim()) || '';
|
|
const branchStr = (branch && String(branch).trim()) || '';
|
|
|
|
if (!commitStr && !branchStr) return 'Unknown';
|
|
|
|
const shortCommit = commitStr.length >= 7 ? commitStr.substring(0, 7) : commitStr;
|
|
|
|
if (branchStr && shortCommit) {
|
|
return `${branchStr} · ${shortCommit}`;
|
|
}
|
|
if (branchStr) {
|
|
return branchStr;
|
|
}
|
|
if (shortCommit) {
|
|
return shortCommit;
|
|
}
|
|
return 'Unknown';
|
|
},
|
|
|
|
formatDateInfo(dateString) {
|
|
// Handle null, undefined, or empty string
|
|
if (!dateString || !String(dateString).trim()) return 'Unknown';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
// Check if date is valid
|
|
if (isNaN(date.getTime())) {
|
|
return 'Unknown';
|
|
}
|
|
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 1) {
|
|
return 'Today';
|
|
} else if (diffDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else if (diffDays < 30) {
|
|
const weeks = Math.floor(diffDays / 7);
|
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
|
} else {
|
|
// Return formatted date for older items
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
} catch (e) {
|
|
console.error('Error formatting date:', e, dateString);
|
|
return 'Unknown';
|
|
}
|
|
}
|
|
};
|
|
|
|
// Update window.app to return full implementation
|
|
window.app = function() {
|
|
return fullImplementation;
|
|
};
|
|
|
|
// If Alpine is already initialized, update the existing component immediately
|
|
if (window.Alpine) {
|
|
// Use requestAnimationFrame for immediate execution without blocking
|
|
requestAnimationFrame(() => {
|
|
const appElement = document.querySelector('[x-data]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
const existingComponent = appElement._x_dataStack[0];
|
|
// Replace all properties and methods from full implementation
|
|
Object.keys(fullImplementation).forEach(key => {
|
|
existingComponent[key] = fullImplementation[key];
|
|
});
|
|
// Call init to load plugins and set up watchers (only if not already initialized)
|
|
if (typeof existingComponent.init === 'function' && !existingComponent._initialized) {
|
|
existingComponent.init();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return fullImplementation;
|
|
}
|
|
|
|
// Make app() available globally
|
|
window.app = app;
|
|
|
|
// ===== DEPRECATED: Plugin Configuration Functions (Global Access) =====
|
|
// These functions are no longer the primary method for loading plugin configs.
|
|
// Plugin configuration forms are now rendered server-side via HTMX.
|
|
// See: /v3/partials/plugin-config/<plugin_id> for the new implementation.
|
|
// Kept for backwards compatibility with any remaining client-side code.
|
|
window.PluginConfigHelpers = {
|
|
loadPluginConfig: async function(pluginId, componentContext) {
|
|
// This function can be called from inline components
|
|
// It loads config, schema, and updates the component context
|
|
if (!componentContext) {
|
|
console.error('loadPluginConfig requires component context');
|
|
return;
|
|
}
|
|
|
|
console.log('Loading config for plugin:', pluginId);
|
|
componentContext.loading = true;
|
|
|
|
try {
|
|
// Load config, schema, and installed plugins (for web_ui_actions) in parallel
|
|
let configData, schemaData, pluginsData;
|
|
|
|
if (window.PluginAPI && window.PluginAPI.batch) {
|
|
try {
|
|
const results = await window.PluginAPI.batch([
|
|
{endpoint: `/plugins/config?plugin_id=${pluginId}`, method: 'GET'},
|
|
{endpoint: `/plugins/schema?plugin_id=${pluginId}`, method: 'GET'},
|
|
{endpoint: '/plugins/installed', method: 'GET'}
|
|
]);
|
|
[configData, schemaData, pluginsData] = results;
|
|
} catch (batchError) {
|
|
console.error('Batch API request failed, falling back to individual requests:', batchError);
|
|
// Fall back to individual requests
|
|
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
|
|
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
|
|
]);
|
|
configData = configResponse;
|
|
schemaData = schemaResponse;
|
|
pluginsData = pluginsResponse;
|
|
}
|
|
} else {
|
|
const [configResponse, schemaResponse, pluginsResponse] = await Promise.all([
|
|
fetch(`/api/v3/plugins/config?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message })),
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json()).catch(e => ({ status: 'error', message: e.message }))
|
|
]);
|
|
configData = configResponse;
|
|
schemaData = schemaResponse;
|
|
pluginsData = pluginsResponse;
|
|
}
|
|
|
|
if (configData && configData.status === 'success') {
|
|
componentContext.config = configData.data;
|
|
} else {
|
|
console.warn('Config API returned non-success status:', configData);
|
|
// Set defaults if config failed to load
|
|
componentContext.config = { enabled: true, display_duration: 30 };
|
|
}
|
|
|
|
if (schemaData && schemaData.status === 'success') {
|
|
componentContext.schema = schemaData.data.schema || {};
|
|
} else {
|
|
console.warn('Schema API returned non-success status:', schemaData);
|
|
// Set empty schema as fallback
|
|
componentContext.schema = {};
|
|
}
|
|
|
|
if (pluginsData && pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
|
|
const pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
|
|
componentContext.webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
|
|
} else {
|
|
console.warn('Plugins API returned non-success status:', pluginsData);
|
|
componentContext.webUiActions = [];
|
|
}
|
|
|
|
console.log('Loaded config, schema, and actions for', pluginId);
|
|
} catch (error) {
|
|
console.error('Error loading plugin config:', error);
|
|
componentContext.config = { enabled: true, display_duration: 30 };
|
|
componentContext.schema = {};
|
|
componentContext.webUiActions = [];
|
|
} finally {
|
|
componentContext.loading = false;
|
|
}
|
|
},
|
|
|
|
generateConfigForm: function(pluginId, config, schema, webUiActions, componentContext) {
|
|
// Try to get the app component
|
|
let appComponent = null;
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
appComponent = appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
|
|
// If we have access to the app component, use its method
|
|
if (appComponent && typeof appComponent.generateConfigForm === 'function') {
|
|
return appComponent.generateConfigForm(pluginId, config, schema, webUiActions);
|
|
}
|
|
|
|
// Fallback: return loading message if function not available
|
|
if (!pluginId || !config) {
|
|
return '<div class="text-gray-500">Loading configuration...</div>';
|
|
}
|
|
return '<div class="text-gray-500">Configuration form not available yet...</div>';
|
|
},
|
|
|
|
savePluginConfig: async function(pluginId, event, componentContext) {
|
|
// Try to get the app component
|
|
let appComponent = null;
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
appComponent = appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
|
|
// If we have access to the app component, use its method
|
|
if (appComponent && typeof appComponent.savePluginConfig === 'function') {
|
|
return appComponent.savePluginConfig(pluginId, event);
|
|
}
|
|
|
|
console.error('savePluginConfig not available');
|
|
throw new Error('Save configuration method not available');
|
|
}
|
|
};
|
|
|
|
// ===== Nested Section Toggle =====
|
|
window.toggleNestedSection = function(sectionId, event) {
|
|
// Prevent event bubbling if event is provided
|
|
if (event) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
}
|
|
|
|
const content = document.getElementById(sectionId);
|
|
const icon = document.getElementById(sectionId + '-icon');
|
|
|
|
if (!content || !icon) {
|
|
console.warn('[toggleNestedSection] Content or icon not found for:', sectionId);
|
|
return;
|
|
}
|
|
|
|
// Check if content is currently collapsed (has 'collapsed' class or display:none)
|
|
const isCollapsed = content.classList.contains('collapsed') ||
|
|
content.style.display === 'none' ||
|
|
(content.style.display === '' && !content.classList.contains('expanded'));
|
|
|
|
if (isCollapsed) {
|
|
// Expand the section
|
|
content.classList.remove('collapsed');
|
|
content.classList.add('expanded');
|
|
content.style.display = 'block';
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// CRITICAL FIX: Use setTimeout to ensure browser has time to layout the element
|
|
// When element goes from display:none to display:block, scrollHeight might be 0
|
|
// We need to wait for the browser to calculate the layout
|
|
setTimeout(() => {
|
|
// Force reflow to ensure transition works
|
|
void content.offsetHeight;
|
|
|
|
// Now measure the actual content height after layout
|
|
const scrollHeight = content.scrollHeight;
|
|
if (scrollHeight > 0) {
|
|
content.style.maxHeight = scrollHeight + 'px';
|
|
} else {
|
|
// Fallback: if scrollHeight is still 0, try measuring again after a brief delay
|
|
setTimeout(() => {
|
|
const retryHeight = content.scrollHeight;
|
|
content.style.maxHeight = retryHeight > 0 ? retryHeight + 'px' : '500px';
|
|
}, 10);
|
|
}
|
|
}, 10);
|
|
|
|
icon.classList.remove('fa-chevron-right');
|
|
icon.classList.add('fa-chevron-down');
|
|
|
|
// After animation completes, remove max-height constraint to allow natural expansion
|
|
setTimeout(() => {
|
|
if (content.classList.contains('expanded') && !content.classList.contains('collapsed')) {
|
|
content.style.maxHeight = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
}, 320); // Slightly longer than transition duration
|
|
} else {
|
|
// Collapse the section
|
|
content.classList.add('collapsed');
|
|
content.classList.remove('expanded');
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// Set max-height to current scroll height first (required for smooth animation)
|
|
const currentHeight = content.scrollHeight;
|
|
content.style.maxHeight = currentHeight + 'px';
|
|
|
|
// Force reflow to apply the height
|
|
void content.offsetHeight;
|
|
|
|
// Then animate to 0
|
|
setTimeout(() => {
|
|
content.style.maxHeight = '0';
|
|
}, 10);
|
|
|
|
// Hide after transition completes
|
|
setTimeout(() => {
|
|
if (content.classList.contains('collapsed')) {
|
|
content.style.display = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
}, 320); // Match the CSS transition duration + small buffer
|
|
|
|
icon.classList.remove('fa-chevron-down');
|
|
icon.classList.add('fa-chevron-right');
|
|
}
|
|
};
|
|
|
|
// ===== Display Preview Functions (from v2) =====
|
|
|
|
function updateDisplayPreview(data) {
|
|
const preview = document.getElementById('displayPreview');
|
|
const stage = document.getElementById('previewStage');
|
|
const img = document.getElementById('displayImage');
|
|
const canvas = document.getElementById('gridOverlay');
|
|
const ledCanvas = document.getElementById('ledCanvas');
|
|
const placeholder = document.getElementById('displayPlaceholder');
|
|
|
|
if (!stage || !img || !placeholder) return; // Not on overview page
|
|
|
|
if (data.image) {
|
|
// Show stage
|
|
placeholder.style.display = 'none';
|
|
stage.style.display = 'inline-block';
|
|
|
|
// Current scale from slider
|
|
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
|
|
|
|
// Update image and meta label
|
|
img.style.imageRendering = 'pixelated';
|
|
img.onload = () => {
|
|
renderLedDots();
|
|
};
|
|
img.src = `data:image/png;base64,${data.image}`;
|
|
|
|
const meta = document.getElementById('previewMeta');
|
|
if (meta) {
|
|
meta.textContent = `${data.width || 128} x ${data.height || 64} @ ${scale}x`;
|
|
}
|
|
|
|
// Size the canvases to match
|
|
const width = (data.width || 128) * scale;
|
|
const height = (data.height || 64) * scale;
|
|
img.style.width = width + 'px';
|
|
img.style.height = height + 'px';
|
|
ledCanvas.width = width;
|
|
ledCanvas.height = height;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
drawGrid(canvas, data.width || 128, data.height || 64, scale);
|
|
renderLedDots();
|
|
} else {
|
|
stage.style.display = 'none';
|
|
placeholder.style.display = 'block';
|
|
placeholder.innerHTML = `<div class="text-center text-gray-400 py-8">
|
|
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
|
|
<p>No display data available</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function renderLedDots() {
|
|
const ledCanvas = document.getElementById('ledCanvas');
|
|
const img = document.getElementById('displayImage');
|
|
const toggle = document.getElementById('toggleLedDots');
|
|
|
|
if (!ledCanvas || !img || !toggle) {
|
|
return;
|
|
}
|
|
|
|
const show = toggle.checked;
|
|
|
|
if (!show) {
|
|
// LED mode OFF: Show image, hide canvas
|
|
img.style.visibility = 'visible';
|
|
ledCanvas.style.display = 'none';
|
|
const ctx = ledCanvas.getContext('2d');
|
|
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
return;
|
|
}
|
|
|
|
// LED mode ON: Hide image (but keep layout space), show only dots on canvas
|
|
img.style.visibility = 'hidden';
|
|
ledCanvas.style.display = 'block';
|
|
|
|
const scale = parseInt(document.getElementById('scaleRange')?.value || '8');
|
|
const fillPct = parseInt(document.getElementById('dotFillRange')?.value || '75');
|
|
const dotRadius = Math.max(1, Math.floor((scale * fillPct) / 200)); // radius in px
|
|
|
|
const ctx = ledCanvas.getContext('2d', { willReadFrequently: true });
|
|
ctx.clearRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
|
|
// Create an offscreen canvas to sample pixel colors
|
|
const off = document.createElement('canvas');
|
|
const logicalWidth = Math.floor(ledCanvas.width / scale);
|
|
const logicalHeight = Math.floor(ledCanvas.height / scale);
|
|
off.width = logicalWidth;
|
|
off.height = logicalHeight;
|
|
const offCtx = off.getContext('2d', { willReadFrequently: true });
|
|
|
|
// Draw the current image scaled down to logical LEDs to sample colors
|
|
try {
|
|
offCtx.drawImage(img, 0, 0, logicalWidth, logicalHeight);
|
|
} catch (e) {
|
|
console.error('Failed to draw image to offscreen canvas:', e);
|
|
return;
|
|
}
|
|
|
|
// Fill canvas with black background (LED matrix bezel)
|
|
ctx.fillStyle = 'rgb(0, 0, 0)';
|
|
ctx.fillRect(0, 0, ledCanvas.width, ledCanvas.height);
|
|
|
|
// Draw circular dots for each LED pixel
|
|
let drawn = 0;
|
|
for (let y = 0; y < logicalHeight; y++) {
|
|
for (let x = 0; x < logicalWidth; x++) {
|
|
const pixel = offCtx.getImageData(x, y, 1, 1).data;
|
|
const r = pixel[0], g = pixel[1], b = pixel[2], a = pixel[3];
|
|
|
|
// Skip fully transparent or black pixels to reduce overdraw
|
|
if (a === 0 || (r|g|b) === 0) continue;
|
|
|
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
|
const cx = Math.floor(x * scale + scale / 2);
|
|
const cy = Math.floor(y * scale + scale / 2);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, dotRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
drawn++;
|
|
}
|
|
}
|
|
|
|
// If nothing was drawn (e.g., image not ready), hide overlay to show base image
|
|
if (drawn === 0) {
|
|
ledCanvas.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function drawGrid(canvas, pixelWidth, pixelHeight, scale) {
|
|
const toggle = document.getElementById('toggleGrid');
|
|
if (!toggle || !toggle.checked) {
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
return;
|
|
}
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)';
|
|
ctx.lineWidth = 1;
|
|
|
|
for (let x = 0; x <= pixelWidth; x++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x * scale, 0);
|
|
ctx.lineTo(x * scale, pixelHeight * scale);
|
|
ctx.stroke();
|
|
}
|
|
|
|
for (let y = 0; y <= pixelHeight; y++) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y * scale);
|
|
ctx.lineTo(pixelWidth * scale, y * scale);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function takeScreenshot() {
|
|
const img = document.getElementById('displayImage');
|
|
if (img && img.src) {
|
|
const link = document.createElement('a');
|
|
link.download = `led_matrix_${new Date().getTime()}.png`;
|
|
link.href = img.src;
|
|
link.click();
|
|
}
|
|
}
|
|
|
|
// ===== Plugin Management Functions =====
|
|
|
|
// Make togglePluginFromTab global so Alpine.js can access it
|
|
window.togglePluginFromTab = async function(pluginId, enabled) {
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/toggle', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, enabled })
|
|
});
|
|
const data = await response.json();
|
|
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
// Update the plugin in window.installedPlugins
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = enabled;
|
|
}
|
|
}
|
|
|
|
// Refresh the plugin list to ensure both management page and config page stay in sync
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
} else {
|
|
// Revert the toggle if API call failed
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
showNotification('Error toggling plugin: ' + error.message, 'error');
|
|
// Revert on error
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to get schema property type for a field path
|
|
function getSchemaPropertyType(schema, path) {
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
const parts = path.split('.');
|
|
let current = schema.properties;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (current && current[part]) {
|
|
if (i === parts.length - 1) {
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function savePluginConfig(pluginId) {
|
|
try {
|
|
console.log('Saving config for plugin:', pluginId);
|
|
|
|
// Load schema for type detection
|
|
let schema = {};
|
|
try {
|
|
const schemaResponse = await fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`);
|
|
const schemaData = await schemaResponse.json();
|
|
if (schemaData.status === 'success' && schemaData.data.schema) {
|
|
schema = schemaData.data.schema;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not load schema for type detection:', e);
|
|
}
|
|
|
|
// Find the form in the active plugin tab
|
|
// Alpine.js hides/shows elements with display:none, so we look for the currently visible one
|
|
const allForms = document.querySelectorAll('form[x-on\\:submit\\.prevent]');
|
|
console.log('Found forms:', allForms.length);
|
|
|
|
let form = null;
|
|
for (const f of allForms) {
|
|
const parent = f.closest('[x-show]');
|
|
if (parent && parent.style.display !== 'none' && parent.offsetParent !== null) {
|
|
form = f;
|
|
console.log('Found visible form');
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!form) {
|
|
throw new Error('Form not found for plugin ' + pluginId);
|
|
}
|
|
|
|
const formData = new FormData(form);
|
|
const flatConfig = {};
|
|
|
|
// First, collect all checkbox states (including unchecked ones)
|
|
// Unchecked checkboxes don't appear in FormData, so we need to iterate form elements
|
|
for (let i = 0; i < form.elements.length; i++) {
|
|
const element = form.elements[i];
|
|
const name = element.name;
|
|
|
|
// Skip elements without names or submit buttons
|
|
if (!name || element.type === 'submit' || element.type === 'button') {
|
|
continue;
|
|
}
|
|
|
|
// Handle checkboxes explicitly (both checked and unchecked)
|
|
if (element.type === 'checkbox') {
|
|
flatConfig[name] = element.checked;
|
|
}
|
|
// Handle radio buttons
|
|
else if (element.type === 'radio') {
|
|
if (element.checked) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
// Handle select elements (including multi-select)
|
|
else if (element.tagName === 'SELECT') {
|
|
if (element.multiple) {
|
|
// Multi-select: get all selected options
|
|
const selectedValues = Array.from(element.selectedOptions).map(opt => opt.value);
|
|
flatConfig[name] = selectedValues;
|
|
} else {
|
|
// Single select: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
// Handle textarea
|
|
else if (element.tagName === 'TEXTAREA') {
|
|
// Textarea: handled by FormData, but ensure it's captured
|
|
if (!(name in flatConfig)) {
|
|
flatConfig[name] = element.value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Now process FormData for other field types
|
|
for (const [key, value] of formData.entries()) {
|
|
// Skip checkboxes - we already handled them above
|
|
const element = form.elements[key];
|
|
if (element && element.type === 'checkbox') {
|
|
continue; // Already processed
|
|
}
|
|
// Skip multi-select - we already handled them above
|
|
if (element && element.tagName === 'SELECT' && element.multiple) {
|
|
continue; // Already processed
|
|
}
|
|
|
|
// Get schema property type if available
|
|
const propSchema = getSchemaPropertyType(schema, key);
|
|
const propType = propSchema ? propSchema.type : null;
|
|
|
|
// Handle based on schema type or field name patterns
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array in hidden input)
|
|
if (propSchema && propSchema['x-widget'] === 'file-upload') {
|
|
try {
|
|
// Unescape HTML entities that were escaped when setting the value
|
|
let unescapedValue = value;
|
|
if (typeof value === 'string') {
|
|
// Reverse the HTML escaping: " -> ", ' -> ', & -> &
|
|
unescapedValue = value
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
try {
|
|
const jsonValue = JSON.parse(unescapedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[key] = jsonValue;
|
|
console.log(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value);
|
|
// Fallback to empty array
|
|
flatConfig[key] = [];
|
|
}
|
|
} catch (e) {
|
|
// Not valid JSON, use empty array or try comma-separated
|
|
if (value && value.trim()) {
|
|
const arrayValue = value.split(',').map(v => v.trim()).filter(v => v);
|
|
flatConfig[key] = arrayValue;
|
|
} else {
|
|
flatConfig[key] = [];
|
|
}
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[key] = arrayValue;
|
|
}
|
|
} else if (propType === 'integer') {
|
|
const numValue = parseInt(value, 10);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
} else if (propType === 'number') {
|
|
const numValue = parseFloat(value);
|
|
flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue;
|
|
} else if (propType === 'boolean') {
|
|
// Boolean from FormData (shouldn't happen for checkboxes, but handle it)
|
|
flatConfig[key] = value === 'on' || value === 'true' || value === true;
|
|
} else {
|
|
// String or other types
|
|
// Check if it's a number field by name pattern (fallback if no schema)
|
|
if (!propType && (key.includes('duration') || key.includes('interval') ||
|
|
key.includes('timeout') || key.includes('teams') || key.includes('fps') ||
|
|
key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) {
|
|
const numValue = parseFloat(value);
|
|
if (!isNaN(numValue)) {
|
|
flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue;
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
} else {
|
|
flatConfig[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes using schema (if available)
|
|
if (schema && schema.properties) {
|
|
const collectBooleanFields = (props, prefix = '') => {
|
|
const boolFields = [];
|
|
for (const [key, prop] of Object.entries(props)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop.properties, fullKey));
|
|
}
|
|
}
|
|
return boolFields;
|
|
};
|
|
|
|
const allBoolFields = collectBooleanFields(schema.properties);
|
|
allBoolFields.forEach(key => {
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const dotToNested = (obj) => {
|
|
const result = {};
|
|
for (const key in obj) {
|
|
const parts = key.split('.');
|
|
let current = result;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) {
|
|
current[parts[i]] = {};
|
|
}
|
|
current = current[parts[i]];
|
|
}
|
|
current[parts[parts.length - 1]] = obj[key];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const config = dotToNested(flatConfig);
|
|
|
|
console.log('Saving config for', pluginId, ':', config);
|
|
console.log('Flat config before nesting:', flatConfig);
|
|
|
|
// Save to backend
|
|
const response = await fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, config })
|
|
});
|
|
|
|
let data;
|
|
try {
|
|
data = await response.json();
|
|
} catch (e) {
|
|
throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
if (!response.ok || data.status !== 'success') {
|
|
let errorMessage = data.message || 'Failed to save configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
throw new Error(errorMessage);
|
|
} else {
|
|
showNotification(`Configuration saved for ${pluginId}`, 'success');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error saving plugin configuration:', error);
|
|
showNotification('Error saving plugin configuration: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Notification helper function
|
|
// Fix invalid number inputs before form submission
|
|
// This prevents "invalid form control is not focusable" errors
|
|
window.fixInvalidNumberInputs = function(form) {
|
|
if (!form) return;
|
|
const allInputs = form.querySelectorAll('input[type="number"]');
|
|
allInputs.forEach(input => {
|
|
const min = parseFloat(input.getAttribute('min'));
|
|
const max = parseFloat(input.getAttribute('max'));
|
|
const value = parseFloat(input.value);
|
|
|
|
if (!isNaN(value)) {
|
|
if (!isNaN(min) && value < min) {
|
|
input.value = min;
|
|
} else if (!isNaN(max) && value > max) {
|
|
input.value = max;
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
function showNotification(message, type = 'info') {
|
|
console.log(`[${type.toUpperCase()}]`, message);
|
|
|
|
// Create a simple toast notification
|
|
const notification = document.createElement('div');
|
|
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 ${
|
|
type === 'success' ? 'bg-green-500' :
|
|
type === 'error' ? 'bg-red-500' :
|
|
'bg-blue-500'
|
|
} text-white`;
|
|
notification.textContent = message;
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
// Fade out and remove after 3 seconds
|
|
setTimeout(() => {
|
|
notification.style.transition = 'opacity 0.5s';
|
|
notification.style.opacity = '0';
|
|
setTimeout(() => notification.remove(), 500);
|
|
}, 3000);
|
|
}
|
|
|
|
// Section toggle function - already defined earlier, but ensure it's not overwritten
|
|
// (duplicate definition removed - function is defined in early script block above)
|
|
|
|
// Plugin config handler functions (idempotent initialization)
|
|
if (!window.__pluginConfigHandlersInitialized) {
|
|
window.__pluginConfigHandlersInitialized = true;
|
|
|
|
// Initialize state on window object
|
|
window.pluginConfigRefreshInProgress = window.pluginConfigRefreshInProgress || new Set();
|
|
|
|
// Validate plugin config form and show helpful error messages
|
|
window.validatePluginConfigForm = function(form, pluginId) {
|
|
// Check HTML5 validation
|
|
if (!form.checkValidity()) {
|
|
// Find all invalid fields
|
|
const invalidFields = Array.from(form.querySelectorAll(':invalid'));
|
|
const errors = [];
|
|
let firstInvalidField = null;
|
|
|
|
invalidFields.forEach((field, index) => {
|
|
// Build error message
|
|
let fieldName = field.name || field.id || 'field';
|
|
// Make field name more readable (remove plugin ID prefix, convert dots/underscores)
|
|
fieldName = fieldName.replace(new RegExp('^' + pluginId + '-'), '')
|
|
.replace(/\./g, ' → ')
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, l => l.toUpperCase()); // Capitalize words
|
|
|
|
let errorMsg = field.validationMessage || 'Invalid value';
|
|
|
|
// Get more specific error message based on validation state
|
|
if (field.validity.valueMissing) {
|
|
errorMsg = 'This field is required';
|
|
} else if (field.validity.rangeUnderflow) {
|
|
errorMsg = `Value must be at least ${field.min || 'the minimum'}`;
|
|
} else if (field.validity.rangeOverflow) {
|
|
errorMsg = `Value must be at most ${field.max || 'the maximum'}`;
|
|
} else if (field.validity.stepMismatch) {
|
|
errorMsg = `Value must be a multiple of ${field.step || 1}`;
|
|
} else if (field.validity.typeMismatch) {
|
|
errorMsg = 'Invalid format (e.g., text in number field)';
|
|
} else if (field.validity.patternMismatch) {
|
|
errorMsg = 'Value does not match required pattern';
|
|
} else if (field.validity.tooShort) {
|
|
errorMsg = `Value must be at least ${field.minLength} characters`;
|
|
} else if (field.validity.tooLong) {
|
|
errorMsg = `Value must be at most ${field.maxLength} characters`;
|
|
} else if (field.validity.badInput) {
|
|
errorMsg = 'Invalid input type';
|
|
}
|
|
|
|
errors.push(`${fieldName}: ${errorMsg}`);
|
|
|
|
// Track first invalid field for focusing
|
|
if (index === 0) {
|
|
firstInvalidField = field;
|
|
}
|
|
|
|
// If field is in a collapsed section, expand it
|
|
const nestedContent = field.closest('.nested-content');
|
|
if (nestedContent && nestedContent.classList.contains('hidden')) {
|
|
// Find the toggle button for this section
|
|
const sectionId = nestedContent.id;
|
|
if (sectionId) {
|
|
// Try multiple selectors to find the toggle button
|
|
const toggleBtn = document.querySelector(`button[aria-controls="${sectionId}"], button[onclick*="${sectionId}"], [data-toggle-section="${sectionId}"]`) ||
|
|
nestedContent.previousElementSibling?.querySelector('button');
|
|
if (toggleBtn && toggleBtn.onclick) {
|
|
toggleBtn.click(); // Expand the section
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Focus and scroll to first invalid field after a brief delay
|
|
// (allows collapsed sections to expand first)
|
|
setTimeout(() => {
|
|
if (firstInvalidField) {
|
|
firstInvalidField.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
firstInvalidField.focus();
|
|
}
|
|
}, 200);
|
|
|
|
// Show error notification with details
|
|
if (errors.length > 0) {
|
|
// Format error message nicely
|
|
const errorList = errors.slice(0, 5).join('\n'); // Show first 5 errors
|
|
const moreErrors = errors.length > 5 ? `\n... and ${errors.length - 5} more error(s)` : '';
|
|
const errorMessage = `Validation failed:\n${errorList}${moreErrors}`;
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(errorMessage, 'error');
|
|
} else {
|
|
alert(errorMessage); // Fallback if showNotification not available
|
|
}
|
|
|
|
// Also log to console for debugging
|
|
console.error('Form validation errors:', errors);
|
|
}
|
|
|
|
// Report validation failure to browser (shows native validation tooltips)
|
|
form.reportValidity();
|
|
|
|
return false; // Prevent form submission
|
|
}
|
|
|
|
return true; // Validation passed
|
|
};
|
|
|
|
// Handle config save response with detailed error logging
|
|
window.handleConfigSave = function(event, pluginId) {
|
|
const btn = event.target.querySelector('[type=submit]');
|
|
if (btn) btn.disabled = false;
|
|
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
// Check if request was successful (2xx status codes)
|
|
if (status >= 200 && status < 300) {
|
|
// Try to get message from response JSON
|
|
let message = 'Configuration saved successfully!';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
message = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const responseData = JSON.parse(xhr.responseText);
|
|
message = responseData.message || message;
|
|
}
|
|
} catch (e) {
|
|
// Use default message if parsing fails
|
|
}
|
|
showNotification(message, 'success');
|
|
} else {
|
|
// Request failed - log detailed error information
|
|
console.error('Config save failed:', {
|
|
status: status,
|
|
statusText: xhr?.statusText,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
// Try to parse error response
|
|
let errorMessage = 'Failed to save configuration';
|
|
try {
|
|
if (xhr?.responseJSON) {
|
|
const errorData = xhr.responseJSON;
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
if (errorData.validation_errors) {
|
|
errorMessage += ': ' + errorData.validation_errors.join(', ');
|
|
}
|
|
} else if (xhr?.responseText) {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
if (errorData.validation_errors) {
|
|
errorMessage += ': ' + errorData.validation_errors.join(', ');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// If parsing fails, use status text
|
|
errorMessage = xhr?.statusText || errorMessage;
|
|
}
|
|
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Handle toggle response
|
|
window.handleToggleResponse = function(event, pluginId) {
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
if (status >= 200 && status < 300) {
|
|
// Update UI in place instead of refreshing to avoid duplication
|
|
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
|
|
const label = checkbox?.nextElementSibling;
|
|
|
|
if (checkbox && label) {
|
|
const isEnabled = checkbox.checked;
|
|
label.textContent = isEnabled ? 'Enabled' : 'Disabled';
|
|
label.className = `ml-2 text-sm ${isEnabled ? 'text-green-600' : 'text-gray-500'}`;
|
|
}
|
|
|
|
// Try to get message from response
|
|
let message = 'Plugin status updated';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
message = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const responseData = JSON.parse(xhr.responseText);
|
|
message = responseData.message || message;
|
|
}
|
|
} catch (e) {
|
|
// Use default message
|
|
}
|
|
showNotification(message, 'success');
|
|
} else {
|
|
// Revert checkbox state on error
|
|
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
|
|
if (checkbox) {
|
|
checkbox.checked = !checkbox.checked;
|
|
}
|
|
|
|
// Try to get error message from response
|
|
let errorMessage = 'Failed to update plugin status';
|
|
try {
|
|
if (xhr?.responseJSON?.message) {
|
|
errorMessage = xhr.responseJSON.message;
|
|
} else if (xhr?.responseText) {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorData.details || errorMessage;
|
|
}
|
|
} catch (e) {
|
|
// Use default message
|
|
}
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Handle plugin update response
|
|
window.handlePluginUpdate = function(event, pluginId) {
|
|
const xhr = event.detail.xhr;
|
|
const status = xhr?.status || 0;
|
|
|
|
// Check if request was successful (2xx status)
|
|
if (status >= 200 && status < 300) {
|
|
// Try to parse the response to get the actual message from server
|
|
let message = 'Plugin updated successfully';
|
|
|
|
if (xhr && xhr.responseText) {
|
|
try {
|
|
const data = JSON.parse(xhr.responseText);
|
|
// Use the server's message, ensuring it says "update" not "save"
|
|
message = data.message || message;
|
|
// Ensure message is about updating, not saving
|
|
if (message.toLowerCase().includes('save') && !message.toLowerCase().includes('update')) {
|
|
message = message.replace(/save/i, 'update');
|
|
}
|
|
} catch (e) {
|
|
// If parsing fails, use default message
|
|
console.warn('Could not parse update response:', e);
|
|
}
|
|
}
|
|
|
|
showNotification(message, 'success');
|
|
} else {
|
|
console.error('Plugin update failed:', {
|
|
status: status,
|
|
statusText: xhr?.statusText,
|
|
responseText: xhr?.responseText
|
|
});
|
|
|
|
// Try to parse error response for better error message
|
|
let errorMessage = 'Failed to update plugin';
|
|
if (xhr?.responseText) {
|
|
try {
|
|
const errorData = JSON.parse(xhr.responseText);
|
|
errorMessage = errorData.message || errorMessage;
|
|
} catch (e) {
|
|
// If parsing fails, use default
|
|
}
|
|
}
|
|
|
|
showNotification(errorMessage, 'error');
|
|
}
|
|
};
|
|
|
|
// Refresh plugin config (with duplicate prevention)
|
|
window.refreshPluginConfig = function(pluginId) {
|
|
// Prevent concurrent refreshes
|
|
if (window.pluginConfigRefreshInProgress.has(pluginId)) {
|
|
return;
|
|
}
|
|
|
|
const container = document.getElementById(`plugin-config-${pluginId}`);
|
|
if (container && window.htmx) {
|
|
window.pluginConfigRefreshInProgress.add(pluginId);
|
|
|
|
// Clear container first, then reload
|
|
container.innerHTML = '';
|
|
window.htmx.ajax('GET', `/v3/partials/plugin-config/${pluginId}`, {
|
|
target: container,
|
|
swap: 'innerHTML'
|
|
});
|
|
|
|
// Clear flag after delay
|
|
setTimeout(() => {
|
|
window.pluginConfigRefreshInProgress.delete(pluginId);
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
// Plugin action handlers
|
|
window.runPluginOnDemand = function(pluginId) {
|
|
if (typeof window.openOnDemandModal === 'function') {
|
|
window.openOnDemandModal(pluginId);
|
|
} else {
|
|
showNotification('On-demand modal not available', 'error');
|
|
}
|
|
};
|
|
|
|
window.stopOnDemand = function() {
|
|
if (typeof window.requestOnDemandStop === 'function') {
|
|
window.requestOnDemandStop({});
|
|
} else {
|
|
showNotification('Stop function not available', 'error');
|
|
}
|
|
};
|
|
|
|
window.executePluginAction = function(pluginId, actionId) {
|
|
fetch(`/api/v3/plugins/action?plugin_id=${pluginId}&action_id=${actionId}`, {
|
|
method: 'POST'
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showNotification(data.message || 'Action executed', 'success');
|
|
} else {
|
|
showNotification(data.message || 'Action failed', 'error');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
showNotification('Failed to execute action', 'error');
|
|
});
|
|
};
|
|
}
|
|
|
|
function getAppComponent() {
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
return appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function updatePlugin(pluginId) {
|
|
try {
|
|
showNotification(`Updating ${pluginId}...`, 'info');
|
|
|
|
const response = await fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
showNotification(data.message, data.status);
|
|
|
|
if (data.status === 'success') {
|
|
// Refresh the plugin list
|
|
const appComponent = getAppComponent();
|
|
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
|
|
await appComponent.loadInstalledPlugins();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error updating plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function updateAllPlugins() {
|
|
try {
|
|
const plugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
|
|
|
|
if (!plugins.length) {
|
|
showNotification('No installed plugins to update.', 'warning');
|
|
return;
|
|
}
|
|
|
|
showNotification(`Checking ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} for updates...`, 'info');
|
|
|
|
let successCount = 0;
|
|
let failureCount = 0;
|
|
|
|
for (const plugin of plugins) {
|
|
const pluginId = plugin.id;
|
|
const pluginName = plugin.name || pluginId;
|
|
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
|
|
const data = await response.json();
|
|
const status = data.status || 'info';
|
|
const message = data.message || `Checked ${pluginName}`;
|
|
|
|
showNotification(message, status);
|
|
|
|
if (status === 'success') {
|
|
successCount += 1;
|
|
} else {
|
|
failureCount += 1;
|
|
}
|
|
} catch (error) {
|
|
failureCount += 1;
|
|
showNotification(`Error updating ${pluginName}: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
const appComponent = getAppComponent();
|
|
if (appComponent && typeof appComponent.loadInstalledPlugins === 'function') {
|
|
await appComponent.loadInstalledPlugins();
|
|
}
|
|
|
|
if (failureCount === 0) {
|
|
showNotification(`Finished checking ${successCount} plugin${successCount === 1 ? '' : 's'} for updates.`, 'success');
|
|
} else {
|
|
showNotification(`Updated ${successCount} plugin${successCount === 1 ? '' : 's'} with ${failureCount} failure${failureCount === 1 ? '' : 's'}. Check logs for details.`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Bulk plugin update failed:', error);
|
|
showNotification('Failed to update all plugins: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
window.updateAllPlugins = updateAllPlugins;
|
|
|
|
|
|
async function uninstallPlugin(pluginId) {
|
|
try {
|
|
// Get plugin info from window.installedPlugins
|
|
const plugin = window.installedPlugins ? window.installedPlugins.find(p => p.id === pluginId) : null;
|
|
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
|
|
|
if (!confirm(`Are you sure you want to uninstall ${pluginName}?`)) {
|
|
return;
|
|
}
|
|
|
|
showNotification(`Uninstalling ${pluginName}...`, 'info');
|
|
|
|
const response = await fetch('/api/v3/plugins/uninstall', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
});
|
|
const data = await response.json();
|
|
|
|
// Check if operation was queued
|
|
if (data.status === 'success' && data.data && data.data.operation_id) {
|
|
// Operation was queued, poll for completion
|
|
const operationId = data.data.operation_id;
|
|
showNotification(`Uninstall queued for ${pluginName}...`, 'info');
|
|
await pollUninstallOperation(operationId, pluginId, pluginName);
|
|
} else if (data.status === 'success') {
|
|
// Direct uninstall completed immediately
|
|
showNotification(data.message || `Plugin ${pluginName} uninstalled successfully`, 'success');
|
|
// Refresh the plugin list
|
|
await app.loadInstalledPlugins();
|
|
} else {
|
|
// Error response
|
|
showNotification(data.message || 'Failed to uninstall plugin', data.status || 'error');
|
|
}
|
|
} catch (error) {
|
|
showNotification('Error uninstalling plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) {
|
|
if (attempt >= maxAttempts) {
|
|
showNotification(`Uninstall operation timed out for ${pluginName}`, 'error');
|
|
// Refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/v3/plugins/operation/${operationId}`);
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success' && data.data) {
|
|
const operation = data.data;
|
|
const status = operation.status;
|
|
|
|
if (status === 'completed') {
|
|
// Operation completed successfully
|
|
showNotification(`Plugin ${pluginName} uninstalled successfully`, 'success');
|
|
await app.loadInstalledPlugins();
|
|
} else if (status === 'failed') {
|
|
// Operation failed
|
|
const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`;
|
|
showNotification(errorMsg, 'error');
|
|
// Refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
} else if (status === 'pending' || status === 'in_progress') {
|
|
// Still in progress, poll again
|
|
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
} else {
|
|
// Unknown status, poll again
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}
|
|
} else {
|
|
// Error getting operation status, try again
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
await pollUninstallOperation(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error polling operation status:', error);
|
|
// On error, refresh plugin list to see actual state
|
|
await app.loadInstalledPlugins();
|
|
}
|
|
}
|
|
|
|
// Assign to window for global access
|
|
window.uninstallPlugin = uninstallPlugin;
|
|
|
|
async function refreshPlugin(pluginId) {
|
|
try {
|
|
// Switch to the plugin manager tab briefly to refresh
|
|
const originalTab = app.activeTab;
|
|
app.activeTab = 'plugins';
|
|
|
|
// Wait a moment then switch back
|
|
setTimeout(() => {
|
|
app.activeTab = originalTab;
|
|
app.showNotification(`Refreshed ${pluginId}`, 'success');
|
|
}, 100);
|
|
|
|
} catch (error) {
|
|
app.showNotification('Error refreshing plugin: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Format commit information for display
|
|
function formatCommitInfo(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 'Latest';
|
|
}
|
|
|
|
// Format date for display
|
|
function formatDateInfo(dateString) {
|
|
if (!dateString) return 'Unknown';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 1) {
|
|
return 'Today';
|
|
} else if (diffDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else if (diffDays < 30) {
|
|
const weeks = Math.floor(diffDays / 7);
|
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
|
} else {
|
|
// Return formatted date for older items
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
} catch (e) {
|
|
return dateString;
|
|
}
|
|
}
|
|
|
|
// Make functions available to Alpine.js
|
|
window.formatCommitInfo = formatCommitInfo;
|
|
window.formatDateInfo = formatDateInfo;
|
|
|
|
</script>
|
|
|
|
<!-- Custom v3 JavaScript -->
|
|
<script src="{{ url_for('static', filename='v3/app.js') }}" defer></script>
|
|
|
|
<!-- Modular Plugin Management JavaScript -->
|
|
<!-- Load utilities first -->
|
|
<script src="{{ url_for('static', filename='v3/js/utils/error_handler.js') }}" defer></script>
|
|
|
|
<!-- Load core API client first (used by other modules) -->
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/api_client.js') }}" defer></script>
|
|
|
|
<!-- Load plugin management modules -->
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/store_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
|
|
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
|
|
|
|
<!-- Load config utilities -->
|
|
<script src="{{ url_for('static', filename='v3/js/config/diff_viewer.js') }}" defer></script>
|
|
|
|
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
|
|
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20241223j" defer></script>
|
|
</body>
|
|
</html>
|
|
|