Add FPS sparkline charts, configurable poll interval, and uptime interpolation

Replace text FPS labels with Chart.js sparklines on running targets,
use emoji icons for metrics, add in-place DOM updates to preserve
chart animations, and add a 1-10s poll interval slider that controls
all dashboard timers. Uptime now ticks every second via client-side
interpolation regardless of poll interval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 03:04:17 +03:00
parent ef925ad0a9
commit 45634836b6
8 changed files with 290 additions and 19 deletions

View File

@@ -35,9 +35,9 @@ import {
updateSettingsBaudFpsHint, updateSettingsBaudFpsHint,
} from './features/devices.js'; } from './features/devices.js';
import { import {
loadDashboard, startDashboardWS, stopDashboardWS, loadDashboard, startDashboardWS, stopDashboardWS, stopUptimeTimer,
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll, dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
toggleDashboardSection, toggleDashboardSection, changeDashboardPollInterval,
} from './features/dashboard.js'; } from './features/dashboard.js';
import { import {
startPerfPolling, stopPerfPolling, startPerfPolling, stopPerfPolling,
@@ -157,6 +157,8 @@ Object.assign(window, {
dashboardStopTarget, dashboardStopTarget,
dashboardStopAll, dashboardStopAll,
toggleDashboardSection, toggleDashboardSection,
changeDashboardPollInterval,
stopUptimeTimer,
startPerfPolling, startPerfPolling,
stopPerfPolling, stopPerfPolling,

View File

@@ -121,6 +121,15 @@ export function setConfirmResolve(v) { confirmResolve = v; }
export let _dashboardLoading = false; export let _dashboardLoading = false;
export function set_dashboardLoading(v) { _dashboardLoading = v; } 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 // Pattern template editor state
export let patternEditorRects = []; export let patternEditorRects = [];
export function setPatternEditorRects(v) { patternEditorRects = v; } export function setPatternEditorRects(v) { patternEditorRects = v; }

View File

@@ -2,14 +2,192 @@
* Dashboard — real-time target status overview. * 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 { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { escapeHtml, handle401Error } from '../core/api.js'; import { escapeHtml, handle401Error } from '../core/api.js';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.js';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
import { startAutoRefresh } from './tabs.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; 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() { function _getCollapsedSections() {
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; } try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; }
@@ -85,6 +263,7 @@ export async function loadDashboard() {
// Build dynamic HTML (targets, profiles) // Build dynamic HTML (targets, profiles)
let dynamicHtml = ''; let dynamicHtml = '';
let runningIds = [];
if (targets.length === 0 && profiles.length === 0) { if (targets.length === 0 && profiles.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
@@ -106,6 +285,16 @@ export async function loadDashboard() {
const running = enriched.filter(t => t.state && t.state.processing); const running = enriched.filter(t => t.state && t.state.processing);
const stopped = 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) { if (profiles.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active); const activeProfiles = profiles.filter(p => p.is_active);
const inactiveProfiles = 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) { 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 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(''); 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 // First load: build everything in one innerHTML to avoid flicker
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent'); const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
const pollSelect = _renderPollIntervalSelect();
if (isFirstLoad) { if (isFirstLoad) {
container.innerHTML = `<div class="dashboard-perf-persistent dashboard-section"> container.innerHTML = `<div class="dashboard-perf-persistent dashboard-section">
${_sectionHeader('perf', t('dashboard.section.performance'), '')} ${_sectionHeader('perf', t('dashboard.section.performance'), '', pollSelect)}
${_sectionContent('perf', renderPerfSection())} ${_sectionContent('perf', renderPerfSection())}
</div> </div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`; <div class="dashboard-dynamic">${dynamicHtml}</div>`;
@@ -152,6 +343,9 @@ export async function loadDashboard() {
dynamic.innerHTML = dynamicHtml; dynamic.innerHTML = dynamicHtml;
} }
} }
_lastRunningIds = runningIds;
_initFpsCharts(runningIds);
_startUptimeTimer();
startPerfPolling(); startPerfPolling();
} catch (error) { } catch (error) {
@@ -183,13 +377,23 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}) {
const uptime = formatUptime(metrics.uptime_seconds); const uptime = formatUptime(metrics.uptime_seconds);
const errors = metrics.errors_count || 0; 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 = ''; let healthDot = '';
if (isLed && state.device_last_checked != null) { if (isLed && state.device_last_checked != null) {
const cls = state.device_online ? 'health-online' : 'health-offline'; const cls = state.device_online ? 'health-online' : 'health-offline';
healthDot = `<span class="health-dot ${cls}"></span>`; healthDot = `<span class="health-dot ${cls}"></span>`;
} }
return `<div class="dashboard-target"> return `<div class="dashboard-target" data-target-id="${target.id}">
<div class="dashboard-target-info"> <div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span> <span class="dashboard-target-icon">${icon}</span>
<div> <div>
@@ -198,17 +402,19 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}) {
</div> </div>
</div> </div>
<div class="dashboard-target-metrics"> <div class="dashboard-target-metrics">
<div class="dashboard-metric"> <div class="dashboard-metric dashboard-fps-metric">
<div class="dashboard-metric-value">${fpsActual}/${fpsTarget}</div> <div class="dashboard-fps-sparkline">
<div class="dashboard-metric-label">${t('dashboard.fps')}</div> <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>
<div class="dashboard-metric"> <div class="dashboard-metric" title="${t('dashboard.uptime')}">
<div class="dashboard-metric-value">${uptime}</div> <div class="dashboard-metric-value" data-uptime-text="${target.id}">🕐 ${uptime}</div>
<div class="dashboard-metric-label">${t('dashboard.uptime')}</div>
</div> </div>
<div class="dashboard-metric"> <div class="dashboard-metric" title="${t('dashboard.errors')}">
<div class="dashboard-metric-value">${errors}</div> <div class="dashboard-metric-value" data-errors-text="${target.id}">${errors > 0 ? '⚠️' : '✅'} ${errors}</div>
<div class="dashboard-metric-label">${t('dashboard.errors')}</div>
</div> </div>
</div> </div>
<div class="dashboard-target-actions"> <div class="dashboard-target-actions">
@@ -374,6 +580,10 @@ export function startDashboardWS() {
} }
} }
export function stopUptimeTimer() {
_stopUptimeTimer();
}
export function stopDashboardWS() { export function stopDashboardWS() {
if (_dashboardWS) { if (_dashboardWS) {
_dashboardWS.close(); _dashboardWS.close();

View File

@@ -4,9 +4,9 @@
import { API_BASE, getHeaders } from '../core/api.js'; import { API_BASE, getHeaders } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { dashboardPollInterval } from '../core/state.js';
const MAX_SAMPLES = 60; const MAX_SAMPLES = 60;
const POLL_INTERVAL_MS = 2000;
const STORAGE_KEY = 'perf_history'; const STORAGE_KEY = 'perf_history';
let _pollTimer = null; let _pollTimer = null;
@@ -168,7 +168,7 @@ async function _fetchPerformance() {
export function startPerfPolling() { export function startPerfPolling() {
if (_pollTimer) return; if (_pollTimer) return;
_fetchPerformance(); _fetchPerformance();
_pollTimer = setInterval(_fetchPerformance, POLL_INTERVAL_MS); _pollTimer = setInterval(_fetchPerformance, dashboardPollInterval);
} }
export function stopPerfPolling() { export function stopPerfPolling() {

View File

@@ -2,7 +2,7 @@
* Tab switching — switchTab, initTabs, startAutoRefresh. * 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) { export function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name));
@@ -15,6 +15,7 @@ export function switchTab(name) {
} else { } else {
if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS(); if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS();
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling(); if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
if (name === 'streams') { if (name === 'streams') {
if (typeof window.loadPictureSources === 'function') window.loadPictureSources(); if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
} else if (name === 'targets') { } else if (name === 'targets') {
@@ -50,5 +51,5 @@ export function startAutoRefresh() {
if (typeof window.loadDashboard === 'function') window.loadDashboard(); if (typeof window.loadDashboard === 'function') window.loadDashboard();
} }
} }
}, 2000)); }, dashboardPollInterval));
} }

