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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 state = stateResp.ok ? await stateResp.json() : {};
|
||||
const metrics = metricsResp.ok ? await metricsResp.json() : {};
|
||||
return { ...target, state, metrics };
|
||||
} catch {
|
||||
return target;
|
||||
}
|
||||
const [batchStatesResp, batchMetricsResp] = await Promise.all([
|
||||
fetchWithAuth('/picture-targets/batch/states'),
|
||||
fetchWithAuth('/picture-targets/batch/metrics'),
|
||||
]);
|
||||
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', () => {
|
||||
|
||||
@@ -219,7 +219,7 @@ export function toggleKCTestAutoRefresh() {
|
||||
} catch (e) {
|
||||
stopKCTestAutoRefresh();
|
||||
}
|
||||
}, 1000));
|
||||
}, 2000));
|
||||
updateAutoRefreshButton(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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];
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user