Add real-time system performance charts to dashboard

Backend: GET /api/v1/system/performance endpoint using psutil (CPU/RAM)
and nvidia-ml-py (GPU utilization, memory, temperature) with graceful
fallback. Frontend: Chart.js line charts with rolling 60-sample history
persisted to sessionStorage, flicker-free updates via persistent DOM
and diff-based dynamic section refresh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 21:02:55 +03:00
parent 66d1a77981
commit 4a1b4f7674
11 changed files with 390 additions and 46 deletions

View File

@@ -39,6 +39,8 @@ dependencies = [
"wmi>=1.5.1; sys_platform == 'win32'", "wmi>=1.5.1; sys_platform == 'win32'",
"zeroconf>=0.131.0", "zeroconf>=0.131.0",
"pyserial>=3.5", "pyserial>=3.5",
"psutil>=5.9.0",
"nvidia-ml-py>=12.0.0; sys_platform == 'win32'",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -1,8 +1,9 @@
"""System routes: health, version, displays.""" """System routes: health, version, displays, performance."""
import sys import sys
from datetime import datetime from datetime import datetime
import psutil
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from wled_controller import __version__ from wled_controller import __version__
@@ -10,7 +11,9 @@ from wled_controller.api.auth import AuthRequired
from wled_controller.api.schemas.system import ( from wled_controller.api.schemas.system import (
DisplayInfo, DisplayInfo,
DisplayListResponse, DisplayListResponse,
GpuInfo,
HealthResponse, HealthResponse,
PerformanceResponse,
ProcessListResponse, ProcessListResponse,
VersionResponse, VersionResponse,
) )
@@ -19,6 +22,23 @@ from wled_controller.utils import get_logger
logger = get_logger(__name__) 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() router = APIRouter()
@@ -113,3 +133,40 @@ async def get_running_processes(_: AuthRequired):
status_code=500, status_code=500,
detail=f"Failed to retrieve process list: {str(e)}" 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(),
)

View File

@@ -47,3 +47,24 @@ class ProcessListResponse(BaseModel):
processes: List[str] = Field(description="Sorted list of unique process names") processes: List[str] = Field(description="Sorted list of unique process names")
count: int = Field(description="Number of unique processes") 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")

View File

@@ -6,6 +6,7 @@
<title>LED Grab</title> <title>LED Grab</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>💡</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>💡</text></svg>">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
</head> </head>
<body style="visibility: hidden;"> <body style="visibility: hidden;">
<div class="container"> <div class="container">

View File

