diff --git a/server/src/wled_controller/api/routes/devices.py b/server/src/wled_controller/api/routes/devices.py index 92ff800..a8028b6 100644 --- a/server/src/wled_controller/api/routes/devices.py +++ b/server/src/wled_controller/api/routes/devices.py @@ -202,6 +202,15 @@ async def discover_devices( ) +@router.get("/api/v1/devices/batch/states", tags=["Devices"]) +async def batch_device_states( + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Get health/connection state for all devices in a single request.""" + return {"states": manager.get_all_device_health_dicts()} + + @router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"]) async def get_device( device_id: str, diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index ab40903..5808461 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -185,6 +185,24 @@ async def list_targets( return PictureTargetListResponse(targets=responses, count=len(responses)) +@router.get("/api/v1/picture-targets/batch/states", tags=["Processing"]) +async def batch_target_states( + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Get processing state for all targets in a single request.""" + return {"states": manager.get_all_target_states()} + + +@router.get("/api/v1/picture-targets/batch/metrics", tags=["Metrics"]) +async def batch_target_metrics( + _auth: AuthRequired, + manager: ProcessorManager = Depends(get_processor_manager), +): + """Get metrics for all targets in a single request.""" + return {"metrics": manager.get_all_target_metrics()} + + @router.get("/api/v1/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"]) async def get_target( target_id: str, diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 7337c99..0f4c492 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -407,6 +407,18 @@ class ProcessorManager: """Get detailed metrics for a target (any type).""" return self._get_processor(target_id).get_metrics() + def get_all_target_states(self) -> Dict[str, dict]: + """Get processing state for all targets (with device health merged).""" + return {tid: self.get_target_state(tid) for tid in self._processors} + + def get_all_target_metrics(self) -> Dict[str, dict]: + """Get metrics for all targets.""" + return {tid: proc.get_metrics() for tid, proc in self._processors.items()} + + def get_all_device_health_dicts(self) -> Dict[str, dict]: + """Get health/connection state for all devices.""" + return {did: self.get_device_health_dict(did) for did in self._devices} + def is_target_processing(self, target_id: str) -> bool: """Check if target is currently processing.""" return self._get_processor(target_id).is_running diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 7ffcb1b..9f57fd6 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -18,6 +18,7 @@ let _fpsCharts = {}; // { targetId: Chart } let _lastRunningIds = []; // sorted target IDs from previous render let _uptimeBase = {}; // { targetId: { seconds, timestamp } } let _uptimeTimer = null; +let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs let _metricsElements = new Map(); function _loadFpsHistory() { @@ -50,11 +51,19 @@ function _getInterpolatedUptime(targetId) { return base.seconds + elapsed; } +function _cacheUptimeElements() { + _uptimeElements = {}; + for (const id of _lastRunningIds) { + const el = document.querySelector(`[data-uptime-text="${id}"]`); + if (el) _uptimeElements[id] = el; + } +} + function _startUptimeTimer() { if (_uptimeTimer) return; _uptimeTimer = setInterval(() => { for (const id of _lastRunningIds) { - const el = document.querySelector(`[data-uptime-text="${id}"]`); + const el = _uptimeElements[id]; if (!el) continue; const seconds = _getInterpolatedUptime(id); if (seconds != null) { @@ -70,6 +79,7 @@ function _stopUptimeTimer() { _uptimeTimer = null; } _uptimeBase = {}; + _uptimeElements = {}; } function _destroyFpsCharts() { @@ -320,18 +330,16 @@ export async function loadDashboard(forceFullRender = false) { if (targets.length === 0 && profiles.length === 0) { dynamicHtml = `
${t('dashboard.no_targets')}
`; } else { - const enriched = await Promise.all(targets.map(async (target) => { - try { - const [stateResp, metricsResp] = await Promise.all([ - fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }), - fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }), - ]); - const state = stateResp.ok ? await stateResp.json() : {}; - const metrics = metricsResp.ok ? await metricsResp.json() : {}; - return { ...target, state, metrics }; - } catch { - return target; - } + const [batchStatesResp, batchMetricsResp] = await Promise.all([ + fetchWithAuth('/picture-targets/batch/states'), + fetchWithAuth('/picture-targets/batch/metrics'), + ]); + const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {}; + const allMetrics = batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {}; + const enriched = targets.map(target => ({ + ...target, + state: allStates[target.id] || {}, + metrics: allMetrics[target.id] || {}, })); const running = enriched.filter(t => t.state && t.state.processing); @@ -412,6 +420,7 @@ export async function loadDashboard(forceFullRender = false) { } } _lastRunningIds = runningIds; + _cacheUptimeElements(); _initFpsCharts(runningIds); _startUptimeTimer(); startPerfPolling(); @@ -637,13 +646,15 @@ function _isDashboardActive() { return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard'; } -document.addEventListener('server:state_change', () => { - if (_isDashboardActive()) loadDashboard(); -}); +let _eventDebounceTimer = null; +function _debouncedDashboardReload(forceFullRender = false) { + if (!_isDashboardActive()) return; + clearTimeout(_eventDebounceTimer); + _eventDebounceTimer = setTimeout(() => loadDashboard(forceFullRender), 300); +} -document.addEventListener('server:profile_state_changed', () => { - if (_isDashboardActive()) loadDashboard(true); -}); +document.addEventListener('server:state_change', () => _debouncedDashboardReload()); +document.addEventListener('server:profile_state_changed', () => _debouncedDashboardReload(true)); // Re-render dashboard when language changes document.addEventListener('languageChanged', () => { 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 f6c27f3..fd6f7aa 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -219,7 +219,7 @@ export function toggleKCTestAutoRefresh() { } catch (e) { stopKCTestAutoRefresh(); } - }, 1000)); + }, 2000)); updateAutoRefreshButton(true); } } diff --git a/server/src/wled_controller/static/js/features/pattern-templates.js b/server/src/wled_controller/static/js/features/pattern-templates.js index 063a636..70d73a6 100644 --- a/server/src/wled_controller/static/js/features/pattern-templates.js +++ b/server/src/wled_controller/static/js/features/pattern-templates.js @@ -36,6 +36,13 @@ class PatternTemplateModal extends Modal { setPatternEditorRects([]); setPatternEditorSelectedIdx(-1); setPatternEditorBgImage(null); + // Clean up ResizeObserver to prevent leaks + const canvas = document.getElementById('pattern-canvas'); + if (canvas?._patternResizeObserver) { + canvas._patternResizeObserver.disconnect(); + canvas._patternResizeObserver = null; + } + if (canvas) canvas._patternEventsAttached = false; } } diff --git a/server/src/wled_controller/static/js/features/profiles.js b/server/src/wled_controller/static/js/features/profiles.js index 546d7bc..8a78f17 100644 --- a/server/src/wled_controller/static/js/features/profiles.js +++ b/server/src/wled_controller/static/js/features/profiles.js @@ -10,8 +10,10 @@ import { Modal } from '../core/modal.js'; const profileModal = new Modal('profile-editor-modal'); -// Re-render profiles when language changes -document.addEventListener('languageChanged', () => { if (apiKey) loadProfiles(); }); +// Re-render profiles when language changes (only if tab is active) +document.addEventListener('languageChanged', () => { + if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') loadProfiles(); +}); // React to real-time profile state changes from global events WS document.addEventListener('server:profile_state_changed', () => { @@ -33,16 +35,11 @@ export async function loadProfiles() { const data = await profilesResp.json(); const targetsData = targetsResp.ok ? await targetsResp.json() : { targets: [] }; const allTargets = targetsData.targets || []; - // State is not included in the list response — fetch per-target in parallel - const stateResults = await Promise.all( - allTargets.map(tgt => - fetchWithAuth(`/picture-targets/${tgt.id}/state`) - .then(r => r.ok ? r.json() : null) - .catch(() => null) - ) - ); + // Batch fetch all target states in a single request + const batchStatesResp = await fetchWithAuth('/picture-targets/batch/states'); + const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {}; const runningTargetIds = new Set( - allTargets.filter((_, i) => stateResults[i]?.processing).map(tgt => tgt.id) + allTargets.filter(tgt => allStates[tgt.id]?.processing).map(tgt => tgt.id) ); set_profilesCache(data.profiles); renderProfiles(data.profiles, runningTargetIds); @@ -390,16 +387,11 @@ export async function toggleProfileTargets(profileId) { const profileResp = await fetchWithAuth(`/profiles/${profileId}`); if (!profileResp.ok) throw new Error('Failed to load profile'); const profile = await profileResp.json(); - // Fetch actual processing state for each target in this profile - const stateResults = await Promise.all( - profile.target_ids.map(id => - fetchWithAuth(`/picture-targets/${id}/state`) - .then(r => r.ok ? r.json() : null) - .catch(() => null) - ) - ); + // Batch fetch all target states to determine which are running + const batchResp = await fetchWithAuth('/picture-targets/batch/states'); + const allStates = batchResp.ok ? (await batchResp.json()).states : {}; const runningSet = new Set( - profile.target_ids.filter((_, i) => stateResults[i]?.processing) + profile.target_ids.filter(id => allStates[id]?.processing) ); const shouldStop = profile.target_ids.some(id => runningSet.has(id)); await Promise.all(profile.target_ids.map(id => diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index bef66e9..196f40d 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -19,12 +19,15 @@ import { createColorStripCard } from './color-strips.js'; // createPatternTemplateCard is imported via window.* to avoid circular deps // (pattern-templates.js calls window.loadTargetsTab) -// Re-render targets tab when language changes -document.addEventListener('languageChanged', () => { if (apiKey) loadTargetsTab(); }); +// Re-render targets tab when language changes (only if tab is active) +document.addEventListener('languageChanged', () => { + if (apiKey && localStorage.getItem('activeTab') === 'targets') loadTargetsTab(); +}); -// --- FPS sparkline history for target cards --- +// --- FPS sparkline history and chart instances for target cards --- const _TARGET_MAX_FPS_SAMPLES = 30; const _targetFpsHistory = {}; +const _targetFpsCharts = {}; function _pushTargetFps(targetId, value) { if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = []; @@ -339,38 +342,31 @@ export async function loadTargetsTab() { patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; }); } - // Fetch state for each device - const devicesWithState = await Promise.all( - devices.map(async (device) => { - try { - const stateResp = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() }); - const state = stateResp.ok ? await stateResp.json() : {}; - return { ...device, state }; - } catch { - return device; - } - }) - ); + // Fetch all device states, target states, and target metrics in batch + const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([ + fetchWithAuth('/devices/batch/states'), + fetchWithAuth('/picture-targets/batch/states'), + fetchWithAuth('/picture-targets/batch/metrics'), + ]); + const allDeviceStates = batchDevStatesResp.ok ? (await batchDevStatesResp.json()).states : {}; + const allTargetStates = batchTgtStatesResp.ok ? (await batchTgtStatesResp.json()).states : {}; + const allTargetMetrics = batchTgtMetricsResp.ok ? (await batchTgtMetricsResp.json()).metrics : {}; - // Fetch state + metrics for each target (+ colors for KC targets) + const devicesWithState = devices.map(d => ({ ...d, state: allDeviceStates[d.id] || {} })); + + // Enrich targets with state/metrics; fetch colors only for running KC targets const targetsWithState = await Promise.all( targets.map(async (target) => { - try { - const stateResp = await fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }); - const state = stateResp.ok ? await stateResp.json() : {}; - const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }); - const metrics = metricsResp.ok ? await metricsResp.json() : {}; - let latestColors = null; - if (target.target_type === 'key_colors' && state.processing) { - try { - const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() }); - if (colorsResp.ok) latestColors = await colorsResp.json(); - } catch {} - } - return { ...target, state, metrics, latestColors }; - } catch { - return target; + const state = allTargetStates[target.id] || {}; + const metrics = allTargetMetrics[target.id] || {}; + let latestColors = null; + if (target.target_type === 'key_colors' && state.processing) { + try { + const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() }); + if (colorsResp.ok) latestColors = await colorsResp.json(); + } catch {} } + return { ...target, state, metrics, latestColors }; }) ); @@ -492,6 +488,12 @@ export async function loadTargetsTab() { if (!processingKCIds.has(id)) disconnectKCWebSocket(id); }); + // Destroy old chart instances before DOM rebuild replaces canvases + for (const id of Object.keys(_targetFpsCharts)) { + _targetFpsCharts[id].destroy(); + delete _targetFpsCharts[id]; + } + // FPS sparkline charts: push samples and init charts after HTML rebuild const allTargets = [...ledTargets, ...kcTargets]; const runningIds = new Set(); @@ -505,10 +507,11 @@ export async function loadTargetsTab() { const fpsTarget = target.state.fps_target || 30; const device = devices.find(d => d.id === target.device_id); const maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null; - _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps); + const chart = _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps); + if (chart) _targetFpsCharts[target.id] = chart; } }); - // Clean up history for targets no longer running + // Clean up history and charts for targets no longer running Object.keys(_targetFpsHistory).forEach(id => { if (!runningIds.has(id)) delete _targetFpsHistory[id]; });