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:
@@ -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 = `<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 {
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user