Add profiles UI, dashboard improvements, and AmbiLED support

- Profile management tab with cards, condition editor, process browser
- Dashboard: add profiles section, compact layout, type subtitles
- AmbiLED serial device support (raw RGB + 0xFF show command)
- Unified serial device handling (isSerialDevice helper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 15:12:45 +03:00
parent 29d9b95885
commit aa105f3958
5 changed files with 914 additions and 59 deletions

View File

@@ -34,6 +34,9 @@ function setupBackdropClose(modal, closeFn) {
modal._backdropCloseSetup = true;
}
// Device type helpers
function isSerialDevice(type) { return type === 'adalight' || type === 'ambiled'; }
// Track logged errors to avoid console spam
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
@@ -598,6 +601,8 @@ function switchTab(name) {
loadPictureSources();
} else if (name === 'targets') {
loadTargetsTab();
} else if (name === 'profiles') {
loadProfiles();
}
}
}
@@ -767,7 +772,7 @@ async function showSettings(deviceId) {
}
const device = await deviceResponse.json();
const isAdalight = device.device_type === 'adalight';
const isAdalight = isSerialDevice(device.device_type);
// Populate fields
document.getElementById('settings-device-id').value = device.id;
@@ -847,7 +852,7 @@ async function showSettings(deviceId) {
}
function _getSettingsUrl() {
if (settingsInitialValues.device_type === 'adalight') {
if (isSerialDevice(settingsInitialValues.device_type)) {
return document.getElementById('settings-serial-port').value;
}
return document.getElementById('settings-device-url').value.trim();
@@ -902,7 +907,7 @@ async function saveDeviceSettings() {
if ((settingsInitialValues.capabilities || []).includes('manual_led_count') && ledCountInput.value) {
body.led_count = parseInt(ledCountInput.value, 10);
}
if (settingsInitialValues.device_type === 'adalight') {
if (isSerialDevice(settingsInitialValues.device_type)) {
const baudVal = document.getElementById('settings-baud-rate').value;
if (baudVal) body.baud_rate = parseInt(baudVal, 10);
}
@@ -1044,7 +1049,7 @@ function onDeviceTypeChanged() {
const baudRateGroup = document.getElementById('device-baud-rate-group');
if (deviceType === 'adalight') {
if (isSerialDevice(deviceType)) {
urlGroup.style.display = 'none';
urlInput.removeAttribute('required');
serialGroup.style.display = '';
@@ -1081,14 +1086,16 @@ function onDeviceTypeChanged() {
}
}
function _computeMaxFps(baudRate, ledCount) {
function _computeMaxFps(baudRate, ledCount, deviceType) {
if (!baudRate || !ledCount || ledCount < 1) return null;
const bitsPerFrame = (ledCount * 3 + 6) * 10;
// Adalight: 6-byte header + RGB data; AmbiLED: RGB data + 1-byte show command
const overhead = deviceType === 'ambiled' ? 1 : 6;
const bitsPerFrame = (ledCount * 3 + overhead) * 10;
return Math.floor(baudRate / bitsPerFrame);
}
function _renderFpsHint(hintEl, baudRate, ledCount) {
const fps = _computeMaxFps(baudRate, ledCount);
function _renderFpsHint(hintEl, baudRate, ledCount, deviceType) {
const fps = _computeMaxFps(baudRate, ledCount, deviceType);
if (fps !== null) {
hintEl.textContent = `Max FPS ≈ ${fps}`;
hintEl.style.display = '';
@@ -1101,22 +1108,23 @@ function updateBaudFpsHint() {
const hintEl = document.getElementById('baud-fps-hint');
const baudRate = parseInt(document.getElementById('device-baud-rate').value, 10);
const ledCount = parseInt(document.getElementById('device-led-count').value, 10);
_renderFpsHint(hintEl, baudRate, ledCount);
const deviceType = document.getElementById('device-type')?.value || 'adalight';
_renderFpsHint(hintEl, baudRate, ledCount, deviceType);
}
function updateSettingsBaudFpsHint() {
const hintEl = document.getElementById('settings-baud-fps-hint');
const baudRate = parseInt(document.getElementById('settings-baud-rate').value, 10);
const ledCount = parseInt(document.getElementById('settings-led-count').value, 10);
_renderFpsHint(hintEl, baudRate, ledCount);
_renderFpsHint(hintEl, baudRate, ledCount, settingsInitialValues.device_type);
}
function _renderDiscoveryList() {
const selectedType = document.getElementById('device-type').value;
const devices = _discoveryCache[selectedType];
// Adalight: populate serial port dropdown instead of discovery list
if (selectedType === 'adalight') {
// Serial devices: populate serial port dropdown instead of discovery list
if (isSerialDevice(selectedType)) {
_populateSerialPortDropdown(devices || []);
return;
}
@@ -1190,8 +1198,9 @@ function _populateSerialPortDropdown(devices) {
function onSerialPortFocus() {
// Lazy-load: trigger discovery when user opens the serial port dropdown
if (!('adalight' in _discoveryCache)) {
scanForDevices('adalight');
const deviceType = document.getElementById('device-type')?.value || 'adalight';
if (!(deviceType in _discoveryCache)) {
scanForDevices(deviceType);
}
}
@@ -1205,7 +1214,8 @@ async function _populateSettingsSerialPorts(currentUrl) {
select.appendChild(loadingOpt);
try {
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=adalight`, {
const discoverType = settingsInitialValues.device_type || 'adalight';
const resp = await fetch(`${API_BASE}/devices/discover?timeout=2&device_type=${encodeURIComponent(discoverType)}`, {
headers: getHeaders()
});
if (!resp.ok) return;
@@ -1279,7 +1289,7 @@ async function scanForDevices(forceType) {
const section = document.getElementById('discovery-section');
const scanBtn = document.getElementById('scan-network-btn');
if (scanType === 'adalight') {
if (isSerialDevice(scanType)) {
// Show loading in the serial port dropdown
const select = document.getElementById('device-serial-port');
select.innerHTML = '';
@@ -1308,7 +1318,7 @@ async function scanForDevices(forceType) {
if (scanBtn) scanBtn.disabled = false;
if (!response.ok) {
if (scanType !== 'adalight') {
if (!isSerialDevice(scanType)) {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
}
@@ -1326,7 +1336,7 @@ async function scanForDevices(forceType) {
} catch (err) {
loading.style.display = 'none';
if (scanBtn) scanBtn.disabled = false;
if (scanType !== 'adalight') {
if (!isSerialDevice(scanType)) {
empty.style.display = 'block';
empty.querySelector('small').textContent = t('device.scan.error');
}
@@ -1343,7 +1353,7 @@ function selectDiscoveredDevice(device) {
const typeSelect = document.getElementById('device-type');
if (typeSelect) typeSelect.value = device.device_type;
onDeviceTypeChanged();
if (device.device_type === 'adalight') {
if (isSerialDevice(device.device_type)) {
document.getElementById('device-serial-port').value = device.url;
} else {
document.getElementById('device-url').value = device.url;
@@ -1356,7 +1366,7 @@ async function handleAddDevice(event) {
const name = document.getElementById('device-name').value.trim();
const deviceType = document.getElementById('device-type')?.value || 'wled';
const url = deviceType === 'adalight'
const url = isSerialDevice(deviceType)
? document.getElementById('device-serial-port').value
: document.getElementById('device-url').value.trim();
const error = document.getElementById('add-device-error');
@@ -1374,7 +1384,7 @@ async function handleAddDevice(event) {
body.led_count = parseInt(ledCountInput.value, 10);
}
const baudRateSelect = document.getElementById('device-baud-rate');
if (deviceType === 'adalight' && baudRateSelect && baudRateSelect.value) {
if (isSerialDevice(deviceType) && baudRateSelect && baudRateSelect.value) {
body.baud_rate = parseInt(baudRateSelect.value, 10);
}
const lastTemplateId = localStorage.getItem('lastCaptureTemplateId');
@@ -1456,13 +1466,19 @@ async function loadDashboard() {
if (!container) { _dashboardLoading = false; return; }
try {
const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
// Fetch targets and profiles in parallel
const [targetsResp, profilesResp] = await Promise.all([
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
fetch(`${API_BASE}/profiles`, { headers: getHeaders() }).catch(() => null),
]);
if (targetsResp.status === 401) { handle401Error(); return; }
const targetsData = await targetsResp.json();
const targets = targetsData.targets || [];
const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] };
const profiles = profilesData.profiles || [];
if (targets.length === 0) {
if (targets.length === 0 && profiles.length === 0) {
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
return;
}
@@ -1487,6 +1503,21 @@ async function loadDashboard() {
let html = '';
// Profiles section
if (profiles.length > 0) {
const activeProfiles = profiles.filter(p => p.is_active);
const inactiveProfiles = profiles.filter(p => !p.is_active);
html += `<div class="dashboard-section">
<div class="dashboard-section-header">
${t('dashboard.section.profiles')}
<span class="dashboard-section-count">${profiles.length}</span>
</div>
${activeProfiles.map(p => renderDashboardProfile(p)).join('')}
${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')}
</div>`;
}
// Running section
if (running.length > 0) {
html += `<div class="dashboard-section">
@@ -1524,9 +1555,10 @@ function renderDashboardTarget(target, isRunning) {
const state = target.state || {};
const metrics = target.metrics || {};
const isLed = target.target_type === 'led' || target.target_type === 'wled';
const icon = isLed ? '💡' : '🎨';
const icon = '⚡';
const typeLabel = isLed ? 'LED' : 'Key Colors';
let subtitleParts = [];
let subtitleParts = [typeLabel];
if (isLed && state.device_name) {
subtitleParts.push(state.device_name);
}
@@ -1566,8 +1598,7 @@ function renderDashboardTarget(target, isRunning) {
</div>
</div>
<div class="dashboard-target-actions">
<span class="dashboard-status-dot active" title="${t('device.status.processing')}"></span>
<button class="btn btn-icon btn-danger" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏹️</button>
<button class="btn btn-icon btn-warning" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}"></button>
</div>
</div>`;
} else {
@@ -1578,16 +1609,83 @@ function renderDashboardTarget(target, isRunning) {
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
<span class="dashboard-badge-stopped">${t('dashboard.section.stopped')}</span>
</div>
<div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions">
<button class="btn btn-icon btn-primary" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶</button>
<button class="btn btn-icon btn-success" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶</button>
</div>
</div>`;
}
}
function renderDashboardProfile(profile) {
const isActive = profile.is_active;
const isDisabled = !profile.enabled;
// Condition summary
let condSummary = '';
if (profile.conditions.length > 0) {
const parts = profile.conditions.map(c => {
if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', ');
const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
return `${apps} (${matchLabel})`;
}
return c.condition_type;
});
const logic = profile.condition_logic === 'and' ? ' & ' : ' | ';
condSummary = parts.join(logic);
}
const statusBadge = isDisabled
? `<span class="dashboard-badge-stopped">${t('profiles.status.disabled')}</span>`
: isActive
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
const targetCount = profile.target_ids.length;
const activeCount = (profile.active_target_ids || []).length;
const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
return `<div class="dashboard-target dashboard-profile">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">📋</span>
<div>
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div>
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
</div>
${statusBadge}
</div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric">
<div class="dashboard-metric-value">${targetsInfo}</div>
<div class="dashboard-metric-label">${t('dashboard.targets')}</div>
</div>
</div>
<div class="dashboard-target-actions">
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
${profile.enabled ? '⏸' : '▶'}
</button>
</div>
</div>`;
}
async function dashboardToggleProfile(profileId, enable) {
try {
const endpoint = enable ? 'enable' : 'disable';
const response = await fetch(`${API_BASE}/profiles/${profileId}/${endpoint}`, {
method: 'POST',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
loadDashboard();
}
} catch (error) {
showToast('Failed to toggle profile', 'error');
}
}
async function dashboardStartTarget(targetId) {
try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, {
@@ -1651,7 +1749,7 @@ function startDashboardWS() {
_dashboardWS.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'state_change') {
if (data.type === 'state_change' || data.type === 'profile_state_changed') {
loadDashboard();
}
} catch {}
@@ -2642,6 +2740,12 @@ document.addEventListener('click', (e) => {
closeAddDeviceModal();
return;
}
// Profile editor modal: close on backdrop
if (modalId === 'profile-editor-modal') {
closeProfileEditorModal();
return;
}
});
// Cleanup on page unload
@@ -6539,3 +6643,392 @@ async function capturePatternBackground() {
showToast('Failed to capture background', 'error');
}
}
// =====================================================================
// PROFILES
// =====================================================================
let _profilesCache = null;
async function loadProfiles() {
const container = document.getElementById('profiles-content');
if (!container) return;
try {
const resp = await fetch(`${API_BASE}/profiles`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load profiles');
const data = await resp.json();
_profilesCache = data.profiles;
renderProfiles(data.profiles);
} catch (error) {
console.error('Failed to load profiles:', error);
container.innerHTML = `<p class="error-message">${error.message}</p>`;
}
}
function renderProfiles(profiles) {
const container = document.getElementById('profiles-content');
let html = '<div class="devices-grid">';
for (const p of profiles) {
html += createProfileCard(p);
}
html += `<div class="template-card add-template-card" onclick="openProfileEditor()">
<div class="add-template-icon">+</div>
</div>`;
html += '</div>';
container.innerHTML = html;
updateAllText();
}
function createProfileCard(profile) {
const statusClass = !profile.enabled ? 'disabled' : profile.is_active ? 'active' : 'inactive';
const statusText = !profile.enabled ? t('profiles.status.disabled') : profile.is_active ? t('profiles.status.active') : t('profiles.status.inactive');
// Condition summary as pills
let condPills = '';
if (profile.conditions.length === 0) {
condPills = `<span class="stream-card-prop">${t('profiles.conditions.empty')}</span>`;
} else {
const parts = profile.conditions.map(c => {
if (c.condition_type === 'application') {
const apps = (c.apps || []).join(', ');
const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
return `<span class="stream-card-prop stream-card-prop-full">${t('profiles.condition.application')}: ${apps} (${matchLabel})</span>`;
}
return `<span class="stream-card-prop">${c.condition_type}</span>`;
});
const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR ';
condPills = parts.join(`<span class="profile-logic-label">${logicLabel}</span>`);
}
// Target count pill
const targetCountText = `${profile.target_ids.length} target(s)${profile.is_active ? ` (${profile.active_target_ids.length} active)` : ''}`;
// Last activation timestamp
let lastActivityMeta = '';
if (profile.last_activated_at) {
const ts = new Date(profile.last_activated_at);
lastActivityMeta = `<span class="card-meta" title="${t('profiles.last_activated')}">🕐 ${ts.toLocaleString()}</span>`;
}
return `
<div class="card${!profile.enabled ? ' profile-status-disabled' : ''}" data-profile-id="${profile.id}">
<div class="card-top-actions">
<button class="card-remove-btn" onclick="deleteProfile('${profile.id}', '${escapeHtml(profile.name)}')" title="${t('common.delete')}">&#x2715;</button>
</div>
<div class="card-header">
<div class="card-title">
${escapeHtml(profile.name)}
<span class="badge badge-profile-${statusClass}">${statusText}</span>
</div>
</div>
<div class="card-subtitle">
<span class="card-meta">${profile.condition_logic === 'and' ? 'ALL' : 'ANY'}</span>
<span class="card-meta">⚡ ${targetCountText}</span>
${lastActivityMeta}
</div>
<div class="stream-card-props">${condPills}</div>
<div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="openProfileEditor('${profile.id}')" title="${t('profiles.edit')}">⚙️</button>
<button class="btn btn-icon ${profile.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleProfileEnabled('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.status.disabled') : t('profiles.status.active')}">
${profile.enabled ? '⏸' : '▶'}
</button>
</div>
</div>`;
}
async function openProfileEditor(profileId) {
const modal = document.getElementById('profile-editor-modal');
const titleEl = document.getElementById('profile-editor-title');
const idInput = document.getElementById('profile-editor-id');
const nameInput = document.getElementById('profile-editor-name');
const enabledInput = document.getElementById('profile-editor-enabled');
const logicSelect = document.getElementById('profile-editor-logic');
const condList = document.getElementById('profile-conditions-list');
const errorEl = document.getElementById('profile-editor-error');
errorEl.style.display = 'none';
condList.innerHTML = '';
// Load available targets for the checklist
await loadProfileTargetChecklist([]);
if (profileId) {
// Edit mode
titleEl.textContent = t('profiles.edit');
try {
const resp = await fetch(`${API_BASE}/profiles/${profileId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load profile');
const profile = await resp.json();
idInput.value = profile.id;
nameInput.value = profile.name;
enabledInput.checked = profile.enabled;
logicSelect.value = profile.condition_logic;
// Populate conditions
for (const c of profile.conditions) {
addProfileConditionRow(c);
}
// Populate target checklist
await loadProfileTargetChecklist(profile.target_ids);
} catch (e) {
showToast(e.message, 'error');
return;
}
} else {
// Create mode
titleEl.textContent = t('profiles.add');
idInput.value = '';
nameInput.value = '';
enabledInput.checked = true;
logicSelect.value = 'or';
}
modal.style.display = 'flex';
lockBody();
updateAllText();
}
function closeProfileEditorModal() {
document.getElementById('profile-editor-modal').style.display = 'none';
unlockBody();
}
async function loadProfileTargetChecklist(selectedIds) {
const container = document.getElementById('profile-targets-list');
try {
const resp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load targets');
const data = await resp.json();
const targets = data.targets || [];
if (targets.length === 0) {
container.innerHTML = `<small class="text-muted">${t('profiles.targets.empty')}</small>`;
return;
}
container.innerHTML = targets.map(tgt => {
const checked = selectedIds.includes(tgt.id) ? 'checked' : '';
return `<label class="profile-target-item">
<input type="checkbox" value="${tgt.id}" ${checked}>
<span>${escapeHtml(tgt.name)}</span>
</label>`;
}).join('');
} catch (e) {
container.innerHTML = `<small class="text-muted">${e.message}</small>`;
}
}
function addProfileCondition() {
addProfileConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
}
function addProfileConditionRow(condition) {
const list = document.getElementById('profile-conditions-list');
const row = document.createElement('div');
row.className = 'profile-condition-row';
const appsValue = (condition.apps || []).join('\n');
const matchType = condition.match_type || 'running';
row.innerHTML = `
<div class="condition-header">
<span class="condition-type-label">${t('profiles.condition.application')}</span>
<button type="button" class="btn-remove-condition" onclick="this.closest('.profile-condition-row').remove()" title="Remove">&#x2715;</button>
</div>
<div class="condition-fields">
<div class="condition-field">
<label data-i18n="profiles.condition.application.match_type">${t('profiles.condition.application.match_type')}</label>
<select class="condition-match-type">
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('profiles.condition.application.match_type.running')}</option>
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('profiles.condition.application.match_type.topmost')}</option>
</select>
</div>
<div class="condition-field">
<div class="condition-apps-header">
<label data-i18n="profiles.condition.application.apps">${t('profiles.condition.application.apps')}</label>
<button type="button" class="btn-browse-apps" title="${t('profiles.condition.application.browse')}">${t('profiles.condition.application.browse')}</button>
</div>
<textarea class="condition-apps" rows="3" placeholder="firefox.exe&#10;chrome.exe">${escapeHtml(appsValue)}</textarea>
<div class="process-picker" style="display:none">
<input type="text" class="process-picker-search" placeholder="${t('profiles.condition.application.search')}" autocomplete="off">
<div class="process-picker-list"></div>
</div>
</div>
</div>
`;
// Wire up browse button
const browseBtn = row.querySelector('.btn-browse-apps');
const picker = row.querySelector('.process-picker');
browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row));
// Wire up search filter
const searchInput = row.querySelector('.process-picker-search');
searchInput.addEventListener('input', () => filterProcessPicker(picker));
list.appendChild(row);
}
async function toggleProcessPicker(picker, row) {
if (picker.style.display !== 'none') {
picker.style.display = 'none';
return;
}
const listEl = picker.querySelector('.process-picker-list');
const searchEl = picker.querySelector('.process-picker-search');
searchEl.value = '';
listEl.innerHTML = `<div class="process-picker-loading">${t('common.loading')}</div>`;
picker.style.display = '';
try {
const resp = await fetch(`${API_BASE}/system/processes`, { headers: getHeaders() });
if (resp.status === 401) { handle401Error(); return; }
if (!resp.ok) throw new Error('Failed to fetch processes');
const data = await resp.json();
// Get already-added apps to mark them
const textarea = row.querySelector('.condition-apps');
const existing = new Set(textarea.value.split('\n').map(a => a.trim().toLowerCase()).filter(Boolean));
picker._processes = data.processes;
picker._existing = existing;
renderProcessPicker(picker, data.processes, existing);
searchEl.focus();
} catch (e) {
listEl.innerHTML = `<div class="process-picker-loading" style="color:var(--danger-color)">${e.message}</div>`;
}
}
function renderProcessPicker(picker, processes, existing) {
const listEl = picker.querySelector('.process-picker-list');
if (processes.length === 0) {
listEl.innerHTML = `<div class="process-picker-loading">${t('profiles.condition.application.no_processes')}</div>`;
return;
}
listEl.innerHTML = processes.map(p => {
const added = existing.has(p.toLowerCase());
return `<div class="process-picker-item${added ? ' added' : ''}" data-process="${escapeHtml(p)}">${escapeHtml(p)}${added ? ' ✓' : ''}</div>`;
}).join('');
// Click handler for each item
listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => {
item.addEventListener('click', () => {
const proc = item.dataset.process;
const row = picker.closest('.profile-condition-row');
const textarea = row.querySelector('.condition-apps');
const current = textarea.value.trim();
textarea.value = current ? current + '\n' + proc : proc;
item.classList.add('added');
item.textContent = proc + ' ✓';
// Update existing set
picker._existing.add(proc.toLowerCase());
});
});
}
function filterProcessPicker(picker) {
const query = picker.querySelector('.process-picker-search').value.toLowerCase();
const filtered = (picker._processes || []).filter(p => p.includes(query));
renderProcessPicker(picker, filtered, picker._existing || new Set());
}
function getProfileEditorConditions() {
const rows = document.querySelectorAll('#profile-conditions-list .profile-condition-row');
const conditions = [];
rows.forEach(row => {
const matchType = row.querySelector('.condition-match-type').value;
const appsText = row.querySelector('.condition-apps').value.trim();
const apps = appsText ? appsText.split('\n').map(a => a.trim()).filter(Boolean) : [];
conditions.push({ condition_type: 'application', apps, match_type: matchType });
});
return conditions;
}
function getProfileEditorTargetIds() {
const checkboxes = document.querySelectorAll('#profile-targets-list input[type="checkbox"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
async function saveProfileEditor() {
const idInput = document.getElementById('profile-editor-id');
const nameInput = document.getElementById('profile-editor-name');
const enabledInput = document.getElementById('profile-editor-enabled');
const logicSelect = document.getElementById('profile-editor-logic');
const errorEl = document.getElementById('profile-editor-error');
const name = nameInput.value.trim();
if (!name) {
errorEl.textContent = 'Name is required';
errorEl.style.display = 'block';
return;
}
const body = {
name,
enabled: enabledInput.checked,
condition_logic: logicSelect.value,
conditions: getProfileEditorConditions(),
target_ids: getProfileEditorTargetIds(),
};
const profileId = idInput.value;
const isEdit = !!profileId;
try {
const url = isEdit ? `${API_BASE}/profiles/${profileId}` : `${API_BASE}/profiles`;
const resp = await fetch(url, {
method: isEdit ? 'PUT' : 'POST',
headers: getHeaders(),
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save profile');
}
closeProfileEditorModal();
showToast(isEdit ? 'Profile updated' : 'Profile created', 'success');
loadProfiles();
} catch (e) {
errorEl.textContent = e.message;
errorEl.style.display = 'block';
}
}
async function toggleProfileEnabled(profileId, enable) {
try {
const action = enable ? 'enable' : 'disable';
const resp = await fetch(`${API_BASE}/profiles/${profileId}/${action}`, {
method: 'POST',
headers: getHeaders(),
});
if (!resp.ok) throw new Error(`Failed to ${action} profile`);
loadProfiles();
} catch (e) {
showToast(e.message, 'error');
}
}
async function deleteProfile(profileId, profileName) {
const msg = t('profiles.delete.confirm').replace('{name}', profileName);
const confirmed = await showConfirm(msg);
if (!confirmed) return;
try {
const resp = await fetch(`${API_BASE}/profiles/${profileId}`, {
method: 'DELETE',
headers: getHeaders(),
});
if (!resp.ok) throw new Error('Failed to delete profile');
showToast('Profile deleted', 'success');
loadProfiles();
} catch (e) {
showToast(e.message, 'error');
}
}

View File

@@ -36,6 +36,7 @@
<div class="tabs">
<div class="tab-bar">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
<button class="tab-btn" data-tab="profiles" onclick="switchTab('profiles')"><span data-i18n="profiles.title">📋 Profiles</span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
</div>
@@ -46,6 +47,12 @@
</div>
</div>
<div class="tab-panel" id="tab-profiles">
<div id="profiles-content">
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-targets">
<div id="targets-panel-content">
<div class="loading-spinner"></div>
@@ -650,6 +657,7 @@
<select id="device-type" onchange="onDeviceTypeChanged()">
<option value="wled">WLED</option>
<option value="adalight">Adalight</option>
<option value="ambiled">AmbiLED</option>
</select>
</div>
<div class="form-group">
@@ -982,6 +990,81 @@
</div>
</div>
<!-- Profile Editor Modal -->
<div id="profile-editor-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="profile-editor-title" data-i18n="profiles.add">📋 Add Profile</h2>
<button class="modal-close-btn" onclick="closeProfileEditorModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="profile-editor-form">
<input type="hidden" id="profile-editor-id">
<div class="form-group">
<div class="label-row">
<label for="profile-editor-name" data-i18n="profiles.name">Name:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.name.hint">A descriptive name for this profile</small>
<input type="text" id="profile-editor-name" required>
</div>
<div class="form-group settings-toggle-group">
<div class="label-row">
<label data-i18n="profiles.enabled">Enabled:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.enabled.hint">Disabled profiles won't activate even when conditions are met</small>
<label class="settings-toggle">
<input type="checkbox" id="profile-editor-enabled" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label for="profile-editor-logic" data-i18n="profiles.condition_logic">Condition Logic:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.condition_logic.hint">How multiple conditions are combined: ANY (OR) or ALL (AND)</small>
<select id="profile-editor-logic">
<option value="or" data-i18n="profiles.condition_logic.or">Any condition (OR)</option>
<option value="and" data-i18n="profiles.condition_logic.and">All conditions (AND)</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="profiles.conditions">Conditions:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.conditions.hint">Rules that determine when this profile activates</small>
<div id="profile-conditions-list"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addProfileCondition()" style="margin-top: 6px;">
+ <span data-i18n="profiles.conditions.add">Add Condition</span>
</button>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="profiles.targets">Targets:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="profiles.targets.hint">Targets to start when this profile activates</small>
<div id="profile-targets-list" class="profile-targets-checklist"></div>
</div>
<div id="profile-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeProfileEditorModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveProfileEditor()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Device Tutorial Overlay (viewport-level) -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
<div class="tutorial-backdrop"></div>

View File

@@ -464,5 +464,42 @@
"dashboard.errors": "Errors",
"dashboard.device": "Device",
"dashboard.stop_all": "Stop All",
"dashboard.failed": "Failed to load dashboard"
"dashboard.failed": "Failed to load dashboard",
"dashboard.section.profiles": "Profiles",
"dashboard.targets": "Targets",
"profiles.title": "\uD83D\uDCCB Profiles",
"profiles.empty": "No profiles configured. Create one to automate target activation.",
"profiles.add": "\uD83D\uDCCB Add Profile",
"profiles.edit": "Edit Profile",
"profiles.delete.confirm": "Delete profile \"{name}\"?",
"profiles.name": "Name:",
"profiles.name.hint": "A descriptive name for this profile",
"profiles.enabled": "Enabled:",
"profiles.enabled.hint": "Disabled profiles won't activate even when conditions are met",
"profiles.condition_logic": "Condition Logic:",
"profiles.condition_logic.hint": "How multiple conditions are combined: ANY (OR) or ALL (AND)",
"profiles.condition_logic.or": "Any condition (OR)",
"profiles.condition_logic.and": "All conditions (AND)",
"profiles.conditions": "Conditions:",
"profiles.conditions.hint": "Rules that determine when this profile activates",
"profiles.conditions.add": "Add Condition",
"profiles.conditions.empty": "No conditions \u2014 profile will never activate automatically",
"profiles.condition.application": "Application",
"profiles.condition.application.apps": "Applications:",
"profiles.condition.application.apps.hint": "Process names, one per line (e.g. firefox.exe)",
"profiles.condition.application.browse": "Browse",
"profiles.condition.application.search": "Filter processes...",
"profiles.condition.application.no_processes": "No processes found",
"profiles.condition.application.match_type": "Match Type:",
"profiles.condition.application.match_type.hint": "How to detect the application",
"profiles.condition.application.match_type.running": "Running",
"profiles.condition.application.match_type.topmost": "Topmost (foreground)",
"profiles.targets": "Targets:",
"profiles.targets.hint": "Targets to start when this profile activates",
"profiles.targets.empty": "No targets available",
"profiles.status.active": "Active",
"profiles.status.inactive": "Inactive",
"profiles.status.disabled": "Disabled",
"profiles.last_activated": "Last activated"
}

View File

@@ -464,5 +464,42 @@
"dashboard.errors": "Ошибки",
"dashboard.device": "Устройство",
"dashboard.stop_all": "Остановить все",
"dashboard.failed": "Не удалось загрузить обзор"
"dashboard.failed": "Не удалось загрузить обзор",
"dashboard.section.profiles": "Профили",
"dashboard.targets": "Цели",
"profiles.title": "\uD83D\uDCCB Профили",
"profiles.empty": "Профили не настроены. Создайте профиль для автоматизации целей.",
"profiles.add": "\uD83D\uDCCB Добавить профиль",
"profiles.edit": "Редактировать профиль",
"profiles.delete.confirm": "Удалить профиль \"{name}\"?",
"profiles.name": "Название:",
"profiles.name.hint": "Описательное имя для профиля",
"profiles.enabled": "Включён:",
"profiles.enabled.hint": "Отключённые профили не активируются даже при выполнении условий",
"profiles.condition_logic": "Логика условий:",
"profiles.condition_logic.hint": "Как объединяются несколько условий: ЛЮБОЕ (ИЛИ) или ВСЕ (И)",
"profiles.condition_logic.or": "Любое условие (ИЛИ)",
"profiles.condition_logic.and": "Все условия (И)",
"profiles.conditions": "Условия:",
"profiles.conditions.hint": "Правила, определяющие когда профиль активируется",
"profiles.conditions.add": "Добавить условие",
"profiles.conditions.empty": "Нет условий \u2014 профиль не активируется автоматически",
"profiles.condition.application": "Приложение",
"profiles.condition.application.apps": "Приложения:",
"profiles.condition.application.apps.hint": "Имена процессов, по одному на строку (например firefox.exe)",
"profiles.condition.application.browse": "Обзор",
"profiles.condition.application.search": "Фильтр процессов...",
"profiles.condition.application.no_processes": "Процессы не найдены",
"profiles.condition.application.match_type": "Тип соответствия:",
"profiles.condition.application.match_type.hint": "Как определять наличие приложения",
"profiles.condition.application.match_type.running": "Запущено",
"profiles.condition.application.match_type.topmost": "На переднем плане",
"profiles.targets": "Цели:",
"profiles.targets.hint": "Цели для запуска при активации профиля",
"profiles.targets.empty": "Нет доступных целей",
"profiles.status.active": "Активен",
"profiles.status.inactive": "Неактивен",
"profiles.status.disabled": "Отключён",
"profiles.last_activated": "Последняя активация"
}

View File

@@ -3225,32 +3225,34 @@ input:-webkit-autofill:focus {
/* ── Dashboard ── */
.dashboard-section {
margin-bottom: 24px;
margin-bottom: 16px;
}
.dashboard-section-header {
font-size: 1rem;
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 10px;
margin-bottom: 6px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dashboard-section-count {
background: var(--border-color);
color: var(--text-secondary);
border-radius: 10px;
padding: 1px 8px;
font-size: 0.8rem;
padding: 0 6px;
font-size: 0.75rem;
font-weight: 600;
}
.dashboard-stop-all {
margin-left: auto;
padding: 3px 10px;
font-size: 0.75rem;
padding: 2px 8px;
font-size: 0.7rem;
white-space: nowrap;
flex: 0 0 auto;
}
@@ -3259,35 +3261,36 @@ input:-webkit-autofill:focus {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 16px;
padding: 12px 16px;
gap: 12px;
padding: 8px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 8px;
border-radius: 6px;
margin-bottom: 4px;
}
.dashboard-target-info {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.dashboard-target-icon {
font-size: 1.2rem;
font-size: 1rem;
flex-shrink: 0;
}
.dashboard-target-name {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 6px;
gap: 4px;
}
.dashboard-target-name .health-dot {
@@ -3296,7 +3299,7 @@ input:-webkit-autofill:focus {
}
.dashboard-target-subtitle {
font-size: 0.75rem;
font-size: 0.7rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
@@ -3304,24 +3307,25 @@ input:-webkit-autofill:focus {
}
.dashboard-target-metrics {
display: grid;
grid-template-columns: 90px 80px 60px;
display: flex;
gap: 12px;
align-items: center;
}
.dashboard-metric {
text-align: center;
min-width: 48px;
}
.dashboard-metric-value {
font-size: 1.05rem;
font-size: 0.85rem;
font-weight: 700;
color: var(--primary-color);
line-height: 1.2;
}
.dashboard-metric-label {
font-size: 0.7rem;
font-size: 0.6rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.3px;
@@ -3330,11 +3334,11 @@ input:-webkit-autofill:focus {
.dashboard-target-actions {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
}
.dashboard-status-dot {
font-size: 1.2rem;
font-size: 1rem;
line-height: 1;
}
@@ -3345,24 +3349,39 @@ input:-webkit-autofill:focus {
.dashboard-no-targets {
text-align: center;
padding: 40px 20px;
padding: 32px 16px;
color: var(--text-secondary);
font-size: 1rem;
font-size: 0.9rem;
}
.dashboard-badge-stopped {
padding: 3px 10px;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-size: 0.7rem;
font-weight: 600;
background: var(--border-color);
color: var(--text-secondary);
flex-shrink: 0;
}
.dashboard-badge-active {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.7rem;
font-weight: 600;
background: var(--success-color, #28a745);
color: #fff;
flex-shrink: 0;
}
.dashboard-profile .dashboard-target-metrics {
min-width: 48px;
}
@media (max-width: 768px) {
.dashboard-target {
grid-template-columns: 1fr;
gap: 10px;
gap: 6px;
}
.dashboard-target-actions {
@@ -3370,3 +3389,189 @@ input:-webkit-autofill:focus {
}
}
/* ===== PROFILES ===== */
.badge-profile-active {
background: var(--success-color, #28a745);
color: #fff;
}
.badge-profile-inactive {
background: var(--border-color);
color: var(--text-color);
}
.badge-profile-disabled {
background: var(--border-color);
color: var(--text-muted);
opacity: 0.7;
}
.profile-status-disabled {
opacity: 0.6;
}
.profile-logic-label {
font-size: 0.7rem;
font-weight: 600;
color: var(--text-muted);
padding: 0 4px;
}
/* Profile condition editor rows */
.profile-condition-row {
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
background: var(--bg-secondary, var(--bg-color));
}
.condition-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.condition-type-label {
font-weight: 600;
font-size: 0.9rem;
}
.btn-remove-condition {
background: none;
border: none;
color: var(--danger-color, #dc3545);
cursor: pointer;
font-size: 1rem;
padding: 2px 6px;
}
.condition-fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.condition-field label {
display: block;
font-size: 0.85rem;
margin-bottom: 3px;
color: var(--text-muted);
}
.condition-field select,
.condition-field textarea {
width: 100%;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-color);
color: var(--text-color);
font-size: 0.9rem;
font-family: inherit;
}
.condition-apps {
resize: vertical;
min-height: 60px;
}
.condition-apps-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-browse-apps {
background: none;
border: 1px solid var(--border-color);
color: var(--text-color);
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.btn-browse-apps:hover {
border-color: var(--primary-color);
background: rgba(33, 150, 243, 0.1);
}
.process-picker {
margin-top: 6px;
border: 1px solid var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.process-picker-search {
width: 100%;
padding: 6px 8px;
border: none;
border-bottom: 1px solid var(--border-color);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.85rem;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.process-picker-list {
max-height: 160px;
overflow-y: auto;
}
.process-picker-item {
padding: 4px 8px;
font-size: 0.8rem;
cursor: pointer;
transition: background 0.1s;
}
.process-picker-item:hover {
background: rgba(33, 150, 243, 0.15);
}
.process-picker-item.added {
color: var(--text-muted);
cursor: default;
opacity: 0.6;
}
.process-picker-loading {
padding: 8px;
font-size: 0.8rem;
color: var(--text-muted);
text-align: center;
}
/* Profile target checklist */
.profile-targets-checklist {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 6px;
}
.profile-target-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
cursor: pointer;
border-radius: 3px;
}
.profile-target-item:hover {
background: var(--bg-secondary, var(--bg-color));
}
.profile-target-item input[type="checkbox"] {
margin: 0;
}