Add max HW FPS line on sparkline chart, fix button click race with polling

- Draw dashed orange line on target FPS sparkline showing hardware max FPS
- Prevent loadTargetsTab polling from rebuilding DOM while a button action
  (start/stop/overlay/delete) is in flight; add reentry guard on the
  refresh function itself

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 01:35:31 +03:00
parent 1d5f542603
commit d4a0f3a7f5

View File

@@ -33,23 +33,32 @@ function _pushTargetFps(targetId, value) {
if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift(); 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); const canvas = document.getElementById(canvasId);
if (!canvas) return null; 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, { return new Chart(canvas, {
type: 'line', type: 'line',
data: { data: { labels: history.map(() => ''), datasets },
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,
}],
},
options: { options: {
responsive: true, maintainAspectRatio: false, responsive: true, maintainAspectRatio: false,
animation: false, animation: false,
@@ -284,9 +293,15 @@ export function switchTargetSubTab(tabKey) {
localStorage.setItem('activeTargetSubTab', tabKey); localStorage.setItem('activeTargetSubTab', tabKey);
} }
let _loadTargetsLock = false;
let _actionInFlight = false;
export async function loadTargetsTab() { export async function loadTargetsTab() {
const container = document.getElementById('targets-panel-content'); const container = document.getElementById('targets-panel-content');
if (!container) return; if (!container) return;
// Skip if another loadTargetsTab or a button action is already running
if (_loadTargetsLock || _actionInFlight) return;
_loadTargetsLock = true;
try { try {
// Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel // 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 history = _targetFpsHistory[target.id] || [];
const fpsTarget = target.state.fps_target || 30; 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 // Clean up history for targets no longer running
@@ -500,6 +517,8 @@ export async function loadTargetsTab() {
if (error.isAuth) return; if (error.isAuth) return;
console.error('Failed to load targets tab:', error); console.error('Failed to load targets tab:', error);
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`; container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
} 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 { 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`, { const response = await fetchWithAuth(`/picture-targets/${targetId}/start`, {
method: 'POST', method: 'POST',
}); });
if (response.ok) { if (response.ok) {
showToast(t('device.started'), 'success'); showToast(t('device.started'), 'success');
loadTargetsTab();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to start: ${error.detail}`, 'error'); showToast(`Failed to start: ${error.detail}`, 'error');
} }
} catch (error) { });
if (error.isAuth) return;
showToast('Failed to start processing', 'error');
}
} }
export async function stopTargetProcessing(targetId) { export async function stopTargetProcessing(targetId) {
try { await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, { const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, {
method: 'POST', method: 'POST',
}); });
if (response.ok) { if (response.ok) {
showToast(t('device.stopped'), 'success'); showToast(t('device.stopped'), 'success');
loadTargetsTab();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to stop: ${error.detail}`, 'error'); showToast(`Failed to stop: ${error.detail}`, 'error');
} }
} catch (error) { });
if (error.isAuth) return;
showToast('Failed to stop processing', 'error');
}
} }
export async function startTargetOverlay(targetId) { export async function startTargetOverlay(targetId) {
try { await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/start`, { const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/start`, {
method: 'POST', method: 'POST',
}); });
if (response.ok) { if (response.ok) {
showToast(t('overlay.started'), 'success'); showToast(t('overlay.started'), 'success');
loadTargetsTab();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(t('overlay.error.start') + ': ' + error.detail, 'error'); 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) { export async function stopTargetOverlay(targetId) {
try { await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/stop`, { const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/stop`, {
method: 'POST', method: 'POST',
}); });
if (response.ok) { if (response.ok) {
showToast(t('overlay.stopped'), 'success'); showToast(t('overlay.stopped'), 'success');
loadTargetsTab();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(t('overlay.error.stop') + ': ' + error.detail, 'error'); 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) { export async function deleteTarget(targetId) {
const confirmed = await showConfirm(t('targets.delete.confirm')); const confirmed = await showConfirm(t('targets.delete.confirm'));
if (!confirmed) return; if (!confirmed) return;
try { await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}`, { const response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (response.ok) { if (response.ok) {
showToast(t('targets.deleted'), 'success'); showToast(t('targets.deleted'), 'success');
loadTargetsTab();
} else { } else {
const error = await response.json(); const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error'); showToast(`Failed to delete: ${error.detail}`, 'error');
} }
} catch (error) { });
if (error.isAuth) return;
showToast('Failed to delete target', 'error');
}
} }