Fix critical frontend issues: race conditions, memory leaks, silent failures

- Add loading guard to loadPictureSources to prevent concurrent fetches
- Pause perf chart polling and uptime timer when browser tab is hidden
- Disconnect KC and LED preview WebSockets when leaving targets tab
- Add error toasts to loadCaptureTemplates and saveKCBrightness
- Skip auto-refresh polling when document is hidden
- Widen auto-start dashboard cards for better text display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 17:29:47 +03:00
parent b51839ef3c
commit 82e12ffaac
9 changed files with 86 additions and 17 deletions

View File

@@ -90,6 +90,7 @@ import {
startTargetOverlay, stopTargetOverlay, deleteTarget,
cloneTarget, toggleLedPreview, toggleTargetAutoStart,
expandAllTargetSections, collapseAllTargetSections,
disconnectAllLedPreviewWS,
} from './features/targets.js';
// Layer 5: color-strip sources
@@ -303,6 +304,7 @@ Object.assign(window, {
cloneTarget,
toggleLedPreview,
toggleTargetAutoStart,
disconnectAllLedPreviewWS,
// color-strip sources
showCSSEditor,
@@ -413,6 +415,7 @@ window.addEventListener('beforeunload', () => {
}
stopEventsWS();
disconnectAllKCWebSockets();
disconnectAllLedPreviewWS();
});
// ─── Initialization ───

View File

@@ -121,10 +121,13 @@ export function setActiveTutorial(v) { activeTutorial = v; }
export let confirmResolve = null;
export function setConfirmResolve(v) { confirmResolve = v; }
// Dashboard loading guard
// Loading guards
export let _dashboardLoading = false;
export function set_dashboardLoading(v) { _dashboardLoading = v; }
export let _sourcesLoading = false;
export function set_sourcesLoading(v) { _sourcesLoading = v; }
// Dashboard poll interval (ms), persisted in localStorage
const _POLL_KEY = 'dashboard_poll_interval';
const _POLL_DEFAULT = 2000;

View File

@@ -370,23 +370,32 @@ export async function loadDashboard(forceFullRender = false) {
const autoStartTargets = enriched.filter(t => t.auto_start);
if (autoStartTargets.length > 0) {
const autoStartItems = autoStartTargets.map(target => {
const autoStartCards = autoStartTargets.map(target => {
const isRunning = !!(target.state && target.state.processing);
const device = devicesMap[target.device_id];
const deviceName = device ? device.name : '';
const typeIcon = getTargetTypeIcon(target.target_type);
const isLed = target.target_type !== 'key_colors';
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc');
const subtitleParts = [typeLabel];
if (isLed) {
const device = target.device_id ? devicesMap[target.device_id] : null;
if (device) subtitleParts.push((device.device_type || '').toUpperCase());
const cssId = target.color_strip_source_id || '';
if (cssId) {
const css = cssSourceMap[cssId];
if (css) subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type);
}
}
const statusBadge = isRunning
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
const subtitle = subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : '';
return `<div class="dashboard-target dashboard-autostart" data-target-id="${target.id}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_AUTOSTART}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)} ${statusBadge}</div>
${deviceName ? `<div class="dashboard-target-subtitle">${typeIcon} ${escapeHtml(deviceName)}</div>` : `<div class="dashboard-target-subtitle">${typeIcon}</div>`}
${subtitle}
</div>
</div>
<div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions">
<button class="btn btn-icon ${isRunning ? 'btn-warning' : 'btn-success'}" onclick="${isRunning ? `dashboardStopTarget('${target.id}')` : `dashboardStartTarget('${target.id}')`}" title="${isRunning ? t('device.stop') : t('device.start')}">
${isRunning ? ICON_STOP_PLAIN : '▶'}
@@ -394,6 +403,7 @@ export async function loadDashboard(forceFullRender = false) {
</div>
</div>`;
}).join('');
const autoStartItems = `<div class="dashboard-autostart-grid">${autoStartCards}</div>`;
dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('autostart', t('autostart.title'), autoStartTargets.length)}
@@ -707,3 +717,13 @@ document.addEventListener('languageChanged', () => {
if (perfEl) perfEl.remove();
loadDashboard();
});
// Pause uptime timer when browser tab is hidden, resume when visible
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
_stopUptimeTimer();
} else if (_isDashboardActive() && _lastRunningIds.length > 0) {
_cacheUptimeElements();
_startUptimeTimer();
}
});

