diff --git a/config/config.template.json b/config/config.template.json index 07a72b60..b3f63589 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -121,6 +121,7 @@ "axis": "horizontal" }, "display_durations": {}, + "plugin_rotation_order": [], "use_short_date_format": true, "vegas_scroll": { "enabled": false, diff --git a/docs/widget-guide.md b/docs/widget-guide.md index dadda4cf..7a1ed850 100644 --- a/docs/widget-guide.md +++ b/docs/widget-guide.md @@ -206,6 +206,40 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the The widget will be automatically rendered when the plugin configuration form is loaded. +## Marking Fields as Advanced (`x-advanced`) + +Add `"x-advanced": true` to any top-level, non-object property to move it out +of the main form and into a single collapsed **Advanced Settings** section at +the bottom of the plugin's configuration page: + +```json +{ + "properties": { + "city": { + "type": "string", + "title": "City" + }, + "request_timeout": { + "type": "integer", + "default": 10, + "description": "HTTP timeout in seconds", + "x-advanced": true + } + } +} +``` + +Guidelines: + +- Use it for fine-tuning knobs most users never touch (timeouts, retry + behavior, cache TTLs, styling overrides). Anything a first-time user must + set to get the plugin working should stay basic. +- Nothing is hidden permanently — the section expands on click, and the + settings search finds and auto-expands advanced fields like any others. +- The flag is ignored on `object`-type properties (they already render as + their own collapsible sections) and is safely ignored by older cores, so + adding it never breaks compatibility. + ## Creating Custom Widgets ### Step 1: Create Widget File diff --git a/src/display_controller.py b/src/display_controller.py index ace55b58..5a341902 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -381,6 +381,10 @@ def load_single_plugin(plugin_id): logger.debug("%d plugin(s) disabled in config", disabled_count) logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time) + # Parallel loading appends modes in load-completion order, which + # varies between restarts; apply the user's configured rotation + # order (no-op when not configured). + self._apply_plugin_rotation_order() logger.info("Total available modes: %d", len(self.available_modes)) logger.info("Available modes: %s", self.available_modes) @@ -2843,11 +2847,52 @@ def _reconcile_enabled_plugins(self) -> bool: except Exception as e: logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True) + # Newly enabled plugins were appended at the end; put them in the + # configured rotation slot before resyncing the index. + self._apply_plugin_rotation_order() self._resync_mode_index_after_change(previous_mode) - logger.info("Plugin reconcile complete: +%s -%s (%d modes)", + logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)", sorted(to_add), sorted(to_remove), len(self.available_modes)) return True + def _apply_plugin_rotation_order(self) -> None: + """Reorder available_modes to follow display.plugin_rotation_order. + + The configured value is a list of plugin ids; their modes rotate in + that order (each plugin's own modes keep their declared order), with + any enabled-but-unlisted plugins appended afterwards in their current + relative order. An empty/missing list leaves available_modes exactly + as built (today's behavior). Mirrors vegas_mode/config.py's + get_ordered_plugins() semantics for the primary rotation. + """ + configured = (self.config.get("display", {}) or {}).get("plugin_rotation_order", []) or [] + # Defensive: hand-edited or migrated configs may hold a non-list or + # non-string entries; keep the existing rotation rather than applying + # a garbage order. + if not isinstance(configured, list): + logger.warning("[DisplayController] Ignoring invalid plugin_rotation_order (not a list): %r", + type(configured).__name__) + return + configured = [p for p in configured if isinstance(p, str)] + if not configured or not self.available_modes: + return + + ordered_ids = [p for p in configured if p in self.plugin_display_modes] + new_modes: List[str] = [] + for plugin_id in ordered_ids: + for mode in self.plugin_display_modes[plugin_id]: + if mode in self.available_modes and mode not in new_modes: + new_modes.append(mode) + # Unlisted plugins' modes (and any mode not attributable to a plugin) + # follow in their existing relative order. + for mode in self.available_modes: + if mode not in new_modes: + new_modes.append(mode) + if new_modes != self.available_modes: + self.available_modes = new_modes + logger.info("[DisplayController] Applied plugin rotation order %s -> modes: %s", + configured, self.available_modes) + def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None: """Clamp rotation state after available_modes changed. Stays on the previous mode if it survived, otherwise restarts cleanly within range.""" diff --git a/test/test_web_smoke.py b/test/test_web_smoke.py new file mode 100644 index 00000000..d0fd30de --- /dev/null +++ b/test/test_web_smoke.py @@ -0,0 +1,184 @@ +""" +Web-UI smoke tests: every page, partial, and critical static asset must render. + +These boot the pages blueprint with the same dual registration app.py uses +(un-prefixed primary + /v3 legacy alias) and assert each surface returns 200 +with its load-bearing markers present. They exist to catch, in CI, the class +of regression that only shows up when a real request renders a real template: +a broken partial, a missing tab wiring, a renamed element id that JS depends +on, or a static asset that stopped being served. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from flask import Flask + +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + + +SMOKE_CONFIG = { + "web_display_autostart": True, + "timezone": "America/Chicago", + "location": {"city": "Dallas", "state": "Texas", "country": "US"}, + "plugin_system": { + "auto_discover": True, + "auto_load_enabled": True, + "development_mode": False, + "plugins_directory": "plugin-repos", + }, + "schedule": {}, + "dim_schedule": {"dim_brightness": 30}, + "sync": {"role": "standalone", "port": 5765, "follower_position": "left"}, + "clock": {"enabled": True}, + "ledmatrix-weather": {"enabled": True}, + "display": { + "hardware": { + "rows": 32, "cols": 64, "chain_length": 2, "parallel": 1, + "brightness": 95, "hardware_mapping": "adafruit-hat-pwm", + "led_rgb_sequence": "RGB", "multiplexing": 0, "panel_type": "", + "row_address_type": 0, "scan_mode": 0, "pwm_bits": 9, + "pwm_dither_bits": 1, "pwm_lsb_nanoseconds": 130, + "limit_refresh_rate_hz": 120, "disable_hardware_pulsing": False, + "inverse_colors": False, "show_refresh_rate": False, + }, + "runtime": {"gpio_slowdown": 3, "rp1_rio": 0}, + "double_sided": {"enabled": False, "copies": 2, "axis": "horizontal"}, + "use_short_date_format": False, + "dynamic_duration": {"max_duration_seconds": 180}, + "vegas_scroll": { + "enabled": False, "scroll_speed": 50, "separator_width": 32, + "target_fps": 125, "buffer_ahead": 2, + "plugin_order": [], "excluded_plugins": [], + }, + "display_durations": {"stale_saved_mode": 45}, + "plugin_rotation_order": ["ledmatrix-weather", "clock"], + }, +} + +PLUGIN_MODES = { + "clock": ["clock"], + "ledmatrix-weather": ["weather_current", "weather_daily"], +} + + +@pytest.fixture +def client(): + base = PROJECT_ROOT / "web_interface" + app = Flask( + __name__, + template_folder=str(base / "templates"), + static_folder=str(base / "static"), + ) + app.config["TESTING"] = True + + from web_interface.blueprints import pages_v3 as pv + + # pages_v3 is a module-level Blueprint singleton shared by the whole test + # process (test_web_settings_ui.py mutates the same attributes) - save + # the originals and restore them on teardown so this fixture can't leak + # its mocks into tests that run afterward. + original_config_manager = getattr(pv.pages_v3, "config_manager", None) + original_plugin_manager = getattr(pv.pages_v3, "plugin_manager", None) + + mock_cm = MagicMock() + mock_cm.load_config.return_value = SMOKE_CONFIG + mock_cm.get_raw_file_content.return_value = SMOKE_CONFIG + mock_cm.get_config_path.return_value = "config/config.json" + mock_cm.get_secrets_path.return_value = "config/config_secrets.json" + pv.pages_v3.config_manager = mock_cm + + mock_pm = MagicMock() + mock_pm.plugins = {} + mock_pm.get_all_plugin_info.return_value = [ + {"id": "clock", "name": "Clock"}, + {"id": "ledmatrix-weather", "name": "Weather"}, + ] + mock_pm.get_plugin_display_modes.side_effect = ( + lambda pid: PLUGIN_MODES.get(pid, []) + ) + pv.pages_v3.plugin_manager = mock_pm + + # Same dual registration as web_interface/app.py: un-prefixed primary, + # /v3 kept as a working legacy alias. + app.register_blueprint(pv.pages_v3, url_prefix="") + app.register_blueprint(pv.pages_v3, url_prefix="/v3", name="pages_v3_legacy") + try: + yield app.test_client() + finally: + pv.pages_v3.config_manager = original_config_manager + pv.pages_v3.plugin_manager = original_plugin_manager + + +# (path, [markers that must appear in the body]) +PAGES = [ + ("/", ["site-nav", "mobileNavOpen", 'rel="manifest"', + "restart-pending-banner", "activeTab = 'durations'"]), + ("/partials/overview", ["getting-started-card", "displayImage"]), + ("/partials/general", ["timezone"]), + ("/partials/display", ["display-section-advanced-hardware", + "display-resolution-value", "vegas_scroll_label"]), + ("/partials/durations", ["rotation_plugin_order", "duration__clock", + "duration__weather_current", + "duration__stale_saved_mode"]), + ("/partials/schedule", ["schedule"]), +] + + +@pytest.mark.parametrize("path,markers", PAGES, ids=[p for p, _ in PAGES]) +def test_page_renders_with_markers(client, path, markers): + resp = client.get(path) + assert resp.status_code == 200, f"{path} -> {resp.status_code}" + body = resp.get_data(as_text=True) + for marker in markers: + assert marker in body, f"{path}: missing marker {marker!r}" + + +@pytest.mark.parametrize("path", [p for p, _ in PAGES if p != "/"]) +def test_legacy_v3_alias_serves_the_same_partials(client, path): + assert client.get("/v3" + path).status_code == 200 + + +STATIC_ASSETS = [ + "/static/v3/app.css", + "/static/v3/app.js", + "/static/v3/manifest.json", + "/static/v3/icons/icon-192.png", + "/static/v3/js/app-shell.js", + "/static/v3/js/app-early.js", + "/static/v3/js/htmx-config.js", + "/static/v3/js/widgets/plugin-order-list.js", + "/static/v3/js/widgets/notification.js", + "/static/v3/vendor/fontawesome/css/all.min.css", + "/static/v3/vendor/codemirror/codemirror.min.js", +] + + +@pytest.mark.parametrize("asset", STATIC_ASSETS) +def test_static_asset_served(client, asset): + resp = client.get(asset) + assert resp.status_code == 200, f"{asset} -> {resp.status_code}" + assert len(resp.data) > 0 + + +def test_durations_page_groups_by_plugin(client): + """One duration input per display mode of each enabled plugin, plus the + leftover group for saved keys no enabled plugin owns.""" + body = client.get("/partials/durations").get_data(as_text=True) + assert body.count("duration__") >= 2 * len( + [m for modes in PLUGIN_MODES.values() for m in modes] + ) # each mode: id= and name= + assert "Other saved entries" in body + + +def test_display_advanced_section_contains_tuning_fields(client): + body = client.get("/partials/display").get_data(as_text=True) + adv = body.find('id="display-section-advanced-hardware"') + adv_close = body.find("/#display-section-advanced-hardware") + assert 0 < adv < adv_close + for field in ["multiplexing", "pwm_bits", "inverse_colors"]: + pos = body.find(f'name="{field}"') + assert adv < pos < adv_close, f"{field} not inside the advanced section" diff --git a/test/test_web_static_audit.py b/test/test_web_static_audit.py new file mode 100644 index 00000000..4d9bae1f --- /dev/null +++ b/test/test_web_static_audit.py @@ -0,0 +1,85 @@ +""" +Static-analysis audits for the web UI, as tests so CI enforces them. + +1. Breakpoint utility audit: app.css hand-maintains a Tailwind-style utility + subset, so a template can reference a responsive class (e.g. sm:block) + that no CSS rule defines — it silently no-ops. This once left the header + search box and system stats invisible at every screen width. The audit + diffs classes used in templates against classes defined in app.css. + +2. Asset reference audit: every url_for('static', filename=...) in the + templates must point to a file that exists, so a renamed/moved asset + can't ship as a broken - - - - - - - - + - + - + - - - - + + + + - - + + @@ -874,14 +296,25 @@
+ +

- LED Matrix Control - v3 + LED Matrix Control

-
+
- + + + + + + + +
@@ -1244,6 +755,18 @@

+ +
+
+
+
+
+
+
+
+
+
+
@@ -1429,3662 +952,70 @@

- - // Update Power (under-voltage / throttling) status in header + banner - updatePowerStatus(data.power); - - // 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/ 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) { - const contentEl = document.getElementById(tab + '-content'); - // data-loaded: already fetched. data-loading: a fetch is queued or in - // flight. Both guard against re-entry so a panel loads exactly once, even - // if the tab is reopened before an in-progress (or polling) load settles. - if (!contentEl || contentEl.hasAttribute('data-loaded') || contentEl.hasAttribute('data-loading')) return; - const url = contentEl.getAttribute('hx-get'); - if (!url) return; - - contentEl.setAttribute('data-loading', 'true'); - - // htmx.ajax issues the request and swaps the response into the panel - // directly, so it works even before htmx has wired up the element's - // hx-trigger listeners. data-loaded is stamped on success so the panel - // loads once; the activeTab check drops loads for a tab the user navigated - // away from while htmx was still loading (avoids fetching hidden panels). - const swap = contentEl.getAttribute('hx-swap') || 'innerHTML'; - const load = () => { - if (this.activeTab !== tab || contentEl.hasAttribute('data-loaded')) { - contentEl.removeAttribute('data-loading'); - return; - } - return htmx.ajax('GET', url, { target: contentEl, swap: swap }) - .then(() => contentEl.setAttribute('data-loaded', 'true')) - .catch(() => {}) // leave unstamped on failure so it can retry - .finally(() => contentEl.removeAttribute('data-loading')); - }; - - if (typeof htmx !== 'undefined') { - load(); - return; - } - - // htmx is loaded from a CDN and may not be ready yet. Poll until it is, - // then load; if it never arrives, fall back to a direct fetch. - let tries = 0; - const timer = setInterval(() => { - if (typeof htmx !== 'undefined') { - clearInterval(timer); - load(); - } else if (++tries > 100) { // ~10s - clearInterval(timer); - contentEl.removeAttribute('data-loading'); - if (tab === 'overview' && typeof loadOverviewDirect === 'function') loadOverviewDirect(); - else if (tab === 'wifi' && typeof loadWifiDirect === 'function') loadWifiDirect(); - else if (tab === 'plugins' && typeof loadPluginsDirect === 'function') loadPluginsDirect(); - else if (tab === 'tools') { - fetch('/v3/partials/tools') - .then(r => { - if (!r.ok) throw new Error(r.status + ' ' + r.statusText); - return r.text(); - }) - .then(html => { - contentEl.innerHTML = html; - contentEl.setAttribute('data-loaded', 'true'); - if (window.Alpine) window.Alpine.initTree(contentEl); - }) - .catch(err => { - console.error('Failed to load tools content:', err); - contentEl.innerHTML = '

Failed to load Tools. Please refresh the page.

'; - }); - } - } - }, 100); - }, - - 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(); - } - }; - // Build the + label as DOM nodes so a - // hostile plugin.icon (e.g. containing a quote) can't - // break out of the attribute. escapeHtml only escapes - // <, >, &, not ", so attribute-context interpolation - // would be unsafe. - const iconEl = document.createElement('i'); - iconEl.className = plugin.icon || 'fas fa-puzzle-piece'; - const labelNode = document.createTextNode(plugin.name || plugin.id); - tabButton.replaceChildren(iconEl, labelNode); - - // Insert before the closing 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 '
Loading configuration...
'; - } - - // 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 += ` -
- - -
- `; - - // Add extra spacing after nested sections to prevent overlap with next section - if (nestingDepth > 0) { - html += `
`; - } - - return html; - } - - // Regular (non-nested) field - html += `
`; - html += ``; - - if (description) { - html += `

${description}

`; - } - - // Generate appropriate input based on type - if (prop.type === 'boolean') { - html += ``; - } 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') ? - `

${description}

` : ''; - - html += ``; - 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 += ` -
- -
- - -

Drag and drop images here or click to browse

-

Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)

