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:
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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="stylesheet" href="/static/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body style="visibility: hidden;">
|
||||
<div class="container">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,11 +83,12 @@ export async function loadDashboard() {
|
||||
const devicesMap = {};
|
||||
for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; }
|
||||
|
||||
if (targets.length === 0 && profiles.length === 0) {
|
||||
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||
return;
|
||||
}
|
||||
// Build dynamic HTML (targets, profiles)
|
||||
let dynamicHtml = '';
|
||||
|
||||
if (targets.length === 0 && profiles.length === 0) {
|
||||
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
||||
} else {
|
||||
const enriched = await Promise.all(targets.map(async (target) => {
|
||||
try {
|
||||
const [stateResp, metricsResp] = await Promise.all([
|
||||
@@ -104,14 +106,12 @@ export async function loadDashboard() {
|
||||
const running = enriched.filter(t => t.state && t.state.processing);
|
||||
const stopped = enriched.filter(t => !t.state || !t.state.processing);
|
||||
|
||||
let html = '';
|
||||
|
||||
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('');
|
||||
|
||||
html += `<div class="dashboard-section">
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)}
|
||||
${_sectionContent('profiles', profileItems)}
|
||||
</div>`;
|
||||
@@ -121,7 +121,7 @@ export async function loadDashboard() {
|
||||
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('');
|
||||
|
||||
html += `<div class="dashboard-section">
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
|
||||
${_sectionContent('running', runningItems)}
|
||||
</div>`;
|
||||
@@ -130,13 +130,29 @@ export async function loadDashboard() {
|
||||
if (stopped.length > 0) {
|
||||
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join('');
|
||||
|
||||
html += `<div class="dashboard-section">
|
||||
dynamicHtml += `<div class="dashboard-section">
|
||||
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
|
||||
${_sectionContent('stopped', stoppedItems)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
// First load: build everything in one innerHTML to avoid flicker
|
||||
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
|
||||
if (isFirstLoad) {
|
||||
container.innerHTML = `<div class="dashboard-perf-persistent dashboard-section">
|
||||
${_sectionHeader('perf', t('dashboard.section.performance'), '')}
|
||||
${_sectionContent('perf', renderPerfSection())}
|
||||
</div>
|
||||
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
|
||||
initPerfCharts();
|
||||
} else {
|
||||
const dynamic = container.querySelector('.dashboard-dynamic');
|
||||
if (dynamic.innerHTML !== dynamicHtml) {
|
||||
dynamic.innerHTML = dynamicHtml;
|
||||
}
|
||||
}
|
||||
startPerfPolling();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard:', error);
|
||||
|
||||
180
server/src/wled_controller/static/js/features/perf-charts.js
Normal file
180
server/src/wled_controller/static/js/features/perf-charts.js
Normal 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();
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Профили не настроены. Создайте профиль для автоматизации целей.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user