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:
@@ -218,6 +218,21 @@
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
.dashboard-autostart-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-autostart {
|
||||
grid-template-columns: 1fr auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dashboard-autostart .dashboard-target-info > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-target {
|
||||
grid-template-columns: 1fr auto;
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user