From 6d33686b79b49966981e927555575657a64c9fc8 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 21 Feb 2026 03:06:18 +0300 Subject: [PATCH] Add FPS sparkline chart to target cards, move timing breakdown inline Replace the three FPS text labels (actual/current/target) with a Chart.js sparkline chart + compact label, matching the dashboard style. FPS history (30 samples) persists across poll rebuilds. Pipeline timing breakdown moved inside the metrics grid directly under the FPS chart. Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/static/css/cards.css | 30 +++++ .../static/js/features/targets.js | 115 +++++++++++++----- 2 files changed, 115 insertions(+), 30 deletions(-) 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) {
${isProcessing ? `
-
-
${t('device.metrics.actual_fps')}
-
${state.fps_actual?.toFixed(1) || '0.0'}
+
+
+ +
+
+ ${state.fps_actual?.toFixed(1) || '0.0'}/${state.fps_target || 0} +
-
-
${t('device.metrics.current_fps')}
-
${state.fps_current ?? '-'}
-
-
-
${t('device.metrics.target_fps')}
-
${state.fps_target || 0}
+ ${state.timing_total_ms != null ? ` +
+
+
${t('device.metrics.timing')}
+
${state.timing_total_ms}ms
+
+
+ ${state.timing_extract_ms != null ? `` : ''} + ${state.timing_map_leds_ms != null ? `` : ''} + ${state.timing_smooth_ms != null ? `` : ''} + +
+
+ ${state.timing_extract_ms != null ? `extract ${state.timing_extract_ms}ms` : ''} + ${state.timing_map_leds_ms != null ? `map ${state.timing_map_leds_ms}ms` : ''} + ${state.timing_smooth_ms != null ? `smooth ${state.timing_smooth_ms}ms` : ''} + send ${state.timing_send_ms}ms +
+ ` : ''}
${t('device.metrics.frames')}
${metrics.frames_processed || 0}
@@ -479,26 +554,6 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
${metrics.errors_count || 0}
- ${state.timing_total_ms != null ? ` -
-
-
${t('device.metrics.timing')}
-
${state.timing_total_ms}ms
-
-
- ${state.timing_extract_ms != null ? `` : ''} - ${state.timing_map_leds_ms != null ? `` : ''} - ${state.timing_smooth_ms != null ? `` : ''} - -
-
- ${state.timing_extract_ms != null ? `extract ${state.timing_extract_ms}ms` : ''} - ${state.timing_map_leds_ms != null ? `map ${state.timing_map_leds_ms}ms` : ''} - ${state.timing_smooth_ms != null ? `smooth ${state.timing_smooth_ms}ms` : ''} - send ${state.timing_send_ms}ms -
-
- ` : ''} ` : ''}