Files
wled-screen-controller-mixed/server/src/wled_controller/static/js/features/targets.js
alexei.dolgolyov 9392741f08 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>
2026-02-22 18:55:09 +03:00

719 lines
32 KiB
JavaScript

/**
* Targets tab — combined view of devices, LED targets, KC targets, pattern templates.
*/
import {
apiKey,
_targetEditorDevices, set_targetEditorDevices,
_deviceBrightnessCache,
kcWebSockets,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, _computeMaxFps } from './devices.js';
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
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 (only if tab is active)
document.addEventListener('languageChanged', () => {
if (apiKey && localStorage.getItem('activeTab') === 'targets') loadTargetsTab();
});
// --- 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] = [];
const h = _targetFpsHistory[targetId];
h.push(value);
if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift();
}
function _createTargetFpsChart(canvasId, history, fpsTarget, maxHwFps) {
const canvas = document.getElementById(canvasId);
if (!canvas) return null;
const datasets = [{
data: [...history],
borderColor: '#2196F3',
backgroundColor: 'rgba(33,150,243,0.12)',
borderWidth: 1.5,
tension: 0.3,
fill: true,
pointRadius: 0,
}];
// Flat line showing hardware max FPS
if (maxHwFps && maxHwFps < fpsTarget * 1.15) {
datasets.push({
data: history.map(() => maxHwFps),
borderColor: 'rgba(255,152,0,0.5)',
borderWidth: 1,
borderDash: [4, 3],
pointRadius: 0,
fill: false,
});
}
return new Chart(canvas, {
type: 'line',
data: { labels: history.map(() => ''), datasets },
options: {
responsive: true, maintainAspectRatio: false,
animation: false,
plugins: { legend: { display: false }, tooltip: { display: false } },
scales: {
x: { display: false },
y: { display: false, min: 0, max: fpsTarget * 1.15 },
},
},
});
}
class TargetEditorModal extends Modal {
constructor() {
super('target-editor-modal');
}
snapshotValues() {
return {
name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value,
css: document.getElementById('target-editor-css').value,
fps: document.getElementById('target-editor-fps').value,
standby_interval: document.getElementById('target-editor-standby-interval').value,
};
}
}
const targetEditorModal = new TargetEditorModal();
let _targetNameManuallyEdited = false;
function _autoGenerateTargetName() {
if (_targetNameManuallyEdited) return;
if (document.getElementById('target-editor-id').value) return;
const deviceSelect = document.getElementById('target-editor-device');
const cssSelect = document.getElementById('target-editor-css');
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
const cssName = cssSelect.selectedOptions[0]?.dataset?.name || '';
if (!deviceName || !cssName) return;
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
}
function _updateFpsRecommendation() {
const el = document.getElementById('target-editor-fps-rec');
const deviceSelect = document.getElementById('target-editor-device');
const device = _targetEditorDevices.find(d => d.id === deviceSelect.value);
if (!device || !device.led_count) {
el.style.display = 'none';
return;
}
const fps = _computeMaxFps(device.baud_rate, device.led_count, device.device_type);
if (fps !== null) {
el.textContent = t('targets.fps.rec', { fps, leds: device.led_count });
el.style.display = '';
} else {
el.style.display = 'none';
}
}
function _updateStandbyVisibility() {
const deviceSelect = document.getElementById('target-editor-device');
const standbyGroup = document.getElementById('target-editor-standby-group');
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
const caps = selectedDevice?.capabilities || [];
standbyGroup.style.display = caps.includes('standby_required') ? '' : 'none';
}
export async function showTargetEditor(targetId = null) {
try {
// Load devices and CSS sources for dropdowns
const [devicesResp, cssResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetchWithAuth('/color-strip-sources'),
]);
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
const cssSources = cssResp.ok ? (await cssResp.json()).sources || [] : [];
set_targetEditorDevices(devices);
// Populate device select
const deviceSelect = document.getElementById('target-editor-device');
deviceSelect.innerHTML = '';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.dataset.name = d.name;
const shortUrl = d.url ? d.url.replace(/^https?:\/\//, '') : '';
const devType = (d.device_type || 'wled').toUpperCase();
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
deviceSelect.appendChild(opt);
});
// Populate color strip source select
const cssSelect = document.getElementById('target-editor-css');
cssSelect.innerHTML = '';
cssSources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
opt.textContent = `🎞️ ${s.name}`;
cssSelect.appendChild(opt);
});
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
document.getElementById('target-editor-id').value = target.id;
document.getElementById('target-editor-name').value = target.name;
deviceSelect.value = target.device_id || '';
cssSelect.value = target.color_strip_source_id || '';
const fps = target.fps ?? 30;
document.getElementById('target-editor-fps').value = fps;
document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.edit');
} else {
// Creating new target — first option is selected by default
document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = '';
document.getElementById('target-editor-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-standby-interval').value = 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add');
}
// Auto-name generation
_targetNameManuallyEdited = !!targetId;
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
deviceSelect.onchange = () => { _updateStandbyVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
cssSelect.onchange = () => _autoGenerateTargetName();
if (!targetId) _autoGenerateTargetName();
// Show/hide standby interval based on selected device capabilities
_updateStandbyVisibility();
_updateFpsRecommendation();
targetEditorModal.snapshot();
targetEditorModal.open();
document.getElementById('target-editor-error').style.display = 'none';
setTimeout(() => document.getElementById('target-editor-name').focus(), 100);
} catch (error) {
console.error('Failed to open target editor:', error);
showToast('Failed to open target editor', 'error');
}
}
export function isTargetEditorDirty() {
return targetEditorModal.isDirty();
}
export async function closeTargetEditorModal() {
await targetEditorModal.close();
}
export function forceCloseTargetEditorModal() {
targetEditorModal.forceClose();
}
export async function saveTargetEditor() {
const targetId = document.getElementById('target-editor-id').value;
const name = document.getElementById('target-editor-name').value.trim();
const deviceId = document.getElementById('target-editor-device').value;
const cssId = document.getElementById('target-editor-css').value;
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
if (!name) {
targetEditorModal.showError(t('targets.error.name_required'));
return;
}
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const payload = {
name,
device_id: deviceId,
color_strip_source_id: cssId,
fps,
standby_interval: standbyInterval,
};
try {
let response;
if (targetId) {
response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'led';
response = await fetchWithAuth('/picture-targets', {
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(targetId ? t('targets.updated') : t('targets.created'), 'success');
targetEditorModal.forceClose();
await loadTargetsTab();
} catch (error) {
if (error.isAuth) return;
console.error('Error saving target:', error);
targetEditorModal.showError(error.message);
}
}
// ===== TARGETS TAB (WLED devices + targets combined) =====
export async function loadTargets() {
// Alias for backward compatibility
await loadTargetsTab();
}
export function switchTargetSubTab(tabKey) {
document.querySelectorAll('.target-sub-tab-btn').forEach(btn =>
btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey)
);
document.querySelectorAll('.target-sub-tab-panel').forEach(panel =>
panel.classList.toggle('active', panel.id === `target-sub-tab-${tabKey}`)
);
localStorage.setItem('activeTargetSubTab', tabKey);
}
let _loadTargetsLock = false;
let _actionInFlight = false;
export async function loadTargetsTab() {
const container = document.getElementById('targets-panel-content');
if (!container) return;
// Skip if another loadTargetsTab or a button action is already running
if (_loadTargetsLock || _actionInFlight) return;
_loadTargetsLock = true;
try {
// Fetch devices, targets, CSS sources, picture sources, and pattern templates in parallel
const [devicesResp, targetsResp, cssResp, psResp, patResp] = await Promise.all([
fetchWithAuth('/devices'),
fetchWithAuth('/picture-targets'),
fetchWithAuth('/color-strip-sources').catch(() => null),
fetchWithAuth('/picture-sources').catch(() => null),
fetchWithAuth('/pattern-templates').catch(() => null),
]);
const devicesData = await devicesResp.json();
const devices = devicesData.devices || [];
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
let colorStripSourceMap = {};
if (cssResp && cssResp.ok) {
const cssData = await cssResp.json();
(cssData.sources || []).forEach(s => { colorStripSourceMap[s.id] = s; });
}
let pictureSourceMap = {};
if (psResp && psResp.ok) {
const psData = await psResp.json();
(psData.streams || []).forEach(s => { pictureSourceMap[s.id] = s; });
}
let patternTemplates = [];
let patternTemplateMap = {};
if (patResp && patResp.ok) {
const patData = await patResp.json();
patternTemplates = patData.templates || [];
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
}
// 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 : {};
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) => {
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 };
})
);
// Build device map for target name resolution
const deviceMap = {};
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
// Group by type
const ledDevices = devicesWithState;
const ledTargets = targetsWithState.filter(t => t.target_type === 'led' || t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
// Backward compat: map stored "wled" sub-tab to "led"
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
if (activeSubTab === 'wled') activeSubTab = 'led';
const subTabs = [
{ key: 'led', icon: '\uD83D\uDCA1', titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
{ key: 'key_colors', icon: '\uD83C\uDFA8', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
];
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
`<button class="target-sub-tab-btn stream-tab-btn${tab.key === activeSubTab ? ' active' : ''}" data-target-sub-tab="${tab.key}" onclick="switchTargetSubTab('${tab.key}')">${tab.icon} ${t(tab.titleKey)} <span class="stream-tab-count">${tab.count}</span></button>`
).join('')}</div>`;
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
// LED panel: devices section + color strip sources section + targets section
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.devices')}</h3>
<div class="devices-grid">
${ledDevices.map(device => createDeviceCard(device)).join('')}
<div class="template-card add-template-card" onclick="showAddDevice()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.color_strips')}</h3>
<div class="devices-grid">
${Object.values(colorStripSourceMap).map(s => createColorStripCard(s, pictureSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showCSSEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.targets')}</h3>
<div class="devices-grid">
${ledTargets.map(target => createTargetCard(target, deviceMap, colorStripSourceMap)).join('')}
<div class="template-card add-template-card" onclick="showTargetEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
</div>`;
// Key Colors panel
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, pictureSourceMap, patternTemplateMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.pattern_templates')}</h3>
<div class="templates-grid">
${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
<div class="template-card add-template-card" onclick="showPatternTemplateEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
</div>`;
container.innerHTML = tabBar + ledPanel + kcPanel;
// Attach event listeners and fetch brightness for device cards
devicesWithState.forEach(device => {
attachDeviceListeners(device.id);
if ((device.capabilities || []).includes('brightness_control')) {
// Only fetch from device if we don't have a cached value yet
if (device.id in _deviceBrightnessCache) {
const bri = _deviceBrightnessCache[device.id];
const slider = document.querySelector(`[data-device-brightness="${device.id}"]`);
if (slider) {
slider.value = bri;
slider.title = Math.round(bri / 255 * 100) + '%';
slider.disabled = false;
}
const wrap = document.querySelector(`[data-brightness-wrap="${device.id}"]`);
if (wrap) wrap.classList.remove('brightness-loading');
} else {
fetchDeviceBrightness(device.id);
}
}
});
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
// Disconnect WebSockets for targets no longer processing
Object.keys(kcWebSockets).forEach(id => {
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();
allTargets.forEach(target => {
if (target.state && target.state.processing) {
runningIds.add(target.id);
if (target.state.fps_actual != null) {
_pushTargetFps(target.id, target.state.fps_actual);
}
const history = _targetFpsHistory[target.id] || [];
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;
const chart = _createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps);
if (chart) _targetFpsCharts[target.id] = chart;
}
});
// Clean up history and charts for targets no longer running
Object.keys(_targetFpsHistory).forEach(id => {
if (!runningIds.has(id)) delete _targetFpsHistory[id];
});
} catch (error) {
if (error.isAuth) return;
console.error('Failed to load targets tab:', error);
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
} finally {
_loadTargetsLock = false;
}
}
export function createTargetCard(target, deviceMap, colorStripSourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const isProcessing = state.processing || false;
const device = deviceMap[target.device_id];
const css = colorStripSourceMap[target.color_strip_source_id];
const deviceName = device ? device.name : (target.device_id || 'No device');
const cssName = css ? css.name : (target.color_strip_source_id || 'No strip source');
// Health info from target state (forwarded from device)
const devOnline = state.device_online || false;
let healthClass = 'health-unknown';
let healthTitle = '';
if (state.device_last_checked !== null && state.device_last_checked !== undefined) {
healthClass = devOnline ? 'health-online' : 'health-offline';
healthTitle = devOnline ? t('device.health.online') : t('device.health.offline');
}
return `
<div class="card" data-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
${escapeHtml(target.name)}
${isProcessing ? `<span class="badge processing">${t('device.status.processing')}</span>` : ''}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${target.fps || 30} fps</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
</div>
<div class="card-content">
${isProcessing ? `
<div class="metrics-grid">
<div class="target-fps-row">
<div class="target-fps-sparkline">
<canvas id="target-fps-${target.id}" data-fps-target="${state.fps_target || 30}"></canvas>
</div>
<div class="target-fps-label">
<span class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}<span class="target-fps-target">/${state.fps_target || 0}</span></span>
</div>
</div>
${state.timing_total_ms != null ? `
<div class="timing-breakdown" style="grid-column:1/-1">
<div class="timing-header">
<div class="metric-label">${t('device.metrics.timing')}</div>
<div class="timing-total"><strong>${state.timing_total_ms}ms</strong></div>
</div>
<div class="timing-bar">
${state.timing_extract_ms != null ? `<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>` : ''}
${state.timing_map_leds_ms != null ? `<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>` : ''}
${state.timing_smooth_ms != null ? `<span class="timing-seg timing-smooth" style="flex:${state.timing_smooth_ms || 0.1}" title="smooth ${state.timing_smooth_ms}ms"></span>` : ''}
<span class="timing-seg timing-send" style="flex:${state.timing_send_ms}" title="send ${state.timing_send_ms}ms"></span>
</div>
<div class="timing-legend">
${state.timing_extract_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>` : ''}
${state.timing_map_leds_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>` : ''}
${state.timing_smooth_ms != null ? `<span class="timing-legend-item"><span class="timing-dot timing-smooth"></span>smooth ${state.timing_smooth_ms}ms</span>` : ''}
<span class="timing-legend-item"><span class="timing-dot timing-send"></span>send ${state.timing_send_ms}ms</span>
</div>
</div>
` : ''}
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value">${metrics.errors_count || 0}</div>
</div>
</div>
` : ''}
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
⏹️
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('device.button.start')}">
▶️
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
${(!css || css.source_type === 'picture') ? (state.overlay_active ? `
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
👁️
</button>
` : `
<button class="btn btn-icon btn-secondary" onclick="startTargetOverlay('${target.id}')" title="${t('overlay.button.show')}">
👁️
</button>
`) : ''}
</div>
</div>
`;
}
async function _targetAction(action) {
_actionInFlight = true;
try {
await action();
} finally {
_actionInFlight = false;
_loadTargetsLock = false; // ensure next poll can run
loadTargetsTab();
}
}
export async function startTargetProcessing(targetId) {
await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}/start`, {
method: 'POST',
});
if (response.ok) {
showToast(t('device.started'), 'success');
} else {
const error = await response.json();
showToast(`Failed to start: ${error.detail}`, 'error');
}
});
}
export async function stopTargetProcessing(targetId) {
await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, {
method: 'POST',
});
if (response.ok) {
showToast(t('device.stopped'), 'success');
} else {
const error = await response.json();
showToast(`Failed to stop: ${error.detail}`, 'error');
}
});
}
export async function startTargetOverlay(targetId) {
await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/start`, {
method: 'POST',
});
if (response.ok) {
showToast(t('overlay.started'), 'success');
} else {
const error = await response.json();
showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
}
});
}
export async function stopTargetOverlay(targetId) {
await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}/overlay/stop`, {
method: 'POST',
});
if (response.ok) {
showToast(t('overlay.stopped'), 'success');
} else {
const error = await response.json();
showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
}
});
}
export async function deleteTarget(targetId) {
const confirmed = await showConfirm(t('targets.delete.confirm'));
if (!confirmed) return;
await _targetAction(async () => {
const response = await fetchWithAuth(`/picture-targets/${targetId}`, {
method: 'DELETE',
});
if (response.ok) {
showToast(t('targets.deleted'), 'success');
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
});
}