- Draw dashed orange line on target FPS sparkline showing hardware max FPS - Prevent loadTargetsTab polling from rebuilding DOM while a button action (start/stop/overlay/delete) is in flight; add reentry guard on the refresh function itself Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
716 lines
32 KiB
JavaScript
716 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
|
|
document.addEventListener('languageChanged', () => { if (apiKey) loadTargetsTab(); });
|
|
|
|
// --- FPS sparkline history for target cards ---
|
|
const _TARGET_MAX_FPS_SAMPLES = 30;
|
|
const _targetFpsHistory = {};
|
|
|
|
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 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 state + metrics for each target (+ colors for 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;
|
|
}
|
|
})
|
|
);
|
|
|
|
// 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);
|
|
});
|
|
|
|
// 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;
|
|
_createTargetFpsChart(`target-fps-${target.id}`, history, fpsTarget, maxHwFps);
|
|
}
|
|
});
|
|
// Clean up history 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')}">✕</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');
|
|
}
|
|
});
|
|
}
|