Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/dashboard.js
alexei.dolgolyov bef28ece5c Add static color support, HAOS light entity, and real-time profile updates
- Add static_color capability to WLED and serial providers with native
  set_color() dispatch (WLED uses JSON API, serial uses idle client)
- Encapsulate device-specific logic in providers instead of device_type
  checks in ProcessorManager and API routes
- Add HAOS light entity for devices with brightness_control + static_color
  (Adalight/AmbiLED get light entity, WLED keeps number entity)
- Fix serial device brightness and turn-off: pass software_brightness
  through provider chain, clear device on color=null, re-send static
  color after brightness change
- Add global events WebSocket (events-ws.js) replacing per-tab WS,
  enabling real-time profile state updates on both dashboard and profiles tabs
- Fix profile activation: mark active when all targets already running,
  add asyncio.Lock to prevent concurrent evaluation races, skip process
  enumeration when no profile has conditions, trigger immediate evaluation
  on enable/create/update for instant target startup
- Add reliable server restart script (restart.ps1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 14:23:47 +03:00

595 lines
24 KiB
JavaScript

/**
* 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}<span class="dashboard-fps-target">/${fpsTarget}</span>`;
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 `<span class="dashboard-poll-wrap" onclick="event.stopPropagation()"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
}
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 `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}" onclick="toggleDashboardSection('${sectionKey}')">
<span class="dashboard-section-chevron">${chevron}</span>
${label}
<span class="dashboard-section-count">${count}</span>
${extraHtml}
</div>`;
}
function _sectionContent(sectionKey, itemsHtml) {
const collapsed = _getCollapsedSections();
const isCollapsed = !!collapsed[sectionKey];
return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
}
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 = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} 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 += `<div class="dashboard-section">
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)}
${_sectionContent('profiles', profileItems)}
</div>`;
}
if (targets.length > 0) {
let targetsInner = '';
if (running.length > 0) {
runningIds = running.map(t => t.id);
const stopAllBtn = `<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>`;
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap)).join('');
targetsInner += `<div class="dashboard-subsection">
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
${_sectionContent('running', runningItems)}
</div>`;
}
if (stopped.length > 0) {
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join('');
targetsInner += `<div class="dashboard-subsection">
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
${_sectionContent('stopped', stoppedItems)}
</div>`;
}
dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('targets', t('dashboard.section.targets'), targets.length)}
${_sectionContent('targets', targetsInner)}
</div>`;
}
}
// First load: build everything in one innerHTML to avoid flicker
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
const pollSelect = _renderPollIntervalSelect();
if (isFirstLoad) {
container.innerHTML = `<div class="dashboard-perf-persistent dashboard-section">
${_sectionHeader('perf', t('dashboard.section.performance'), '', pollSelect)}
${_sectionContent('perf', renderPerfSection())}
</div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
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 = `<div class="dashboard-no-targets">${t('dashboard.failed')}</div>`;
} 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 = `<span class="health-dot ${cls}"></span>`;
}
return `<div class="dashboard-target" data-target-id="${target.id}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)}${healthDot}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
</div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric dashboard-fps-metric">
<div class="dashboard-fps-sparkline">
<canvas id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
</div>
<div class="dashboard-fps-label">
<span class="dashboard-metric-value" data-fps-text="${target.id}">${fpsActual}<span class="dashboard-fps-target">/${fpsTarget}</span></span>
</div>
</div>
<div class="dashboard-metric" title="${t('dashboard.uptime')}">
<div class="dashboard-metric-value" data-uptime-text="${target.id}">🕐 ${uptime}</div>
</div>
<div class="dashboard-metric" title="${t('dashboard.errors')}">
<div class="dashboard-metric-value" data-errors-text="${target.id}">${errors > 0 ? '⚠️' : '✅'} ${errors}</div>
</div>
</div>
<div class="dashboard-target-actions">
<button class="btn btn-icon btn-warning" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏸</button>
</div>
</div>`;
} else {
return `<div class="dashboard-target">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
</div>
<div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions">
<button class="btn btn-icon btn-success" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶</button>
</div>
</div>`;
}
}
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
? `<span class="dashboard-badge-stopped">${t('profiles.status.disabled')}</span>`
: isActive
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
const targetCount = profile.target_ids.length;
const activeCount = (profile.active_target_ids || []).length;
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
return `<div class="dashboard-target dashboard-profile">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">📋</span>
<div>
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div>
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
</div>
${statusBadge}
</div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric">
<div class="dashboard-metric-value">${targetsInfo}</div>
<div class="dashboard-metric-label">${t('dashboard.targets')}</div>
</div>
</div>
<div class="dashboard-target-actions">
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
${profile.enabled ? '⏸' : '▶'}
</button>
</div>
</div>`;
}
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();
});