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
-
+
-
+
--%
@@ -970,6 +403,36 @@
+
+
+
+
+
+
+
+ Configuration saved — restart the display to apply the changes
+
+
+
+
+
+
+
+
+
+
@@ -993,6 +456,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Live Preview
+
+
+
+
+
+
+
+
+
@@ -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 += `
-
-
-
- `;
-
- // Recursively generate fields for nested properties
- // Get ordered properties if x-propertyOrder is defined
- let nestedPropertyEntries = Object.entries(prop.properties);
- if (prop['x-propertyOrder'] && Array.isArray(prop['x-propertyOrder'])) {
- const order = prop['x-propertyOrder'];
- const orderedEntries = [];
- const unorderedEntries = [];
-
- // Separate ordered and unordered properties
- nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
- const index = order.indexOf(nestedKey);
- if (index !== -1) {
- orderedEntries[index] = [nestedKey, nestedProp];
- } else {
- unorderedEntries.push([nestedKey, nestedProp]);
- }
- });
-
- // Combine ordered entries (filter out undefined from sparse array) with unordered entries
- nestedPropertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
- }
-
- nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
- // Use config value if it exists and is not null (including false), otherwise use schema default
- // Check if key exists in config and value is not null/undefined
- const hasValue = nestedKey in nestedConfig && nestedConfig[nestedKey] !== null && nestedConfig[nestedKey] !== undefined;
- // For nested objects, if the value is an empty object, still use it (don't fall back to default)
- const isNestedObject = nestedProp.type === 'object' && nestedProp.properties;
- const nestedValue = hasValue ? nestedConfig[nestedKey] :
- (nestedProp.default !== undefined ? nestedProp.default :
- (isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : ''))));
-
- // Debug logging for file-upload widgets
- if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) {
- console.log('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
- }
-
- html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
- });
-
- html += `
-
-
- `;
-
- // 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)
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 = '
- {% 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') %}
+
+
+
+
Getting Started
+
A few steps to get your display up and running. Click a step to jump there, or click its checkbox to mark it done yourself. The card hides once everything is checked.