diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index afbf1c3..48dcdc5 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -519,6 +519,36 @@ ul.section-tip li { color: #999; } +/* Target FPS sparkline row */ +.target-fps-row { + display: flex; + align-items: center; + gap: 8px; + grid-column: 1 / -1; + padding: 3px 8px; + background: var(--bg-color); + border-radius: 4px; +} + +.target-fps-sparkline { + position: relative; + flex: 1; + min-width: 0; + overflow: hidden; + height: 36px; +} + +.target-fps-label { + flex-shrink: 0; + text-align: right; +} + +.target-fps-target { + font-weight: 400; + opacity: 0.5; + font-size: 0.75rem; +} + /* Timing breakdown bar */ .timing-breakdown { margin-top: 8px; diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 9f4b22d..e6a117e 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -22,6 +22,46 @@ import { createColorStripCard } from './color-strips.js'; // Re-render targets tab when language changes document.addEventListener('languageChanged', () => { if (apiKey) loadTargetsTab(); }); +// --- FPS sparkline history for target cards --- +const _TARGET_MAX_FPS_SAMPLES = 30; +const _targetFpsHistory = {}; + +function _pushTargetFps(targetId, value) { + if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = []; + const h = _targetFpsHistory[targetId]; + h.push(value); + if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift(); +} + +function _createTargetFpsChart(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: { display: false } }, + scales: { + x: { display: false }, + y: { display: false, min: 0, max: fpsTarget * 1.15 }, + }, + }, + }); +} + class TargetEditorModal extends Modal { constructor() { super('target-editor-modal'); @@ -410,6 +450,25 @@ export async function loadTargetsTab() { if (!processingKCIds.has(id)) disconnectKCWebSocket(id); }); + // FPS sparkline charts: push samples and init charts after HTML rebuild + const allTargets = [...ledTargets, ...kcTargets]; + const runningIds = new Set(); + allTargets.forEach(target => { + if (target.state && target.state.processing) { + runningIds.add(target.id); + if (target.state.fps_actual != null) { + _pushTargetFps(target.id, target.state.fps_actual); + } + const history = _targetFpsHistory[target.id] || []; + const fpsTarget = target.state.fps_target || 30; + _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget); + } + }); + // Clean up history for targets no longer running + Object.keys(_targetFpsHistory).forEach(id => { + if (!runningIds.has(id)) delete _targetFpsHistory[id]; + }); + } catch (error) { if (error.isAuth) return; console.error('Failed to load targets tab:', error); @@ -454,18 +513,34 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {