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

@@ -202,6 +202,15 @@ async def discover_devices(
)
@router.get("/api/v1/devices/batch/states", tags=["Devices"])
async def batch_device_states(
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get health/connection state for all devices in a single request."""
return {"states": manager.get_all_device_health_dicts()}
@router.get("/api/v1/devices/{device_id}", response_model=DeviceResponse, tags=["Devices"])
async def get_device(
device_id: str,

View File

@@ -185,6 +185,24 @@ async def list_targets(
return PictureTargetListResponse(targets=responses, count=len(responses))
@router.get("/api/v1/picture-targets/batch/states", tags=["Processing"])
async def batch_target_states(
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get processing state for all targets in a single request."""
return {"states": manager.get_all_target_states()}
@router.get("/api/v1/picture-targets/batch/metrics", tags=["Metrics"])
async def batch_target_metrics(
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get metrics for all targets in a single request."""
return {"metrics": manager.get_all_target_metrics()}
@router.get("/api/v1/picture-targets/{target_id}", response_model=PictureTargetResponse, tags=["Targets"])
async def get_target(
target_id: str,

View File

@@ -407,6 +407,18 @@ class ProcessorManager:
"""Get detailed metrics for a target (any type)."""
return self._get_processor(target_id).get_metrics()
def get_all_target_states(self) -> Dict[str, dict]:
"""Get processing state for all targets (with device health merged)."""
return {tid: self.get_target_state(tid) for tid in self._processors}
def get_all_target_metrics(self) -> Dict[str, dict]:
"""Get metrics for all targets."""
return {tid: proc.get_metrics() for tid, proc in self._processors.items()}
def get_all_device_health_dicts(self) -> Dict[str, dict]:
"""Get health/connection state for all devices."""
return {did: self.get_device_health_dict(did) for did in self._devices}
def is_target_processing(self, target_id: str) -> bool:
"""Check if target is currently processing."""
return self._get_processor(target_id).is_running

View File

@@ -18,6 +18,7 @@ let _fpsCharts = {}; // { targetId: Chart }
let _lastRunningIds = []; // sorted target IDs from previous render
let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
let _uptimeTimer = null;
let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs
let _metricsElements = new Map();
function _loadFpsHistory() {
@@ -50,11 +51,19 @@ function _getInterpolatedUptime(targetId) {
return base.seconds + elapsed;
}
function _cacheUptimeElements() {
_uptimeElements = {};
for (const id of _lastRunningIds) {
const el = document.querySelector(`[data-uptime-text="${id}"]`);
if (el) _uptimeElements[id] = el;
}
}
function _startUptimeTimer() {
if (_uptimeTimer) return;
_uptimeTimer = setInterval(() => {
for (const id of _lastRunningIds) {
const el = document.querySelector(`[data-uptime-text="${id}"]`);
const el = _uptimeElements[id];
if (!el) continue;
const seconds = _getInterpolatedUptime(id);
if (seconds != null) {
@@ -70,6 +79,7 @@ function _stopUptimeTimer() {
_uptimeTimer = null;
}
_uptimeBase = {};
_uptimeElements = {};
}
function _destroyFpsCharts() {
@@ -320,18 +330,16 @@ export async function loadDashboard(forceFullRender = false) {
if (targets.length === 0 && profiles.length === 0) {
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 [batchStatesResp, batchMetricsResp] = await Promise.all([
fetchWithAuth('/picture-targets/batch/states'),
fetchWithAuth('/picture-targets/batch/metrics'),
]);
const state = stateResp.ok ? await stateResp.json() : {};
const metrics = metricsResp.ok ? await metricsResp.json() : {};
return { ...target, state, metrics };
} catch {
return target;
}
const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
const allMetrics = batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {};
const enriched = targets.map(target => ({
...target,
state: allStates[target.id] || {},
metrics: allMetrics[target.id] || {},
}));
const running = enriched.filter(t => t.state && t.state.processing);
@@ -412,6 +420,7 @@ export async function loadDashboard(forceFullRender = false) {
}
}
_lastRunningIds = runningIds;
_cacheUptimeElements();
_initFpsCharts(runningIds);
_startUptimeTimer();
startPerfPolling();
@@ -637,13 +646,15 @@ function _isDashboardActive() {
return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard';
}
document.addEventListener('server:state_change', () => {
if (_isDashboardActive()) loadDashboard();
});
let _eventDebounceTimer = null;
function _debouncedDashboardReload(forceFullRender = false) {
if (!_isDashboardActive()) return;
clearTimeout(_eventDebounceTimer);
_eventDebounceTimer = setTimeout(() => loadDashboard(forceFullRender), 300);
}
document.addEventListener('server:profile_state_changed', () => {
if (_isDashboardActive()) loadDashboard(true);
});
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
document.addEventListener('server:profile_state_changed', () => _debouncedDashboardReload(true));
// Re-render dashboard when language changes
document.addEventListener('languageChanged', () => {

View File

@@ -219,7 +219,7 @@ export function toggleKCTestAutoRefresh() {
} catch (e) {
stopKCTestAutoRefresh();
}
}, 1000));
}, 2000));
updateAutoRefreshButton(true);
}
}

View File

@@ -36,6 +36,13 @@ class PatternTemplateModal extends Modal {
setPatternEditorRects([]);
setPatternEditorSelectedIdx(-1);
setPatternEditorBgImage(null);
// Clean up ResizeObserver to prevent leaks
const canvas = document.getElementById('pattern-canvas');
if (canvas?._patternResizeObserver) {
canvas._patternResizeObserver.disconnect();
canvas._patternResizeObserver = null;
}
if (canvas) canvas._patternEventsAttached = false;
}
}

View File

@@ -10,8 +10,10 @@ import { Modal } from '../core/modal.js';
const profileModal = new Modal('profile-editor-modal');
// Re-render profiles when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadProfiles(); });
// Re-render profiles when language changes (only if tab is active)
document.addEventListener('languageChanged', () => {
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'profiles') loadProfiles();
});
// React to real-time profile state changes from global events WS
document.addEventListener('server:profile_state_changed', () => {
@@ -33,16 +35,11 @@ export async function loadProfiles() {
const data = await profilesResp.json();
const targetsData = targetsResp.ok ? await targetsResp.json() : { targets: [] };
const allTargets = targetsData.targets || [];
// State is not included in the list response — fetch per-target in parallel
const stateResults = await Promise.all(
allTargets.map(tgt =>
fetchWithAuth(`/picture-targets/${tgt.id}/state`)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
)
);
// Batch fetch all target states in a single request
const batchStatesResp = await fetchWithAuth('/picture-targets/batch/states');
const allStates = batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
const runningTargetIds = new Set(
allTargets.filter((_, i) => stateResults[i]?.processing).map(tgt => tgt.id)
allTargets.filter(tgt => allStates[tgt.id]?.processing).map(tgt => tgt.id)
);
set_profilesCache(data.profiles);
renderProfiles(data.profiles, runningTargetIds);
@@ -390,16 +387,11 @@ export async function toggleProfileTargets(profileId) {
const profileResp = await fetchWithAuth(`/profiles/${profileId}`);
if (!profileResp.ok) throw new Error('Failed to load profile');
const profile = await profileResp.json();
// Fetch actual processing state for each target in this profile
const stateResults = await Promise.all(
profile.target_ids.map(id =>
fetchWithAuth(`/picture-targets/${id}/state`)
.then(r => r.ok ? r.json() : null)
.catch(() => null)
)
);
// Batch fetch all target states to determine which are running
const batchResp = await fetchWithAuth('/picture-targets/batch/states');
const allStates = batchResp.ok ? (await batchResp.json()).states : {};
const runningSet = new Set(
profile.target_ids.filter((_, i) => stateResults[i]?.processing)
profile.target_ids.filter(id => allStates[id]?.processing)
);
const shouldStop = profile.target_ids.some(id => runningSet.has(id));
await Promise.all(profile.target_ids.map(id =>

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,27 +342,23 @@ 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() : {};
const state = allTargetStates[target.id] || {};
const metrics = allTargetMetrics[target.id] || {};
let latestColors = null;
if (target.target_type === 'key_colors' && state.processing) {
try {
@@ -368,9 +367,6 @@ export async function loadTargetsTab() {
} catch {}
}
return { ...target, state, metrics, latestColors };
} catch {
return target;
}
})
);
@@ -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];
});