diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index b4ed93a..c098aff 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -6,11 +6,12 @@ from datetime import datetime from typing import Optional import psutil -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from wled_controller import __version__ from wled_controller.api.auth import AuthRequired +from wled_controller.api.dependencies import get_processor_manager from wled_controller.api.schemas.system import ( DisplayInfo, DisplayListResponse, @@ -192,6 +193,19 @@ def get_system_performance(_: AuthRequired): ) +@router.get("/api/v1/system/metrics-history", tags=["Config"]) +async def get_metrics_history( + _: AuthRequired, + manager=Depends(get_processor_manager), +): + """Return the last ~2 minutes of system and per-target metrics. + + Used by the dashboard to seed charts on page load so history + survives browser refreshes. + """ + return manager.metrics_history.get_history() + + # --------------------------------------------------------------------------- # ADB helpers (for Android / scrcpy engine) # --------------------------------------------------------------------------- diff --git a/server/src/wled_controller/core/processing/metrics_history.py b/server/src/wled_controller/core/processing/metrics_history.py new file mode 100644 index 0000000..3870e73 --- /dev/null +++ b/server/src/wled_controller/core/processing/metrics_history.py @@ -0,0 +1,123 @@ +"""Server-side ring buffer for system and per-target metrics.""" + +import asyncio +from collections import deque +from datetime import datetime +from typing import Dict, Optional + +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +MAX_SAMPLES = 120 # ~2 minutes at 1-second interval +SAMPLE_INTERVAL = 1.0 # seconds + + +def _collect_system_snapshot() -> dict: + """Collect CPU/RAM/GPU metrics (blocking — run in thread pool). + + Returns a dict suitable for direct JSON serialization. + """ + import psutil + + mem = psutil.virtual_memory() + snapshot = { + "t": datetime.utcnow().isoformat(), + "cpu": psutil.cpu_percent(interval=None), + "ram_pct": mem.percent, + "ram_used": round(mem.used / 1024 / 1024, 1), + "ram_total": round(mem.total / 1024 / 1024, 1), + "gpu_util": None, + "gpu_temp": None, + } + + try: + from wled_controller.api.routes.system import _nvml_available, _nvml, _nvml_handle + + if _nvml_available: + util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle) + temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU) + snapshot["gpu_util"] = float(util.gpu) + snapshot["gpu_temp"] = float(temp) + except Exception: + pass + + return snapshot + + +class MetricsHistory: + """In-memory ring buffer collecting system and per-target metrics.""" + + def __init__(self, processor_manager): + self._manager = processor_manager + self._system: deque = deque(maxlen=MAX_SAMPLES) + self._targets: Dict[str, deque] = {} + self._task: Optional[asyncio.Task] = None + + async def start(self): + """Start the background sampling loop.""" + if self._task and not self._task.done(): + return + self._task = asyncio.create_task(self._sample_loop()) + logger.info("Metrics history sampling started") + + async def stop(self): + """Stop the background sampling loop.""" + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + logger.info("Metrics history sampling stopped") + + async def _sample_loop(self): + """Sample system + target metrics every SAMPLE_INTERVAL seconds.""" + while True: + try: + await self._sample() + except asyncio.CancelledError: + raise + except Exception as e: + logger.warning(f"Metrics sampling error: {e}") + await asyncio.sleep(SAMPLE_INTERVAL) + + async def _sample(self): + """Collect one snapshot of system and target metrics.""" + # System metrics (blocking psutil/nvml calls in thread pool) + sys_snap = await asyncio.to_thread(_collect_system_snapshot) + self._system.append(sys_snap) + + # Per-target metrics from processor states + try: + all_states = self._manager.get_all_target_states() + except Exception: + all_states = {} + + now = datetime.utcnow().isoformat() + active_ids = set() + for target_id, state in all_states.items(): + active_ids.add(target_id) + if target_id not in self._targets: + self._targets[target_id] = deque(maxlen=MAX_SAMPLES) + if state.get("processing"): + self._targets[target_id].append({ + "t": now, + "fps": state.get("fps_actual"), + "fps_target": state.get("fps_target"), + "timing": state.get("timing_total_ms"), + "errors": state.get("errors_count", 0), + }) + + # Prune deques for targets no longer registered + for tid in list(self._targets.keys()): + if tid not in active_ids: + del self._targets[tid] + + def get_history(self) -> dict: + """Return all history for the API response.""" + return { + "system": list(self._system), + "targets": {tid: list(dq) for tid, dq in self._targets.items()}, + } diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 1384060..94b8f0d 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -16,6 +16,7 @@ from wled_controller.core.devices.led_client import ( from wled_controller.core.audio.audio_capture import AudioCaptureManager from wled_controller.core.processing.live_stream_manager import LiveStreamManager from wled_controller.core.processing.color_strip_stream_manager import ColorStripStreamManager +from wled_controller.core.processing.metrics_history import MetricsHistory from wled_controller.core.processing.value_stream import ValueStreamManager from wled_controller.core.capture.screen_overlay import OverlayManager from wled_controller.core.processing.target_processor import ( @@ -97,8 +98,13 @@ class ProcessorManager: ) if value_source_store else None self._overlay_manager = OverlayManager() self._event_queues: List[asyncio.Queue] = [] + self._metrics_history = MetricsHistory(self) logger.info("Processor manager initialized") + @property + def metrics_history(self) -> MetricsHistory: + return self._metrics_history + # ===== SHARED CONTEXT (passed to target processors) ===== def _build_context(self) -> TargetContext: @@ -718,6 +724,7 @@ class ProcessorManager: async def stop_all(self): """Stop processing and health monitoring for all targets and devices.""" + await self._metrics_history.stop() await self.stop_health_monitoring() # Stop all processors @@ -761,6 +768,7 @@ class ProcessorManager: self._health_monitoring_active = True for device_id in self._devices: self._start_device_health_check(device_id) + await self._metrics_history.start() logger.info("Started health monitoring for all devices") async def stop_health_monitoring(self): diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index 5ce1409..286f1a1 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -10,10 +10,9 @@ import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } import { startAutoRefresh } from './tabs.js'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; -const FPS_HISTORY_KEY = 'dashboard_fps_history'; -const MAX_FPS_SAMPLES = 30; +const MAX_FPS_SAMPLES = 120; -let _fpsHistory = _loadFpsHistory(); // { targetId: number[] } +let _fpsHistory = {}; // { targetId: number[] } let _fpsCharts = {}; // { targetId: Chart } let _lastRunningIds = []; // sorted target IDs from previous render let _uptimeBase = {}; // { targetId: { seconds, timestamp } } @@ -21,19 +20,6 @@ let _uptimeTimer = null; let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs let _metricsElements = new Map(); -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); @@ -120,8 +106,26 @@ function _createFpsChart(canvasId, history, fpsTarget) { }); } -function _initFpsCharts(runningTargetIds) { +async function _initFpsCharts(runningTargetIds) { _destroyFpsCharts(); + + // Seed FPS history from server ring buffer on first load + if (Object.keys(_fpsHistory).length === 0 && runningTargetIds.length > 0) { + try { + const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() }); + if (resp.ok) { + const data = await resp.json(); + const serverTargets = data.targets || {}; + for (const id of runningTargetIds) { + const samples = serverTargets[id] || []; + _fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null); + } + } + } catch { + // Silently ignore — charts will fill from polling + } + } + // Clean up history for targets that are no longer running for (const id of Object.keys(_fpsHistory)) { if (!runningTargetIds.includes(id)) delete _fpsHistory[id]; @@ -133,7 +137,7 @@ function _initFpsCharts(runningTargetIds) { const fpsTarget = parseFloat(canvas.dataset.fpsTarget) || 30; _fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, history, fpsTarget); } - _saveFpsHistory(); + _cacheMetricsElements(runningTargetIds); } @@ -194,7 +198,7 @@ function _updateRunningMetrics(enrichedRunning) { } } } - _saveFpsHistory(); + } function _updateProfilesInPlace(profiles) { @@ -412,7 +416,7 @@ export async function loadDashboard(forceFullRender = false) { ${_sectionContent('perf', renderPerfSection())}
${dynamicHtml}
`; - initPerfCharts(); + await initPerfCharts(); } else { const dynamic = container.querySelector('.dashboard-dynamic'); if (dynamic.innerHTML !== dynamicHtml) { @@ -421,7 +425,7 @@ export async function loadDashboard(forceFullRender = false) { } _lastRunningIds = runningIds; _cacheUptimeElements(); - _initFpsCharts(runningIds); + await _initFpsCharts(runningIds); _startUptimeTimer(); startPerfPolling(); 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 eef23d8..d352a94 100644 --- a/server/src/wled_controller/static/js/features/perf-charts.js +++ b/server/src/wled_controller/static/js/features/perf-charts.js @@ -1,35 +1,19 @@ /** * Performance charts — real-time CPU, RAM, GPU usage with Chart.js. + * History is seeded from the server-side ring buffer on init. */ 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 STORAGE_KEY = 'perf_history'; +const MAX_SAMPLES = 120; let _pollTimer = null; let _charts = {}; // { cpu: Chart, ram: Chart, gpu: Chart } -let _history = _loadHistory(); +let _history = { cpu: [], ram: [], gpu: [] }; let _hasGpu = null; // null = unknown, true/false after first fetch -function _loadHistory() { - try { - const raw = sessionStorage.getItem(STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (parsed.cpu && parsed.ram && parsed.gpu) return parsed; - } - } catch {} - return { cpu: [], ram: [], gpu: [] }; -} - -function _saveHistory() { - try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(_history)); } - catch {} -} - /** Returns the static HTML for the perf section (canvas placeholders). */ export function renderPerfSection() { return `
@@ -88,20 +72,41 @@ function _createChart(canvasId, color, fillColor) { }); } +/** Seed charts from server-side metrics history. */ +async function _seedFromServer() { + try { + const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() }); + if (!resp.ok) return; + const data = await resp.json(); + const samples = data.system || []; + _history.cpu = samples.map(s => s.cpu).filter(v => v != null); + _history.ram = samples.map(s => s.ram_pct).filter(v => v != null); + _history.gpu = samples.map(s => s.gpu_util).filter(v => v != null); + + // Detect GPU availability from history + if (_history.gpu.length > 0) { + _hasGpu = true; + } + + for (const key of ['cpu', 'ram', 'gpu']) { + if (_charts[key] && _history[key].length > 0) { + _charts[key].data.datasets[0].data = [..._history[key]]; + _charts[key].data.labels = _history[key].map(() => ''); + _charts[key].update(); + } + } + } catch { + // Silently ignore — charts will fill from polling + } +} + /** Initialize Chart.js instances on the already-mounted canvases. */ -export function initPerfCharts() { +export async function initPerfCharts() { _destroyCharts(); _charts.cpu = _createChart('perf-chart-cpu', '#2196F3', 'rgba(33,150,243,0.15)'); _charts.ram = _createChart('perf-chart-ram', '#4CAF50', 'rgba(76,175,80,0.15)'); _charts.gpu = _createChart('perf-chart-gpu', '#FF9800', 'rgba(255,152,0,0.15)'); - // Restore any existing history data into the freshly created charts - for (const key of ['cpu', 'ram', 'gpu']) { - if (_charts[key] && _history[key].length > 0) { - _charts[key].data.datasets[0].data = [..._history[key]]; - _charts[key].data.labels = _history[key].map(() => ''); - _charts[key].update(); - } - } + await _seedFromServer(); } function _destroyCharts() { @@ -158,8 +163,6 @@ async function _fetchPerformance() { card.appendChild(noGpu); } } - - _saveHistory(); } catch { // Silently ignore fetch errors (e.g., network issues, tab hidden) } @@ -176,5 +179,4 @@ export function stopPerfPolling() { clearInterval(_pollTimer); _pollTimer = null; } - _saveHistory(); } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index b1ff583..0595eb6 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -424,7 +424,7 @@ "kc.pattern_template": "Pattern Template:", "kc.pattern_template.hint": "Select the rectangle pattern to use for color extraction", "kc.pattern_template.none": "-- Select a pattern template --", - "kc.brightness_vs": "🔢 Brightness Source:", + "kc.brightness_vs": "Brightness Source:", "kc.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (multiplied with the manual brightness slider)", "kc.brightness_vs.none": "None (manual brightness only)", "kc.created": "Key colors target created successfully", @@ -810,7 +810,7 @@ "value_source.deleted": "Value source deleted", "value_source.delete.confirm": "Are you sure you want to delete this value source?", "value_source.error.name_required": "Please enter a name", - "targets.brightness_vs": "🔢 Brightness Source:", + "targets.brightness_vs": "Brightness Source:", "targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)", "targets.brightness_vs.none": "None (device brightness)" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 1e452f3..10bd15c 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -424,7 +424,7 @@ "kc.pattern_template": "Шаблон Паттерна:", "kc.pattern_template.hint": "Выберите шаблон прямоугольников для извлечения цветов", "kc.pattern_template.none": "-- Выберите шаблон паттерна --", - "kc.brightness_vs": "🔢 Источник Яркости:", + "kc.brightness_vs": "Источник Яркости:", "kc.brightness_vs.hint": "Опциональный источник значений, динамически управляющий яркостью каждый кадр (умножается на ручной слайдер яркости)", "kc.brightness_vs.none": "Нет (только ручная яркость)", "kc.created": "Цель ключевых цветов успешно создана", @@ -810,7 +810,7 @@ "value_source.deleted": "Источник значений удалён", "value_source.delete.confirm": "Удалить этот источник значений?", "value_source.error.name_required": "Введите название", - "targets.brightness_vs": "🔢 Источник яркости:", + "targets.brightness_vs": "Источник яркости:", "targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)", "targets.brightness_vs.none": "Нет (яркость устройства)" } diff --git a/server/src/wled_controller/templates/modals/kc-editor.html b/server/src/wled_controller/templates/modals/kc-editor.html index ebeb147..ebe0889 100644 --- a/server/src/wled_controller/templates/modals/kc-editor.html +++ b/server/src/wled_controller/templates/modals/kc-editor.html @@ -34,7 +34,7 @@
- +
diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index 984caee..7262115 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -35,7 +35,7 @@
- +