/** * Dashboard — real-time target status overview. */ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval, colorStripSourcesCache, devicesCache, outputTargetsCache } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { showToast, showConfirm, formatUptime, formatCompact, setTabRefreshing } from '../core/ui.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE, } from '../core/icons.js'; import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js'; import { cardColorStyle } from '../core/card-colors.js'; import { createFpsSparkline } from '../core/chart-utils.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 _lastSyncClockIds = ''; // comma-joined sorted sync clock 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 _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) { return createFpsSparkline(canvasId, actualHistory, currentHistory, fpsTarget); } 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] || []; // Mutate in-place to avoid array copies const ds0 = chart.data.datasets[0].data; ds0.length = 0; ds0.push(...actualH); const ds1 = chart.data.datasets[1].data; ds1.length = 0; ds1.push(...currentH); while (chart.data.labels.length < ds0.length) chart.data.labels.push(''); chart.data.labels.length = ds0.length; chart.update('none'); } // 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} ${formatCompact(errors)}`; errorsEl.title = String(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 _updateAutomationsInPlace(automations) { for (const a of automations) { const card = document.querySelector(`[data-automation-id="${a.id}"]`); if (!card) continue; const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped'); if (badge) { if (!a.enabled) { badge.className = 'dashboard-badge-stopped'; badge.textContent = t('automations.status.disabled'); } else if (a.is_active) { badge.className = 'dashboard-badge-active'; badge.textContent = t('automations.status.active'); } else { badge.className = 'dashboard-badge-stopped'; badge.textContent = t('automations.status.inactive'); } } const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn'); if (btn) { btn.className = `dashboard-action-btn ${a.enabled ? 'stop' : 'start'}`; btn.setAttribute('onclick', `dashboardToggleAutomation('${a.id}', ${!a.enabled})`); btn.innerHTML = a.enabled ? ICON_STOP_PLAIN : ICON_START; } } } function _updateSyncClocksInPlace(syncClocks) { for (const c of syncClocks) { const card = document.querySelector(`[data-sync-clock-id="${c.id}"]`); if (!card) continue; const speedEl = card.querySelector('.dashboard-clock-speed'); if (speedEl) speedEl.textContent = `${c.speed}x`; const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn'); if (btn) { btn.className = `dashboard-action-btn ${c.is_running ? 'stop' : 'start'}`; btn.setAttribute('onclick', c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`); btn.innerHTML = c.is_running ? ICON_PAUSE : ICON_START; } } } function renderDashboardSyncClock(clock) { const toggleAction = clock.is_running ? `dashboardPauseClock('${clock.id}')` : `dashboardResumeClock('${clock.id}')`; const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume'); const subtitle = [ `${clock.speed}x`, clock.description ? escapeHtml(clock.description) : '', ].filter(Boolean).join(' · '); const scStyle = cardColorStyle(clock.id); return ``; } 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; } if (!container.children.length) setTabRefreshing('dashboard-content', true); try { // Fire all requests in a single batch to avoid sequential RTTs const [targets, automationsResp, devicesArr, cssArr, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([ outputTargetsCache.fetch().catch(() => []), fetchWithAuth('/automations').catch(() => null), devicesCache.fetch().catch(() => []), colorStripSourcesCache.fetch().catch(() => []), fetchWithAuth('/output-targets/batch/states').catch(() => null), fetchWithAuth('/output-targets/batch/metrics').catch(() => null), loadScenePresets(), fetchWithAuth('/sync-clocks').catch(() => null), ]); const automationsData = automationsResp && automationsResp.ok ? await automationsResp.json() : { automations: [] }; const automations = automationsData.automations || []; const devicesMap = {}; for (const d of devicesArr) { devicesMap[d.id] = d; } const cssSourceMap = {}; for (const s of (cssArr || [])) { cssSourceMap[s.id] = s; } const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] }; const syncClocks = syncClocksData.clocks || []; const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {}; const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {}; // Build dynamic HTML (targets, automations) let dynamicHtml = ''; let runningIds = []; if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) { dynamicHtml = `
${t('dashboard.no_targets')}
`; } else { 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(','); const newSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(','); const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent'); const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newSyncClockIds === _lastSyncClockIds; if (structureUnchanged && !forceFullRender && running.length > 0) { _updateRunningMetrics(running); _updateSyncClocksInPlace(syncClocks); _cacheUptimeElements(); _startUptimeTimer(); startPerfPolling(); set_dashboardLoading(false); return; } if (structureUnchanged && forceFullRender) { if (running.length > 0) _updateRunningMetrics(running); _updateAutomationsInPlace(automations); _updateSyncClocksInPlace(syncClocks); _cacheUptimeElements(); _startUptimeTimer(); startPerfPolling(); set_dashboardLoading(false); return; } if (automations.length > 0) { const activeAutomations = automations.filter(a => a.is_active); const inactiveAutomations = automations.filter(a => !a.is_active); updateTabBadge('automations', activeAutomations.length); const sceneMap = new Map(scenePresets.map(s => [s.id, s])); const automationItems = [...activeAutomations, ...inactiveAutomations].map(a => renderDashboardAutomation(a, sceneMap)).join(''); dynamicHtml += `
${_sectionHeader('automations', t('dashboard.section.automations'), automations.length)} ${_sectionContent('automations', automationItems)}
`; } // Scene Presets section if (scenePresets.length > 0) { const sceneSec = renderScenePresetsSection(scenePresets); if (sceneSec) { dynamicHtml += `
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)} ${_sectionContent('scenes', sceneSec.content)}
`; } } // Sync Clocks section if (syncClocks.length > 0) { const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join(''); const clockGrid = `
${clockCards}
`; dynamicHtml += `
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)} ${_sectionContent('sync-clocks', clockGrid)}
`; } 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; _lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(','); _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-targets' : 'kc-targets'; 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 = ``; } const cStyle = cardColorStyle(target.id); return ``; } else { const cStyle2 = cardColorStyle(target.id); return ``; } } function renderDashboardAutomation(automation, sceneMap = new Map()) { const isActive = automation.is_active; const isDisabled = !automation.enabled; let condSummary = ''; if (automation.conditions.length > 0) { const parts = automation.conditions.map(c => { if (c.condition_type === 'application') { const apps = (c.apps || []).join(', '); const matchLabel = c.match_type === 'topmost' ? t('automations.condition.application.match_type.topmost') : t('automations.condition.application.match_type.running'); return `${apps} (${matchLabel})`; } if (c.condition_type === 'startup') return t('automations.condition.startup'); if (c.condition_type === 'time_of_day') return t('automations.condition.time_of_day'); return t(`automations.condition.${c.condition_type}`) || c.condition_type; }); const logic = automation.condition_logic === 'and' ? ' & ' : ' | '; condSummary = parts.join(logic); } const statusBadge = isDisabled ? `${t('automations.status.disabled')}` : isActive ? `${t('automations.status.active')}` : `${t('automations.status.inactive')}`; // Scene info const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null; const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected'); const aStyle = cardColorStyle(automation.id); return ``; } export async function dashboardToggleAutomation(automationId, enable) { try { const endpoint = enable ? 'enable' : 'disable'; const response = await fetchWithAuth(`/automations/${automationId}/${endpoint}`, { method: 'POST', }); if (response.ok) { loadDashboard(); } } catch (error) { if (error.isAuth) return; showToast(t('dashboard.error.automation_toggle_failed'), 'error'); } } export async function dashboardStartTarget(targetId) { try { const response = await fetchWithAuth(`/output-targets/${targetId}/start`, { method: 'POST', }); if (response.ok) { showToast(t('device.started'), 'success'); loadDashboard(); } else { const error = await response.json(); showToast(error.detail || t('dashboard.error.start_failed'), 'error'); } } catch (error) { if (error.isAuth) return; showToast(t('dashboard.error.start_failed'), 'error'); } } export async function dashboardStopTarget(targetId) { try { const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, { method: 'POST', }); if (response.ok) { showToast(t('device.stopped'), 'success'); loadDashboard(); } else { const error = await response.json(); showToast(error.detail || t('dashboard.error.stop_failed'), 'error'); } } catch (error) { if (error.isAuth) return; showToast(t('dashboard.error.stop_failed'), 'error'); } } export async function dashboardStopAll() { const confirmed = await showConfirm(t('confirm.stop_all')); if (!confirmed) return; try { const [allTargets, statesResp] = await Promise.all([ outputTargetsCache.fetch().catch(() => []), fetchWithAuth('/output-targets/batch/states'), ]); const statesData = statesResp.ok ? await statesResp.json() : { states: {} }; const states = statesData.states || {}; const running = allTargets.filter(t => states[t.id]?.processing); await Promise.all(running.map(t => fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {}) )); loadDashboard(); } catch (error) { if (error.isAuth) return; showToast(t('dashboard.error.stop_all'), 'error'); } } export async function dashboardPauseClock(clockId) { try { const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); showToast(t('sync_clock.paused'), 'success'); loadDashboard(true); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); } } export async function dashboardResumeClock(clockId) { try { const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); showToast(t('sync_clock.resumed'), 'success'); loadDashboard(true); } catch (e) { if (e.isAuth) return; showToast(e.message, 'error'); } } export async function dashboardResetClock(clockId) { try { const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); showToast(t('sync_clock.reset_done'), 'success'); loadDashboard(true); } catch (e) { if (e.isAuth) return; showToast(e.message, '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:automation_state_changed', () => _debouncedDashboardReload(true)); document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload()); const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device']); document.addEventListener('server:entity_changed', (e) => { const { entity_type } = e.detail || {}; if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _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(); } });