diff --git a/server/pyproject.toml b/server/pyproject.toml index 967b716..da52924 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -39,6 +39,8 @@ dependencies = [ "wmi>=1.5.1; sys_platform == 'win32'", "zeroconf>=0.131.0", "pyserial>=3.5", + "psutil>=5.9.0", + "nvidia-ml-py>=12.0.0; sys_platform == 'win32'", ] [project.optional-dependencies] diff --git a/server/src/wled_controller/api/routes/system.py b/server/src/wled_controller/api/routes/system.py index 54d3aa0..494386c 100644 --- a/server/src/wled_controller/api/routes/system.py +++ b/server/src/wled_controller/api/routes/system.py @@ -1,8 +1,9 @@ -"""System routes: health, version, displays.""" +"""System routes: health, version, displays, performance.""" import sys from datetime import datetime +import psutil from fastapi import APIRouter, HTTPException from wled_controller import __version__ @@ -10,7 +11,9 @@ from wled_controller.api.auth import AuthRequired from wled_controller.api.schemas.system import ( DisplayInfo, DisplayListResponse, + GpuInfo, HealthResponse, + PerformanceResponse, ProcessListResponse, VersionResponse, ) @@ -19,6 +22,23 @@ from wled_controller.utils import get_logger logger = get_logger(__name__) +# Prime psutil CPU counter (first call always returns 0.0) +psutil.cpu_percent(interval=None) + +# Try to initialize NVIDIA GPU monitoring +_nvml_available = False +try: + import pynvml as _pynvml_mod # nvidia-ml-py (the pynvml wrapper is deprecated) + + _pynvml_mod.nvmlInit() + _nvml_handle = _pynvml_mod.nvmlDeviceGetHandleByIndex(0) + _nvml_available = True + _nvml = _pynvml_mod + logger.info(f"NVIDIA GPU monitoring enabled: {_nvml.nvmlDeviceGetName(_nvml_handle)}") +except Exception: + _nvml = None + logger.info("NVIDIA GPU monitoring unavailable (pynvml not installed or no NVIDIA GPU)") + router = APIRouter() @@ -113,3 +133,40 @@ async def get_running_processes(_: AuthRequired): status_code=500, detail=f"Failed to retrieve process list: {str(e)}" ) + + +@router.get( + "/api/v1/system/performance", + response_model=PerformanceResponse, + tags=["Config"], +) +async def get_system_performance(_: AuthRequired): + """Get current system performance metrics (CPU, RAM, GPU).""" + mem = psutil.virtual_memory() + + gpu = None + if _nvml_available: + try: + util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle) + mem_info = _nvml.nvmlDeviceGetMemoryInfo(_nvml_handle) + temp = _nvml.nvmlDeviceGetTemperature( + _nvml_handle, _nvml.NVML_TEMPERATURE_GPU + ) + gpu = GpuInfo( + name=_nvml.nvmlDeviceGetName(_nvml_handle), + utilization=float(util.gpu), + memory_used_mb=round(mem_info.used / 1024 / 1024, 1), + memory_total_mb=round(mem_info.total / 1024 / 1024, 1), + temperature_c=float(temp), + ) + except Exception: + pass + + return PerformanceResponse( + cpu_percent=psutil.cpu_percent(interval=None), + ram_used_mb=round(mem.used / 1024 / 1024, 1), + ram_total_mb=round(mem.total / 1024 / 1024, 1), + ram_percent=mem.percent, + gpu=gpu, + timestamp=datetime.utcnow(), + ) diff --git a/server/src/wled_controller/api/schemas/system.py b/server/src/wled_controller/api/schemas/system.py index e871cd2..461729e 100644 --- a/server/src/wled_controller/api/schemas/system.py +++ b/server/src/wled_controller/api/schemas/system.py @@ -47,3 +47,24 @@ class ProcessListResponse(BaseModel): processes: List[str] = Field(description="Sorted list of unique process names") count: int = Field(description="Number of unique processes") + + +class GpuInfo(BaseModel): + """GPU performance information.""" + + name: str | None = Field(default=None, description="GPU device name") + utilization: float | None = Field(default=None, description="GPU core usage percent") + memory_used_mb: float | None = Field(default=None, description="GPU memory used in MB") + memory_total_mb: float | None = Field(default=None, description="GPU total memory in MB") + temperature_c: float | None = Field(default=None, description="GPU temperature in Celsius") + + +class PerformanceResponse(BaseModel): + """System performance metrics.""" + + cpu_percent: float = Field(description="System-wide CPU usage percent") + ram_used_mb: float = Field(description="RAM used in MB") + ram_total_mb: float = Field(description="RAM total in MB") + ram_percent: float = Field(description="RAM usage percent") + gpu: GpuInfo | None = Field(default=None, description="GPU info (null if unavailable)") + timestamp: datetime = Field(description="Measurement timestamp") diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 74480d6..6c7166a 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -6,6 +6,7 @@ LED Grab +
diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 2f633b4..cfa99f5 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -39,6 +39,9 @@ import { dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll, toggleDashboardSection, } from './features/dashboard.js'; +import { + startPerfPolling, stopPerfPolling, +} from './features/perf-charts.js'; import { loadPictureSources, switchStreamTab, showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate, @@ -153,6 +156,8 @@ Object.assign(window, { dashboardStopTarget, dashboardStopAll, toggleDashboardSection, + startPerfPolling, + stopPerfPolling, // streams / capture templates / PP templates loadPictureSources, diff --git a/server/src/wled_controller/static/js/features/dashboard.js b/server/src/wled_controller/static/js/features/dashboard.js index c7244aa..4af2723 100644 --- a/server/src/wled_controller/static/js/features/dashboard.js +++ b/server/src/wled_controller/static/js/features/dashboard.js @@ -7,6 +7,7 @@ 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'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; @@ -82,61 +83,76 @@ export async function loadDashboard() { const devicesMap = {}; for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; } + // Build dynamic HTML (targets, profiles) + let dynamicHtml = ''; + if (targets.length === 0 && profiles.length === 0) { - container.innerHTML = `
${t('dashboard.no_targets')}
`; - return; - } + dynamicHtml = `
${t('dashboard.no_targets')}
`; + } else { + const enriched = await Promise.all(targets.map(async (target) => { + try { + const [stateResp, metricsResp] = await Promise.all([ + fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }), + fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }), + ]); + const state = stateResp.ok ? await stateResp.json() : {}; + const metrics = metricsResp.ok ? await metricsResp.json() : {}; + return { ...target, state, metrics }; + } catch { + return target; + } + })); - const enriched = await Promise.all(targets.map(async (target) => { - try { - const [stateResp, metricsResp] = await Promise.all([ - fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }), - fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }), - ]); - const state = stateResp.ok ? await stateResp.json() : {}; - const metrics = metricsResp.ok ? await metricsResp.json() : {}; - return { ...target, state, metrics }; - } catch { - return target; + const running = enriched.filter(t => t.state && t.state.processing); + const stopped = enriched.filter(t => !t.state || !t.state.processing); + + if (profiles.length > 0) { + const activeProfiles = profiles.filter(p => p.is_active); + const inactiveProfiles = profiles.filter(p => !p.is_active); + const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join(''); + + dynamicHtml += `
+ ${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)} + ${_sectionContent('profiles', profileItems)} +
`; } - })); - const running = enriched.filter(t => t.state && t.state.processing); - const stopped = enriched.filter(t => !t.state || !t.state.processing); + if (running.length > 0) { + const stopAllBtn = ``; + const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap)).join(''); - let html = ''; + dynamicHtml += `
+ ${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)} + ${_sectionContent('running', runningItems)} +
`; + } - if (profiles.length > 0) { - const activeProfiles = profiles.filter(p => p.is_active); - const inactiveProfiles = profiles.filter(p => !p.is_active); - const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join(''); + if (stopped.length > 0) { + const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join(''); - html += `
- ${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)} - ${_sectionContent('profiles', profileItems)} -
`; + dynamicHtml += `
+ ${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)} + ${_sectionContent('stopped', stoppedItems)} +
`; + } } - if (running.length > 0) { - const stopAllBtn = ``; - const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap)).join(''); - - html += `
- ${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)} - ${_sectionContent('running', runningItems)} -
`; + // First load: build everything in one innerHTML to avoid flicker + const isFirstLoad = !container.querySelector('.dashboard-perf-persistent'); + if (isFirstLoad) { + container.innerHTML = `
+ ${_sectionHeader('perf', t('dashboard.section.performance'), '')} + ${_sectionContent('perf', renderPerfSection())} +
+
${dynamicHtml}
`; + initPerfCharts(); + } else { + const dynamic = container.querySelector('.dashboard-dynamic'); + if (dynamic.innerHTML !== dynamicHtml) { + dynamic.innerHTML = dynamicHtml; + } } - - if (stopped.length > 0) { - const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join(''); - - html += `
- ${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)} - ${_sectionContent('stopped', stoppedItems)} -
`; - } - - container.innerHTML = html; + startPerfPolling(); } catch (error) { console.error('Failed to load dashboard:', error); diff --git a/server/src/wled_controller/static/js/features/perf-charts.js b/server/src/wled_controller/static/js/features/perf-charts.js new file mode 100644 index 0000000..66e5642 --- /dev/null +++ b/server/src/wled_controller/static/js/features/perf-charts.js @@ -0,0 +1,180 @@ +/** + * Performance charts — real-time CPU, RAM, GPU usage with Chart.js. + */ + +import { API_BASE, getHeaders } from '../core/api.js'; +import { t } from '../core/i18n.js'; + +const MAX_SAMPLES = 60; +const POLL_INTERVAL_MS = 2000; +const STORAGE_KEY = 'perf_history'; + +let _pollTimer = null; +let _charts = {}; // { cpu: Chart, ram: Chart, gpu: Chart } +let _history = _loadHistory(); +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 `
+
+
+ ${t('dashboard.perf.cpu')} + - +
+
+
+
+
+ ${t('dashboard.perf.ram')} + - +
+
+
+
+
+ ${t('dashboard.perf.gpu')} + - +
+
+
+
`; +} + +function _createChart(canvasId, color, fillColor) { + const ctx = document.getElementById(canvasId); + if (!ctx) return null; + return new Chart(ctx, { + type: 'line', + data: { + labels: Array(MAX_SAMPLES).fill(''), + datasets: [{ + data: [], + borderColor: color, + backgroundColor: fillColor, + borderWidth: 1.5, + tension: 0.3, + fill: true, + pointRadius: 0, + }], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { + x: { display: false }, + y: { min: 0, max: 100, display: false }, + }, + layout: { padding: 0 }, + }, + }); +} + +/** Initialize Chart.js instances on the already-mounted canvases. */ +export 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(); + } + } +} + +function _destroyCharts() { + for (const key of Object.keys(_charts)) { + if (_charts[key]) { _charts[key].destroy(); _charts[key] = null; } + } +} + +function _pushSample(key, value) { + _history[key].push(value); + if (_history[key].length > MAX_SAMPLES) _history[key].shift(); + const chart = _charts[key]; + if (!chart) return; + chart.data.datasets[0].data = [..._history[key]]; + chart.data.labels = _history[key].map(() => ''); + chart.update(); +} + +async function _fetchPerformance() { + try { + const resp = await fetch(`${API_BASE}/system/performance`, { headers: getHeaders() }); + if (!resp.ok) return; + const data = await resp.json(); + + // CPU + _pushSample('cpu', data.cpu_percent); + const cpuEl = document.getElementById('perf-cpu-value'); + if (cpuEl) cpuEl.textContent = `${data.cpu_percent.toFixed(0)}%`; + + // RAM + _pushSample('ram', data.ram_percent); + const ramEl = document.getElementById('perf-ram-value'); + if (ramEl) { + const usedGb = (data.ram_used_mb / 1024).toFixed(1); + const totalGb = (data.ram_total_mb / 1024).toFixed(1); + ramEl.textContent = `${usedGb}/${totalGb} GB`; + } + + // GPU + if (data.gpu) { + _hasGpu = true; + _pushSample('gpu', data.gpu.utilization); + const gpuEl = document.getElementById('perf-gpu-value'); + if (gpuEl) gpuEl.textContent = `${data.gpu.utilization.toFixed(0)}% · ${data.gpu.temperature_c}°C`; + } else if (_hasGpu === null) { + _hasGpu = false; + const card = document.getElementById('perf-gpu-card'); + if (card) { + const canvas = card.querySelector('canvas'); + if (canvas) canvas.style.display = 'none'; + const noGpu = document.createElement('div'); + noGpu.className = 'perf-chart-unavailable'; + noGpu.textContent = t('dashboard.perf.unavailable'); + card.appendChild(noGpu); + } + } + + _saveHistory(); + } catch { + // Silently ignore fetch errors (e.g., network issues, tab hidden) + } +} + +export function startPerfPolling() { + if (_pollTimer) return; + _fetchPerformance(); + _pollTimer = setInterval(_fetchPerformance, POLL_INTERVAL_MS); +} + +export function stopPerfPolling() { + if (_pollTimer) { + clearInterval(_pollTimer); + _pollTimer = null; + } + _saveHistory(); +} diff --git a/server/src/wled_controller/static/js/features/tabs.js b/server/src/wled_controller/static/js/features/tabs.js index 61befd2..2e85d21 100644 --- a/server/src/wled_controller/static/js/features/tabs.js +++ b/server/src/wled_controller/static/js/features/tabs.js @@ -14,6 +14,7 @@ export function switchTab(name) { if (typeof window.startDashboardWS === 'function') window.startDashboardWS(); } else { if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS(); + if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling(); if (name === 'streams') { if (typeof window.loadPictureSources === 'function') window.loadPictureSources(); } else if (name === 'targets') { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 65b886b..7b84e34 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -468,6 +468,11 @@ "dashboard.failed": "Failed to load dashboard", "dashboard.section.profiles": "Profiles", "dashboard.targets": "Targets", + "dashboard.section.performance": "System Performance", + "dashboard.perf.cpu": "CPU", + "dashboard.perf.ram": "RAM", + "dashboard.perf.gpu": "GPU", + "dashboard.perf.unavailable": "unavailable", "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 e568873..f1a961f 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -468,6 +468,11 @@ "dashboard.failed": "Не удалось загрузить обзор", "dashboard.section.profiles": "Профили", "dashboard.targets": "Цели", + "dashboard.section.performance": "Производительность системы", + "dashboard.perf.cpu": "ЦП", + "dashboard.perf.ram": "ОЗУ", + "dashboard.perf.gpu": "ГП", + "dashboard.perf.unavailable": "недоступно", "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 f7d30cb..2ade2ef 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -3416,6 +3416,57 @@ input:-webkit-autofill:focus { } } +/* ===== PERFORMANCE CHARTS ===== */ + +.perf-charts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.perf-chart-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px 12px; +} + +.perf-chart-wrap { + position: relative; + height: 60px; +} + +.perf-chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.perf-chart-label { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--text-secondary); +} + +.perf-chart-value { + font-size: 0.85rem; + font-weight: 700; +} + +.perf-chart-value.cpu { color: #2196F3; } +.perf-chart-value.ram { color: #4CAF50; } +.perf-chart-value.gpu { color: #FF9800; } + +.perf-chart-unavailable { + text-align: center; + padding: 20px 0; + color: var(--text-secondary); + font-size: 0.8rem; +} + /* ===== PROFILES ===== */ .badge-profile-active {