diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index aa879e8..b6d5710 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -35,9 +35,9 @@ import { updateSettingsBaudFpsHint, } from './features/devices.js'; import { - loadDashboard, startDashboardWS, stopDashboardWS, + loadDashboard, startDashboardWS, stopDashboardWS, stopUptimeTimer, dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll, - toggleDashboardSection, + toggleDashboardSection, changeDashboardPollInterval, } from './features/dashboard.js'; import { startPerfPolling, stopPerfPolling, @@ -157,6 +157,8 @@ Object.assign(window, { dashboardStopTarget, dashboardStopAll, toggleDashboardSection, + changeDashboardPollInterval, + stopUptimeTimer, startPerfPolling, stopPerfPolling, diff --git a/server/src/wled_controller/static/js/core/state.js b/server/src/wled_controller/static/js/core/state.js index 17301bd..0d50c79 100644 --- a/server/src/wled_controller/static/js/core/state.js +++ b/server/src/wled_controller/static/js/core/state.js @@ -121,6 +121,15 @@ export function setConfirmResolve(v) { confirmResolve = v; } export let _dashboardLoading = false; export function set_dashboardLoading(v) { _dashboardLoading = v; } +// Dashboard poll interval (ms), persisted in localStorage +const _POLL_KEY = 'dashboard_poll_interval'; +const _POLL_DEFAULT = 2000; +export let dashboardPollInterval = parseInt(localStorage.getItem(_POLL_KEY), 10) || _POLL_DEFAULT; +export function setDashboardPollInterval(v) { + dashboardPollInterval = v; + localStorage.setItem(_POLL_KEY, String(v)); +} + // Pattern template editor state export let patternEditorRects = []; export function setPatternEditorRects(v) { patternEditorRects = v; } diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 4af2723..b9d1199 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -2,14 +2,192 @@ * Dashboard — real-time target status overview. */ -import { apiKey, _dashboardWS, set_dashboardWS, _dashboardLoading, set_dashboardLoading } from '../core/state.js'; +import { apiKey, _dashboardWS, set_dashboardWS, _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)) || {}; } @@ -85,6 +263,7 @@ export async function loadDashboard() { // Build dynamic HTML (targets, profiles) let dynamicHtml = ''; + let runningIds = []; if (targets.length === 0 && profiles.length === 0) { dynamicHtml = `
${t('dashboard.no_targets')}
`; @@ -106,6 +285,16 @@ export async function loadDashboard() { 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 (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); @@ -118,6 +307,7 @@ export async function loadDashboard() { } if (running.length > 0) { + runningIds = running.map(t => t.id); const stopAllBtn = ``; const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap)).join(''); @@ -139,9 +329,10 @@ export async function loadDashboard() { // 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'), '')} + ${_sectionHeader('perf', t('dashboard.section.performance'), '', pollSelect)} ${_sectionContent('perf', renderPerfSection())}
${dynamicHtml}
`; @@ -152,6 +343,9 @@ export async function loadDashboard() { dynamic.innerHTML = dynamicHtml; } } + _lastRunningIds = runningIds; + _initFpsCharts(runningIds); + _startUptimeTimer(); startPerfPolling(); } catch (error) { @@ -183,13 +377,23 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}) { 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 `
+ return `
${icon}
@@ -198,17 +402,19 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}) {
-
-
${fpsActual}/${fpsTarget}
-
${t('dashboard.fps')}
+
+
+ +
+
+ ${fpsActual}/${fpsTarget} +
-
-
${uptime}
-
${t('dashboard.uptime')}
+
+
🕐 ${uptime}
-
-
${errors}
-
${t('dashboard.errors')}
+
+
${errors > 0 ? '⚠️' : '✅'} ${errors}
@@ -374,6 +580,10 @@ export function startDashboardWS() { } } +export function stopUptimeTimer() { + _stopUptimeTimer(); +} + export function stopDashboardWS() { if (_dashboardWS) { _dashboardWS.close(); diff --git a/server/src/wled_controller/static/js/features/perf-charts.js b/server/src/wled_controller/static/js/features/perf-charts.js index ce69258..379ba7e 100644 --- a/server/src/wled_controller/static/js/features/perf-charts.js +++ b/server/src/wled_controller/static/js/features/perf-charts.js @@ -4,9 +4,9 @@ import { API_BASE, getHeaders } from '../core/api.js'; import { t } from '../core/i18n.js'; +import { dashboardPollInterval } from '../core/state.js'; const MAX_SAMPLES = 60; -const POLL_INTERVAL_MS = 2000; const STORAGE_KEY = 'perf_history'; let _pollTimer = null; @@ -168,7 +168,7 @@ async function _fetchPerformance() { export function startPerfPolling() { if (_pollTimer) return; _fetchPerformance(); - _pollTimer = setInterval(_fetchPerformance, POLL_INTERVAL_MS); + _pollTimer = setInterval(_fetchPerformance, dashboardPollInterval); } export function stopPerfPolling() { diff --git a/server/src/wled_controller/static/js/features/tabs.js b/server/src/wled_controller/static/js/features/tabs.js index 04ebd1b..88fa1f0 100644 --- a/server/src/wled_controller/static/js/features/tabs.js +++ b/server/src/wled_controller/static/js/features/tabs.js @@ -2,7 +2,7 @@ * Tab switching — switchTab, initTabs, startAutoRefresh. */ -import { apiKey, refreshInterval, setRefreshInterval } from '../core/state.js'; +import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.js'; export function switchTab(name) { document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); @@ -15,6 +15,7 @@ export function switchTab(name) { } else { if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS(); if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling(); + if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer(); if (name === 'streams') { if (typeof window.loadPictureSources === 'function') window.loadPictureSources(); } else if (name === 'targets') { @@ -50,5 +51,5 @@ export function startAutoRefresh() { if (typeof window.loadDashboard === 'function') window.loadDashboard(); } } - }, 2000)); + }, dashboardPollInterval)); } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 7b84e34..85a501c 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -473,6 +473,7 @@ "dashboard.perf.ram": "RAM", "dashboard.perf.gpu": "GPU", "dashboard.perf.unavailable": "unavailable", + "dashboard.poll_interval": "Refresh interval", "profiles.title": "\uD83D\uDCCB Profiles", "profiles.empty": "No profiles configured. Create one to automate target activation.", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index f1a961f..1f6f2a8 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -473,6 +473,7 @@ "dashboard.perf.ram": "ОЗУ", "dashboard.perf.gpu": "ГП", "dashboard.perf.unavailable": "недоступно", + "dashboard.poll_interval": "Интервал обновления", "profiles.title": "\uD83D\uDCCB Профили", "profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 2ade2ef..9b5c189 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -3284,12 +3284,32 @@ input:-webkit-autofill:focus { flex: 0 0 auto; } +.dashboard-poll-wrap { + margin-left: auto; + display: flex; + align-items: center; + gap: 3px; +} + +.dashboard-poll-slider { + width: 48px; + height: 12px; + accent-color: var(--primary-color); + cursor: pointer; +} + +.dashboard-poll-value { + font-size: 0.6rem; + color: var(--text-secondary); + min-width: 18px; +} + .dashboard-target { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 12px; - padding: 8px 12px; + padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 6px; @@ -3358,6 +3378,33 @@ input:-webkit-autofill:focus { letter-spacing: 0.3px; } +.dashboard-fps-metric { + display: flex; + align-items: center; + gap: 6px; + min-width: auto; +} + +.dashboard-fps-sparkline { + position: relative; + width: 100px; + height: 36px; +} + +.dashboard-fps-label { + display: flex; + flex-direction: column; + align-items: center; + min-width: 36px; + line-height: 1.1; +} + +.dashboard-fps-target { + font-weight: 400; + opacity: 0.5; + font-size: 0.75rem; +} + .dashboard-target-actions { display: flex; align-items: center;