/** * Dashboard — real-time target status overview. */ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { getTargetTypeIcon, ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, } from '../core/icons.js'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const MAX_FPS_SAMPLES = 120; let _fpsHistory = {}; // { targetId: number[] } — fps_actual let _fpsCurrentHistory = {}; // { targetId: number[] } — fps_current let _fpsCharts = {}; // { targetId: Chart } let _lastRunningIds = []; // sorted target IDs from previous render let _lastAutoStartIds = ''; // comma-joined sorted auto-start IDs let _uptimeBase = {}; // { targetId: { seconds, timestamp } } let _uptimeTimer = null; let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs let _metricsElements = new Map(); function _pushFps(targetId, actual, current) { if (!_fpsHistory[targetId]) _fpsHistory[targetId] = []; _fpsHistory[targetId].push(actual); if (_fpsHistory[targetId].length > MAX_FPS_SAMPLES) _fpsHistory[targetId].shift(); if (!_fpsCurrentHistory[targetId]) _fpsCurrentHistory[targetId] = []; _fpsCurrentHistory[targetId].push(current); if (_fpsCurrentHistory[targetId].length > MAX_FPS_SAMPLES) _fpsCurrentHistory[targetId].shift(); } function _setUptimeBase(targetId, seconds) { _uptimeBase[targetId] = { seconds, timestamp: Date.now() }; } function _getInterpolatedUptime(targetId) { const base = _uptimeBase[targetId]; if (!base) return null; const elapsed = (Date.now() - base.timestamp) / 1000; return base.seconds + elapsed; } function _cacheUptimeElements() { _uptimeElements = {}; for (const id of _lastRunningIds) { const el = document.querySelector(`[data-uptime-text="${id}"]`); if (el) _uptimeElements[id] = el; } } function _startUptimeTimer() { if (_uptimeTimer) return; _uptimeTimer = setInterval(() => { for (const id of _lastRunningIds) { const el = _uptimeElements[id]; if (!el) continue; const seconds = _getInterpolatedUptime(id); if (seconds != null) { el.innerHTML = `${ICON_CLOCK} ${formatUptime(seconds)}`; } } }, 1000); } function _stopUptimeTimer() { if (_uptimeTimer) { clearInterval(_uptimeTimer); _uptimeTimer = null; } _uptimeBase = {}; _uptimeElements = {}; } function _destroyFpsCharts() { for (const id of Object.keys(_fpsCharts)) { if (_fpsCharts[id]) { _fpsCharts[id].destroy(); } } _fpsCharts = {}; } function _getAccentColor() { return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4CAF50'; } function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) { const canvas = document.getElementById(canvasId); if (!canvas) return null; const accent = _getAccentColor(); return new Chart(canvas, { type: 'line', data: { labels: actualHistory.map(() => ''), datasets: [ { data: [...actualHistory], borderColor: accent, backgroundColor: accent + '1f', borderWidth: 1.5, tension: 0.3, fill: true, pointRadius: 0, }, { data: [...currentHistory], borderColor: accent + '80', borderWidth: 1.5, tension: 0.3, fill: false, pointRadius: 0, }, ], }, options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { display: false }, tooltip: { enabled: false } }, scales: { x: { display: false }, y: { min: 0, max: fpsTarget * 1.15, display: false }, }, layout: { padding: 0 }, }, }); } async function _initFpsCharts(runningTargetIds) { _destroyFpsCharts(); // Seed FPS history from server ring buffer on first load if (Object.keys(_fpsHistory).length === 0 && runningTargetIds.length > 0) { try { const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() }); if (resp.ok) { const data = await resp.json(); const serverTargets = data.targets || {}; for (const id of runningTargetIds) { const samples = serverTargets[id] || []; _fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null); _fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null); } } } catch { // Silently ignore — charts will fill from polling } } // Clean up history for targets that are no longer running for (const id of Object.keys(_fpsHistory)) { if (!runningTargetIds.includes(id)) { delete _fpsHistory[id]; delete _fpsCurrentHistory[id]; } } for (const id of runningTargetIds) { const canvas = document.getElementById(`dashboard-fps-${id}`); if (!canvas) continue; const actualH = _fpsHistory[id] || []; const currentH = _fpsCurrentHistory[id] || []; const fpsTarget = parseFloat(canvas.dataset.fpsTarget) || 30; _fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, actualH, currentH, fpsTarget); } _cacheMetricsElements(runningTargetIds); } function _cacheMetricsElements(runningIds) { _metricsElements.clear(); for (const id of runningIds) { _metricsElements.set(id, { fps: document.querySelector(`[data-fps-text="${id}"]`), errors: document.querySelector(`[data-errors-text="${id}"]`), row: document.querySelector(`[data-target-id="${id}"]`), }); } } /** Update running target metrics in-place (no HTML rebuild). */ function _updateRunningMetrics(enrichedRunning) { for (const target of enrichedRunning) { const state = target.state || {}; const metrics = target.metrics || {}; const fpsCurrent = state.fps_current ?? 0; const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-'; const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-'; const errors = metrics.errors_count || 0; // Push FPS and update chart if (state.fps_actual != null) { _pushFps(target.id, state.fps_actual, fpsCurrent); } const chart = _fpsCharts[target.id]; if (chart) { const actualH = _fpsHistory[target.id] || []; const currentH = _fpsCurrentHistory[target.id] || []; chart.data.datasets[0].data = [...actualH]; chart.data.datasets[1].data = [...currentH]; chart.data.labels = actualH.map(() => ''); chart.update(); } // Refresh uptime base for interpolation if (metrics.uptime_seconds != null) { _setUptimeBase(target.id, metrics.uptime_seconds); } // Update text values (use cached refs, fallback to querySelector) const cached = _metricsElements.get(target.id); const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`); if (fpsEl) { const effFps = state.fps_effective; const fpsTargetLabel = (effFps != null && effFps < fpsTarget) ? `${fpsCurrent}/${effFps}↓${fpsTarget}` : `${fpsCurrent}/${fpsTarget}`; const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : ''; fpsEl.innerHTML = `${fpsTargetLabel}` + `avg ${fpsActual}`; } const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`); if (errorsEl) errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}`; // Update health dot — prefer streaming reachability when processing const isLed = target.target_type === 'led' || target.target_type === 'wled'; if (isLed) { const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`); if (row) { const dot = row.querySelector('.health-dot'); if (dot) { const streamReachable = state.device_streaming_reachable; if (state.processing && streamReachable != null) { dot.className = `health-dot ${streamReachable ? 'health-online' : 'health-offline'}`; } else if (state.device_last_checked != null) { dot.className = `health-dot ${state.device_online ? 'health-online' : 'health-offline'}`; } } } } } } function _updateProfilesInPlace(profiles) { for (const p of profiles) { const card = document.querySelector(`[data-profile-id="${p.id}"]`); if (!card) continue; const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped'); if (badge) { if (!p.enabled) { badge.className = 'dashboard-badge-stopped'; badge.textContent = t('profiles.status.disabled'); } else if (p.is_active) { badge.className = 'dashboard-badge-active'; badge.textContent = t('profiles.status.active'); } else { badge.className = 'dashboard-badge-stopped'; badge.textContent = t('profiles.status.inactive'); } } const metricVal = card.querySelector('.dashboard-metric-value'); if (metricVal) { const cnt = p.target_ids.length; const active = (p.active_target_ids || []).length; metricVal.textContent = p.is_active ? `${active}/${cnt}` : `${cnt}`; } const btn = card.querySelector('.dashboard-target-actions .btn'); if (btn) { btn.className = `btn btn-icon ${p.enabled ? 'btn-warning' : 'btn-success'}`; btn.setAttribute('onclick', `dashboardToggleProfile('${p.id}', ${!p.enabled})`); btn.innerHTML = p.enabled ? ICON_STOP_PLAIN : ICON_START; } } } function _renderPollIntervalSelect() { const sec = Math.round(dashboardPollInterval / 1000); return `${sec}s`; } let _pollDebounce = null; export function changeDashboardPollInterval(value) { const label = document.querySelector('.dashboard-poll-value'); if (label) label.textContent = `${value}s`; clearTimeout(_pollDebounce); _pollDebounce = setTimeout(() => { const ms = parseInt(value, 10) * 1000; setDashboardPollInterval(ms); startAutoRefresh(); stopPerfPolling(); startPerfPolling(); }, 300); } function _getCollapsedSections() { try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; } catch { return {}; } } export function toggleDashboardSection(sectionKey) { const collapsed = _getCollapsedSections(); collapsed[sectionKey] = !collapsed[sectionKey]; localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed)); const header = document.querySelector(`[data-dashboard-section="${sectionKey}"]`); if (!header) return; const content = header.nextElementSibling; const chevron = header.querySelector('.dashboard-section-chevron'); const nowCollapsed = collapsed[sectionKey]; if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)'; // Animate collapse/expand unless reduced motion if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { content.style.display = nowCollapsed ? 'none' : ''; return; } if (content._dsAnim) { content._dsAnim.cancel(); content._dsAnim = null; } if (nowCollapsed) { const h = content.offsetHeight; content.style.overflow = 'hidden'; const anim = content.animate( [{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }], { duration: 200, easing: 'ease-in-out' } ); content._dsAnim = anim; anim.onfinish = () => { content.style.display = 'none'; content.style.overflow = ''; content._dsAnim = null; }; } else { content.style.display = ''; content.style.overflow = 'hidden'; const h = content.scrollHeight; const anim = content.animate( [{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }], { duration: 200, easing: 'ease-in-out' } ); content._dsAnim = anim; anim.onfinish = () => { content.style.overflow = ''; content._dsAnim = null; }; } } function _sectionHeader(sectionKey, label, count, extraHtml = '') { const collapsed = _getCollapsedSections(); const isCollapsed = !!collapsed[sectionKey]; const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"'; return `
${label} ${count} ${extraHtml}
`; } function _sectionContent(sectionKey, itemsHtml) { const collapsed = _getCollapsedSections(); const isCollapsed = !!collapsed[sectionKey]; return ``; } export async function loadDashboard(forceFullRender = false) { if (_dashboardLoading) return; set_dashboardLoading(true); const container = document.getElementById('dashboard-content'); if (!container) { set_dashboardLoading(false); return; } setTabRefreshing('dashboard-content', true); try { const [targetsResp, profilesResp, devicesResp, cssResp] = await Promise.all([ fetchWithAuth('/picture-targets'), fetchWithAuth('/profiles').catch(() => null), fetchWithAuth('/devices').catch(() => null), fetchWithAuth('/color-strip-sources').catch(() => null), ]); const targetsData = await targetsResp.json(); const targets = targetsData.targets || []; const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] }; const profiles = profilesData.profiles || []; const devicesData = devicesResp && devicesResp.ok ? await devicesResp.json() : { devices: [] }; const devicesMap = {}; for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; } const cssData = cssResp && cssResp.ok ? await cssResp.json() : { sources: [] }; const cssSourceMap = {}; for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; } // Build dynamic HTML (targets, profiles) let dynamicHtml = ''; let runningIds = []; let newAutoStartIds = ''; if (targets.length === 0 && profiles.length === 0) { dynamicHtml = `
${t('dashboard.no_targets')}
`; } else { const [batchStatesResp, batchMetricsResp] = await Promise.all([ fetchWithAuth('/picture-targets/batch/states'), fetchWithAuth('/picture-targets/batch/metrics'), ]); const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {}; const allMetrics = batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {}; const enriched = targets.map(target => ({ ...target, state: allStates[target.id] || {}, metrics: allMetrics[target.id] || {}, })); const running = enriched.filter(t => t.state && t.state.processing); const stopped = enriched.filter(t => !t.state || !t.state.processing); updateTabBadge('targets', running.length); // Check if we can do an in-place metrics update (same targets, not first load) const newRunningIds = running.map(t => t.id).sort().join(','); const prevRunningIds = [..._lastRunningIds].sort().join(','); newAutoStartIds = enriched.filter(t => t.auto_start).map(t => t.id).sort().join(','); const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent'); const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newAutoStartIds === _lastAutoStartIds; if (structureUnchanged && !forceFullRender && running.length > 0) { _updateRunningMetrics(running); _cacheUptimeElements(); _startUptimeTimer(); startPerfPolling(); set_dashboardLoading(false); return; } if (structureUnchanged && forceFullRender) { if (running.length > 0) _updateRunningMetrics(running); _updateProfilesInPlace(profiles); _cacheUptimeElements(); _startUptimeTimer(); startPerfPolling(); set_dashboardLoading(false); return; } const autoStartTargets = enriched.filter(t => t.auto_start); if (autoStartTargets.length > 0) { const autoStartCards = autoStartTargets.map(target => { const isRunning = !!(target.state && target.state.processing); 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 ? `${t('profiles.status.active')}` : `${t('profiles.status.inactive')}`; const subtitle = subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''; const asNavSub = isLed ? 'led' : 'key_colors'; const asNavSec = isLed ? 'led-targets' : 'kc-targets'; const asNavAttr = isLed ? 'data-target-id' : 'data-kc-target-id'; return ``; }).join(''); const autoStartItems = `
${autoStartCards}
`; dynamicHtml += `
${_sectionHeader('autostart', t('autostart.title'), autoStartTargets.length)} ${_sectionContent('autostart', autoStartItems)}
`; } if (profiles.length > 0) { const activeProfiles = profiles.filter(p => p.is_active); const inactiveProfiles = profiles.filter(p => !p.is_active); updateTabBadge('profiles', activeProfiles.length); const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join(''); dynamicHtml += `
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)} ${_sectionContent('profiles', profileItems)}
`; } if (targets.length > 0) { let targetsInner = ''; if (running.length > 0) { runningIds = running.map(t => t.id); const stopAllBtn = ``; const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap, cssSourceMap)).join(''); targetsInner += `
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)} ${_sectionContent('running', runningItems)}
`; } if (stopped.length > 0) { const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap, cssSourceMap)).join(''); targetsInner += `
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)} ${_sectionContent('stopped', stoppedItems)}
`; } dynamicHtml += `
${_sectionHeader('targets', t('dashboard.section.targets'), targets.length)} ${_sectionContent('targets', targetsInner)}
`; } } // First load: build everything in one innerHTML to avoid flicker const isFirstLoad = !container.querySelector('.dashboard-perf-persistent'); const pollSelect = _renderPollIntervalSelect(); const toolbar = `
${pollSelect}
`; if (isFirstLoad) { container.innerHTML = `${toolbar}
${_sectionHeader('perf', t('dashboard.section.performance'), '')} ${_sectionContent('perf', renderPerfSection())}
${dynamicHtml}
`; await initPerfCharts(); } else { const dynamic = container.querySelector('.dashboard-dynamic'); if (dynamic.innerHTML !== dynamicHtml) { dynamic.innerHTML = dynamicHtml; } } _lastRunningIds = runningIds; _lastAutoStartIds = newAutoStartIds; _cacheUptimeElements(); await _initFpsCharts(runningIds); _startUptimeTimer(); startPerfPolling(); } catch (error) { if (error.isAuth) return; console.error('Failed to load dashboard:', error); container.innerHTML = `
${t('dashboard.failed')}
`; } finally { set_dashboardLoading(false); setTabRefreshing('dashboard-content', false); } } function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap = {}) { const state = target.state || {}; const metrics = target.metrics || {}; const isLed = target.target_type === 'led' || target.target_type === 'wled'; const icon = ICON_TARGET; const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc'); const navSubTab = isLed ? 'led' : 'key_colors'; const navSection = isLed ? 'led-targets' : 'kc-targets'; const navAttr = isLed ? 'data-target-id' : 'data-kc-target-id'; const navOnclick = `if(!event.target.closest('button')){navigateToCard('targets','${navSubTab}','${navSection}','${navAttr}','${target.id}')}`; let 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); } } } if (isRunning) { const fpsCurrent = state.fps_current ?? 0; const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-'; const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-'; const uptime = formatUptime(metrics.uptime_seconds); const errors = metrics.errors_count || 0; // Set uptime base for interpolation if (metrics.uptime_seconds != null) { _setUptimeBase(target.id, metrics.uptime_seconds); } // Push FPS samples to history if (state.fps_actual != null) { _pushFps(target.id, state.fps_actual, fpsCurrent); } let healthDot = ''; if (isLed && state.device_last_checked != null) { const cls = state.device_online ? 'health-online' : 'health-offline'; healthDot = ``; } return ``; } else { return ``; } } function renderDashboardProfile(profile) { const isActive = profile.is_active; const isDisabled = !profile.enabled; let condSummary = ''; if (profile.conditions.length > 0) { const parts = profile.conditions.map(c => { if (c.condition_type === 'application') { const apps = (c.apps || []).join(', '); const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running'); return `${apps} (${matchLabel})`; } return c.condition_type; }); const logic = profile.condition_logic === 'and' ? ' & ' : ' | '; condSummary = parts.join(logic); } const statusBadge = isDisabled ? `${t('profiles.status.disabled')}` : isActive ? `${t('profiles.status.active')}` : `${t('profiles.status.inactive')}`; const targetCount = profile.target_ids.length; const activeCount = (profile.active_target_ids || []).length; const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`; return ``; } export async function dashboardToggleProfile(profileId, enable) { try { const endpoint = enable ? 'enable' : 'disable'; const response = await fetchWithAuth(`/profiles/${profileId}/${endpoint}`, { method: 'POST', }); if (response.ok) { loadDashboard(); } } catch (error) { if (error.isAuth) return; showToast('Failed to toggle profile', 'error'); } } export async function dashboardStartTarget(targetId) { try { const response = await fetchWithAuth(`/picture-targets/${targetId}/start`, { method: 'POST', }); if (response.ok) { showToast(t('device.started'), 'success'); loadDashboard(); } 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 dashboardStopTarget(targetId) { try { const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, { method: 'POST', }); if (response.ok) { showToast(t('device.stopped'), 'success'); loadDashboard(); } 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 dashboardToggleAutoStart(targetId, enable) { try { const response = await fetchWithAuth(`/picture-targets/${targetId}`, { method: 'PUT', body: JSON.stringify({ auto_start: enable }), }); if (response.ok) { showToast(t(enable ? 'autostart.toggle.enabled' : 'autostart.toggle.disabled'), 'success'); loadDashboard(); } else { const error = await response.json(); showToast(`Failed: ${error.detail}`, 'error'); } } catch (error) { if (error.isAuth) return; showToast('Failed to toggle auto-start', 'error'); } } export async function dashboardStopAll() { try { const targetsResp = await fetchWithAuth('/picture-targets'); const data = await targetsResp.json(); const running = (data.targets || []).filter(t => t.id); await Promise.all(running.map(t => fetchWithAuth(`/picture-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {}) )); loadDashboard(); } catch (error) { if (error.isAuth) return; showToast('Failed to stop all targets', 'error'); } } export function stopUptimeTimer() { _stopUptimeTimer(); } // React to global server events when dashboard tab is active function _isDashboardActive() { return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard'; } let _eventDebounceTimer = null; function _debouncedDashboardReload(forceFullRender = false) { if (!_isDashboardActive()) return; clearTimeout(_eventDebounceTimer); _eventDebounceTimer = setTimeout(() => loadDashboard(forceFullRender), 300); } document.addEventListener('server:state_change', () => _debouncedDashboardReload()); document.addEventListener('server:profile_state_changed', () => _debouncedDashboardReload(true)); // Re-render dashboard when language changes document.addEventListener('languageChanged', () => { if (!apiKey) return; // Force perf section rebuild with new locale const perfEl = document.querySelector('.dashboard-perf-persistent'); 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(); } });