diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 4d69216..bef66e9 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -33,23 +33,32 @@ function _pushTargetFps(targetId, value) { if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift(); } -function _createTargetFpsChart(canvasId, history, fpsTarget) { +function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) { const canvas = document.getElementById(canvasId); if (!canvas) return null; + const datasets = [{ + data: [...history], + borderColor: '#2196F3', + backgroundColor: 'rgba(33,150,243,0.12)', + borderWidth: 1.5, + tension: 0.3, + fill: true, + pointRadius: 0, + }]; + // Flat line showing hardware max FPS + if (maxHwFps && maxHwFps < fpsTarget * 1.15) { + datasets.push({ + data: history.map(() => maxHwFps), + borderColor: 'rgba(255,152,0,0.5)', + borderWidth: 1, + borderDash: [4, 3], + pointRadius: 0, + fill: false, + }); + } return new Chart(canvas, { type: 'line', - data: { - labels: history.map(() => ''), - datasets: [{ - data: [...history], - borderColor: '#2196F3', - backgroundColor: 'rgba(33,150,243,0.12)', - borderWidth: 1.5, - tension: 0.3, - fill: true, - pointRadius: 0, - }], - }, + data: { labels: history.map(() => ''), datasets }, options: { responsive: true, maintainAspectRatio: false, animation: false, @@ -284,9 +293,15 @@ export function switchTargetSubTab(tabKey) { localStorage.setItem('activeTargetSubTab', tabKey); } +let _loadTargetsLock = false; +let _actionInFlight = false; + export async function loadTargetsTab() { const container = document.getElementById('targets-panel-content'); if (!container) return; + // Skip if another loadTargetsTab or a button action is already running + if (_loadTargetsLock || _actionInFlight) return; + _loadTargetsLock = true; try { // Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel @@ -488,7 +503,9 @@ export async function loadTargetsTab() { } const history = _targetFpsHistory[target.id] || []; const fpsTarget = target.state.fps_target || 30; - _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget); + 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); } }); // Clean up history for targets no longer running @@ -500,6 +517,8 @@ export async function loadTargetsTab() { if (error.isAuth) return; console.error('Failed to load targets tab:', error); container.innerHTML = `
${t('targets.failed')}
`; + } finally { + _loadTargetsLock = false; } } @@ -611,95 +630,86 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) { `; } -export async function startTargetProcessing(targetId) { +async function _targetAction(action) { + _actionInFlight = true; try { + await action(); + } finally { + _actionInFlight = false; + _loadTargetsLock = false; // ensure next poll can run + loadTargetsTab(); + } +} + +export async function startTargetProcessing(targetId) { + await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}/start`, { method: 'POST', }); if (response.ok) { showToast(t('device.started'), 'success'); - loadTargetsTab(); } else { const error = await response.json(); showToast(`Failed to start: ${error.detail}`, 'error'); } - } catch (error) { - if (error.isAuth) return; - showToast('Failed to start processing', 'error'); - } + }); } export async function stopTargetProcessing(targetId) { - try { + await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, { method: 'POST', }); if (response.ok) { showToast(t('device.stopped'), 'success'); - loadTargetsTab(); } else { const error = await response.json(); showToast(`Failed to stop: ${error.detail}`, 'error'); } - } catch (error) { - if (error.isAuth) return; - showToast('Failed to stop processing', 'error'); - } + }); } export async function startTargetOverlay(targetId) { - try { + await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/start`, { method: 'POST', }); if (response.ok) { showToast(t('overlay.started'), 'success'); - loadTargetsTab(); } else { const error = await response.json(); showToast(t('overlay.error.start') + ': ' + error.detail, 'error'); } - } catch (error) { - if (error.isAuth) return; - showToast(t('overlay.error.start'), 'error'); - } + }); } export async function stopTargetOverlay(targetId) { - try { + await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/stop`, { method: 'POST', }); if (response.ok) { showToast(t('overlay.stopped'), 'success'); - loadTargetsTab(); } else { const error = await response.json(); showToast(t('overlay.error.stop') + ': ' + error.detail, 'error'); } - } catch (error) { - if (error.isAuth) return; - showToast(t('overlay.error.stop'), 'error'); - } + }); } export async function deleteTarget(targetId) { const confirmed = await showConfirm(t('targets.delete.confirm')); if (!confirmed) return; - try { + await _targetAction(async () => { const response = await fetchWithAuth(`/picture-targets/${targetId}`, { method: 'DELETE', }); if (response.ok) { showToast(t('targets.deleted'), 'success'); - loadTargetsTab(); } else { const error = await response.json(); showToast(`Failed to delete: ${error.detail}`, 'error'); } - } catch (error) { - if (error.isAuth) return; - showToast('Failed to delete target', 'error'); - } + }); }