- Add `tags: List[str]` field to all 13 entity types (devices, output targets, CSS sources, picture sources, audio sources, value sources, sync clocks, automations, scene presets, capture/audio/PP/pattern templates) - Update all stores, schemas, and route handlers for tag CRUD - Add GET /api/v1/tags endpoint aggregating unique tags across all stores - Create TagInput component with chip display, autocomplete dropdown, keyboard navigation, and API-backed suggestions - Display tag chips on all entity cards (searchable via existing text filter) - Add tag input to all 14 editor modals with dirty check support - Add CSS styles and i18n keys (en/ru/zh) for tag UI - Also includes code review fixes: thread safety, perf, store dedup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1365 lines
63 KiB
JavaScript
1365 lines
63 KiB
JavaScript
/**
|
|
* Targets tab — combined view of devices, LED targets, KC targets, pattern templates.
|
|
*/
|
|
|
|
import {
|
|
apiKey,
|
|
_targetEditorDevices, set_targetEditorDevices,
|
|
_deviceBrightnessCache,
|
|
kcWebSockets,
|
|
ledPreviewWebSockets,
|
|
_cachedValueSources, valueSourcesCache,
|
|
streamsCache, audioSourcesCache, syncClocksCache,
|
|
colorStripSourcesCache, devicesCache, outputTargetsCache, patternTemplatesCache,
|
|
} from '../core/state.js';
|
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isOpenrgbDevice } from '../core/api.js';
|
|
import { t } from '../core/i18n.js';
|
|
import { showToast, showConfirm, formatUptime, setTabRefreshing } from '../core/ui.js';
|
|
import { Modal } from '../core/modal.js';
|
|
import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, enrichOpenrgbZoneBadges, _computeMaxFps, getZoneCountCache } from './devices.js';
|
|
import { _splitOpenrgbZone } from './device-discovery.js';
|
|
import { createKCTargetCard, patchKCTargetMetrics, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
|
import { createColorStripCard } from './color-strips.js';
|
|
import {
|
|
getValueSourceIcon, getTargetTypeIcon, getDeviceTypeIcon, getColorStripIcon,
|
|
ICON_CLONE, ICON_EDIT, ICON_START, ICON_STOP,
|
|
ICON_LED, ICON_FPS, ICON_OVERLAY, ICON_LED_PREVIEW,
|
|
ICON_GLOBE, ICON_RADIO, ICON_PLUG, ICON_FILM, ICON_SUN_DIM, ICON_TARGET_ICON, ICON_HELP,
|
|
ICON_WARNING, ICON_PALETTE, ICON_WRENCH,
|
|
} from '../core/icons.js';
|
|
import { EntitySelect } from '../core/entity-palette.js';
|
|
import { wrapCard } from '../core/card-colors.js';
|
|
import { TagInput, renderTagChips } from '../core/tag-input.js';
|
|
import { CardSection } from '../core/card-sections.js';
|
|
import { updateSubTabHash, updateTabBadge } from './tabs.js';
|
|
|
|
// createPatternTemplateCard is imported via window.* to avoid circular deps
|
|
// (pattern-templates.js calls window.loadTargetsTab)
|
|
|
|
// ── Card section instances ──
|
|
const csDevices = new CardSection('led-devices', { titleKey: 'targets.section.devices', gridClass: 'devices-grid', addCardOnclick: "showAddDevice()", keyAttr: 'data-device-id' });
|
|
const csColorStrips = new CardSection('led-css', { titleKey: 'targets.section.color_strips', gridClass: 'devices-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' });
|
|
const csLedTargets = new CardSection('led-targets', { titleKey: 'targets.section.targets', gridClass: 'devices-grid', addCardOnclick: "showTargetEditor()", keyAttr: 'data-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllLedTargets()" data-stop-all="led">${ICON_STOP}</button>` });
|
|
const csKCTargets = new CardSection('kc-targets', { titleKey: 'targets.section.key_colors', gridClass: 'devices-grid', addCardOnclick: "showKCEditor()", keyAttr: 'data-kc-target-id', headerExtra: `<button class="btn btn-sm btn-danger" onclick="event.stopPropagation(); stopAllKCTargets()" data-stop-all="kc">${ICON_STOP}</button>` });
|
|
const csPatternTemplates = new CardSection('kc-patterns', { titleKey: 'targets.section.pattern_templates', gridClass: 'templates-grid', addCardOnclick: "showPatternTemplateEditor()", keyAttr: 'data-pattern-template-id' });
|
|
|
|
// 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 = {}; // fps_actual (rolling avg)
|
|
const _targetFpsCurrentHistory = {}; // fps_current (sends/sec)
|
|
const _targetFpsCharts = {};
|
|
|
|
function _pushTargetFps(targetId, actual, current) {
|
|
if (!_targetFpsHistory[targetId]) _targetFpsHistory[targetId] = [];
|
|
const h = _targetFpsHistory[targetId];
|
|
h.push(actual);
|
|
if (h.length > _TARGET_MAX_FPS_SAMPLES) h.shift();
|
|
|
|
if (!_targetFpsCurrentHistory[targetId]) _targetFpsCurrentHistory[targetId] = [];
|
|
const c = _targetFpsCurrentHistory[targetId];
|
|
c.push(current);
|
|
if (c.length > _TARGET_MAX_FPS_SAMPLES) c.shift();
|
|
}
|
|
|
|
function _createTargetFpsChart(canvasId, actualHistory, currentHistory, fpsTarget, maxHwFps) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return null;
|
|
const labels = actualHistory.map(() => '');
|
|
const datasets = [
|
|
{
|
|
data: [...actualHistory],
|
|
borderColor: '#2196F3',
|
|
backgroundColor: 'rgba(33,150,243,0.12)',
|
|
borderWidth: 1.5,
|
|
tension: 0.3,
|
|
fill: true,
|
|
pointRadius: 0,
|
|
},
|
|
{
|
|
data: [...currentHistory],
|
|
borderColor: '#4CAF50',
|
|
borderWidth: 1.5,
|
|
tension: 0.3,
|
|
fill: false,
|
|
pointRadius: 0,
|
|
},
|
|
];
|
|
// Flat line showing hardware max FPS
|
|
if (maxHwFps && maxHwFps < fpsTarget * 1.15) {
|
|
datasets.push({
|
|
data: actualHistory.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, 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 },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function _updateTargetFpsChart(targetId, fpsTarget) {
|
|
const chart = _targetFpsCharts[targetId];
|
|
if (!chart) return;
|
|
const actualH = _targetFpsHistory[targetId] || [];
|
|
const currentH = _targetFpsCurrentHistory[targetId] || [];
|
|
// Mutate in-place to avoid array copies
|
|
const ds0 = chart.data.datasets[0].data;
|
|
ds0.length = 0;
|
|
ds0.push(...actualH);
|
|
const ds1 = chart.data.datasets[1].data;
|
|
ds1.length = 0;
|
|
ds1.push(...currentH);
|
|
while (chart.data.labels.length < ds0.length) chart.data.labels.push('');
|
|
chart.data.labels.length = ds0.length;
|
|
chart.options.scales.y.max = fpsTarget * 1.15;
|
|
chart.update('none');
|
|
}
|
|
|
|
function _updateSubTabCounts(subTabs) {
|
|
subTabs.forEach(tab => {
|
|
const btn = document.querySelector(`.target-sub-tab-btn[data-target-sub-tab="${tab.key}"] .stream-tab-count`);
|
|
if (btn) btn.textContent = tab.count;
|
|
});
|
|
}
|
|
|
|
// --- Editor state ---
|
|
let _editorCssSources = []; // populated when editor opens
|
|
let _targetTagsInput = null;
|
|
|
|
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,
|
|
protocol: document.getElementById('target-editor-protocol').value,
|
|
css_source: document.getElementById('target-editor-css-source').value,
|
|
brightness_vs: document.getElementById('target-editor-brightness-vs').value,
|
|
brightness_threshold: document.getElementById('target-editor-brightness-threshold').value,
|
|
fps: document.getElementById('target-editor-fps').value,
|
|
keepalive_interval: document.getElementById('target-editor-keepalive-interval').value,
|
|
adaptive_fps: document.getElementById('target-editor-adaptive-fps').checked,
|
|
tags: JSON.stringify(_targetTagsInput ? _targetTagsInput.getValue() : []),
|
|
};
|
|
}
|
|
}
|
|
|
|
const targetEditorModal = new TargetEditorModal();
|
|
|
|
function _protocolBadge(device, target) {
|
|
const dt = device?.device_type;
|
|
if (!dt || dt === 'wled') {
|
|
const proto = target.protocol === 'http' ? 'HTTP' : 'DDP';
|
|
return `${target.protocol === 'http' ? ICON_GLOBE : ICON_RADIO} ${proto}`;
|
|
}
|
|
const map = {
|
|
openrgb: [ICON_PALETTE, 'OpenRGB SDK'],
|
|
adalight: [ICON_PLUG, t('targets.protocol.serial')],
|
|
ambiled: [ICON_PLUG, t('targets.protocol.serial')],
|
|
mqtt: [ICON_GLOBE, 'MQTT'],
|
|
ws: [ICON_GLOBE, 'WebSocket'],
|
|
mock: [ICON_WRENCH, 'Mock'],
|
|
};
|
|
const [icon, label] = map[dt] || [ICON_PLUG, dt];
|
|
return `${icon} ${label}`;
|
|
}
|
|
|
|
let _targetNameManuallyEdited = false;
|
|
|
|
function _autoGenerateTargetName() {
|
|
if (_targetNameManuallyEdited) return;
|
|
if (document.getElementById('target-editor-id').value) return;
|
|
const deviceSelect = document.getElementById('target-editor-device');
|
|
const deviceName = deviceSelect.selectedOptions[0]?.dataset?.name || '';
|
|
const cssSelect = document.getElementById('target-editor-css-source');
|
|
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 _updateDeviceInfo() {
|
|
const deviceSelect = document.getElementById('target-editor-device');
|
|
const el = document.getElementById('target-editor-device-info');
|
|
const device = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
|
if (device && device.led_count) {
|
|
el.textContent = `${device.led_count} LEDs`;
|
|
el.style.display = '';
|
|
} else {
|
|
el.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function _updateKeepaliveVisibility() {
|
|
const deviceSelect = document.getElementById('target-editor-device');
|
|
const keepaliveGroup = document.getElementById('target-editor-keepalive-group');
|
|
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
|
const caps = selectedDevice?.capabilities || [];
|
|
keepaliveGroup.style.display = caps.includes('standby_required') ? '' : 'none';
|
|
}
|
|
|
|
function _updateSpecificSettingsVisibility() {
|
|
const deviceSelect = document.getElementById('target-editor-device');
|
|
const selectedDevice = _targetEditorDevices.find(d => d.id === deviceSelect.value);
|
|
const isWled = !selectedDevice || selectedDevice.device_type === 'wled';
|
|
// Hide entire Specific Settings section for non-WLED devices (protocol + keepalive are WLED-only)
|
|
document.getElementById('target-editor-device-settings').style.display = isWled ? '' : 'none';
|
|
}
|
|
|
|
function _updateBrightnessThresholdVisibility() {
|
|
// Always visible — threshold considers both brightness source and pixel content
|
|
document.getElementById('target-editor-brightness-threshold-group').style.display = '';
|
|
}
|
|
|
|
// ── EntitySelect instances for target editor ──
|
|
let _deviceEntitySelect = null;
|
|
let _cssEntitySelect = null;
|
|
let _brightnessVsEntitySelect = null;
|
|
|
|
function _populateCssDropdown(selectedId = '') {
|
|
const select = document.getElementById('target-editor-css-source');
|
|
select.innerHTML = _editorCssSources.map(s =>
|
|
`<option value="${s.id}" data-name="${escapeHtml(s.name)}" ${s.id === selectedId ? 'selected' : ''}>${escapeHtml(s.name)}</option>`
|
|
).join('');
|
|
}
|
|
|
|
function _populateBrightnessVsDropdown(selectedId = '') {
|
|
const select = document.getElementById('target-editor-brightness-vs');
|
|
let html = `<option value="">${t('targets.brightness_vs.none')}</option>`;
|
|
_cachedValueSources.forEach(vs => {
|
|
html += `<option value="${vs.id}"${vs.id === selectedId ? ' selected' : ''}>${escapeHtml(vs.name)}</option>`;
|
|
});
|
|
select.innerHTML = html;
|
|
}
|
|
|
|
function _ensureTargetEntitySelects() {
|
|
// Device
|
|
if (_deviceEntitySelect) _deviceEntitySelect.destroy();
|
|
_deviceEntitySelect = new EntitySelect({
|
|
target: document.getElementById('target-editor-device'),
|
|
getItems: () => _targetEditorDevices.map(d => ({
|
|
value: d.id,
|
|
label: d.name,
|
|
icon: getDeviceTypeIcon(d.device_type),
|
|
desc: (d.device_type || 'wled').toUpperCase() + (d.url ? ` · ${d.url.replace(/^https?:\/\//, '')}` : ''),
|
|
})),
|
|
placeholder: t('palette.search'),
|
|
});
|
|
|
|
// CSS source
|
|
if (_cssEntitySelect) _cssEntitySelect.destroy();
|
|
_cssEntitySelect = new EntitySelect({
|
|
target: document.getElementById('target-editor-css-source'),
|
|
getItems: () => _editorCssSources.map(s => ({
|
|
value: s.id,
|
|
label: s.name,
|
|
icon: getColorStripIcon(s.source_type),
|
|
desc: s.source_type,
|
|
})),
|
|
placeholder: t('palette.search'),
|
|
});
|
|
|
|
// Brightness value source
|
|
if (_brightnessVsEntitySelect) _brightnessVsEntitySelect.destroy();
|
|
_brightnessVsEntitySelect = new EntitySelect({
|
|
target: document.getElementById('target-editor-brightness-vs'),
|
|
getItems: () => _cachedValueSources.map(vs => ({
|
|
value: vs.id,
|
|
label: vs.name,
|
|
icon: getValueSourceIcon(vs.source_type),
|
|
desc: vs.source_type,
|
|
})),
|
|
placeholder: t('palette.search'),
|
|
allowNone: true,
|
|
noneLabel: t('targets.brightness_vs.none'),
|
|
});
|
|
}
|
|
|
|
export async function showTargetEditor(targetId = null, cloneData = null) {
|
|
try {
|
|
// Load devices, CSS sources, and value sources for dropdowns
|
|
const [devices, cssSources] = await Promise.all([
|
|
devicesCache.fetch().catch(() => []),
|
|
colorStripSourcesCache.fetch().catch(() => []),
|
|
valueSourcesCache.fetch(),
|
|
]);
|
|
|
|
set_targetEditorDevices(devices);
|
|
_editorCssSources = cssSources;
|
|
|
|
// 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.startsWith('http') ? d.url.replace(/^https?:\/\//, '') : '';
|
|
const devType = (d.device_type || 'wled').toUpperCase();
|
|
opt.textContent = `${d.name} [${devType}]${shortUrl ? ' (' + shortUrl + ')' : ''}`;
|
|
deviceSelect.appendChild(opt);
|
|
});
|
|
|
|
let _editorTags = [];
|
|
if (targetId) {
|
|
// Editing existing target
|
|
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
|
if (!resp.ok) throw new Error('Failed to load target');
|
|
const target = await resp.json();
|
|
_editorTags = target.tags || [];
|
|
|
|
document.getElementById('target-editor-id').value = target.id;
|
|
document.getElementById('target-editor-name').value = target.name;
|
|
deviceSelect.value = target.device_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-keepalive-interval').value = target.keepalive_interval ?? 1.0;
|
|
document.getElementById('target-editor-keepalive-interval-value').textContent = target.keepalive_interval ?? 1.0;
|
|
document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.edit')}`;
|
|
|
|
const thresh = target.min_brightness_threshold ?? 0;
|
|
document.getElementById('target-editor-brightness-threshold').value = thresh;
|
|
document.getElementById('target-editor-brightness-threshold-value').textContent = thresh;
|
|
|
|
document.getElementById('target-editor-adaptive-fps').checked = target.adaptive_fps ?? false;
|
|
document.getElementById('target-editor-protocol').value = target.protocol || 'ddp';
|
|
|
|
_populateCssDropdown(target.color_strip_source_id || '');
|
|
_populateBrightnessVsDropdown(target.brightness_value_source_id || '');
|
|
} else if (cloneData) {
|
|
// Cloning — create mode but pre-filled from clone data
|
|
_editorTags = cloneData.tags || [];
|
|
document.getElementById('target-editor-id').value = '';
|
|
document.getElementById('target-editor-name').value = (cloneData.name || '') + ' (Copy)';
|
|
deviceSelect.value = cloneData.device_id || '';
|
|
const fps = cloneData.fps ?? 30;
|
|
document.getElementById('target-editor-fps').value = fps;
|
|
document.getElementById('target-editor-fps-value').textContent = fps;
|
|
document.getElementById('target-editor-keepalive-interval').value = cloneData.keepalive_interval ?? 1.0;
|
|
document.getElementById('target-editor-keepalive-interval-value').textContent = cloneData.keepalive_interval ?? 1.0;
|
|
document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
|
|
|
const cloneThresh = cloneData.min_brightness_threshold ?? 0;
|
|
document.getElementById('target-editor-brightness-threshold').value = cloneThresh;
|
|
document.getElementById('target-editor-brightness-threshold-value').textContent = cloneThresh;
|
|
|
|
document.getElementById('target-editor-adaptive-fps').checked = cloneData.adaptive_fps ?? false;
|
|
document.getElementById('target-editor-protocol').value = cloneData.protocol || 'ddp';
|
|
|
|
_populateCssDropdown(cloneData.color_strip_source_id || '');
|
|
_populateBrightnessVsDropdown(cloneData.brightness_value_source_id || '');
|
|
} else {
|
|
// Creating new target
|
|
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-keepalive-interval').value = 1.0;
|
|
document.getElementById('target-editor-keepalive-interval-value').textContent = '1.0';
|
|
document.getElementById('target-editor-title').innerHTML = `${ICON_TARGET_ICON} ${t('targets.add')}`;
|
|
|
|
document.getElementById('target-editor-brightness-threshold').value = 0;
|
|
document.getElementById('target-editor-brightness-threshold-value').textContent = '0';
|
|
|
|
document.getElementById('target-editor-adaptive-fps').checked = false;
|
|
document.getElementById('target-editor-protocol').value = 'ddp';
|
|
|
|
_populateCssDropdown('');
|
|
_populateBrightnessVsDropdown('');
|
|
}
|
|
|
|
// Entity palette selectors
|
|
_ensureTargetEntitySelects();
|
|
|
|
// Auto-name generation
|
|
_targetNameManuallyEdited = !!(targetId || cloneData);
|
|
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
|
window._targetAutoName = _autoGenerateTargetName;
|
|
deviceSelect.onchange = () => { _updateDeviceInfo(); _updateKeepaliveVisibility(); _updateSpecificSettingsVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
|
|
document.getElementById('target-editor-css-source').onchange = () => { _autoGenerateTargetName(); };
|
|
document.getElementById('target-editor-brightness-vs').onchange = () => { _updateBrightnessThresholdVisibility(); };
|
|
if (!targetId && !cloneData) _autoGenerateTargetName();
|
|
|
|
// Show/hide conditional fields
|
|
_updateDeviceInfo();
|
|
_updateKeepaliveVisibility();
|
|
_updateSpecificSettingsVisibility();
|
|
_updateFpsRecommendation();
|
|
_updateBrightnessThresholdVisibility();
|
|
|
|
// Tags
|
|
if (_targetTagsInput) _targetTagsInput.destroy();
|
|
_targetTagsInput = new TagInput(document.getElementById('target-tags-container'), {
|
|
placeholder: window.t ? t('tags.placeholder') : 'Add tag...'
|
|
});
|
|
_targetTagsInput.setValue(_editorTags);
|
|
|
|
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(t('target.error.editor_open_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
export function isTargetEditorDirty() {
|
|
return targetEditorModal.isDirty();
|
|
}
|
|
|
|
export async function closeTargetEditorModal() {
|
|
await targetEditorModal.close();
|
|
}
|
|
|
|
export function forceCloseTargetEditorModal() {
|
|
if (_targetTagsInput) { _targetTagsInput.destroy(); _targetTagsInput = null; }
|
|
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 standbyInterval = parseFloat(document.getElementById('target-editor-keepalive-interval').value);
|
|
|
|
if (!name) {
|
|
targetEditorModal.showError(t('targets.error.name_required'));
|
|
return;
|
|
}
|
|
|
|
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
|
const colorStripSourceId = document.getElementById('target-editor-css-source').value;
|
|
|
|
const brightnessVsId = document.getElementById('target-editor-brightness-vs').value;
|
|
const minBrightnessThreshold = parseInt(document.getElementById('target-editor-brightness-threshold').value) || 0;
|
|
|
|
const adaptiveFps = document.getElementById('target-editor-adaptive-fps').checked;
|
|
const protocol = document.getElementById('target-editor-protocol').value;
|
|
|
|
const payload = {
|
|
name,
|
|
device_id: deviceId,
|
|
color_strip_source_id: colorStripSourceId,
|
|
brightness_value_source_id: brightnessVsId,
|
|
min_brightness_threshold: minBrightnessThreshold,
|
|
fps,
|
|
keepalive_interval: standbyInterval,
|
|
adaptive_fps: adaptiveFps,
|
|
protocol,
|
|
tags: _targetTagsInput ? _targetTagsInput.getValue() : [],
|
|
};
|
|
|
|
try {
|
|
let response;
|
|
if (targetId) {
|
|
response = await fetchWithAuth(`/output-targets/${targetId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} else {
|
|
payload.target_type = 'led';
|
|
response = await fetchWithAuth('/output-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');
|
|
outputTargetsCache.invalidate();
|
|
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 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);
|
|
updateSubTabHash('targets', tabKey);
|
|
}
|
|
|
|
export function expandAllTargetSections() {
|
|
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
|
const sections = activeSubTab === 'key_colors'
|
|
? [csKCTargets, csPatternTemplates]
|
|
: [csDevices, csColorStrips, csLedTargets];
|
|
CardSection.expandAll(sections);
|
|
}
|
|
|
|
export function collapseAllTargetSections() {
|
|
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
|
const sections = activeSubTab === 'key_colors'
|
|
? [csKCTargets, csPatternTemplates]
|
|
: [csDevices, csColorStrips, csLedTargets];
|
|
CardSection.collapseAll(sections);
|
|
}
|
|
|
|
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;
|
|
if (!csDevices.isMounted()) setTabRefreshing('targets-panel-content', true);
|
|
|
|
try {
|
|
// Fetch all entities via DataCache
|
|
const [devices, targets, cssArr, patternTemplates, psArr, valueSrcArr, asSrcArr] = await Promise.all([
|
|
devicesCache.fetch().catch(() => []),
|
|
outputTargetsCache.fetch().catch(() => []),
|
|
colorStripSourcesCache.fetch().catch(() => []),
|
|
patternTemplatesCache.fetch().catch(() => []),
|
|
streamsCache.fetch().catch(() => []),
|
|
valueSourcesCache.fetch().catch(() => []),
|
|
audioSourcesCache.fetch().catch(() => []),
|
|
syncClocksCache.fetch().catch(() => []),
|
|
]);
|
|
|
|
let colorStripSourceMap = {};
|
|
cssArr.forEach(s => { colorStripSourceMap[s.id] = s; });
|
|
|
|
let pictureSourceMap = {};
|
|
psArr.forEach(s => { pictureSourceMap[s.id] = s; });
|
|
|
|
let patternTemplateMap = {};
|
|
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
|
|
|
|
let valueSourceMap = {};
|
|
valueSrcArr.forEach(s => { valueSourceMap[s.id] = s; });
|
|
|
|
let audioSourceMap = {};
|
|
asSrcArr.forEach(s => { audioSourceMap[s.id] = s; });
|
|
|
|
// Fetch all device states, target states, and target metrics in batch
|
|
const [batchDevStatesResp, batchTgtStatesResp, batchTgtMetricsResp] = await Promise.all([
|
|
fetchWithAuth('/devices/batch/states'),
|
|
fetchWithAuth('/output-targets/batch/states'),
|
|
fetchWithAuth('/output-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}/output-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');
|
|
|
|
// Update tab badge with running target count
|
|
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
|
|
updateTabBadge('targets', runningCount);
|
|
|
|
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
|
|
|
const subTabs = [
|
|
{ key: 'led', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
|
|
{ key: 'key_colors', icon: getTargetTypeIcon('key_colors'), 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} <span data-i18n="${tab.titleKey}">${t(tab.titleKey)}</span> <span class="stream-tab-count">${tab.count}</span></button>`
|
|
).join('')}<span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllTargetSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
|
|
|
|
// Use window.createPatternTemplateCard to avoid circular import
|
|
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
|
|
|
|
// Build items arrays for each section (apply saved drag order)
|
|
const deviceItems = csDevices.applySortOrder(ledDevices.map(d => ({ key: d.id, html: createDeviceCard(d) })));
|
|
const cssItems = csColorStrips.applySortOrder(Object.values(colorStripSourceMap).map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
|
|
const ledTargetItems = csLedTargets.applySortOrder(ledTargets.map(t => ({ key: t.id, html: createTargetCard(t, deviceMap, colorStripSourceMap, valueSourceMap) })));
|
|
const kcTargetItems = csKCTargets.applySortOrder(kcTargets.map(t => ({ key: t.id, html: createKCTargetCard(t, pictureSourceMap, patternTemplateMap, valueSourceMap) })));
|
|
const patternItems = csPatternTemplates.applySortOrder(patternTemplates.map(pt => ({ key: pt.id, html: createPatternTemplateCard(pt) })));
|
|
|
|
// Track which target cards were replaced/added (need chart re-init)
|
|
let changedTargetIds = null;
|
|
|
|
if (csDevices.isMounted()) {
|
|
// ── Incremental update: reconcile cards in-place ──
|
|
_updateSubTabCounts(subTabs);
|
|
csDevices.reconcile(deviceItems);
|
|
csColorStrips.reconcile(cssItems);
|
|
const ledResult = csLedTargets.reconcile(ledTargetItems);
|
|
const kcResult = csKCTargets.reconcile(kcTargetItems);
|
|
csPatternTemplates.reconcile(patternItems);
|
|
changedTargetIds = new Set([...ledResult.added, ...ledResult.replaced, ...ledResult.removed,
|
|
...kcResult.added, ...kcResult.replaced, ...kcResult.removed]);
|
|
|
|
// Re-render cached LED preview frames onto new canvas elements after reconciliation
|
|
for (const id of ledResult.replaced) {
|
|
const frame = _ledPreviewLastFrame[id];
|
|
if (frame && ledPreviewWebSockets[id]) {
|
|
const canvas = document.getElementById(`led-preview-canvas-${id}`);
|
|
if (canvas) _renderLedStrip(canvas, frame);
|
|
}
|
|
}
|
|
} else {
|
|
// ── First render: build full HTML ──
|
|
const ledPanel = `
|
|
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
|
|
${csDevices.render(deviceItems)}
|
|
${csColorStrips.render(cssItems)}
|
|
${csLedTargets.render(ledTargetItems)}
|
|
</div>`;
|
|
const kcPanel = `
|
|
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
|
|
${csKCTargets.render(kcTargetItems)}
|
|
${csPatternTemplates.render(patternItems)}
|
|
</div>`;
|
|
container.innerHTML = tabBar + ledPanel + kcPanel;
|
|
CardSection.bindAll([csDevices, csColorStrips, csLedTargets, csKCTargets, csPatternTemplates]);
|
|
}
|
|
|
|
// Show/hide stop-all buttons based on running state
|
|
const ledRunning = ledTargets.some(t => t.state && t.state.processing);
|
|
const kcRunning = kcTargets.some(t => t.state && t.state.processing);
|
|
const ledStopBtn = container.querySelector('[data-stop-all="led"]');
|
|
const kcStopBtn = container.querySelector('[data-stop-all="kc"]');
|
|
if (ledStopBtn) ledStopBtn.style.display = ledRunning ? '' : 'none';
|
|
if (kcStopBtn) kcStopBtn.style.display = kcRunning ? '' : 'none';
|
|
|
|
// Patch volatile metrics in-place (avoids full card replacement on polls)
|
|
for (const tgt of ledTargets) {
|
|
if (tgt.state && tgt.state.processing) _patchTargetMetrics(tgt);
|
|
}
|
|
for (const tgt of kcTargets) {
|
|
if (tgt.state && tgt.state.processing) patchKCTargetMetrics(tgt);
|
|
}
|
|
|
|
// Attach event listeners and fetch brightness for device cards
|
|
devicesWithState.forEach(device => {
|
|
attachDeviceListeners(device.id);
|
|
if ((device.capabilities || []).includes('brightness_control')) {
|
|
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);
|
|
}
|
|
}
|
|
// Enrich OpenRGB zone badges with per-zone LED counts
|
|
if (device.device_type === 'openrgb') {
|
|
enrichOpenrgbZoneBadges(device.id, device.url);
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
});
|
|
Object.keys(kcWebSockets).forEach(id => {
|
|
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
|
|
});
|
|
|
|
// Auto-disconnect LED preview WebSockets for targets that stopped
|
|
const processingLedIds = new Set();
|
|
ledTargets.forEach(target => {
|
|
if (target.state && target.state.processing) processingLedIds.add(target.id);
|
|
});
|
|
Object.keys(ledPreviewWebSockets).forEach(id => {
|
|
if (!processingLedIds.has(id)) disconnectLedPreviewWS(id);
|
|
});
|
|
|
|
// FPS charts: only destroy charts for replaced/removed cards (or all on first render)
|
|
if (changedTargetIds) {
|
|
// Incremental: destroy only charts whose cards were replaced or removed
|
|
for (const id of changedTargetIds) {
|
|
if (_targetFpsCharts[id]) {
|
|
_targetFpsCharts[id].destroy();
|
|
delete _targetFpsCharts[id];
|
|
}
|
|
}
|
|
} else {
|
|
// First render: destroy all old charts
|
|
for (const id of Object.keys(_targetFpsCharts)) {
|
|
_targetFpsCharts[id].destroy();
|
|
delete _targetFpsCharts[id];
|
|
}
|
|
}
|
|
|
|
// Push FPS samples and create/update charts for running targets
|
|
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, target.state.fps_current ?? 0);
|
|
}
|
|
// Create chart if it doesn't exist (new or replaced card)
|
|
if (!_targetFpsCharts[target.id]) {
|
|
const actualH = _targetFpsHistory[target.id] || [];
|
|
const currentH = _targetFpsCurrentHistory[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}`, actualH, currentH, fpsTarget, maxHwFps);
|
|
if (chart) _targetFpsCharts[target.id] = chart;
|
|
} else {
|
|
// Chart survived reconcile — just update data
|
|
_updateTargetFpsChart(target.id, target.state.fps_target || 30);
|
|
}
|
|
}
|
|
});
|
|
// Clean up history and charts for targets no longer running
|
|
Object.keys(_targetFpsHistory).forEach(id => {
|
|
if (!runningIds.has(id)) { delete _targetFpsHistory[id]; delete _targetFpsCurrentHistory[id]; }
|
|
});
|
|
Object.keys(_targetFpsCharts).forEach(id => {
|
|
if (!runningIds.has(id)) {
|
|
_targetFpsCharts[id].destroy();
|
|
delete _targetFpsCharts[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;
|
|
setTabRefreshing('targets-panel-content', false);
|
|
}
|
|
}
|
|
|
|
function _cssSourceName(cssId, colorStripSourceMap) {
|
|
if (!cssId) return t('targets.no_css');
|
|
const css = colorStripSourceMap[cssId];
|
|
return css ? escapeHtml(css.name) : escapeHtml(cssId);
|
|
}
|
|
|
|
// ─── In-place metric patching (avoids full card replacement on polls) ───
|
|
|
|
function _buildLedTimingHTML(state) {
|
|
const isAudio = state.timing_audio_read_ms != null;
|
|
return `
|
|
<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">
|
|
${isAudio ? `
|
|
<span class="timing-seg timing-audio-read" style="flex:${state.timing_audio_read_ms}" title="read ${state.timing_audio_read_ms}ms"></span>
|
|
<span class="timing-seg timing-audio-fft" style="flex:${state.timing_audio_fft_ms}" title="fft ${state.timing_audio_fft_ms}ms"></span>
|
|
<span class="timing-seg timing-audio-render" style="flex:${state.timing_audio_render_ms || 0.1}" title="render ${state.timing_audio_render_ms}ms"></span>
|
|
` : `
|
|
${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">
|
|
${isAudio ? `
|
|
<span class="timing-legend-item"><span class="timing-dot timing-audio-read"></span>read ${state.timing_audio_read_ms}ms</span>
|
|
<span class="timing-legend-item"><span class="timing-dot timing-audio-fft"></span>fft ${state.timing_audio_fft_ms}ms</span>
|
|
<span class="timing-legend-item"><span class="timing-dot timing-audio-render"></span>render ${state.timing_audio_render_ms}ms</span>
|
|
` : `
|
|
${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>`;
|
|
}
|
|
|
|
function _patchTargetMetrics(target) {
|
|
const container = document.getElementById('targets-panel-content');
|
|
if (!container) return;
|
|
const card = container.querySelector(`[data-target-id="${target.id}"]`);
|
|
if (!card) return;
|
|
const state = target.state || {};
|
|
const metrics = target.metrics || {};
|
|
|
|
const fps = card.querySelector('[data-tm="fps"]');
|
|
if (fps) {
|
|
const effFps = state.fps_effective;
|
|
const tgtFps = state.fps_target || 0;
|
|
const fpsLabel = (effFps != null && effFps < tgtFps)
|
|
? `${state.fps_current ?? 0}<span class="target-fps-target">/${effFps}↓${tgtFps}</span>`
|
|
: `${state.fps_current ?? 0}<span class="target-fps-target">/${tgtFps}</span>`;
|
|
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
|
|
fps.innerHTML = `<span class="${unreachableClass}">${fpsLabel}</span>`
|
|
+ `<span class="target-fps-avg">avg ${state.fps_actual?.toFixed(1) || '0.0'}</span>`;
|
|
}
|
|
|
|
// Update health dot to reflect streaming reachability when processing
|
|
const healthDot = card.querySelector('.health-dot');
|
|
if (healthDot && state.processing) {
|
|
const reachable = state.device_streaming_reachable;
|
|
if (reachable === false) {
|
|
healthDot.className = 'health-dot health-offline';
|
|
healthDot.title = t('device.health.streaming_unreachable') || 'Unreachable during streaming';
|
|
} else if (reachable === true) {
|
|
healthDot.className = 'health-dot health-online';
|
|
healthDot.title = t('device.health.online');
|
|
}
|
|
}
|
|
|
|
const timing = card.querySelector('[data-tm="timing"]');
|
|
if (timing && state.timing_total_ms != null) timing.innerHTML = _buildLedTimingHTML(state);
|
|
|
|
const frames = card.querySelector('[data-tm="frames"]');
|
|
if (frames) frames.textContent = metrics.frames_processed || 0;
|
|
|
|
const keepalive = card.querySelector('[data-tm="keepalive"]');
|
|
if (keepalive) keepalive.textContent = state.frames_keepalive ?? '-';
|
|
|
|
const errors = card.querySelector('[data-tm="errors"]');
|
|
if (errors) errors.textContent = metrics.errors_count || 0;
|
|
|
|
// Error indicator near target name
|
|
const errorIndicator = card.querySelector('.target-error-indicator');
|
|
if (errorIndicator) {
|
|
const hasErrors = (metrics.errors_count || 0) > 0;
|
|
errorIndicator.classList.toggle('visible', hasErrors);
|
|
}
|
|
|
|
const uptime = card.querySelector('[data-tm="uptime"]');
|
|
if (uptime) uptime.textContent = formatUptime(metrics.uptime_seconds);
|
|
}
|
|
|
|
export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSourceMap) {
|
|
const state = target.state || {};
|
|
|
|
const isProcessing = state.processing || false;
|
|
|
|
const device = deviceMap[target.device_id];
|
|
const deviceName = device ? device.name : (target.device_id || 'No device');
|
|
|
|
const cssId = target.color_strip_source_id || '';
|
|
const cssSummary = _cssSourceName(cssId, colorStripSourceMap);
|
|
|
|
const bvsId = target.brightness_value_source_id || '';
|
|
const bvs = bvsId && valueSourceMap ? valueSourceMap[bvsId] : null;
|
|
|
|
// Determine if overlay is available (picture-based CSS)
|
|
const css = cssId ? colorStripSourceMap[cssId] : null;
|
|
const overlayAvailable = !css || css.source_type === 'picture';
|
|
|
|
// 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 wrapCard({
|
|
dataAttr: 'data-target-id',
|
|
id: target.id,
|
|
removeOnclick: `deleteTarget('${target.id}')`,
|
|
removeTitle: t('common.delete'),
|
|
content: `
|
|
<div class="card-header">
|
|
<div class="card-title">
|
|
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
|
${escapeHtml(target.name)}
|
|
<span class="target-error-indicator" title="${t('device.metrics.errors')}">${ICON_WARNING}</span>
|
|
</div>
|
|
</div>
|
|
<div class="stream-card-props">
|
|
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
|
|
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
|
|
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
|
|
<span class="stream-card-prop stream-card-prop-full${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('targets','led','led-css','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>
|
|
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}
|
|
${target.min_brightness_threshold > 0 ? `<span class="stream-card-prop" title="${t('targets.min_brightness_threshold')}">${ICON_SUN_DIM} <${target.min_brightness_threshold} → off</span>` : ''}
|
|
</div>
|
|
${renderTagChips(target.tags)}
|
|
<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" data-tm="fps">---</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="target-metrics-collapse">
|
|
<button type="button" class="target-metrics-toggle" onclick="this.parentElement.classList.toggle('open')">${t('targets.metrics.pipeline')}</button>
|
|
<div class="target-metrics-animate">
|
|
<div class="metrics-grid target-metrics-expanded">
|
|
<div class="timing-breakdown" data-tm="timing" style="grid-column:1/-1"></div>
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.frames')}</div>
|
|
<div class="metric-value" data-tm="frames">---</div>
|
|
</div>
|
|
${state.needs_keepalive !== false ? `
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.keepalive')}</div>
|
|
<div class="metric-value" data-tm="keepalive">---</div>
|
|
</div>
|
|
` : ''}
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.errors')}</div>
|
|
<div class="metric-value" data-tm="errors">---</div>
|
|
</div>
|
|
<div class="metric">
|
|
<div class="metric-label">${t('device.metrics.uptime')}</div>
|
|
<div class="metric-value" data-tm="uptime">---</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
${_buildLedPreviewHtml(target.id, device, bvsId)}`,
|
|
actions: `
|
|
${isProcessing ? `
|
|
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
|
|
${ICON_STOP}
|
|
</button>
|
|
` : `
|
|
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('device.button.start')}">
|
|
${ICON_START}
|
|
</button>
|
|
`}
|
|
${isProcessing ? `
|
|
<button class="btn btn-icon ${ledPreviewWebSockets[target.id] ? 'btn-warning' : 'btn-secondary'}" onclick="toggleLedPreview('${target.id}')" title="LED Preview">
|
|
${ICON_LED_PREVIEW}
|
|
</button>
|
|
` : ''}
|
|
<button class="btn btn-icon btn-secondary" onclick="cloneTarget('${target.id}')" title="${t('common.clone')}">
|
|
${ICON_CLONE}
|
|
</button>
|
|
<button class="btn btn-icon btn-secondary" onclick="showTargetEditor('${target.id}')" title="${t('common.edit')}">
|
|
${ICON_EDIT}
|
|
</button>
|
|
${overlayAvailable ? (state.overlay_active ? `
|
|
<button class="btn btn-icon btn-warning" onclick="stopTargetOverlay('${target.id}')" title="${t('overlay.button.hide')}">
|
|
${ICON_OVERLAY}
|
|
</button>
|
|
` : `
|
|
<button class="btn btn-icon btn-secondary" onclick="startTargetOverlay('${target.id}')" title="${t('overlay.button.show')}">
|
|
${ICON_OVERLAY}
|
|
</button>
|
|
`) : ''}`,
|
|
});
|
|
}
|
|
|
|
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(`/output-targets/${targetId}/start`, {
|
|
method: 'POST',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('device.started'), 'success');
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(error.detail || t('target.error.start_failed'), 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function stopTargetProcessing(targetId) {
|
|
await _targetAction(async () => {
|
|
const response = await fetchWithAuth(`/output-targets/${targetId}/stop`, {
|
|
method: 'POST',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('device.stopped'), 'success');
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(error.detail || t('target.error.stop_failed'), 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function stopAllLedTargets() {
|
|
await _stopAllByType('led');
|
|
}
|
|
|
|
export async function stopAllKCTargets() {
|
|
await _stopAllByType('key_colors');
|
|
}
|
|
|
|
async function _stopAllByType(targetType) {
|
|
try {
|
|
const [allTargets, statesResp] = await Promise.all([
|
|
outputTargetsCache.fetch().catch(() => []),
|
|
fetchWithAuth('/output-targets/batch/states'),
|
|
]);
|
|
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
|
const states = statesData.states || {};
|
|
const typeMatch = targetType === 'led' ? t => t.target_type === 'led' || t.target_type === 'wled' : t => t.target_type === targetType;
|
|
const running = allTargets.filter(t => typeMatch(t) && states[t.id]?.processing);
|
|
if (!running.length) {
|
|
showToast(t('targets.stop_all.none_running'), 'info');
|
|
return;
|
|
}
|
|
await Promise.all(running.map(t =>
|
|
fetchWithAuth(`/output-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
|
|
));
|
|
showToast(t('targets.stop_all.stopped', { count: running.length }), 'success');
|
|
loadTargetsTab();
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('targets.stop_all.error'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function startTargetOverlay(targetId) {
|
|
await _targetAction(async () => {
|
|
const response = await fetchWithAuth(`/output-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(`/output-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 cloneTarget(targetId) {
|
|
try {
|
|
const resp = await fetch(`${API_BASE}/output-targets/${targetId}`, { headers: getHeaders() });
|
|
if (!resp.ok) throw new Error('Failed to load target');
|
|
const target = await resp.json();
|
|
showTargetEditor(null, target);
|
|
} catch (error) {
|
|
console.error('Failed to clone target:', error);
|
|
showToast(t('target.error.clone_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function deleteTarget(targetId) {
|
|
const confirmed = await showConfirm(t('targets.delete.confirm'));
|
|
if (!confirmed) return;
|
|
|
|
await _targetAction(async () => {
|
|
const response = await fetchWithAuth(`/output-targets/${targetId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('targets.deleted'), 'success');
|
|
outputTargetsCache.invalidate();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(error.detail || t('target.error.delete_failed'), 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
/* ── LED Strip Preview ────────────────────────────────────────── */
|
|
|
|
const _ledPreviewLastFrame = {};
|
|
|
|
/**
|
|
* Build the LED preview panel HTML for a target card.
|
|
* For OpenRGB devices in "separate" zone mode with 2+ zones, renders
|
|
* one canvas per zone with labels. Otherwise, a single canvas.
|
|
*/
|
|
function _buildLedPreviewHtml(targetId, device, bvsId) {
|
|
const visible = ledPreviewWebSockets[targetId] ? '' : 'none';
|
|
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
|
|
|
|
// Check for per-zone preview
|
|
if (device && isOpenrgbDevice(device.device_type) && device.zone_mode === 'separate') {
|
|
const { baseUrl, zones } = _splitOpenrgbZone(device.url);
|
|
if (zones.length > 1) {
|
|
const zoneCanvases = zones.map(z =>
|
|
`<div class="led-preview-zone">` +
|
|
`<canvas class="led-preview-canvas led-preview-zone-canvas" data-zone-name="${escapeHtml(z)}"></canvas>` +
|
|
`<span class="led-preview-zone-label">${escapeHtml(z)}</span>` +
|
|
`</div>`
|
|
).join('');
|
|
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel led-preview-zones" data-zone-mode="separate" data-zone-base-url="${escapeHtml(baseUrl)}" style="display:${visible}">` +
|
|
zoneCanvases +
|
|
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
|
|
`</div>`;
|
|
}
|
|
}
|
|
|
|
// Default: single canvas
|
|
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel" style="display:${visible}">` +
|
|
`<canvas id="led-preview-canvas-${targetId}" class="led-preview-canvas"></canvas>` +
|
|
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
|
|
`</div>`;
|
|
}
|
|
|
|
/**
|
|
* Resample an RGB byte array from srcCount pixels to dstCount pixels
|
|
* using linear interpolation (matches backend np.interp behavior).
|
|
*/
|
|
function _resampleStrip(srcBytes, srcCount, dstCount) {
|
|
if (dstCount === srcCount) return srcBytes;
|
|
const dst = new Uint8Array(dstCount * 3);
|
|
for (let i = 0; i < dstCount; i++) {
|
|
const t = dstCount > 1 ? i / (dstCount - 1) : 0;
|
|
const srcPos = t * (srcCount - 1);
|
|
const lo = Math.floor(srcPos);
|
|
const hi = Math.min(lo + 1, srcCount - 1);
|
|
const frac = srcPos - lo;
|
|
for (let ch = 0; ch < 3; ch++) {
|
|
dst[i * 3 + ch] = Math.round(
|
|
srcBytes[lo * 3 + ch] * (1 - frac) + srcBytes[hi * 3 + ch] * frac
|
|
);
|
|
}
|
|
}
|
|
return dst;
|
|
}
|
|
|
|
/**
|
|
* Render per-zone LED previews: resample the full frame independently
|
|
* for each zone canvas (matching the backend's separate-mode behavior).
|
|
*/
|
|
function _renderLedStripZones(panel, rgbBytes) {
|
|
const baseUrl = panel.dataset.zoneBaseUrl;
|
|
const cache = baseUrl ? getZoneCountCache(baseUrl) : null;
|
|
const srcCount = Math.floor(rgbBytes.length / 3);
|
|
if (srcCount < 1) return;
|
|
|
|
const zoneCanvases = panel.querySelectorAll('.led-preview-zone-canvas');
|
|
if (!cache) {
|
|
// Zone sizes unknown — render full frame to all canvases
|
|
for (const canvas of zoneCanvases) {
|
|
_renderLedStrip(canvas, rgbBytes);
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const canvas of zoneCanvases) {
|
|
const zoneName = canvas.dataset.zoneName;
|
|
const zoneSize = cache[zoneName.toLowerCase()];
|
|
if (!zoneSize || zoneSize < 1) continue;
|
|
const resampled = _resampleStrip(rgbBytes, srcCount, zoneSize);
|
|
_renderLedStrip(canvas, resampled);
|
|
}
|
|
}
|
|
|
|
function _renderLedStrip(canvas, rgbBytes) {
|
|
const ledCount = rgbBytes.length / 3;
|
|
if (ledCount <= 0) return;
|
|
|
|
// Set canvas resolution to match LED count (1px per LED)
|
|
canvas.width = ledCount;
|
|
canvas.height = 1;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const imageData = ctx.createImageData(ledCount, 1);
|
|
const data = imageData.data;
|
|
|
|
for (let i = 0; i < ledCount; i++) {
|
|
const si = i * 3;
|
|
const di = i * 4;
|
|
data[di] = rgbBytes[si];
|
|
data[di + 1] = rgbBytes[si + 1];
|
|
data[di + 2] = rgbBytes[si + 2];
|
|
data[di + 3] = 255;
|
|
}
|
|
|
|
ctx.putImageData(imageData, 0, 0);
|
|
}
|
|
|
|
function connectLedPreviewWS(targetId) {
|
|
disconnectLedPreviewWS(targetId);
|
|
|
|
const key = localStorage.getItem('wled_api_key');
|
|
if (!key) return;
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/output-targets/${targetId}/led-preview/ws?token=${encodeURIComponent(key)}`;
|
|
|
|
try {
|
|
const ws = new WebSocket(wsUrl);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.onmessage = (event) => {
|
|
if (event.data instanceof ArrayBuffer) {
|
|
const raw = new Uint8Array(event.data);
|
|
// Wire format: [brightness_byte] [R G B R G B ...]
|
|
const brightness = raw[0];
|
|
const frame = raw.subarray(1);
|
|
_ledPreviewLastFrame[targetId] = frame;
|
|
|
|
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
|
if (panel) {
|
|
if (panel.dataset.zoneMode === 'separate') {
|
|
_renderLedStripZones(panel, frame);
|
|
} else {
|
|
const canvas = panel.querySelector('.led-preview-canvas');
|
|
if (canvas) _renderLedStrip(canvas, frame);
|
|
}
|
|
}
|
|
|
|
// Show brightness label: always when a brightness source is set, otherwise only below 100%
|
|
const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
|
|
if (bLabel) {
|
|
const pct = Math.round(brightness / 255 * 100);
|
|
if (pct < 100 || bLabel.dataset.hasBvs) {
|
|
bLabel.innerHTML = `${ICON_SUN_DIM} ${pct}%`;
|
|
bLabel.style.display = '';
|
|
} else {
|
|
bLabel.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
delete ledPreviewWebSockets[targetId];
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error(`LED preview WebSocket error for ${targetId}:`, error);
|
|
};
|
|
|
|
ledPreviewWebSockets[targetId] = ws;
|
|
} catch (error) {
|
|
console.error(`Failed to connect LED preview WebSocket for ${targetId}:`, error);
|
|
}
|
|
}
|
|
|
|
function disconnectLedPreviewWS(targetId) {
|
|
const ws = ledPreviewWebSockets[targetId];
|
|
if (ws) {
|
|
ws.onclose = null;
|
|
ws.close();
|
|
delete ledPreviewWebSockets[targetId];
|
|
}
|
|
delete _ledPreviewLastFrame[targetId];
|
|
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
|
if (panel) panel.style.display = 'none';
|
|
}
|
|
|
|
export function disconnectAllLedPreviewWS() {
|
|
Object.keys(ledPreviewWebSockets).forEach(id => disconnectLedPreviewWS(id));
|
|
}
|
|
|
|
export function toggleLedPreview(targetId) {
|
|
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
|
if (!panel) return;
|
|
|
|
if (ledPreviewWebSockets[targetId]) {
|
|
disconnectLedPreviewWS(targetId);
|
|
} else {
|
|
panel.style.display = '';
|
|
connectLedPreviewWS(targetId);
|
|
}
|
|
}
|