/** * Dashboard — real-time target status overview. */ import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js'; import { t } from '../core/i18n.js'; import { escapeHtml, handle401Error } from '../core/api.js'; import { showToast } from '../core/ui.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { startAutoRefresh } from './tabs.js'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const FPS_HISTORY_KEY = 'dashboard_fps_history'; const MAX_FPS_SAMPLES = 30; let _fpsHistory = _loadFpsHistory(); // { targetId: number[] } let _fpsCharts = {}; // { targetId: Chart } let _lastRunningIds = []; // sorted target IDs from previous render let _uptimeBase = {}; // { targetId: { seconds, timestamp } } let _uptimeTimer = null; function _loadFpsHistory() { try { const raw = sessionStorage.getItem(FPS_HISTORY_KEY); if (raw) return JSON.parse(raw); } catch {} return {}; } function _saveFpsHistory() { try { sessionStorage.setItem(FPS_HISTORY_KEY, JSON.stringify(_fpsHistory)); } catch {} } 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 _startUptimeTimer() { if (_uptimeTimer) return; _uptimeTimer = setInterval(() => { for (const id of _lastRunningIds) { const el = document.querySelector(`[data-uptime-text="${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 = {}; } 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: true, plugins: { legend: { display: false }, tooltip: { enabled: false } }, scales: { x: { display: false }, y: { min: 0, max: fpsTarget * 1.15, display: false }, }, layout: { padding: 0 }, }, }); } function _initFpsCharts(runningTargetIds) { _destroyFpsCharts(); // 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); } _saveFpsHistory(); } /** 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 const fpsEl = document.querySelector(`[data-fps-text="${target.id}"]`); if (fpsEl) fpsEl.innerHTML = `${fpsActual}/${fpsTarget}`; const errorsEl = 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 = 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'}`; } } } } _saveFpsHistory(); } function _renderPollIntervalSelect() { const sec = Math.round(dashboardPollInterval / 1000); return `${sec}s`; } export function changeDashboardPollInterval(value) { const ms = parseInt(value, 10) * 1000; setDashboardPollInterval(ms); startAutoRefresh(); stopPerfPolling(); startPerfPolling(); const label = document.querySelector('.dashboard-poll-value'); if (label) label.textContent = `${value}s`; } 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 ``; } function formatUptime(seconds) { if (!seconds || seconds <= 0) return '-'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } 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] = await Promise.all([ fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }), fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null), fetch(`${API_BASE}/devices`, { headers: getHeaders() }).catch(() => null), ]); if (targetsResp.status === 401) { handle401Error(); return; } 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; } // Build dynamic HTML (targets, profiles) let dynamicHtml = ''; let runningIds = []; if (targets.length === 0 && profiles.length === 0) { dynamicHtml = `
${t('dashboard.no_targets')}
`; } else { const enriched = await Promise.all(targets.map(async (target) => { try { const [stateResp, metricsResp] = await Promise.all([ fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }), fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }), ]); const state = stateResp.ok ? await stateResp.json() : {}; const metrics = metricsResp.ok ? await metricsResp.json() : {}; return { ...target, state, metrics }; } catch { return target; } })); const running = enriched.filter(t => t.state && t.state.processing); const stopped = enriched.filter(t => !t.state || !t.state.processing); // 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'); if (!forceFullRender && hasExistingDom && newRunningIds === prevRunningIds && newRunningIds !== '') { _updateRunningMetrics(running); set_dashboardLoading(false); return; } if (profiles.length > 0) { const activeProfiles = profiles.filter(p => p.is_active); const inactiveProfiles = profiles.filter(p => !p.is_active); 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)).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)).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}
`; initPerfCharts(); } else { const dynamic = container.querySelector('.dashboard-dynamic'); if (dynamic.innerHTML !== dynamicHtml) { dynamic.innerHTML = dynamicHtml; } } _lastRunningIds = runningIds; _initFpsCharts(runningIds); _startUptimeTimer(); startPerfPolling(); } catch (error) { console.error('Failed to load dashboard:', error); container.innerHTML = `
${t('dashboard.failed')}
`; } finally { set_dashboardLoading(false); } } function renderDashboardTarget(target, isRunning, devicesMap = {}) { const state = target.state || {}; const metrics = target.metrics || {}; const isLed = target.target_type === 'led' || target.target_type === 'wled'; const icon = '⚡'; const typeLabel = isLed ? 'LED' : 'Key Colors'; let subtitleParts = [typeLabel]; if (isLed) { const device = target.device_id ? devicesMap[target.device_id] : null; if (device) { subtitleParts.push((device.device_type || '').toUpperCase()); } } 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 fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, { method: 'POST', headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { loadDashboard(); } } catch (error) { showToast('Failed to toggle profile', 'error'); } } export async function dashboardStartTarget(targetId) { try { const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, { method: 'POST', headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { showToast(t('device.started'), 'success'); loadDashboard(); } else { const error = await response.json(); showToast(`Failed to start: ${error.detail}`, 'error'); } } catch (error) { showToast('Failed to start processing', 'error'); } } export async function dashboardStopTarget(targetId) { try { const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, { method: 'POST', headers: getHeaders() }); if (response.status === 401) { handle401Error(); return; } if (response.ok) { showToast(t('device.stopped'), 'success'); loadDashboard(); } else { const error = await response.json(); showToast(`Failed to stop: ${error.detail}`, 'error'); } } catch (error) { showToast('Failed to stop processing', 'error'); } } export async function dashboardStopAll() { try { const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }); if (targetsResp.status === 401) { handle401Error(); return; } const data = await targetsResp.json(); const running = (data.targets || []).filter(t => t.id); await Promise.all(running.map(t => fetch(`${API_BASE}/picture-targets/${t.id}/stop`, { method: 'POST', headers: getHeaders() }).catch(() => {}) )); loadDashboard(); } catch (error) { 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'; } document.addEventListener('server:state_change', () => { if (_isDashboardActive()) loadDashboard(); }); document.addEventListener('server:profile_state_changed', () => { if (_isDashboardActive()) loadDashboard(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(); });