Batch API endpoints, reduce frontend polling by ~75%, fix resource leaks

Backend: add batch endpoints for target states, metrics, and device
health to replace O(N) individual API calls per poll cycle.
Frontend: use batch endpoints in dashboard/targets/profiles tabs,
fix Chart.js instance leaks, debounce server event reloads, add
i18n active-tab guards, clean up ResizeObserver on pattern editor
close, cache uptime timer DOM refs, increase KC auto-refresh to 2s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 18:55:09 +03:00
parent d4a0f3a7f5
commit 9392741f08
8 changed files with 125 additions and 73 deletions

View File

@@ -19,12 +19,15 @@ import { createColorStripCard } from './color-strips.js';
// createPatternTemplateCard is imported via window.* to avoid circular deps
// (pattern-templates.js calls window.loadTargetsTab)
// Re-render targets tab when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadTargetsTab(); });
// Re-render targets tab when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
if (apiKey && localStorage.getItem('activeTab') === 'targets') loadTargetsTab();
});
// --- FPS sparkline history for target cards ---
// --- FPS sparkline history and chart instances for target cards ---
const _TARGET_MAX_FPS_SAMPLES = 30;
const _targetFpsHistory = {};
const _targetFpsCharts = {};
function _pushTargetFps(targetId, value) {
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
@@ -339,38 +342,31 @@ export async function loadTargetsTab() {
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
}
// Fetch state for each device
const devicesWithState = await Promise.all(
devices.map(async (device) => {
try {
const stateResp = await fetch(`${API_BASE}/devices/${device.id}/state`, { headers: getHeaders() });
const state = stateResp.ok ? await stateResp.json() : {};
return { ...device, state };
} catch {
return device;
}
})
);
// Fetch all device states, target states, and target metrics in batch
const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([
fetchWithAuth('/devices/batch/states'),
fetchWithAuth('/picture-targets/batch/states'),
fetchWithAuth('/picture-targets/batch/metrics'),
]);
const allDeviceStates = batchDevStatesResp.ok ? (await batchDevStatesResp.json()).states : {};
const allTargetStates = batchTgtStatesResp.ok ? (await batchTgtStatesResp.json()).states : {};
const allTargetMetrics = batchTgtMetricsResp.ok ? (await batchTgtMetricsResp.json()).metrics : {};
// Fetch state + metrics for each target (+ colors for KC targets)
const devicesWithState = devices.map(d => ({ ...d, state: allDeviceStates[d.id] || {} }));
// Enrich targets with state/metrics; fetch colors only for running KC targets
const targetsWithState = await Promise.all(
targets.map(async (target) => {
try {
const stateResp = await fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() });
const state = stateResp.ok ? await stateResp.json() : {};
const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() });
const metrics = metricsResp.ok ? await metricsResp.json() : {};
let latestColors = null;
if (target.target_type === 'key_colors' && state.processing) {
try {
const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
if (colorsResp.ok) latestColors = await colorsResp.json();
} catch {}
}
return { ...target, state, metrics, latestColors };
} catch {
return target;
const state = allTargetStates[target.id] || {};
const metrics = allTargetMetrics[target.id] || {};
let latestColors = null;
if (target.target_type === 'key_colors' && state.processing) {
try {
const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
if (colorsResp.ok) latestColors = await colorsResp.json();
} catch {}
}
return { ...target, state, metrics, latestColors };
})
);
@@ -492,6 +488,12 @@ export async function loadTargetsTab() {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
// Destroy old chart instances before DOM rebuild replaces canvases
for (const id of Object.keys(_targetFpsCharts)) {
_targetFpsCharts[id].destroy();
delete _targetFpsCharts[id];
}
// FPS sparkline charts: push samples and init charts after HTML rebuild
const allTargets = [...ledTargets, ...kcTargets];
const runningIds = new Set();
@@ -505,10 +507,11 @@ export async function loadTargetsTab() {
const fpsTarget = target.state.fps_target || 30;
const device = devices.find(d => d.id === target.device_id);
const maxHwFps = device ? _computeMaxFps(device.baud_rate, device.led_count, device.device_type) : null;
_createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps);
const chart = _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps);
if (chart) _targetFpsCharts[target.id] = chart;
}
});
// Clean up history for targets no longer running
// Clean up history and charts for targets no longer running
Object.keys(_targetFpsHistory).forEach(id => {
if (!runningIds.has(id)) delete _targetFpsHistory[id];
});