From 82e12ffaacb9c068f59c748b4ed9db165d92f03d Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Feb 2026 17:29:47 +0300 Subject: [PATCH] Fix critical frontend issues: race conditions, memory leaks, silent failures - Add loading guard to loadPictureSources to prevent concurrent fetches - Pause perf chart polling and uptime timer when browser tab is hidden - Disconnect KC and LED preview WebSockets when leaving targets tab - Add error toasts to loadCaptureTemplates and saveKCBrightness - Skip auto-refresh polling when document is hidden - Widen auto-start dashboard cards for better text display Co-Authored-By: Claude Opus 4.6 --- .../wled_controller/static/css/dashboard.css | 15 +++++++++ server/src/wled_controller/static/js/app.js | 3 ++ .../wled_controller/static/js/core/state.js | 5 ++- .../static/js/features/dashboard.js | 32 +++++++++++++++---- .../static/js/features/kc-targets.js | 1 + .../static/js/features/perf-charts.js | 11 +++++++ .../static/js/features/streams.js | 8 +++++ .../static/js/features/tabs.js | 24 ++++++++------ .../static/js/features/targets.js | 4 +++ 9 files changed, 86 insertions(+), 17 deletions(-) diff --git a/server/src/wled_controller/static/css/dashboard.css b/server/src/wled_controller/static/css/dashboard.css index 8cddbca..9cc69cb 100644 --- a/server/src/wled_controller/static/css/dashboard.css +++ b/server/src/wled_controller/static/css/dashboard.css @@ -218,6 +218,21 @@ min-width: 48px; } +.dashboard-autostart-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 4px; +} + +.dashboard-autostart { + grid-template-columns: 1fr auto; + margin-bottom: 0; +} + +.dashboard-autostart .dashboard-target-info > div { + min-width: 0; +} + @media (max-width: 768px) { .dashboard-target { grid-template-columns: 1fr auto; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 267a466..34d09ca 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -90,6 +90,7 @@ import { startTargetOverlay, stopTargetOverlay, deleteTarget, cloneTarget, toggleLedPreview, toggleTargetAutoStart, expandAllTargetSections, collapseAllTargetSections, + disconnectAllLedPreviewWS, } from './features/targets.js'; // Layer 5: color-strip sources @@ -303,6 +304,7 @@ Object.assign(window, { cloneTarget, toggleLedPreview, toggleTargetAutoStart, + disconnectAllLedPreviewWS, // color-strip sources showCSSEditor, @@ -413,6 +415,7 @@ window.addEventListener('beforeunload', () => { } stopEventsWS(); disconnectAllKCWebSockets(); + disconnectAllLedPreviewWS(); }); // ─── Initialization ─── diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index 94144cc..12ddadf 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -121,10 +121,13 @@ export function setActiveTutorial(v) { activeTutorial = v; } export let confirmResolve = null; export function setConfirmResolve(v) { confirmResolve = v; } -// Dashboard loading guard +// Loading guards export let _dashboardLoading = false; export function set_dashboardLoading(v) { _dashboardLoading = v; } +export let _sourcesLoading = false; +export function set_sourcesLoading(v) { _sourcesLoading = v; } + // Dashboard poll interval (ms), persisted in localStorage const _POLL_KEY = 'dashboard_poll_interval'; const _POLL_DEFAULT = 2000; diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 654b452..ee95075 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -370,23 +370,32 @@ export async function loadDashboard(forceFullRender = false) { const autoStartTargets = enriched.filter(t => t.auto_start); if (autoStartTargets.length > 0) { - const autoStartItems = autoStartTargets.map(target => { + const autoStartCards = autoStartTargets.map(target => { const isRunning = !!(target.state && target.state.processing); - const device = devicesMap[target.device_id]; - const deviceName = device ? device.name : ''; - const typeIcon = getTargetTypeIcon(target.target_type); + const isLed = target.target_type !== 'key_colors'; + const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc'); + const subtitleParts = [typeLabel]; + if (isLed) { + const device = target.device_id ? devicesMap[target.device_id] : null; + if (device) subtitleParts.push((device.device_type || '').toUpperCase()); + const cssId = target.color_strip_source_id || ''; + if (cssId) { + const css = cssSourceMap[cssId]; + if (css) subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type); + } + } const statusBadge = isRunning ? `${t('profiles.status.active')}` : `${t('profiles.status.inactive')}`; + const subtitle = subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''; return `
${ICON_AUTOSTART}
${escapeHtml(target.name)} ${statusBadge}
- ${deviceName ? `
${typeIcon} ${escapeHtml(deviceName)}
` : `
${typeIcon}
`} + ${subtitle}
-
`; }).join(''); + const autoStartItems = `
${autoStartCards}
`; dynamicHtml += `
${_sectionHeader('autostart', t('autostart.title'), autoStartTargets.length)} @@ -707,3 +717,13 @@ document.addEventListener('languageChanged', () => { if (perfEl) perfEl.remove(); loadDashboard(); }); + +// Pause uptime timer when browser tab is hidden, resume when visible +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + _stopUptimeTimer(); + } else if (_isDashboardActive() && _lastRunningIds.length > 0) { + _cacheUptimeElements(); + _startUptimeTimer(); + } +}); diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index ba786a5..ed37bcb 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -607,6 +607,7 @@ export async function saveKCBrightness(targetId, value) { }); } catch (err) { console.error('Failed to save KC brightness:', err); + showToast(t('kc.error.brightness') || 'Failed to save brightness', 'error'); } } diff --git a/server/src/wled_controller/static/js/features/perf-charts.js b/server/src/wled_controller/static/js/features/perf-charts.js index d352a94..51d8f30 100644 --- a/server/src/wled_controller/static/js/features/perf-charts.js +++ b/server/src/wled_controller/static/js/features/perf-charts.js @@ -180,3 +180,14 @@ export function stopPerfPolling() { _pollTimer = null; } } + +// Pause polling when browser tab becomes hidden, resume when visible +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + stopPerfPolling(); + } else { + // Only resume if dashboard is active + const activeTab = localStorage.getItem('activeTab') || 'dashboard'; + if (activeTab === 'dashboard') startPerfPolling(); + } +}); diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index a334361..0a951a5 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -20,6 +20,7 @@ import { _lastValidatedImageSource, set_lastValidatedImageSource, _cachedAudioSources, set_cachedAudioSources, _cachedValueSources, set_cachedValueSources, + _sourcesLoading, set_sourcesLoading, apiKey, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; @@ -129,7 +130,9 @@ async function loadCaptureTemplates() { set_cachedCaptureTemplates(data.templates || []); renderPictureSourcesList(_cachedStreams); } catch (error) { + if (error.isAuth) return; console.error('Error loading capture templates:', error); + showToast(t('streams.error.load'), 'error'); } } @@ -511,6 +514,8 @@ export async function deleteTemplate(templateId) { // ===== Picture Sources ===== export async function loadPictureSources() { + if (_sourcesLoading) return; + set_sourcesLoading(true); try { const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp] = await Promise.all([ _availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null), @@ -546,10 +551,13 @@ export async function loadPictureSources() { set_cachedStreams(data.streams || []); renderPictureSourcesList(_cachedStreams); } catch (error) { + if (error.isAuth) return; console.error('Error loading picture sources:', error); document.getElementById('streams-list').innerHTML = `
${t('streams.error.load')}: ${error.message}
`; + } finally { + set_sourcesLoading(false); } } diff --git a/server/src/wled_controller/static/js/features/tabs.js b/server/src/wled_controller/static/js/features/tabs.js index 73d39b1..6fa1af3 100644 --- a/server/src/wled_controller/static/js/features/tabs.js +++ b/server/src/wled_controller/static/js/features/tabs.js @@ -44,6 +44,11 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) { } else { if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling(); if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer(); + // Clean up WebSockets when leaving targets tab + if (name !== 'targets') { + if (typeof window.disconnectAllKCWebSockets === 'function') window.disconnectAllKCWebSockets(); + if (typeof window.disconnectAllLedPreviewWS === 'function') window.disconnectAllLedPreviewWS(); + } if (!apiKey || skipLoad) return; if (name === 'streams') { if (typeof window.loadPictureSources === 'function') window.loadPictureSources(); @@ -100,16 +105,15 @@ export function startAutoRefresh() { } setRefreshInterval(setInterval(() => { - if (apiKey) { - const activeTab = localStorage.getItem('activeTab') || 'dashboard'; - if (activeTab === 'targets') { - // Skip refresh while user interacts with a picker or slider - const panel = document.getElementById('targets-panel-content'); - if (panel && panel.contains(document.activeElement) && document.activeElement.matches('input')) return; - if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); - } else if (activeTab === 'dashboard') { - if (typeof window.loadDashboard === 'function') window.loadDashboard(); - } + if (!apiKey || document.hidden) return; + const activeTab = localStorage.getItem('activeTab') || 'dashboard'; + if (activeTab === 'targets') { + // Skip refresh while user interacts with a picker or slider + const panel = document.getElementById('targets-panel-content'); + if (panel && panel.contains(document.activeElement) && document.activeElement.matches('input')) return; + if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab(); + } else if (activeTab === 'dashboard') { + if (typeof window.loadDashboard === 'function') window.loadDashboard(); } }, dashboardPollInterval)); } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index dff9311..0e90cd6 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -1003,6 +1003,10 @@ function disconnectLedPreviewWS(targetId) { if (panel) panel.style.display = 'none'; } +export function disconnectAllLedPreviewWS() { + Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id)); +} + export function toggleLedPreview(targetId) { const panel = document.getElementById(`led-preview-panel-${targetId}`); if (!panel) return;