Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/perf-charts.ts
alexei.dolgolyov f2871319cb
Some checks failed
Lint & Test / test (push) Failing after 48s
refactor: comprehensive code quality, security, and release readiness improvements
Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
2026-03-22 00:38:28 +03:00

243 lines
9.4 KiB
TypeScript

/**
* Performance charts — real-time CPU, RAM, GPU usage with Chart.js.
* History is seeded from the server-side ring buffer on init.
*/
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
window.Chart = Chart; // expose globally for targets.js, dashboard.js
import { API_BASE, getHeaders } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { dashboardPollInterval } from '../core/state.ts';
import { createColorPicker, registerColorPicker } from '../core/color-picker.ts';
const MAX_SAMPLES = 120;
const CHART_KEYS = ['cpu', 'ram', 'gpu'];
let _pollTimer: ReturnType<typeof setInterval> | null = null;
let _charts: Record<string, any> = {}; // { cpu: Chart, ram: Chart, gpu: Chart }
let _history: Record<string, number[]> = { cpu: [], ram: [], gpu: [] };
let _hasGpu: boolean | null = null; // null = unknown, true/false after first fetch
function _getColor(key: string): string {
return localStorage.getItem(`perfChartColor_${key}`)
|| getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim()
|| '#4CAF50';
}
function _onChartColorChange(key: string, hex: string | null): void {
if (hex) {
localStorage.setItem(`perfChartColor_${key}`, hex);
} else {
// Reset: remove saved color, fall back to default
localStorage.removeItem(`perfChartColor_${key}`);
hex = _getColor(key);
// Update swatch to show the actual default color
const swatch = document.getElementById(`cp-swatch-perf-${key}`);
if (swatch) swatch.style.background = 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(): string {
// 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'), onPick: undefined, anchor: 'left', showReset: true })}</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'), onPick: undefined, anchor: 'left', showReset: true })}</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'), onPick: undefined, anchor: 'left', showReset: true })}</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: string, key: string): any {
const ctx = document.getElementById(canvasId) as HTMLCanvasElement | null;
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(): Promise<void> {
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(): Promise<void> {
_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(): void {
for (const key of Object.keys(_charts)) {
if (_charts[key]) { _charts[key].destroy(); _charts[key] = null; }
}
}
function _pushSample(key: string, value: number): void {
_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(): Promise<void> {
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(): void {
if (_pollTimer) return;
_fetchPerformance();
_pollTimer = setInterval(_fetchPerformance, dashboardPollInterval);
}
export function stopPerfPolling(): void {
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();
}
});