Files
LEDMatrix/web_interface/templates/v3/base.html
ChuckBuilds cfd4a93b28 fix(web): resolve plugin settings tabs not loading due to enhancement race
Two co-occurring bugs prevented plugin setting tabs from loading:

1. Both stub-to-full app() enhancement paths (tryEnhance and
   requestAnimationFrame) could fire independently, with the second
   overwriting installedPlugins back to [] after init() already fetched
   them. Added a guard flag (_appEnhanced) and runtime state preservation
   to prevent this race.

2. Plugin config x-init only loaded content if window.htmx was available
   at that exact moment, with no retry or fallback. Added retry loop
   (up to 3s) and fetch() fallback for resilience.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:57:50 -04:00

4877 lines
270 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>
<!-- Theme initialization (must run before CSS to prevent flash) -->
<script>
(function() {
// Safely read from localStorage (may throw in private browsing / restricted contexts)
function getStorage(key) {
try { return localStorage.getItem(key); } catch (e) { return null; }
}
function setStorage(key, value) {
try { localStorage.setItem(key, value); } catch (e) { /* no-op */ }
}
// Safely query prefers-color-scheme (matchMedia may be unavailable)
function prefersDark() {
try {
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
} catch (e) { return false; }
}
var saved = getStorage('theme');
var theme = saved || (prefersDark() ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
// Theme toggle function
window.toggleTheme = function() {
try {
var current = document.documentElement.getAttribute('data-theme');
var next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
setStorage('theme', next);
window.updateThemeIcon(next);
} catch (e) { /* no-op */ }
};
// Update icon visibility and ARIA state based on current theme
window.updateThemeIcon = function(theme) {
var darkIcon = document.getElementById('theme-icon-dark');
var lightIcon = document.getElementById('theme-icon-light');
var btn = document.getElementById('theme-toggle');
if (darkIcon && lightIcon) {
if (theme === 'dark') {
darkIcon.classList.add('hidden');
lightIcon.classList.remove('hidden');
} else {
darkIcon.classList.remove('hidden');
lightIcon.classList.add('hidden');
}
}
if (btn) {
var isDark = theme === 'dark';
btn.setAttribute('aria-pressed', String(isDark));
var label = isDark ? 'Switch to light mode' : 'Switch to dark mode';
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
}
};
// Initialize icon state once DOM is ready
document.addEventListener('DOMContentLoaded', function() {
window.updateThemeIcon(document.documentElement.getAttribute('data-theme') || 'light');
});
// Listen for OS theme changes (only when no explicit user preference)
try {
var mql = window.matchMedia('(prefers-color-scheme: dark)');
var handler = function(e) {
if (!getStorage('theme')) {
var t = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', t);
window.updateThemeIcon(t);
}
};
if (mql.addEventListener) {
mql.addEventListener('change', handler);
} else if (mql.addListener) {
mql.addListener(handler);
}
} catch (e) { /* matchMedia unavailable */ }
})();
</script>
<!-- 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');
}
};
})();
</script>
<!-- Fallback for loading plugins if HTMX fails -->
<script>
function loadPluginsDirect() {
const content = document.getElementById('plugins-content');
if (content && !content.hasAttribute('data-loaded')) {
content.setAttribute('data-loaded', 'true');
console.log('Loading plugins directly via fetch (HTMX fallback)...');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
fetch('/v3/partials/plugins', { signal: controller.signal })
.then(r => {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.text();
})
.then(html => {
clearTimeout(timeout);
content.innerHTML = html;
// Trigger full initialization chain
if (window.pluginManager) {
window.pluginManager.initialized = false;
window.pluginManager.initializing = false;
}
if (window.initPluginsPage) {
window.initPluginsPage();
}
})
.catch(err => {
clearTimeout(timeout);
console.error('Failed to load plugins:', err);
content.removeAttribute('data-loaded');
content.innerHTML = '<div class="bg-red-50 border border-red-200 rounded-lg p-4"><p class="text-red-800">Failed to load Plugin Manager. Please refresh the page.</p></div>';
});
}
}
// Fallback if HTMX doesn't load within 5 seconds
setTimeout(() => {
if (typeof htmx === 'undefined') {
console.warn('HTMX not loaded after 5 seconds, using direct fetch for plugins');
// Load plugins tab content directly regardless of active tab,
// so it's ready when the user navigates to it
loadPluginsDirect();
}
}, 5000);
</script>
<!-- Alpine.js app function - defined early so it's available when Alpine initializes -->
<script>
// Helper function to get installed plugins with fallback
// 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;
// Only call updatePluginTabStates if it exists
if (typeof appElement._x_dataStack[0].updatePluginTabStates === 'function') {
appElement._x_dataStack[0].updatePluginTabStates();
}
}
}
};
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${(plugin.name || plugin.id).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}`;
pluginTabsNav.appendChild(tabButton);
});
console.log('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
}
});
// Guard flag to prevent duplicate stub-to-full enhancement
window._appEnhanced = false;
// 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 (window._appEnhanced) return true;
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')) {
window._appEnhanced = true;
// Preserve runtime state that should not be reset
const preservedPlugins = this.installedPlugins;
const preservedTab = this.activeTab;
const wasInitialized = this._initialized;
Object.assign(this, fullApp);
// Restore runtime state if non-default
if (preservedPlugins && preservedPlugins.length > 0) {
this.installedPlugins = preservedPlugins;
}
if (preservedTab && preservedTab !== 'overview') {
this.activeTab = preservedTab;
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
};
};
})();
</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') }}?v=20260216b">
</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 and theme toggle -->
<div class="flex items-center space-x-4">
<!-- Theme toggle -->
<button id="theme-toggle"
type="button"
onclick="toggleTheme()"
class="theme-toggle-btn p-2 rounded-md"
title="Switch to dark mode"
aria-label="Switch to dark mode"
aria-pressed="false">
<i class="fas fa-moon" id="theme-icon-dark" aria-hidden="true"></i>
<i class="fas fa-sun hidden" id="theme-icon-light" aria-hidden="true"></i>
</button>
<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 flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<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 flex-wrap gap-y-2 gap-x-2 lg:gap-x-3 xl:gap-x-4">
<button @click="activeTab = 'plugins'; if (typeof htmx === 'undefined') { $nextTick(() => loadPluginsDirect()); }"
:class="activeTab === 'plugins' ? 'nav-tab-active' : ''"
class="nav-tab">
<i class="fas fa-plug"></i>Plugin Manager
</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>
<div id="plugins-content" hx-get="/v3/partials/plugins" hx-trigger="revealed" hx-swap="innerHTML"
hx-on::response-error="loadPluginsDirect()">
<div class="animate-pulse">
<div class="bg-white rounded-lg shadow p-6">
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<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(() => {
const el = $el;
const pid = plugin.id;
const loadContent = (retries) => {
if (window.htmx && !el.dataset.htmxLoaded) {
el.dataset.htmxLoaded = 'true';
htmx.ajax('GET', '/v3/partials/plugin-config/' + pid, {target: el, swap: 'innerHTML'});
} else if (!window.htmx && retries < 15) {
setTimeout(() => loadContent(retries + 1), 200);
} else if (!window.htmx) {
fetch('/v3/partials/plugin-config/' + pid)
.then(r => r.text())
.then(html => { el.innerHTML = html; });
}
};
loadContent(0);
})">
<!-- 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() {
// Note: On-demand can work with disabled plugins - the backend will temporarily enable them
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
if (typeof this.updatePluginTabStates === 'function') {
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;
if (typeof this.updatePluginTabStates === 'function') {
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') {
// Use global notification widget
if (typeof window.showNotification === 'function') {
window.showNotification(message, type);
} else {
console.log(`[${type.toUpperCase()}]`, message);
}
},
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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, '&amp;').replace(/"/g, '&quot;')}"
alt="${(img.filename || '').replace(/"/g, '&quot;')}"
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>
<p class="text-xs text-gray-500">${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&amp;')}</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, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')}">
</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;
},
// Helper function to escape CSS selector special characters
escapeCssSelector(str) {
if (typeof str !== 'string') {
str = String(str);
}
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
if (typeof CSS !== 'undefined' && CSS.escape) {
return CSS.escape(str);
}
// Fallback to regex-based escaping for older browsers
// First, handle leading digits and whitespace (must be done before regex)
let escaped = str;
let hasLeadingHexEscape = false;
if (escaped.length > 0) {
const firstChar = escaped[0];
const firstCode = firstChar.charCodeAt(0);
// Escape leading digit (0-9: U+0030-U+0039)
if (firstCode >= 0x30 && firstCode <= 0x39) {
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
escaped = '\\' + hex + ' ' + escaped.slice(1);
hasLeadingHexEscape = true;
}
// Escape leading whitespace (space: U+0020, tab: U+0009, etc.)
else if (/\s/.test(firstChar)) {
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
escaped = '\\' + hex + ' ' + escaped.slice(1);
hasLeadingHexEscape = true;
}
}
// Escape special characters
escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
// Escape internal spaces (replace spaces with \ ), but preserve space in hex escape
if (hasLeadingHexEscape) {
// Skip the first 6 characters (e.g., "\0030 ") when replacing spaces
escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ ');
} else {
escaped = escaped.replace(/ /g, '\\ ');
}
return escaped;
},
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
// Use querySelector to reliably find element by name (handles dot notation)
const escapedKey = this.escapeCssSelector(key);
const element = form.querySelector(`[name="${escapedKey}"]`);
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: &quot; -> ", &#39; -> ', &amp; -> &
unescapedValue = value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/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(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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(() => {
if (window._appEnhanced) return;
window._appEnhanced = true;
const appElement = document.querySelector('[x-data]');
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
const existingComponent = appElement._x_dataStack[0];
// Preserve runtime state that should not be reset
const preservedPlugins = existingComponent.installedPlugins;
const preservedTab = existingComponent.activeTab;
// Replace all properties and methods from full implementation
Object.keys(fullImplementation).forEach(key => {
existingComponent[key] = fullImplementation[key];
});
// Restore runtime state if non-default
if (preservedPlugins && preservedPlugins.length > 0) {
existingComponent.installedPlugins = preservedPlugins;
}
if (preservedTab && preservedTab !== 'overview') {
existingComponent.activeTab = preservedTab;
}
// 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;
}
// Helper function to escape CSS selector special characters
function escapeCssSelector(str) {
if (typeof str !== 'string') {
str = String(str);
}
// Use CSS.escape() when available (handles unicode, leading digits, and edge cases)
if (typeof CSS !== 'undefined' && CSS.escape) {
return CSS.escape(str);
}
// Fallback to regex-based escaping for older browsers
// First, handle leading digits and whitespace (must be done before regex)
let escaped = str;
let hasLeadingHexEscape = false;
if (escaped.length > 0) {
const firstChar = escaped[0];
const firstCode = firstChar.charCodeAt(0);
// Escape leading digit (0-9: U+0030-U+0039)
if (firstCode >= 0x30 && firstCode <= 0x39) {
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
escaped = '\\' + hex + ' ' + escaped.slice(1);
hasLeadingHexEscape = true;
}
// Escape leading whitespace (space: U+0020, tab: U+0009, etc.)
else if (/\s/.test(firstChar)) {
const hex = firstCode.toString(16).toUpperCase().padStart(4, '0');
escaped = '\\' + hex + ' ' + escaped.slice(1);
hasLeadingHexEscape = true;
}
}
// Escape special characters
escaped = escaped.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
// Escape internal spaces (replace spaces with \ ), but preserve space in hex escape
if (hasLeadingHexEscape) {
// Skip the first 6 characters (e.g., "\0030 ") when replacing spaces
escaped = escaped.slice(0, 6) + escaped.slice(6).replace(/ /g, '\\ ');
} else {
escaped = escaped.replace(/ /g, '\\ ');
}
return escaped;
}
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
// Use querySelector to reliably find element by name (handles dot notation)
const escapedKey = escapeCssSelector(key);
const element = form.querySelector(`[name="${escapedKey}"]`);
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: &quot; -> ", &#39; -> ', &amp; -> &
unescapedValue = value
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/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;
}
}
});
};
// showNotification is provided by notification.js widget
// This fallback is only used if the widget hasn't loaded yet
if (typeof window.showNotification !== 'function') {
window.showNotification = function(message, type = 'info') {
console.log(`[${type.toUpperCase()}]`, message);
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);
setTimeout(() => { 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>
<!-- Widget Registry System -->
<script src="{{ url_for('static', filename='v3/js/widgets/registry.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/base-widget.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/notification.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/file-upload.js') }}?v=20260307" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/checkbox-group.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/custom-feeds.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/array-table.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/google-calendar-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/day-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/time-range.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/schedule-picker.js') }}" defer></script>
<!-- Basic input widgets -->
<script src="{{ url_for('static', filename='v3/js/widgets/text-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/number-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/slider.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/color-picker.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/email-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/url-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/password-input.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/timezone-selector.js') }}" defer></script>
<script src="{{ url_for('static', filename='v3/js/widgets/plugin-loader.js') }}" defer></script>
<!-- Legacy plugins_manager.js (for backward compatibility during migration) -->
<script src="{{ url_for('static', filename='v3/plugins_manager.js') }}?v=20260307" defer></script>
<!-- Custom feeds table helper functions -->
<script>
function addCustomFeedRow(fieldId, fullKey, maxItems, pluginId) {
const tbody = document.getElementById(fieldId + '_tbody');
if (!tbody) return;
const currentRows = tbody.querySelectorAll('.custom-feed-row');
if (currentRows.length >= maxItems) {
alert(`Maximum ${maxItems} feeds allowed`);
return;
}
const newIndex = currentRows.length;
const newRow = document.createElement('tr');
newRow.className = 'custom-feed-row';
newRow.setAttribute('data-index', newIndex);
// Create name cell
const nameCell = document.createElement('td');
nameCell.className = 'px-4 py-3 whitespace-nowrap';
const nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.name = `${fullKey}.${newIndex}.name`;
nameInput.value = '';
nameInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
nameInput.placeholder = 'Feed Name';
nameInput.required = true;
nameCell.appendChild(nameInput);
// Create URL cell
const urlCell = document.createElement('td');
urlCell.className = 'px-4 py-3 whitespace-nowrap';
const urlInput = document.createElement('input');
urlInput.type = 'url';
urlInput.name = `${fullKey}.${newIndex}.url`;
urlInput.value = '';
urlInput.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
urlInput.placeholder = 'https://example.com/feed';
urlInput.required = true;
urlCell.appendChild(urlInput);
// Create logo cell
const logoCell = document.createElement('td');
logoCell.className = 'px-4 py-3 whitespace-nowrap';
const logoContainer = document.createElement('div');
logoContainer.className = 'flex items-center space-x-2';
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = `${fieldId}_logo_${newIndex}`;
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.index = String(newIndex);
// Use addEventListener with dataset index to allow reindexing
fileInput.addEventListener('change', function(e) {
const idx = parseInt(e.target.dataset.index || '0', 10);
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
});
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
// Use fileInput directly instead of getElementById for reindexing compatibility
uploadButton.addEventListener('click', function() {
fileInput.click();
});
const uploadIcon = document.createElement('i');
uploadIcon.className = 'fas fa-upload mr-1';
uploadButton.appendChild(uploadIcon);
uploadButton.appendChild(document.createTextNode(' Upload'));
const noLogoSpan = document.createElement('span');
noLogoSpan.className = 'text-xs text-gray-400';
noLogoSpan.textContent = 'No logo';
logoContainer.appendChild(fileInput);
logoContainer.appendChild(uploadButton);
logoContainer.appendChild(noLogoSpan);
logoCell.appendChild(logoContainer);
// Create enabled cell
const enabledCell = document.createElement('td');
enabledCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const enabledInput = document.createElement('input');
enabledInput.type = 'checkbox';
enabledInput.name = `${fullKey}.${newIndex}.enabled`;
enabledInput.checked = true;
enabledInput.value = 'true';
enabledInput.className = 'h-4 w-4 text-blue-600';
enabledCell.appendChild(enabledInput);
// Create remove cell
const removeCell = document.createElement('td');
removeCell.className = 'px-4 py-3 whitespace-nowrap text-center';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
// Use addEventListener instead of string-based onclick to prevent injection
removeButton.addEventListener('click', function() {
removeCustomFeedRow(this);
});
const removeIcon = document.createElement('i');
removeIcon.className = 'fas fa-trash';
removeButton.appendChild(removeIcon);
removeCell.appendChild(removeButton);
// Append all cells to row
newRow.appendChild(nameCell);
newRow.appendChild(urlCell);
newRow.appendChild(logoCell);
newRow.appendChild(enabledCell);
newRow.appendChild(removeCell);
tbody.appendChild(newRow);
}
function removeCustomFeedRow(button) {
const row = button.closest('tr');
if (!row) return;
if (confirm('Remove this feed?')) {
const tbody = row.parentElement;
if (!tbody) return;
row.remove();
// Re-index remaining rows
const rows = tbody.querySelectorAll('.custom-feed-row');
rows.forEach((r, index) => {
const oldIndex = r.getAttribute('data-index');
r.setAttribute('data-index', index);
// Update all input names with new index
r.querySelectorAll('input, button').forEach(input => {
const name = input.getAttribute('name');
if (name) {
// Replace pattern like "feeds.custom_feeds.0.name" with "feeds.custom_feeds.1.name"
input.setAttribute('name', name.replace(/\.\d+\./, `.${index}.`));
}
const id = input.id;
if (id) {
// Keep IDs aligned after reindex (supports both _logo_<n> and _logo_preview_<n>)
input.id = id
.replace(/_logo_preview_\d+$/, `_logo_preview_${index}`)
.replace(/_logo_\d+$/, `_logo_${index}`);
}
// Keep dataset index aligned so event handlers remain correct after reindex
if (input.dataset && 'index' in input.dataset) {
input.dataset.index = String(index);
}
});
});
}
}
function handleCustomFeedLogoUpload(event, fieldId, index, pluginId, fullKey) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
formData.append('plugin_id', pluginId);
fetch('/api/v3/plugins/assets/upload', {
method: 'POST',
body: formData
})
.then(response => {
// Check HTTP status before parsing JSON
if (!response.ok) {
return response.text().then(text => {
throw new Error(`Upload failed: ${response.status} ${response.statusText}${text ? ': ' + text : ''}`);
});
}
return response.json();
})
.then(data => {
if (data.status === 'success' && data.data && data.data.files && data.data.files.length > 0) {
const uploadedFile = data.data.files[0];
const row = document.querySelector(`#${fieldId}_tbody tr[data-index="${index}"]`);
if (row) {
const logoCell = row.querySelector('td:nth-child(3)');
const existingPathInput = logoCell.querySelector('input[name*=".logo.path"]');
const existingIdInput = logoCell.querySelector('input[name*=".logo.id"]');
const pathName = existingPathInput ? existingPathInput.name : `${fullKey}.${index}.logo.path`;
const idName = existingIdInput ? existingIdInput.name : `${fullKey}.${index}.logo.id`;
// Normalize path: remove leading slashes, then add single leading slash
const normalizedPath = String(uploadedFile.path || '').replace(/^\/+/, '');
const imageSrc = '/' + normalizedPath;
// Clear logoCell and build DOM safely to prevent XSS
logoCell.textContent = ''; // Clear existing content
// Create container div
const container = document.createElement('div');
container.className = 'flex items-center space-x-2';
// Create file input
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = `${fieldId}_logo_${index}`;
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
fileInput.style.display = 'none';
fileInput.dataset.index = String(index);
// Use addEventListener with dataset index to allow reindexing
fileInput.addEventListener('change', function(e) {
const idx = parseInt(e.target.dataset.index || '0', 10);
handleCustomFeedLogoUpload(e, fieldId, idx, pluginId, fullKey);
});
// Create upload button
const uploadButton = document.createElement('button');
uploadButton.type = 'button';
uploadButton.className = 'px-2 py-1 text-xs bg-gray-200 hover:bg-gray-300 rounded';
// Use fileInput directly instead of getElementById for reindexing compatibility
uploadButton.addEventListener('click', function() {
fileInput.click();
});
const uploadIcon = document.createElement('i');
uploadIcon.className = 'fas fa-upload mr-1';
uploadButton.appendChild(uploadIcon);
uploadButton.appendChild(document.createTextNode(' Upload'));
// Create img element - use normalized path, set src via property to prevent XSS
const img = document.createElement('img');
img.src = imageSrc; // Use property assignment with normalized path
img.alt = 'Logo';
img.className = 'w-8 h-8 object-cover rounded border';
img.id = `${fieldId}_logo_preview_${index}`;
// Create hidden input for path - set value via property to prevent XSS
const pathInput = document.createElement('input');
pathInput.type = 'hidden';
pathInput.name = pathName;
pathInput.value = imageSrc;
// Create hidden input for id - set value via property to prevent XSS
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = idName;
idInput.value = String(uploadedFile.id); // Ensure it's a string
// Append all elements to container
container.appendChild(fileInput);
container.appendChild(uploadButton);
container.appendChild(img);
container.appendChild(pathInput);
container.appendChild(idInput);
// Append container to logoCell
logoCell.appendChild(container);
}
// Allow re-uploading the same file (change event won't fire otherwise)
event.target.value = '';
} else {
alert('Upload failed: ' + (data.message || 'Unknown error'));
}
})
.catch(error => {
console.error('Upload error:', error);
alert('Upload failed: ' + error.message);
});
}
</script>
<!-- On-Demand Modal (moved here from plugins.html so it's always available) -->
<div id="on-demand-modal" class="fixed inset-0 modal-backdrop flex items-center justify-center z-50" style="display: none;">
<div class="modal-content p-6 w-full max-w-md bg-white rounded-lg shadow-lg">
<div class="flex justify-between items-center mb-4">
<h3 id="on-demand-modal-title" class="text-lg font-semibold">Run Plugin On-Demand</h3>
<button id="close-on-demand-modal" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Service Status Alert -->
<div id="on-demand-service-warning" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5 mr-2"></i>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">Display service is not running</p>
<p class="text-xs text-yellow-700 mt-1">
The on-demand request will be queued but won't display until the service starts.
Enable "Start display service" below to automatically start it.
</p>
</div>
</div>
</div>
<form id="on-demand-form" class="space-y-4">
<div>
<label for="on-demand-mode" class="block text-sm font-medium text-gray-700 mb-1">Display Mode</label>
<select id="on-demand-mode" name="mode"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</select>
<p id="on-demand-mode-hint" class="text-xs text-gray-500 mt-1"></p>
</div>
<div>
<label for="on-demand-duration" class="block text-sm font-medium text-gray-700 mb-1">
Duration (seconds, optional)
</label>
<input type="number" min="0" id="on-demand-duration" name="duration"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
placeholder="Leave blank to use plugin default">
<p class="text-xs text-gray-500 mt-1">
Use 0 or leave empty to keep the plugin running until stopped manually.
</p>
</div>
<div class="flex items-center">
<input id="on-demand-pinned" name="pinned" type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="on-demand-pinned" class="ml-2 block text-sm text-gray-700">
Pin plugin to prevent rotation until stopped
</label>
</div>
<div class="flex items-center">
<input id="on-demand-start-service" name="start_service" type="checkbox" checked
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
<label for="on-demand-start-service" class="ml-2 block text-sm text-gray-700">
Start display service if it is not running
</label>
</div>
<div class="flex justify-end gap-3 pt-3">
<button type="button" id="cancel-on-demand"
class="px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md font-semibold">
Start On-Demand
</button>
</div>
</form>
</div>
</div>
</body>
</html>