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 @@
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 `
`;
+}
+
+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 {