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;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.dashboard-target {
|
.dashboard-target {
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||||
cloneTarget, toggleLedPreview, toggleTargetAutoStart,
|
cloneTarget, toggleLedPreview, toggleTargetAutoStart,
|
||||||
expandAllTargetSections, collapseAllTargetSections,
|
expandAllTargetSections, collapseAllTargetSections,
|
||||||
|
disconnectAllLedPreviewWS,
|
||||||
} from './features/targets.js';
|
} from './features/targets.js';
|
||||||
|
|
||||||
// Layer 5: color-strip sources
|
// Layer 5: color-strip sources
|
||||||
@@ -303,6 +304,7 @@ Object.assign(window, {
|
|||||||
cloneTarget,
|
cloneTarget,
|
||||||
toggleLedPreview,
|
toggleLedPreview,
|
||||||
toggleTargetAutoStart,
|
toggleTargetAutoStart,
|
||||||
|
disconnectAllLedPreviewWS,
|
||||||
|
|
||||||
// color-strip sources
|
// color-strip sources
|
||||||
showCSSEditor,
|
showCSSEditor,
|
||||||
@@ -413,6 +415,7 @@ window.addEventListener('beforeunload', () => {
|
|||||||
}
|
}
|
||||||
stopEventsWS();
|
stopEventsWS();
|
||||||
disconnectAllKCWebSockets();
|
disconnectAllKCWebSockets();
|
||||||
|
disconnectAllLedPreviewWS();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Initialization ───
|
// ─── Initialization ───
|
||||||
|
|||||||
@@ -121,10 +121,13 @@ export function setActiveTutorial(v) { activeTutorial = v; }
|
|||||||
export let confirmResolve = null;
|
export let confirmResolve = null;
|
||||||
export function setConfirmResolve(v) { confirmResolve = v; }
|
export function setConfirmResolve(v) { confirmResolve = v; }
|
||||||
|
|
||||||
// Dashboard loading guard
|
// Loading guards
|
||||||
export let _dashboardLoading = false;
|
export let _dashboardLoading = false;
|
||||||
export function set_dashboardLoading(v) { _dashboardLoading = v; }
|
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
|
// Dashboard poll interval (ms), persisted in localStorage
|
||||||
const _POLL_KEY = 'dashboard_poll_interval';
|
const _POLL_KEY = 'dashboard_poll_interval';
|
||||||
const _POLL_DEFAULT = 2000;
|
const _POLL_DEFAULT = 2000;
|
||||||
|
|||||||
@@ -370,23 +370,32 @@ export async function loadDashboard(forceFullRender = false) {
|
|||||||
|
|
||||||
const autoStartTargets = enriched.filter(t => t.auto_start);
|
const autoStartTargets = enriched.filter(t => t.auto_start);
|
||||||
if (autoStartTargets.length > 0) {
|
if (autoStartTargets.length > 0) {
|
||||||
const autoStartItems = autoStartTargets.map(target => {
|
const autoStartCards = autoStartTargets.map(target => {
|
||||||
const isRunning = !!(target.state && target.state.processing);
|
const isRunning = !!(target.state && target.state.processing);
|
||||||
const device = devicesMap[target.device_id];
|
const isLed = target.target_type !== 'key_colors';
|
||||||
const deviceName = device ? device.name : '';
|
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc');
|
||||||
const typeIcon = getTargetTypeIcon(target.target_type);
|
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
|
const statusBadge = isRunning
|
||||||
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
|
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
|
||||||
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</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}">
|
return `<div class="dashboard-target dashboard-autostart" data-target-id="${target.id}">
|
||||||
<div class="dashboard-target-info">
|
<div class="dashboard-target-info">
|
||||||
<span class="dashboard-target-icon">${ICON_AUTOSTART}</span>
|
<span class="dashboard-target-icon">${ICON_AUTOSTART}</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="dashboard-target-name">${escapeHtml(target.name)} ${statusBadge}</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>
|
</div>
|
||||||
<div class="dashboard-target-metrics"></div>
|
|
||||||
<div class="dashboard-target-actions">
|
<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')}">
|
<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 : '▶'}
|
${isRunning ? ICON_STOP_PLAIN : '▶'}
|
||||||
@@ -394,6 +403,7 @@ export async function loadDashboard(forceFullRender = false) {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
const autoStartItems = `<div class="dashboard-autostart-grid">${autoStartCards}</div>`;
|
||||||
|
|
||||||
dynamicHtml += `<div class="dashboard-section">
|
dynamicHtml += `<div class="dashboard-section">
|
||||||
${_sectionHeader('autostart', t('autostart.title'), autoStartTargets.length)}
|
${_sectionHeader('autostart', t('autostart.title'), autoStartTargets.length)}
|
||||||
@@ -707,3 +717,13 @@ document.addEventListener('languageChanged', () => {
|
|||||||
if (perfEl) perfEl.remove();
|
if (perfEl) perfEl.remove();
|
||||||
loadDashboard();
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to save KC brightness:', 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;
|
_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,
|
_lastValidatedImageSource, set_lastValidatedImageSource,
|
||||||
_cachedAudioSources, set_cachedAudioSources,
|
_cachedAudioSources, set_cachedAudioSources,
|
||||||
_cachedValueSources, set_cachedValueSources,
|
_cachedValueSources, set_cachedValueSources,
|
||||||
|
_sourcesLoading, set_sourcesLoading,
|
||||||
apiKey,
|
apiKey,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
||||||
@@ -129,7 +130,9 @@ async function loadCaptureTemplates() {
|
|||||||
set_cachedCaptureTemplates(data.templates || []);
|
set_cachedCaptureTemplates(data.templates || []);
|
||||||
renderPictureSourcesList(_cachedStreams);
|
renderPictureSourcesList(_cachedStreams);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.isAuth) return;
|
||||||
console.error('Error loading capture templates:', error);
|
console.error('Error loading capture templates:', error);
|
||||||
|
showToast(t('streams.error.load'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +514,8 @@ export async function deleteTemplate(templateId) {
|
|||||||
// ===== Picture Sources =====
|
// ===== Picture Sources =====
|
||||||
|
|
||||||
export async function loadPictureSources() {
|
export async function loadPictureSources() {
|
||||||
|
if (_sourcesLoading) return;
|
||||||
|
set_sourcesLoading(true);
|
||||||
try {
|
try {
|
||||||
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp] = await Promise.all([
|
const [filtersResp, ppResp, captResp, streamsResp, audioResp, valueResp] = await Promise.all([
|
||||||
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
|
_availableFilters.length === 0 ? fetchWithAuth('/filters') : Promise.resolve(null),
|
||||||
@@ -546,10 +551,13 @@ export async function loadPictureSources() {
|
|||||||
set_cachedStreams(data.streams || []);
|
set_cachedStreams(data.streams || []);
|
||||||
renderPictureSourcesList(_cachedStreams);
|
renderPictureSourcesList(_cachedStreams);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.isAuth) return;
|
||||||
console.error('Error loading picture sources:', error);
|
console.error('Error loading picture sources:', error);
|
||||||
document.getElementById('streams-list').innerHTML = `
|
document.getElementById('streams-list').innerHTML = `
|
||||||
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
|
<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 {
|
} else {
|
||||||
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
|
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
|
||||||
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
|
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 (!apiKey || skipLoad) return;
|
||||||
if (name === 'streams') {
|
if (name === 'streams') {
|
||||||
if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
|
if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
|
||||||
@@ -100,7 +105,7 @@ export function startAutoRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRefreshInterval(setInterval(() => {
|
setRefreshInterval(setInterval(() => {
|
||||||
if (apiKey) {
|
if (!apiKey || document.hidden) return;
|
||||||
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
|
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
|
||||||
if (activeTab === 'targets') {
|
if (activeTab === 'targets') {
|
||||||
// Skip refresh while user interacts with a picker or slider
|
// Skip refresh while user interacts with a picker or slider
|
||||||
@@ -110,7 +115,6 @@ export function startAutoRefresh() {
|
|||||||
} else if (activeTab === 'dashboard') {
|
} else if (activeTab === 'dashboard') {
|
||||||
if (typeof window.loadDashboard === 'function') window.loadDashboard();
|
if (typeof window.loadDashboard === 'function') window.loadDashboard();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, dashboardPollInterval));
|
}, dashboardPollInterval));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1003,6 +1003,10 @@ function disconnectLedPreviewWS(targetId) {
|
|||||||
if (panel) panel.style.display = 'none';
|
if (panel) panel.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function disconnectAllLedPreviewWS() {
|
||||||
|
Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id));
|
||||||
|
}
|
||||||
|
|
||||||
export function toggleLedPreview(targetId) {
|
export function toggleLedPreview(targetId) {
|
||||||
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user