/* 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. function _setConnectionStatus(connected, reconnecting, paused) { const el = document.getElementById('connection-status'); if (!el) return; if (paused) { // Intentionally closed while the page is hidden — not an error. el.innerHTML = `
Paused `; } else if (connected) { el.innerHTML = ` Connected `; } else if (reconnecting) { el.innerHTML = ` Reconnecting… `; } else { el.innerHTML = ` Disconnected `; } } var _statsErrorCount = 0; // Kept on window for backward compatibility; LEDStreams attaches them // to every EventSource it opens. 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 + ')'); }; // ===== Tab/page visibility — shared by partials ===== // LEDVisibility.onActive(tab, start, stop[, key]) runs start() when // `tab` is the active tab AND the page is visible, and stop() when // either stops being true. start() should refresh immediately. The // registration is keyed (default: tab name), so a partial re-injected // by HTMX replaces its previous registration — the old stop() runs // first — instead of stacking intervals. Returns an unregister fn. // Tab changes are also broadcast as `ledmatrix:tab-changed` on document. window.LEDVisibility = (function() { const regs = new Map(); let lastTab = null; function activeTab() { const el = document.querySelector('[x-data="app()"]'); const data = el && el._x_dataStack && el._x_dataStack[0]; return (data && data.activeTab) || lastTab; } function isActive(tab) { return !document.hidden && activeTab() === tab; } function evaluate(reg) { const want = isActive(reg.tab); if (want === reg.running) return; reg.running = want; try { (want ? reg.start : reg.stop)(); } catch (e) { console.error('[LEDVisibility] ' + reg.key + (want ? ' start' : ' stop') + ' failed:', e); } } function onActive(tab, start, stop, key) { key = key || tab; const prev = regs.get(key); if (prev && prev.running) { prev.running = false; try { prev.stop(); } catch (e) { console.error('[LEDVisibility] ' + key + ' stop failed:', e); } } const reg = { tab: tab, start: start, stop: stop, key: key, running: false }; regs.set(key, reg); evaluate(reg); return function() { if (regs.get(key) !== reg) return; regs.delete(key); if (reg.running) { reg.running = false; stop(); } }; } function refresh() { regs.forEach(evaluate); if (window.LEDStreams) window.LEDStreams.refresh(); } document.addEventListener('visibilitychange', refresh); document.addEventListener('ledmatrix:tab-changed', function(e) { lastTab = (e.detail && e.detail.tab) || lastTab; refresh(); }); return { activeTab: activeTab, isActive: isActive, onActive: onActive, refresh: refresh }; })(); // ===== Live SSE streams — the single owner ===== // /stream/stats feeds the header stats and connection badge on every // tab, so it stays open while the page is visible. /stream/display // pushes base64 PNG frames and is only opened while a preview is on // screen: the Overview live preview, or the floating preview when open. // Both close while the page is hidden. The server's broadcaster ends its // generator thread once the last subscriber leaves, so closing here // saves Pi CPU, not only bandwidth. // // window.statsSource / window.displaySource remain public. An // EventSource can't be reopened, so each reopen creates a new one; // listeners other code attached with addEventListener (e.g. the Tools // power panel) are recorded and re-attached to the replacement. window.LEDStreams = (function() { const CONFIG = { stats: { url: '/api/v3/stream/stats', prop: 'statsSource', onmessage: function(data) { updateSystemStats(data); }, wire: function(src) { src.onopen = window._statsOpenHandler; src.onerror = window._statsErrorHandler; } }, display: { url: '/api/v3/stream/display', prop: 'displaySource', onmessage: function(data) { updateDisplayPreview(data); }, wire: function(src) { src.onerror = window._displayErrorHandler; } } }; const open = { stats: null, display: null }; const external = { stats: [], display: [] }; function openStream(name) { if (open[name]) return; const cfg = CONFIG[name]; const src = new EventSource(cfg.url); const add = src.addEventListener.bind(src); const remove = src.removeEventListener.bind(src); src.addEventListener = function(type, listener, options) { external[name].push([type, listener, options]); add(type, listener, options); }; src.removeEventListener = function(type, listener, options) { external[name] = external[name].filter(function(l) { return !(l[0] === type && l[1] === listener); }); remove(type, listener, options); }; external[name].forEach(function(l) { add(l[0], l[1], l[2]); }); src.onmessage = function(event) { let data; try { data = JSON.parse(event.data); } catch { return; } cfg.onmessage(data); }; cfg.wire(src); open[name] = src; window[cfg.prop] = src; } // The closed instance stays on window so late `addEventListener` // calls are still recorded and replayed on the next open. function closeStream(name) { if (!open[name]) return; open[name].close(); open[name] = null; } function previewOnScreen() { if (window.LEDVisibility.activeTab() === 'overview') return true; const panel = document.getElementById('floating-preview'); return !!(panel && panel.style.display === 'block'); } function refresh() { if (document.hidden) { // Also covers a page first loaded in a background tab. closeStream('stats'); closeStream('display'); _setConnectionStatus(false, false, true); return; } if (!open.stats) { _statsErrorCount = 0; openStream('stats'); } if (previewOnScreen()) openStream('display'); else closeStream('display'); } function reconnect() { closeStream('stats'); closeStream('display'); refresh(); } return { refresh: refresh, reconnect: reconnect, isOpen: function(name) { return !!open[name]; } }; })(); // Public helper (previously duplicated in app.js). window.reconnectSSE = function() { window.LEDStreams.reconnect(); }; // Header stats start immediately; the display stream opens once the // active tab is known (Alpine init dispatches ledmatrix:tab-changed). window.LEDStreams.refresh(); document.addEventListener('DOMContentLoaded', function() { window.LEDStreams.refresh(); }); // 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); } // Pause/resume per-tab timers and SSE (LEDVisibility) document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', { detail: { tab: newTab, previous: oldTab } })); // 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); } document.dispatchEvent(new CustomEvent('ledmatrix:tab-changed', { detail: { tab: this.activeTab, previous: null } })); }); // 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.
No display data available