Add server-side metrics ring buffer, seed dashboard charts from server history

Background task samples system (CPU/RAM/GPU) and per-target (FPS/timing) metrics
every 1s into a 120-sample ring buffer (~2 min). New API endpoint
GET /system/metrics-history returns the buffer. Dashboard charts now seed from
server history on load instead of sessionStorage, surviving page refreshes.

Also removes emoji from brightness source labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 13:21:37 +03:00
parent 8f79b77fe4
commit 425deb9570
9 changed files with 210 additions and 59 deletions

View File

@@ -10,10 +10,9 @@ import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling }
import { startAutoRefresh } from './tabs.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const FPS_HISTORY_KEY = 'dashboard_fps_history';
const MAX_FPS_SAMPLES = 30;
const MAX_FPS_SAMPLES = 120;
let _fpsHistory = _loadFpsHistory(); // { targetId: number[] }
let _fpsHistory = {}; // { targetId: number[] }
let _fpsCharts = {}; // { targetId: Chart }
let _lastRunningIds = []; // sorted target IDs from previous render
let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
@@ -21,19 +20,6 @@ let _uptimeTimer = null;
let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs
let _metricsElements = new Map();
function _loadFpsHistory() {
try {
const raw = sessionStorage.getItem(FPS_HISTORY_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return {};
}
function _saveFpsHistory() {
try { sessionStorage.setItem(FPS_HISTORY_KEY, JSON.stringify(_fpsHistory)); }
catch {}
}
function _pushFps(targetId, value) {
if (!_fpsHistory[targetId]) _fpsHistory[targetId] = [];
_fpsHistory[targetId].push(value);
@@ -120,8 +106,26 @@ function _createFpsChart(canvasId, history, fpsTarget) {
});
}
function _initFpsCharts(runningTargetIds) {
async function _initFpsCharts(runningTargetIds) {
_destroyFpsCharts();
// Seed FPS history from server ring buffer on first load
if (Object.keys(_fpsHistory).length === 0 && runningTargetIds.length > 0) {
try {
const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() });
if (resp.ok) {
const data = await resp.json();
const serverTargets = data.targets || {};
for (const id of runningTargetIds) {
const samples = serverTargets[id] || [];
_fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null);
}
}
} catch {
// Silently ignore — charts will fill from polling
}
}
// Clean up history for targets that are no longer running
for (const id of Object.keys(_fpsHistory)) {
if (!runningTargetIds.includes(id)) delete _fpsHistory[id];
@@ -133,7 +137,7 @@ function _initFpsCharts(runningTargetIds) {
const fpsTarget = parseFloat(canvas.dataset.fpsTarget) || 30;
_fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, history, fpsTarget);
}
_saveFpsHistory();
_cacheMetricsElements(runningTargetIds);
}
@@ -194,7 +198,7 @@ function _updateRunningMetrics(enrichedRunning) {
}
}
}
_saveFpsHistory();
}
function _updateProfilesInPlace(profiles) {
@@ -412,7 +416,7 @@ export async function loadDashboard(forceFullRender = false) {
${_sectionContent('perf', renderPerfSection())}
</div>
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
initPerfCharts();
await initPerfCharts();
} else {
const dynamic = container.querySelector('.dashboard-dynamic');
if (dynamic.innerHTML !== dynamicHtml) {
@@ -421,7 +425,7 @@ export async function loadDashboard(forceFullRender = false) {
}
_lastRunningIds = runningIds;
_cacheUptimeElements();
_initFpsCharts(runningIds);
await _initFpsCharts(runningIds);
_startUptimeTimer();
startPerfPolling();

View File

@@ -1,35 +1,19 @@
/**
* 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';
const MAX_SAMPLES = 60;
const STORAGE_KEY = 'perf_history';
const MAX_SAMPLES = 120;
let _pollTimer = null;
let _charts = {}; // { cpu: Chart, ram: Chart, gpu: Chart }
let _history = _loadHistory();
let _history = { cpu: [], ram: [], gpu: [] };
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">
@@ -88,20 +72,41 @@ function _createChart(canvasId, color, fillColor) {
});
}
/** 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 ['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();
}
}
} catch {
// Silently ignore — charts will fill from polling
}
}
/** Initialize Chart.js instances on the already-mounted canvases. */
export function initPerfCharts() {
export async 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();
}
}
await _seedFromServer();
}
function _destroyCharts() {
@@ -158,8 +163,6 @@ async function _fetchPerformance() {
card.appendChild(noGpu);
}
}
_saveHistory();
} catch {
// Silently ignore fetch errors (e.g., network issues, tab hidden)
}
@@ -176,5 +179,4 @@ export function stopPerfPolling() {
clearInterval(_pollTimer);
_pollTimer = null;
}
_saveHistory();
}

View File

@@ -424,7 +424,7 @@
"kc.pattern_template": "Pattern Template:",
"kc.pattern_template.hint": "Select the rectangle pattern to use for color extraction",
"kc.pattern_template.none": "-- Select a pattern template --",
"kc.brightness_vs": "🔢 Brightness Source:",
"kc.brightness_vs": "Brightness Source:",
"kc.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (multiplied with the manual brightness slider)",
"kc.brightness_vs.none": "None (manual brightness only)",
"kc.created": "Key colors target created successfully",
@@ -810,7 +810,7 @@
"value_source.deleted": "Value source deleted",
"value_source.delete.confirm": "Are you sure you want to delete this value source?",
"value_source.error.name_required": "Please enter a name",
"targets.brightness_vs": "🔢 Brightness Source:",
"targets.brightness_vs": "Brightness Source:",
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
"targets.brightness_vs.none": "None (device brightness)"
}

View File

@@ -424,7 +424,7 @@
"kc.pattern_template": "Шаблон Паттерна:",
"kc.pattern_template.hint": "Выберите шаблон прямоугольников для извлечения цветов",
"kc.pattern_template.none": "-- Выберите шаблон паттерна --",
"kc.brightness_vs": "🔢 Источник Яркости:",
"kc.brightness_vs": "Источник Яркости:",
"kc.brightness_vs.hint": "Опциональный источник значений, динамически управляющий яркостью каждый кадр (умножается на ручной слайдер яркости)",
"kc.brightness_vs.none": "Нет (только ручная яркость)",
"kc.created": "Цель ключевых цветов успешно создана",
@@ -810,7 +810,7 @@
"value_source.deleted": "Источник значений удалён",
"value_source.delete.confirm": "Удалить этот источник значений?",
"value_source.error.name_required": "Введите название",
"targets.brightness_vs": "🔢 Источник яркости:",
"targets.brightness_vs": "Источник яркости:",
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
"targets.brightness_vs.none": "Нет (яркость устройства)"
}