Split monolithic app.js into native ES modules
Replace the single 7034-line app.js with 17 ES module files organized into core/ (state, api, i18n, ui) and features/ (calibration, dashboard, device-discovery, devices, displays, kc-targets, pattern-templates, profiles, streams, tabs, targets, tutorials) with an app.js entry point that registers ~90 onclick globals on window. No bundler needed — FastAPI serves modules directly via <script type="module">. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
632
server/src/wled_controller/static/js/features/targets.js
Normal file
632
server/src/wled_controller/static/js/features/targets.js
Normal file
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* Targets tab — combined view of devices, LED targets, KC targets, pattern templates.
|
||||
*/
|
||||
|
||||
import {
|
||||
targetEditorInitialValues, setTargetEditorInitialValues,
|
||||
_targetEditorDevices, set_targetEditorDevices,
|
||||
_deviceBrightnessCache,
|
||||
kcWebSockets,
|
||||
} from '../core/state.js';
|
||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, handle401Error } from '../core/api.js';
|
||||
import { t } from '../core/i18n.js';
|
||||
import { lockBody, unlockBody, showToast, showConfirm, setupBackdropClose } from '../core/ui.js';
|
||||
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js';
|
||||
import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
||||
|
||||
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
||||
// (pattern-templates.js calls window.loadTargetsTab)
|
||||
|
||||
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 sources for dropdowns
|
||||
const [devicesResp, sourcesResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/picture-sources'),
|
||||
]);
|
||||
|
||||
const devices = devicesResp.ok ? (await devicesResp.json()).devices || [] : [];
|
||||
const sources = sourcesResp.ok ? (await sourcesResp.json()).streams || [] : [];
|
||||
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;
|
||||
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);
|
||||
});
|
||||
deviceSelect.onchange = _updateStandbyVisibility;
|
||||
|
||||
// Populate source select
|
||||
const sourceSelect = document.getElementById('target-editor-source');
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
const typeIcon = s.stream_type === 'raw' ? '\uD83D\uDDA5\uFE0F' : s.stream_type === 'static_image' ? '\uD83D\uDDBC\uFE0F' : '\uD83C\uDFA8';
|
||||
opt.textContent = `${typeIcon} ${s.name}`;
|
||||
sourceSelect.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 || '';
|
||||
sourceSelect.value = target.picture_source_id || '';
|
||||
document.getElementById('target-editor-fps').value = target.settings?.fps ?? 30;
|
||||
document.getElementById('target-editor-fps-value').textContent = target.settings?.fps ?? 30;
|
||||
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average';
|
||||
document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3;
|
||||
document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3;
|
||||
document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0;
|
||||
document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0;
|
||||
document.getElementById('target-editor-title').textContent = t('targets.edit');
|
||||
} else {
|
||||
// Creating new target
|
||||
document.getElementById('target-editor-id').value = '';
|
||||
document.getElementById('target-editor-name').value = '';
|
||||
deviceSelect.value = '';
|
||||
sourceSelect.value = '';
|
||||
document.getElementById('target-editor-fps').value = 30;
|
||||
document.getElementById('target-editor-fps-value').textContent = '30';
|
||||
document.getElementById('target-editor-interpolation').value = 'average';
|
||||
document.getElementById('target-editor-smoothing').value = 0.3;
|
||||
document.getElementById('target-editor-smoothing-value').textContent = '0.3';
|
||||
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');
|
||||
}
|
||||
|
||||
// Show/hide standby interval based on selected device capabilities
|
||||
_updateStandbyVisibility();
|
||||
|
||||
setTargetEditorInitialValues({
|
||||
name: document.getElementById('target-editor-name').value,
|
||||
device: deviceSelect.value,
|
||||
source: sourceSelect.value,
|
||||
fps: document.getElementById('target-editor-fps').value,
|
||||
interpolation: document.getElementById('target-editor-interpolation').value,
|
||||
smoothing: document.getElementById('target-editor-smoothing').value,
|
||||
standby_interval: document.getElementById('target-editor-standby-interval').value,
|
||||
});
|
||||
|
||||
const modal = document.getElementById('target-editor-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
setupBackdropClose(modal, closeTargetEditorModal);
|
||||
|
||||
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 (
|
||||
document.getElementById('target-editor-name').value !== targetEditorInitialValues.name ||
|
||||
document.getElementById('target-editor-device').value !== targetEditorInitialValues.device ||
|
||||
document.getElementById('target-editor-source').value !== targetEditorInitialValues.source ||
|
||||
document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps ||
|
||||
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation ||
|
||||
document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing ||
|
||||
document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeTargetEditorModal() {
|
||||
if (isTargetEditorDirty()) {
|
||||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
forceCloseTargetEditorModal();
|
||||
}
|
||||
|
||||
export function forceCloseTargetEditorModal() {
|
||||
document.getElementById('target-editor-modal').style.display = 'none';
|
||||
document.getElementById('target-editor-error').style.display = 'none';
|
||||
unlockBody();
|
||||
setTargetEditorInitialValues({});
|
||||
}
|
||||
|
||||
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 sourceId = document.getElementById('target-editor-source').value;
|
||||
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
||||
const interpolation = document.getElementById('target-editor-interpolation').value;
|
||||
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value);
|
||||
const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value);
|
||||
const errorEl = document.getElementById('target-editor-error');
|
||||
|
||||
if (!name) {
|
||||
errorEl.textContent = t('targets.error.name_required');
|
||||
errorEl.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
picture_source_id: sourceId,
|
||||
settings: {
|
||||
fps: fps,
|
||||
interpolation_mode: interpolation,
|
||||
smoothing: smoothing,
|
||||
standby_interval: standbyInterval,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (targetId) {
|
||||
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
payload.target_type = 'led';
|
||||
response = await fetch(`${API_BASE}/picture-targets`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
|
||||
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');
|
||||
forceCloseTargetEditorModal();
|
||||
await loadTargetsTab();
|
||||
} catch (error) {
|
||||
console.error('Error saving target:', error);
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 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);
|
||||
}
|
||||
|
||||
export async function loadTargetsTab() {
|
||||
const container = document.getElementById('targets-panel-content');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
// Fetch devices, targets, sources, and pattern templates in parallel
|
||||
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/picture-sources').catch(() => null),
|
||||
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||
]);
|
||||
|
||||
if (devicesResp.status === 401 || targetsResp.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
const devicesData = await devicesResp.json();
|
||||
const devices = devicesData.devices || [];
|
||||
|
||||
const targetsData = await targetsResp.json();
|
||||
const targets = targetsData.targets || [];
|
||||
|
||||
let sourceMap = {};
|
||||
if (sourcesResp && sourcesResp.ok) {
|
||||
const srcData = await sourcesResp.json();
|
||||
(srcData.streams || []).forEach(s => { sourceMap[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 + 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 + 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.targets')}</h3>
|
||||
<div class="devices-grid">
|
||||
${ledTargets.map(target => createTargetCard(target, deviceMap, sourceMap)).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, sourceMap, 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);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load targets tab:', error);
|
||||
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createTargetCard(target, deviceMap, sourceMap) {
|
||||
const state = target.state || {};
|
||||
const metrics = target.metrics || {};
|
||||
const settings = target.settings || {};
|
||||
|
||||
const isProcessing = state.processing || false;
|
||||
|
||||
const device = deviceMap[target.device_id];
|
||||
const source = sourceMap[target.picture_source_id];
|
||||
const deviceName = device ? device.name : (target.device_id || 'No device');
|
||||
const sourceName = source ? source.name : (target.picture_source_id || 'No 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')}">⚡ ${settings.fps || 30}</span>
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.source')}">📺 ${escapeHtml(sourceName)}</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
${isProcessing ? `
|
||||
<div class="metrics-grid">
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
|
||||
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.current_fps')}</div>
|
||||
<div class="metric-value">${state.fps_current ?? '-'}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
||||
<div class="metric-value">${state.fps_target || 0}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
|
||||
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</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>
|
||||
${state.timing_total_ms != null ? `
|
||||
<div class="timing-breakdown">
|
||||
<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">
|
||||
<span class="timing-seg timing-extract" style="flex:${state.timing_extract_ms}" title="extract ${state.timing_extract_ms}ms"></span>
|
||||
<span class="timing-seg timing-map" style="flex:${state.timing_map_leds_ms}" title="map ${state.timing_map_leds_ms}ms"></span>
|
||||
<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">
|
||||
<span class="timing-legend-item"><span class="timing-dot timing-extract"></span>extract ${state.timing_extract_ms}ms</span>
|
||||
<span class="timing-legend-item"><span class="timing-dot timing-map"></span>map ${state.timing_map_leds_ms}ms</span>
|
||||
<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>
|
||||
<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>
|
||||
${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>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function startTargetProcessing(targetId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.ok) {
|
||||
showToast(t('device.started'), 'success');
|
||||
loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to start: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to start processing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopTargetProcessing(targetId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.ok) {
|
||||
showToast(t('device.stopped'), 'success');
|
||||
loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to stop: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to stop processing', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function startTargetOverlay(targetId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/start`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.ok) {
|
||||
showToast(t('overlay.started'), 'success');
|
||||
loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(t('overlay.error.start') + ': ' + error.detail, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('overlay.error.start'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopTargetOverlay(targetId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/overlay/stop`, {
|
||||
method: 'POST',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.ok) {
|
||||
showToast(t('overlay.stopped'), 'success');
|
||||
loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(t('overlay.error.stop') + ': ' + error.detail, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('overlay.error.stop'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTarget(targetId) {
|
||||
const confirmed = await showConfirm(t('targets.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
|
||||
method: 'DELETE',
|
||||
headers: getHeaders()
|
||||
});
|
||||
if (response.status === 401) { handle401Error(); return; }
|
||||
if (response.ok) {
|
||||
showToast(t('targets.deleted'), 'success');
|
||||
loadTargetsTab();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
showToast(`Failed to delete: ${error.detail}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to delete target', 'error');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user