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];
});