/* global debugLog */
// SSE wiring + full Alpine app() implementation and tab logic
// Extracted from templates/v3/base.html so browsers cache it as a static asset.
// Assign to window so reconnectSSE() in app.js can reach them.
window.statsSource = new EventSource('/api/v3/stream/stats');
window.displaySource = new EventSource('/api/v3/stream/display');
window.statsSource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateSystemStats(data);
};
window.displaySource.onmessage = function(event) {
const data = JSON.parse(event.data);
updateDisplayPreview(data);
};
function _setConnectionStatus(connected, reconnecting) {
const el = document.getElementById('connection-status');
if (!el) return;
if (connected) {
el.innerHTML = `
Connected
`;
} else if (reconnecting) {
el.innerHTML = `
Reconnecting…
`;
} else {
el.innerHTML = `
Disconnected
`;
}
}
var _statsErrorCount = 0;
// Named on window so reconnectSSE() in app.js can reattach them after
// replacing the EventSource instances.
window._statsOpenHandler = function() {
_statsErrorCount = 0;
_setConnectionStatus(true, false);
};
window._statsErrorHandler = function() {
_statsErrorCount++;
// EventSource readyState 0 = CONNECTING (auto-retrying), 2 = CLOSED
var reconnecting = window.statsSource.readyState === EventSource.CONNECTING;
_setConnectionStatus(false, reconnecting && _statsErrorCount <= 3);
};
window._displayErrorHandler = function() {
// Display stream errors don't change the status badge but log to console
// so failures aren't completely silent.
console.warn('LEDMatrix: display preview stream error (readyState=' + window.displaySource.readyState + ')');
};
window.statsSource.addEventListener('open', window._statsOpenHandler);
window.statsSource.addEventListener('error', window._statsErrorHandler);
window.displaySource.addEventListener('error', window._displayErrorHandler);
// Reset any time the currently-active warning clears, so a future
// (new) occurrence shows the banner again even if this one was dismissed.
window._powerWarningDismissed = false;
window.dismissPowerWarningBanner = function() {
const banner = document.getElementById('power-warning-banner');
if (banner) banner.style.display = 'none';
window._powerWarningDismissed = true;
};
// Labels for whichever flags from _get_power_status() are set (pass
// suffix='_occurred' for the "happened earlier" variant), used to
// build accurate banner/tooltip text instead of hardcoding
// "under-voltage" for what may actually be throttling/freq-capping/
// thermal limiting.
function _activePowerConditionLabels(power, suffix) {
suffix = suffix || '_now';
const labels = [];
if (power['under_voltage' + suffix]) labels.push('under-voltage');
if (power['throttled' + suffix]) labels.push('throttling');
if (power['freq_capped' + suffix]) labels.push('CPU frequency capped');
if (power['soft_temp_limit' + suffix]) labels.push('soft thermal limit');
return labels;
}
function updatePowerStatus(power) {
const statEl = document.getElementById('power-stat');
const banner = document.getElementById('power-warning-banner');
const bannerText = document.getElementById('power-warning-banner-text');
if (!power) {
if (statEl) statEl.classList.add('hidden');
if (banner) {
banner.style.display = 'none';
// Let a future occurrence show the banner again rather
// than leaving stale text/visibility from before this
// (likely transient) missing-data tick.
window._powerWarningDismissed = false;
}
return;
}
const activeNow = power.under_voltage_now || power.throttled_now ||
power.freq_capped_now || power.soft_temp_limit_now;
const occurredEarlier = power.under_voltage_occurred || power.throttled_occurred ||
power.freq_capped_occurred || power.soft_temp_limit_occurred;
if (statEl) {
statEl.classList.remove('text-red-600', 'text-yellow-600');
if (activeNow) {
statEl.classList.remove('hidden');
statEl.classList.add('flex', 'text-red-600');
statEl.title = _activePowerConditionLabels(power).join('/') +
' detected right now — check your power supply and cooling';
} else if (occurredEarlier) {
statEl.classList.remove('hidden');
statEl.classList.add('flex', 'text-yellow-600');
const occurredLabels = _activePowerConditionLabels(power, '_occurred');
statEl.title = (occurredLabels.length ? occurredLabels.join('/') : 'An issue') +
' was detected earlier (currently OK)';
} else {
statEl.classList.add('hidden');
}
}
if (banner) {
if (activeNow) {
if (bannerText) {
const labels = _activePowerConditionLabels(power);
bannerText.textContent = (labels.length ? labels.join('/') : 'A power/thermal issue') +
' detected right now — the display may flicker or degrade. Check your power supply and cooling.';
}
if (!window._powerWarningDismissed) {
banner.style.display = '';
}
} else {
banner.style.display = 'none';
// Let a future occurrence show the banner again.
window._powerWarningDismissed = false;
}
}
}
function updateSystemStats(data) {
// Update CPU in header
const cpuEl = document.getElementById('cpu-stat');
if (cpuEl && data.cpu_percent !== undefined) {
const spans = cpuEl.querySelectorAll('span');
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_percent + '%';
}
// Update Memory in header
const memEl = document.getElementById('memory-stat');
if (memEl && data.memory_used_percent !== undefined) {
const spans = memEl.querySelectorAll('span');
if (spans.length > 0) spans[spans.length - 1].textContent = data.memory_used_percent + '%';
}
// Update Temperature in header
const tempEl = document.getElementById('temp-stat');
if (tempEl && data.cpu_temp !== undefined) {
const spans = tempEl.querySelectorAll('span');
if (spans.length > 0) spans[spans.length - 1].textContent = data.cpu_temp + '°C';
}
// Update 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'
});
}
}
});
}
});
// 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';
})(),
mobileNavOpen: false,
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;
debugLog('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();
}
// Screen readers announce the current tab (covers every
// path that changes tabs: clicks, search deep links,
// the getting-started checklist)
if (typeof window.updateNavAriaCurrent === 'function') {
window.updateNavAriaCurrent(newTab);
}
// Floating preview hides on Overview (full preview
// there), reappears per its saved state elsewhere
if (typeof window.updateFloatingPreviewVisibility === 'function') {
window.updateFloatingPreviewVisibility(newTab);
}
// Trigger content load when tab changes
this.$nextTick(() => {
this.loadTabContent(newTab);
});
});
// Load initial tab content
this.$nextTick(() => {
this.loadTabContent(this.activeTab);
if (typeof window.updateNavAriaCurrent === 'function') {
window.updateNavAriaCurrent(this.activeTab);
}
});
// Listen for plugin updates from pluginManager
document.addEventListener('pluginsUpdated', (event) => {
debugLog('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(',');
// Always take the new list — same-ID updates
// still carry changed metadata/enabled state.
_installedPluginsValue = newPlugins;
this.installedPlugins = newPlugins;
// Only rebuild the tab row when the ID set
// actually changed.
if (oldIds !== newIds) {
debugLog('window.installedPlugins changed:', newPlugins.length, 'plugins');
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) {
debugLog('[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;
debugLog('[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 {
debugLog('[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;
debugLog(`[FULL] Loaded ${this.installedPlugins.length} plugins:`, this.installedPlugins.map(p => p.id));
// Debug: Log enabled status for each plugin
this.installedPlugins.forEach(plugin => {
debugLog(`[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) {
debugLog('[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 || []);
debugLog('[FULL] _doUpdatePluginTabs called with:', pluginsToShow.length, 'plugins (attempt', retryCount + 1, ')');
debugLog('[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
debugLog('[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) {
debugLog('[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');
debugLog('[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;
}
debugLog(`[FULL] Updating plugin tabs for ${pluginsToShow.length} plugins`);
// Always show the plugin tabs row (Plugin Manager should always be available)
debugLog('[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');
debugLog(`[FULL] Removing ${existingTabs.length} existing plugin tabs`);
existingTabs.forEach(tab => tab.remove());
// Add tabs for each installed plugin
debugLog('[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);
debugLog('[FULL] Added tab for plugin:', plugin.id);
});
debugLog('[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 {
debugLog(`[${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) {
debugLog('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 || []) : [];
debugLog('[DEBUG] Loaded web_ui_actions for', pluginId, ':', this.webUiActions.length, 'actions');
debugLog('[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 = [];
}
debugLog('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) {
debugLog('[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;
debugLog('[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;
debugLog('[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')) {
debugLog('[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'] || {};
debugLog('[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'] || {};
debugLog('[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'] || {};
debugLog('[DEBUG] ✅ Detection via schema lookup');
}
}
// Debug logging for ALL array fields to diagnose
debugLog('[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) {
debugLog('[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)