/** * 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 } from '../core/ui.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const MAX_FPS_SAMPLES = 120; let _fpsHistory = {}; // { targetId: number[] } let _fpsCharts = {}; // { targetId: Chart } let _lastRunningIds = []; // sorted target IDs from previous render let _uptimeBase = {}; // { targetId: { seconds, timestamp } } let _uptimeTimer = null; let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs let _metricsElements = new Map(); function _pushFps(targetId, value) { if (!_fpsHistory[targetId]) _fpsHistory[targetId] = []; _fpsHistory[targetId].push(value); if (_fpsHistory[targetId].length > MAX_FPS_SAMPLES) _fpsHistory[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.textContent = `🕐 ${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, history, fpsTarget) { const canvas = document.getElementById(canvasId); if (!canvas) return null; return new Chart(canvas, { type: 'line', data: { labels: history.map(() => ''), datasets: [{ data: [...history], borderColor: '#2196F3', backgroundColor: 'rgba(33,150,243,0.12)', borderWidth: 1.5, tension: 0.3, fill: true, 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); } } } 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]; } for (const id of runningTargetIds) { const canvas = document.getElementById(`dashboard-fps-${id}`); if (!canvas) continue; const history = _fpsHistory[id] || []; const fpsTarget = parseFloat(canvas.dataset.fpsTarget) || 30; _fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, history, 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 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); } const chart = _fpsCharts[target.id]; if (chart) { const history = _fpsHistory[target.id] || []; chart.data.datasets[0].data = [...history]; chart.data.labels = history.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) fpsEl.innerHTML = `${fpsActual}/${fpsTarget}`; const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`); if (errorsEl) errorsEl.textContent = `${errors > 0 ? '⚠️' : '✅'} ${errors}`; // Update health dot 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 && 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.textContent = p.enabled ? '⏹' : '▶'; } } } 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'); if (collapsed[sectionKey]) { content.style.display = 'none'; chevron.textContent = '\u25B6'; } else { content.style.display = ''; chevron.textContent = '\u25BC'; } } function _sectionHeader(sectionKey, label, count, extraHtml = '') { const collapsed = _getCollapsedSections(); const isCollapsed = !!collapsed[sectionKey]; const chevron = isCollapsed ? '\u25B6' : '\u25BC'; return `
${chevron} ${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; } 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 = []; 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(','); const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent'); const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds; 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 autoStartItems = autoStartTargets.map(target => { const isRunning = !!(target.state && target.state.processing); const device = devicesMap[target.device_id]; const deviceName = device ? device.name : ''; const typeIcon = target.target_type === 'key_colors' ? '🎨' : '💡'; const statusBadge = isRunning ? `${t('profiles.status.active')}` : `${t('profiles.status.inactive')}`; return `
${escapeHtml(target.name)} ${statusBadge}
${deviceName ? `
${typeIcon} ${escapeHtml(deviceName)}
` : `
${typeIcon}
`}
`; }).join(''); 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(); if (isFirstLoad) { container.innerHTML = `
${_sectionHeader('perf', t('dashboard.section.performance'), '', pollSelect)} ${_sectionContent('perf', renderPerfSection())}
${dynamicHtml}
`; await initPerfCharts(); } else { const dynamic = container.querySelector('.dashboard-dynamic'); if (dynamic.innerHTML !== dynamicHtml) { dynamic.innerHTML = dynamicHtml; } } _lastRunningIds = runningIds; _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); } } 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 = '⚡'; const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc'); 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 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 sample to history if (state.fps_actual != null) { _pushFps(target.id, state.fps_actual); } let healthDot = ''; if (isLed && state.device_last_checked != null) { const cls = state.device_online ? 'health-online' : 'health-offline'; healthDot = ``; } return `
${icon}
${escapeHtml(target.name)}${healthDot}
${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''}
${fpsActual}/${fpsTarget}
🕐 ${uptime}
${errors > 0 ? '⚠️' : '✅'} ${errors}
`; } else { return `
${icon}
${escapeHtml(target.name)}
${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''}
`; } } 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 `
📋
${escapeHtml(profile.name)}
${condSummary ? `
${escapeHtml(condSummary)}
` : ''}
${statusBadge}
${targetsInfo}
${t('dashboard.targets')}
`; } 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 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(); });