View File

@@ -473,6 +473,7 @@
"dashboard.perf.ram": "RAM", "dashboard.perf.ram": "RAM",
"dashboard.perf.gpu": "GPU", "dashboard.perf.gpu": "GPU",
"dashboard.perf.unavailable": "unavailable", "dashboard.perf.unavailable": "unavailable",
"dashboard.poll_interval": "Refresh interval",
"profiles.title": "\uD83D\uDCCB Profiles", "profiles.title": "\uD83D\uDCCB Profiles",
"profiles.empty": "No profiles configured. Create one to automate target activation.", "profiles.empty": "No profiles configured. Create one to automate target activation.",

View File

@@ -473,6 +473,7 @@
"dashboard.perf.ram": "ОЗУ", "dashboard.perf.ram": "ОЗУ",
"dashboard.perf.gpu": "ГП", "dashboard.perf.gpu": "ГП",
"dashboard.perf.unavailable": "недоступно", "dashboard.perf.unavailable": "недоступно",
"dashboard.poll_interval": "Интервал обновления",
"profiles.title": "\uD83D\uDCCB Профили", "profiles.title": "\uD83D\uDCCB Профили",
"profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.", "profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.",

View File

@@ -3284,12 +3284,32 @@ input:-webkit-autofill:focus {
flex: 0 0 auto; 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 { .dashboard-target {
display: grid; display: grid;
grid-template-columns: 1fr auto auto; grid-template-columns: 1fr auto auto;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 8px 12px; padding: 6px 12px;
background: var(--card-bg); background: var(--card-bg);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
@@ -3358,6 +3378,33 @@ input:-webkit-autofill:focus {
letter-spacing: 0.3px; 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 { .dashboard-target-actions {
display: flex; display: flex;
align-items: center; align-items: center;