@@ -39,6 +39,9 @@ import {
dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll, dashboardToggleProfile, dashboardStartTarget, dashboardStopTarget, dashboardStopAll,
toggleDashboardSection, toggleDashboardSection,
} from './features/dashboard.js'; } from './features/dashboard.js';
import {
startPerfPolling, stopPerfPolling,
} from './features/perf-charts.js';
import { import {
loadPictureSources, switchStreamTab, loadPictureSources, switchStreamTab,
showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate, showAddTemplateModal, editTemplate, closeTemplateModal, saveTemplate, deleteTemplate,
@@ -153,6 +156,8 @@ Object.assign(window, {
dashboardStopTarget, dashboardStopTarget,
dashboardStopAll, dashboardStopAll,
toggleDashboardSection, toggleDashboardSection,
startPerfPolling,
stopPerfPolling,
// streams / capture templates / PP templates // streams / capture templates / PP templates
loadPictureSources, loadPictureSources,

View File

@@ -7,6 +7,7 @@ import { API_BASE, getHeaders, fetchWithAuth } from '../core/api.js';
import { t } from '../core/i18n.js'; import { t } from '../core/i18n.js';
import { escapeHtml, handle401Error } from '../core/api.js'; import { escapeHtml, handle401Error } from '../core/api.js';
import { showToast } from '../core/ui.js'; import { showToast } from '../core/ui.js';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
@@ -82,61 +83,76 @@ export async function loadDashboard() {
const devicesMap = {}; const devicesMap = {};
for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; } for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; }
// Build dynamic HTML (targets, profiles)
let dynamicHtml = '';
if (targets.length === 0 && profiles.length === 0) { if (targets.length === 0 && profiles.length === 0) {
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
return; } 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) => { const running = enriched.filter(t => t.state && t.state.processing);
try { const stopped = enriched.filter(t => !t.state || !t.state.processing);
const [stateResp, metricsResp] = await Promise.all([
fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }), if (profiles.length > 0) {
fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }), const activeProfiles = profiles.filter(p => p.is_active);
]); const inactiveProfiles = profiles.filter(p => !p.is_active);
const state = stateResp.ok ? await stateResp.json() : {}; const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join('');
const metrics = metricsResp.ok ? await metricsResp.json() : {};
return { ...target, state, metrics }; dynamicHtml += `<div class="dashboard-section">
} catch { ${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)}
return target; ${_sectionContent('profiles', profileItems)}
</div>`;
} }
}));
const running = enriched.filter(t => t.state && t.state.processing); if (running.length > 0) {
const stopped = enriched.filter(t => !t.state || !t.state.processing); const stopAllBtn = `<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>`;
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap)).join('');
let html = ''; dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
${_sectionContent('running', runningItems)}
</div>`;
}
if (profiles.length > 0) { if (stopped.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active); const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join('');
const inactiveProfiles = profiles.filter(p => !p.is_active);
const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p)).join('');
html += `<div class="dashboard-section"> dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)} ${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
${_sectionContent('profiles', profileItems)} ${_sectionContent('stopped', stoppedItems)}
</div>`; </div>`;
}
} }
if (running.length > 0) { // First load: build everything in one innerHTML to avoid flicker
const stopAllBtn = `<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>`; const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap)).join(''); if (isFirstLoad) {
container.innerHTML = `<div class="dashboard-perf-persistent dashboard-section">
html += `<div class="dashboard-section"> ${_sectionHeader('perf', t('dashboard.section.performance'), '')}
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)} ${_sectionContent('perf', renderPerfSection())}
${_sectionContent('running', runningItems)} </div>
</div>`; <div class="dashboard-dynamic">${dynamicHtml}</div>`;
initPerfCharts();
} else {
const dynamic = container.querySelector('.dashboard-dynamic');
if (dynamic.innerHTML !== dynamicHtml) {
dynamic.innerHTML = dynamicHtml;
}
} }
startPerfPolling();
if (stopped.length > 0) {
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join('');
html += `<div class="dashboard-section">
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
${_sectionContent('stopped', stoppedItems)}
</div>`;
}
container.innerHTML = html;
} catch (error) { } catch (error) {
console.error('Failed to load dashboard:', error); console.error('Failed to load dashboard:', error);

View File

@@ -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 `<div class="perf-charts-grid">
<div class="perf-chart-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.cpu')}</span>
<span class="perf-chart-value cpu" id="perf-cpu-value">-</span>
</div>
<div class="perf-chart-wrap"><canvas id="perf-chart-cpu"></canvas></div>
</div>
<div class="perf-chart-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.ram')}</span>
<span class="perf-chart-value ram" id="perf-ram-value">-</span>
</div>
<div class="perf-chart-wrap"><canvas id="perf-chart-ram"></canvas></div>
</div>
<div class="perf-chart-card" id="perf-gpu-card">
<div class="perf-chart-header">
<span class="perf-chart-label">${t('dashboard.perf.gpu')}</span>
<span class="perf-chart-value gpu" id="perf-gpu-value">-</span>
</div>
<div class="perf-chart-wrap"><canvas id="perf-chart-gpu"></canvas></div>
</div>
</div>`;
}
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();
}

View File

@@ -14,6 +14,7 @@ export function switchTab(name) {
if (typeof window.startDashboardWS === 'function') window.startDashboardWS(); if (typeof window.startDashboardWS === 'function') window.startDashboardWS();
} else { } else {
if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS(); if (typeof window.stopDashboardWS === 'function') window.stopDashboardWS();
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
if (name === 'streams') { if (name === 'streams') {
if (typeof window.loadPictureSources === 'function') window.loadPictureSources(); if (typeof window.loadPictureSources === 'function') window.loadPictureSources();
} else if (name === 'targets') { } else if (name === 'targets') {

View File

@@ -468,6 +468,11 @@
"dashboard.failed": "Failed to load dashboard", "dashboard.failed": "Failed to load dashboard",
"dashboard.section.profiles": "Profiles", "dashboard.section.profiles": "Profiles",
"dashboard.targets": "Targets", "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.title": "\uD83D\uDCCB Profiles",
"profiles.empty": "No profiles configured. Create one to automate target activation.", "profiles.empty": "No profiles configured. Create one to automate target activation.",

View File

@@ -468,6 +468,11 @@
"dashboard.failed": "Не удалось загрузить обзор", "dashboard.failed": "Не удалось загрузить обзор",
"dashboard.section.profiles": "Профили", "dashboard.section.profiles": "Профили",
"dashboard.targets": "Цели", "dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы",
"dashboard.perf.cpu": "ЦП",
"dashboard.perf.ram": "ОЗУ",
"dashboard.perf.gpu": "ГП",
"dashboard.perf.unavailable": "недоступно",
"profiles.title": "\uD83D\uDCCB Профили", "profiles.title": "\uD83D\uDCCB Профили",
"profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.", "profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.",

View File

@@ -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 ===== */ /* ===== PROFILES ===== */
.badge-profile-active { .badge-profile-active {