-
- - -
- ${currentImages.map((img, idx) => { - const imgSchedule = img.schedule || {}; - const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always'; - let scheduleSummary = 'Always shown'; - if (hasSchedule && window.getScheduleSummary) { - try { - scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled'; - } catch (e) { - scheduleSummary = 'Scheduled'; - } - } else if (hasSchedule) { - scheduleSummary = 'Scheduled'; - } - // Escape the summary for HTML - scheduleSummary = String(scheduleSummary).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - - return ` -
-
-
- ${(img.filename || '').replace(/ - -
-

${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&').replace(//g, '>')}

-

${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&')}

-

- ${scheduleSummary} -

-
-
-
- - -
-
- - -
- `; - }).join('')} -
- - - -
- `; - } else { - // Regular array input - const arrayValue = Array.isArray(value) ? value.join(', ') : ''; - html += ``; - html += `

Enter values separated by commas

`; - } - } else if (prop.enum) { - html += ``; - } 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 += ` -
-
- - -

- ${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename} -

-

Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})

-
- -
- `; - } else { - // Default to text input - const maxLength = prop.maxLength || ''; - const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : ''; - html += ``; - } - - html += `
`; - 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 += ` -
-

Actions

-

${webUiActions[0].section_description || 'Perform actions for this plugin'}

- -
- `; - - 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 += ` -
-
-
-

- ${action.icon ? `` : ''}${action.title || action.id} -

-

${action.description || ''}

-
- -
- -
- `; - }); - - formHtml += ` -
-
- `; - } - - 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 = ` -
-

Actions

-
- `; - 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 += ` -
-
-
-

- ${action.icon ? `` : ''}${action.title || action.id} -

-

${action.description || ''}

-
- -
- -
- `; - }); - actionsHtml += ` -
-
- `; - } - - return ` -
- - -

How long to show this plugin's content

-
- ${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: " -> ", ' -> ', & -> & - unescapedValue = value - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>'); - } - - // Try to parse as JSON - const jsonValue = JSON.parse(unescapedValue); - if (Array.isArray(jsonValue)) { - flatConfig[key] = jsonValue; - console.log(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`); - } else { - // Fallback to empty array - flatConfig[key] = []; - } - } catch (e) { - console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value); - // Not valid JSON, use empty array or try comma-separated - if (value && value.trim()) { - // Try to unescape and parse again - try { - const unescaped = value - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); - const jsonValue = JSON.parse(unescaped); - if (Array.isArray(jsonValue)) { - flatConfig[key] = jsonValue; - } else { - flatConfig[key] = []; - } - } catch (e2) { - // If still fails, try comma-separated or empty array - const arrayValue = value.split(',').map(v => v.trim()).filter(v => v); - flatConfig[key] = arrayValue.length > 0 ? arrayValue : []; - } - } else { - flatConfig[key] = []; - } - } - } else { - // Regular array: convert comma-separated string to array - const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : []; - flatConfig[key] = arrayValue; - } - } else if (propType === 'integer' || (Array.isArray(propType) && propType.includes('integer'))) { - // Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null) - if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) { - flatConfig[key] = ''; // Send empty string, backend will normalize to null - } else { - const numValue = parseInt(value, 10); - flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue; - } - } else if (propType === 'number' || (Array.isArray(propType) && propType.includes('number'))) { - // Handle union types - if null is allowed and value is empty, keep as empty string (backend will convert to null) - if (Array.isArray(propType) && propType.includes('null') && (!value || value.trim() === '')) { - flatConfig[key] = ''; // Send empty string, backend will normalize to null - } else { - const numValue = parseFloat(value); - flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue; - } - } else if (propType === 'boolean') { - // Boolean from FormData (shouldn't happen for checkboxes, but handle it) - flatConfig[key] = value === 'on' || value === 'true' || value === true; - } else { - // String or other types - // Check if it's a number field by name pattern (fallback if no schema) - if (!propType && (key.includes('duration') || key.includes('interval') || - key.includes('timeout') || key.includes('teams') || key.includes('fps') || - key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) { - const numValue = parseFloat(value); - if (!isNaN(numValue)) { - flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue; - } else { - flatConfig[key] = value; - } - } else { - flatConfig[key] = value; - } - } - } - - // Handle unchecked checkboxes using schema (if available) - if (schema && schema.properties) { - const collectBooleanFields = (props, prefix = '') => { - const boolFields = []; - for (const [key, prop] of Object.entries(props)) { - const fullKey = prefix ? `${prefix}.${key}` : key; - if (prop.type === 'boolean') { - boolFields.push(fullKey); - } else if (prop.type === 'object' && prop.properties) { - boolFields.push(...collectBooleanFields(prop.properties, fullKey)); - } - } - return boolFields; - }; - - const allBoolFields = collectBooleanFields(schema.properties); - allBoolFields.forEach(key => { - // Only set to false if the field is completely missing from flatConfig - // Don't override existing false values - they're explicitly set by the user - if (!(key in flatConfig)) { - flatConfig[key] = false; - } - }); - } - - // Convert dot notation to nested object - const dotToNested = (obj) => { - const result = {}; - for (const key in obj) { - const parts = key.split('.'); - let current = result; - for (let i = 0; i < parts.length - 1; i++) { - if (!current[parts[i]]) { - current[parts[i]] = {}; - } - current = current[parts[i]]; - } - current[parts[parts.length - 1]] = obj[key]; - } - return result; - }; - - const config = dotToNested(flatConfig); - - // Save to backend - const response = await fetch('/api/v3/plugins/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - plugin_id: pluginId, - config: config - }) - }); - - let data; - try { - data = await response.json(); - } catch (e) { - console.error('Failed to parse JSON response:', e); - console.error('Response status:', response.status, response.statusText); - console.error('Response text:', await response.text()); - throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`); - } - - console.log('Response status:', response.status, 'Response OK:', response.ok); - console.log('Response data:', JSON.stringify(data, null, 2)); - - if (!response.ok || data.status !== 'success') { - let errorMessage = data.message || 'Failed to save configuration'; - if (data.validation_errors && Array.isArray(data.validation_errors)) { - console.error('Validation errors:', data.validation_errors); - errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n'); - } - if (data.config_keys && data.schema_keys) { - console.error('Config keys sent:', data.config_keys); - console.error('Schema keys expected:', data.schema_keys); - const extraKeys = data.config_keys.filter(k => !data.schema_keys.includes(k)); - const missingKeys = data.schema_keys.filter(k => !data.config_keys.includes(k)); - if (extraKeys.length > 0) { - errorMessage += '\n\nExtra keys (not in schema): ' + extraKeys.join(', '); - } - if (missingKeys.length > 0) { - errorMessage += '\n\nMissing keys (in schema): ' + missingKeys.join(', '); - } - } - this.showNotification(errorMessage, 'error'); - console.error('Config save failed - Full error response:', JSON.stringify(data, null, 2)); - } else { - this.showNotification('Configuration saved successfully', 'success'); - // Reload plugin config to reflect changes - await this.loadPluginConfig(pluginId); - } - } catch (error) { - console.error('Error saving plugin config:', error); - this.showNotification('Error saving configuration: ' + error.message, 'error'); - } - }, - - formatCommitInfo(commit, branch) { - // Handle null, undefined, or empty string - const commitStr = (commit && String(commit).trim()) || ''; - const branchStr = (branch && String(branch).trim()) || ''; - - if (!commitStr && !branchStr) return 'Unknown'; - - const shortCommit = commitStr.length >= 7 ? commitStr.substring(0, 7) : commitStr; - - if (branchStr && shortCommit) { - return `${branchStr} · ${shortCommit}`; - } - if (branchStr) { - return branchStr; - } - if (shortCommit) { - return shortCommit; - } - return 'Unknown'; - }, - - formatDateInfo(dateString) { - // Handle null, undefined, or empty string - if (!dateString || !String(dateString).trim()) return 'Unknown'; - - try { - const date = new Date(dateString); - // Check if date is valid - if (isNaN(date.getTime())) { - return 'Unknown'; - } - - const now = new Date(); - const diffTime = Math.abs(now - date); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - if (diffDays < 1) { - return 'Today'; - } else if (diffDays < 2) { - return 'Yesterday'; - } else if (diffDays < 7) { - return `${diffDays} days ago`; - } else if (diffDays < 30) { - const weeks = Math.floor(diffDays / 7); - return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`; - } else { - // Return formatted date for older items - return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); - } - } catch (e) { - console.error('Error formatting date:', e, dateString); - return 'Unknown'; - } - } - }; - - // Update window.app to return full implementation - window.app = function() { - return fullImplementation; - }; - - // If Alpine is already initialized, update the existing component immediately - if (window.Alpine) { - // Use requestAnimationFrame for immediate execution without blocking - requestAnimationFrame(() => { - if (window._appEnhanced) return; - window._appEnhanced = true; - const isAPMode = window.location.hostname === '192.168.4.1' || - window.location.hostname.startsWith('192.168.4.'); - const defaultTab = isAPMode ? 'wifi' : 'overview'; - 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 !== defaultTab) { - 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/ 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 '
Loading configuration...
'; - } - return '
Configuration form not available yet...
'; - }, - - 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 = `
- -

No display data available

-
`; - } - } - - 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: " -> ", ' -> ', & -> & - unescapedValue = value - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>'); - } - - try { - const jsonValue = JSON.parse(unescapedValue); - if (Array.isArray(jsonValue)) { - flatConfig[key] = jsonValue; - console.log(`File upload array field ${key}: parsed JSON array with ${jsonValue.length} items`); - } else { - // Fallback to empty array - flatConfig[key] = []; - } - } catch (e) { - console.warn(`Failed to parse JSON for file upload field ${key}:`, e, 'Value:', value); - // Fallback to empty array - flatConfig[key] = []; - } - } catch (e) { - // Not valid JSON, use empty array or try comma-separated - if (value && value.trim()) { - const arrayValue = value.split(',').map(v => v.trim()).filter(v => v); - flatConfig[key] = arrayValue; - } else { - flatConfig[key] = []; - } - } - } else { - // Regular array: convert comma-separated string to array - const arrayValue = value ? value.split(',').map(v => v.trim()).filter(v => v) : []; - flatConfig[key] = arrayValue; - } - } else if (propType === 'integer') { - const numValue = parseInt(value, 10); - flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue; - } else if (propType === 'number') { - const numValue = parseFloat(value); - flatConfig[key] = isNaN(numValue) ? (propSchema && propSchema.default !== undefined ? propSchema.default : 0) : numValue; - } else if (propType === 'boolean') { - // Boolean from FormData (shouldn't happen for checkboxes, but handle it) - flatConfig[key] = value === 'on' || value === 'true' || value === true; - } else { - // String or other types - // Check if it's a number field by name pattern (fallback if no schema) - if (!propType && (key.includes('duration') || key.includes('interval') || - key.includes('timeout') || key.includes('teams') || key.includes('fps') || - key.includes('bits') || key.includes('nanoseconds') || key.includes('hz'))) { - const numValue = parseFloat(value); - if (!isNaN(numValue)) { - flatConfig[key] = Number.isInteger(numValue) ? parseInt(value, 10) : numValue; - } else { - flatConfig[key] = value; - } - } else { - flatConfig[key] = value; - } - } - } - - // Handle unchecked checkboxes using schema (if available) - if (schema && schema.properties) { - const collectBooleanFields = (props, prefix = '') => { - const boolFields = []; - for (const [key, prop] of Object.entries(props)) { - const fullKey = prefix ? `${prefix}.${key}` : key; - if (prop.type === 'boolean') { - boolFields.push(fullKey); - } else if (prop.type === 'object' && prop.properties) { - boolFields.push(...collectBooleanFields(prop.properties, fullKey)); - } - } - return boolFields; - }; - - const allBoolFields = collectBooleanFields(schema.properties); - allBoolFields.forEach(key => { - if (!(key in flatConfig)) { - flatConfig[key] = false; - } - }); - } - - // Convert dot notation to nested object - const dotToNested = (obj) => { - const result = {}; - for (const key in obj) { - const parts = key.split('.'); - let current = result; - for (let i = 0; i < parts.length - 1; i++) { - if (!current[parts[i]]) { - current[parts[i]] = {}; - } - current = current[parts[i]]; - } - current[parts[parts.length - 1]] = obj[key]; - } - return result; - }; - - const config = dotToNested(flatConfig); - - console.log('Saving config for', pluginId, ':', config); - console.log('Flat config before nesting:', flatConfig); - - // Save to backend - const response = await fetch('/api/v3/plugins/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plugin_id: pluginId, config }) - }); - - let data; - try { - data = await response.json(); - } catch (e) { - throw new Error(`Failed to parse server response: ${response.status} ${response.statusText}`); - } - - if (!response.ok || data.status !== 'success') { - let errorMessage = data.message || 'Failed to save configuration'; - if (data.validation_errors && Array.isArray(data.validation_errors)) { - errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n'); - } - throw new Error(errorMessage); - } else { - showNotification(`Configuration saved for ${pluginId}`, 'success'); - } - - } catch (error) { - console.error('Error saving plugin configuration:', error); - showNotification('Error saving plugin configuration: ' + error.message, 'error'); - } - } - - // Notification helper function - // Fix invalid number inputs before form submission - // This prevents "invalid form control is not focusable" errors - window.fixInvalidNumberInputs = function(form) { - if (!form) return; - const allInputs = form.querySelectorAll('input[type="number"]'); - allInputs.forEach(input => { - const min = parseFloat(input.getAttribute('min')); - const max = parseFloat(input.getAttribute('max')); - const value = parseFloat(input.value); - - if (!isNaN(value)) { - if (!isNaN(min) && value < min) { - input.value = min; - } else if (!isNaN(max) && value > max) { - input.value = max; - } - } - }); - }; - - // 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; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + +

@@ -454,6 +470,48 @@

Plugin Order

+ +
+

Double-Sided Display

+

Show the same content on every panel in the chain — e.g. two 64×32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.

+ +
+
+ +
+ +
+ + +
+ +
+ + +
+
+
+ + +
@@ -583,183 +641,32 @@

}); } - // Initialize plugin order list - function initPluginOrderList() { + // Initialize plugin order list via the shared drag-and-drop module + // (static/v3/js/widgets/plugin-order-list.js) — the same component the + // Durations tab uses for the primary rotation order. + function initPluginOrderList(attempt) { const container = document.getElementById('vegas_plugin_order'); if (!container) return; - - // Fetch available plugins - fetch('/api/v3/plugins/installed') - .then(response => response.json()) - .then(data => { - // Handle both {data: {plugins: []}} and {plugins: []} response formats - const allPlugins = data.data?.plugins || data.plugins || []; - if (!allPlugins || allPlugins.length === 0) { - container.innerHTML = '

No plugins available

'; - return; - } - - // Get current order and exclusions - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - let currentOrder = []; - let excluded = []; - - try { - currentOrder = JSON.parse(orderInput.value || '[]'); - excluded = JSON.parse(excludedInput.value || '[]'); - } catch (e) { - console.error('Error parsing vegas config:', e); - } - - // Build ordered plugin list (only enabled plugins) - const plugins = allPlugins.filter(p => p.enabled); - const orderedPlugins = []; - - // First add plugins in current order - currentOrder.forEach(id => { - const plugin = plugins.find(p => p.id === id); - if (plugin) orderedPlugins.push(plugin); - }); - - // Then add remaining plugins - plugins.forEach(plugin => { - if (!orderedPlugins.find(p => p.id === plugin.id)) { - orderedPlugins.push(plugin); - } - }); - - // Build HTML with display mode indicators - let html = ''; - orderedPlugins.forEach((plugin, index) => { - const isExcluded = excluded.includes(plugin.id); - // Determine display mode (from plugin config or default) - const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed'; - const modeLabels = { - 'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }, - 'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }, - 'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' } - }; - const modeInfo = modeLabels[vegasMode] || modeLabels['fixed']; - // Escape plugin metadata to prevent XSS - const safePluginId = escapeAttr(plugin.id); - const safePluginName = escapeHtml(plugin.name || plugin.id); - html += ` -
- - - - ${modeInfo.label} - -
- `; - }); - - container.innerHTML = html || '

No enabled plugins

'; - - // Setup drag and drop - setupDragAndDrop(container); - - // Setup checkbox handlers - container.querySelectorAll('.vegas-plugin-include').forEach(checkbox => { - checkbox.addEventListener('change', updatePluginConfig); - }); - - // Initialize hidden inputs with current state - updatePluginConfig(); - }) - .catch(error => { - console.error('Error fetching plugins:', error); - container.innerHTML = '

Error loading plugins

'; - }); - } - - function setupDragAndDrop(container) { - let draggedItem = null; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - item.addEventListener('dragstart', function(e) { - draggedItem = this; - this.style.opacity = '0.5'; - e.dataTransfer.effectAllowed = 'move'; - }); - - item.addEventListener('dragend', function() { - this.style.opacity = '1'; - draggedItem = null; - updatePluginConfig(); - }); - - item.addEventListener('dragover', function(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - this.style.borderTop = '2px solid #3b82f6'; - this.style.borderBottom = ''; - } else { - this.style.borderBottom = '2px solid #3b82f6'; - this.style.borderTop = ''; - } - }); - - item.addEventListener('dragleave', function() { - this.style.borderTop = ''; - this.style.borderBottom = ''; - }); - - item.addEventListener('drop', function(e) { - e.preventDefault(); - this.style.borderTop = ''; - this.style.borderBottom = ''; - - if (draggedItem && draggedItem !== this) { - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - container.insertBefore(draggedItem, this); - } else { - container.insertBefore(draggedItem, this.nextSibling); - } - } - }); - }); - } - - function updatePluginConfig() { - const container = document.getElementById('vegas_plugin_order'); - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - - if (!container || !orderInput || !excludedInput) return; - - const order = []; - const excluded = []; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - const pluginId = item.dataset.pluginId; - const checkbox = item.querySelector('.vegas-plugin-include'); - - order.push(pluginId); - if (checkbox && !checkbox.checked) { - excluded.push(pluginId); + if (!window.PluginOrderList) { + // Widget script is deferred; retry briefly, then surface a real + // error instead of waiting forever. + if ((attempt || 0) < 50) { + setTimeout(function() { initPluginOrderList((attempt || 0) + 1); }, 100); + } else { + container.textContent = 'Could not load the reorder widget — reload the page to try again.'; + container.className = 'text-sm text-red-500'; } + return; + } + window.PluginOrderList.init({ + containerId: 'vegas_plugin_order', + orderInputId: 'vegas_plugin_order_value', + excludedInputId: 'vegas_excluded_plugins_value', + showVegasModeBadge: true }); - - orderInput.value = JSON.stringify(order); - excludedInput.value = JSON.stringify(excluded); } + // Initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPluginOrderList); diff --git a/web_interface/templates/v3/partials/durations.html b/web_interface/templates/v3/partials/durations.html index 0a1f05aa..e05f8aa1 100644 --- a/web_interface/templates/v3/partials/durations.html +++ b/web_interface/templates/v3/partials/durations.html @@ -1,8 +1,8 @@ {% import 'v3/partials/_macros.html' as ui %}
-

Display Durations

-

Configure how long each screen is shown before switching. Values in seconds.

+

Rotation & Durations

+

Set the order plugins rotate on the display and how long each screen is shown. Durations are in seconds.

{{ ui.settings_filter() }} @@ -16,22 +16,53 @@

Display Durations

novalidate onsubmit="fixInvalidNumberInputs(this); return true;"> -
- {% for key, value in main_config.display.display_durations.items() %} -
- - + +
+

Rotation Order

+

Drag plugins to set the order they rotate on the display. Each plugin's screens keep their own order within its turn. Takes effect after saving and restarting the display.

+
+

Loading plugins…

+
+ +
+ + {% if duration_groups %} +
+
+

Screen Durations

+

How long each screen stays on before rotating to the next one, in seconds (5–600, default 30).

+
+ {% for group in duration_groups %} +
+

{{ group.plugin_name }}

+
+ {% for mode in group.modes %} +
+ + +
+ {% endfor %} +
{% endfor %}
+ {% else %} +
+

No enabled plugins found — enable a plugin in the Plugin Manager to set its screen durations here.

+
+ {% endif %}
@@ -43,3 +74,34 @@

Display Durations

+ + diff --git a/web_interface/templates/v3/partials/overview.html b/web_interface/templates/v3/partials/overview.html index cc74120a..47713d8d 100644 --- a/web_interface/templates/v3/partials/overview.html +++ b/web_interface/templates/v3/partials/overview.html @@ -61,6 +61,149 @@ }()); + +{% set _hw = main_config.display.hardware if main_config and main_config.display else {} %} +{% set _hw_done = (_hw.rows or 0) > 0 and (_hw.cols or 0) > 0 and (_hw.chain_length or 0) > 0 %} +{% set _loc = main_config.location if main_config and main_config.location else {} %} +{% set _loc_done = (main_config.timezone and main_config.timezone != 'America/New_York') + or (_loc.city and _loc.city != 'Tampa') %} + + +

System Overview

@@ -221,7 +364,10 @@

Quick Actions

Live Display Preview

-
+ +
+

{# Use property order if defined, otherwise use natural order #} {# Skip 'enabled' field - it's handled by the header toggle #} {% set property_order = schema['x-propertyOrder'] if 'x-propertyOrder' in schema else schema.properties.keys()|list %} + {# Flat (non-object) properties flagged "x-advanced": true are + grouped into one collapsed "Advanced Settings" section after + the basic fields. Object-type properties already render as + their own collapsible sections, so the flag is ignored for + them. Schemas without the flag render exactly as before. #} + {% set tiers = namespace(basic=[], advanced=[]) %} {% for key in property_order %} {% if key in schema.properties and key != 'enabled' %} {% set prop = schema.properties[key] %} - {% set value = config[key] if key in config else none %} - {{ render_field(key, prop, value, '', plugin.id) }} + {% set is_object = prop.type is defined and 'object' in prop.type %} + {% if prop.get('x-advanced') and not is_object %} + {% set tiers.advanced = tiers.advanced + [key] %} + {% else %} + {% set tiers.basic = tiers.basic + [key] %} + {% endif %} {% endif %} {% endfor %} + {% for key in tiers.basic %} + {% set prop = schema.properties[key] %} + {% set value = config[key] if key in config else none %} + {{ render_field(key, prop, value, '', plugin.id) }} + {% endfor %} + {% if tiers.advanced %} + {% set adv_section_id = (plugin.id ~ '-section-advanced-settings')|replace('.', '-')|replace('_', '-') %} +
+ + +
+ {% endif %} {% else %} {# No schema - render simple form from config #} {% if config %} diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index cf6aa726..ba7f0c88 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -466,65 +466,6 @@

- - -
-