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');
- }
+ });
}