Stability: - Fix race condition: set _is_running before create_task in target processors - Await probe task after cancel in wled_target_processor - Replace raw fetch() with fetchWithAuth() across devices, kc-targets, pattern-templates - Add try/catch to showTestTemplateModal in streams.js - Wrap blocking I/O in asyncio.to_thread (picture_targets, system restore) - Fix dashboardStopAll to filter only running targets with ok guard Performance: - Vectorize fire effect spark loop with numpy in effect_stream - Vectorize FFT band binning with cumulative sum in analysis.py - Rewrite pixel_processor with vectorized numpy (accept ndarray or list) - Add httpx.AsyncClient connection pooling with lock in wled_provider - Optimize _send_pixels_http to avoid np.hstack allocation in wled_client - Mutate chart arrays in-place in dashboard, perf-charts, targets - Merge dashboard 2-batch fetch into single Promise.all - Hoist frame_time outside loop in mapped_stream Usability: - Fix health check interval load/save in device settings - Swap confirm modal button classes (No=secondary, Yes=danger) - Add aria-modal to audio/value source editors, fix close button aria-labels - Add modal footer close button to settings modal - Add dedicated calibration LED count validation error keys i18n: - Replace ~50 hardcoded English strings with t() calls across 12 JS files - Add 50 new keys to en.json, ru.json, zh.json - Localize inline toasts in index.html with window.t fallback - Add data-i18n to command palette footer - Add localization policy to CLAUDE.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
230 lines
8.5 KiB
JavaScript
230 lines
8.5 KiB
JavaScript
/**
|
|
* 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';
|
|
import { createColorPicker, registerColorPicker } from '../core/color-picker.js';
|
|
|
|
const MAX_SAMPLES = 120;
|
|
const CHART_KEYS = ['cpu', 'ram', 'gpu'];
|
|
|
|
let _pollTimer = null;
|
|
let _charts = {}; // { cpu: Chart, ram: Chart, gpu: Chart }
|
|
let _history = { cpu: [], ram: [], gpu: [] };
|
|
let _hasGpu = null; // null = unknown, true/false after first fetch
|
|
|
|
function _getColor(key) {
|
|
return localStorage.getItem(`perfChartColor_${key}`)
|
|
|| getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim()
|
|
|| '#4CAF50';
|
|
}
|
|
|
|
function _onChartColorChange(key, hex) {
|
|
localStorage.setItem(`perfChartColor_${key}`, hex);
|
|
const chart = _charts[key];
|
|
if (chart) {
|
|
chart.data.datasets[0].borderColor = hex;
|
|
chart.data.datasets[0].backgroundColor = hex + '26';
|
|
chart.update();
|
|
}
|
|
}
|
|
|
|
/** Returns the static HTML for the perf section (canvas placeholders). */
|
|
export function renderPerfSection() {
|
|
// Register callbacks before rendering
|
|
for (const key of CHART_KEYS) {
|
|
registerColorPicker(`perf-${key}`, hex => _onChartColorChange(key, hex));
|
|
}
|
|
|
|
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')} ${createColorPicker({ id: 'perf-cpu', currentColor: _getColor('cpu'), anchor: 'left' })}</span>
|
|
<span class="perf-chart-value" id="perf-cpu-value">-</span>
|
|
</div>
|
|
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-cpu-name"></span><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')} ${createColorPicker({ id: 'perf-ram', currentColor: _getColor('ram'), anchor: 'left' })}</span>
|
|
<span class="perf-chart-value" 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')} ${createColorPicker({ id: 'perf-gpu', currentColor: _getColor('gpu'), anchor: 'left' })}</span>
|
|
<span class="perf-chart-value" id="perf-gpu-value">-</span>
|
|
</div>
|
|
<div class="perf-chart-wrap"><span class="perf-chart-subtitle" id="perf-gpu-name"></span><canvas id="perf-chart-gpu"></canvas></div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function _createChart(canvasId, key) {
|
|
const ctx = document.getElementById(canvasId);
|
|
if (!ctx) return null;
|
|
const color = _getColor(key);
|
|
return new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: Array(MAX_SAMPLES).fill(''),
|
|
datasets: [{
|
|
data: [],
|
|
borderColor: color,
|
|
backgroundColor: color + '26',
|
|
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 },
|
|
},
|
|
});
|
|
}
|
|
|
|
/** 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 CHART_KEYS) {
|
|
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 async function initPerfCharts() {
|
|
_destroyCharts();
|
|
_charts.cpu = _createChart('perf-chart-cpu', 'cpu');
|
|
_charts.ram = _createChart('perf-chart-ram', 'ram');
|
|
_charts.gpu = _createChart('perf-chart-gpu', 'gpu');
|
|
await _seedFromServer();
|
|
}
|
|
|
|
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;
|
|
const ds = chart.data.datasets[0].data;
|
|
ds.length = 0;
|
|
ds.push(..._history[key]);
|
|
// Ensure labels array matches length (reuse existing array)
|
|
while (chart.data.labels.length < ds.length) chart.data.labels.push('');
|
|
chart.data.labels.length = ds.length;
|
|
chart.update('none');
|
|
}
|
|
|
|
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)}%`;
|
|
if (data.cpu_name) {
|
|
const nameEl = document.getElementById('perf-cpu-name');
|
|
if (nameEl && !nameEl.textContent) nameEl.textContent = data.cpu_name;
|
|
}
|
|
|
|
// 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`;
|
|
if (data.gpu.name) {
|
|
const nameEl = document.getElementById('perf-gpu-name');
|
|
if (nameEl && !nameEl.textContent) nameEl.textContent = data.gpu.name;
|
|
}
|
|
} 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);
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently ignore fetch errors (e.g., network issues, tab hidden)
|
|
}
|
|
}
|
|
|
|
export function startPerfPolling() {
|
|
if (_pollTimer) return;
|
|
_fetchPerformance();
|
|
_pollTimer = setInterval(_fetchPerformance, dashboardPollInterval);
|
|
}
|
|
|
|
export function stopPerfPolling() {
|
|
if (_pollTimer) {
|
|
clearInterval(_pollTimer);
|
|
_pollTimer = null;
|
|
}
|
|
}
|
|
|
|
// Pause polling when browser tab becomes hidden, resume when visible
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
stopPerfPolling();
|
|
} else {
|
|
// Only resume if dashboard is active
|
|
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
|
|
if (activeTab === 'dashboard') startPerfPolling();
|
|
}
|
|
});
|