View File

@@ -607,6 +607,7 @@ export async function saveKCBrightness(targetId, value) {
});
} catch (err) {
console.error('Failed to save KC brightness:', err);
showToast(t('kc.error.brightness') || 'Failed to save brightness', 'error');
}
}

View File

@@ -180,3 +180,14 @@ export function stopPerfPolling() {
_pollTimer = null;
}
}
// Pause polling when browser tab becomes hidden, resume when visible
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
stopPerfPolling();
} else {
// Only resume if dashboard is active
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
if (activeTab === 'dashboard') startPerfPolling();
}
});

View File

@@ -20,6 +20,7 @@ import {
_lastValidatedImageSource, set_lastValidatedImageSource,
_cachedAudioSources, set_cachedAudioSources,
_cachedValueSources, set_cachedValueSources,
_sourcesLoading, set_sourcesLoading,
apiKey,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
@@ -129,7 +130,9 @@ async function loadCaptureTemplates() {
set_cachedCaptureTemplates(data.templates || []);
renderPictureSourcesList(_cachedStreams);
} catch (error) {
if (error.isAuth) return;
console.error('Error loading capture templates:', error);
showToast(t('streams.error.load'), 'error');
}
}
@@ -511,6 +514,8 @@ export async function deleteTemplate(templateId) {
// ===== Picture Sources =====
export async function loadPictureSources() {
if (_sourcesLoading) return;
set_sourcesLoading(true);
try {
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp] = await Promise.all([
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
@@ -546,10 +551,13 @@ export async function loadPictureSources() {
set_cachedStreams(data.streams || []);
renderPictureSourcesList(_cachedStreams);
} catch (error) {
if (error.isAuth) return;
console.error('Error loading picture sources:', error);
document.getElementById('streams-list').innerHTML = `
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
`;
} finally {
set_sourcesLoading(false);
}
}

View File

@@ -44,6 +44,11 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
} else {
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
// Clean up WebSockets when leaving targets tab
if (name !== 'targets') {
if (typeof window.disconnectAllKCWebSockets === 'function') window.disconnectAllKCWebSockets();
if (typeof window.disconnectAllLedPreviewWS === 'function') window.disconnectAllLedPreviewWS();
}
if (!apiKey || skipLoad) return;
if (name === 'streams') {
if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
@@ -100,16 +105,15 @@ export function startAutoRefresh() {
}
setRefreshInterval(setInterval(() => {
if (apiKey) {
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
if (activeTab === 'targets') {
// Skip refresh while user interacts with a picker or slider
const panel = document.getElementById('targets-panel-content');
if (panel && panel.contains(document.activeElement) && document.activeElement.matches('input')) return;
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else if (activeTab === 'dashboard') {
if (typeof window.loadDashboard === 'function') window.loadDashboard();
}
if (!apiKey || document.hidden) return;
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
if (activeTab === 'targets') {
// Skip refresh while user interacts with a picker or slider
const panel = document.getElementById('targets-panel-content');
if (panel && panel.contains(document.activeElement) && document.activeElement.matches('input')) return;
if (typeof window.loadTargetsTab === 'function') window.loadTargetsTab();
} else if (activeTab === 'dashboard') {
if (typeof window.loadDashboard === 'function') window.loadDashboard();
}
}, dashboardPollInterval));
}

View File

@@ -1003,6 +1003,10 @@ function disconnectLedPreviewWS(targetId) {
if (panel) panel.style.display = 'none';
}
export function disconnectAllLedPreviewWS() {
Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id));
}
export function toggleLedPreview(targetId) {
const panel = document.getElementById(`led-preview-panel-${targetId}`);
if (!panel) return;