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

@@ -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,61 +83,76 @@ export async function loadDashboard() {
const devicesMap = {};
for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; }
// Build dynamic HTML (targets, profiles)
let dynamicHtml = '';
if (targets.length === 0 && profiles.length === 0) {
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
return;
}
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([
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) => {
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 running = enriched.filter(t => t.state && t.state.processing);
const stopped = enriched.filter(t => !t.state || !t.state.processing);
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('');
dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)}
${_sectionContent('profiles', profileItems)}
</div>`;
}
}));
const running = enriched.filter(t => t.state && t.state.processing);
const stopped = enriched.filter(t => !t.state || !t.state.processing);
if (running.length > 0) {
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) {
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('');
if (stopped.length > 0) {
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap)).join('');
html += `<div class="dashboard-section">
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)}
${_sectionContent('profiles', profileItems)}
</div>`;
dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
${_sectionContent('stopped', stoppedItems)}
</div>`;
}
}
if (running.length > 0) {
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">
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
${_sectionContent('running', runningItems)}
</div>`;
// 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;
}
}
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;
startPerfPolling();
} catch (error) {
console.error('Failed to load dashboard:', error);