diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js
index 940b67d..2a76bac 100644
--- a/server/src/wled_controller/static/app.js
+++ b/server/src/wled_controller/static/app.js
@@ -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 = `
${t('dashboard.no_targets')}
`;
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 += `
+
+ ${activeProfiles.map(p => renderDashboardProfile(p)).join('')}
+ ${inactiveProfiles.map(p => renderDashboardProfile(p)).join('')}
+
`;
+ }
+
// Running section
if (running.length > 0) {
html += `
@@ -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) {
- ●
-
+
`;
} else {
@@ -1578,16 +1609,83 @@ function renderDashboardTarget(target, isRunning) {
${escapeHtml(target.name)}
${subtitleParts.length ? `${escapeHtml(subtitleParts.join(' · '))}
` : ''}
- ${t('dashboard.section.stopped')}
-
+
`;
}
}
+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
+ ? `${t('profiles.status.disabled')}`
+ : isActive
+ ? `${t('profiles.status.active')}`
+ : `${t('profiles.status.inactive')}`;
+
+ const targetCount = profile.target_ids.length;
+ const activeCount = (profile.active_target_ids || []).length;
+ const targetsInfo = isActive ? `${activeCount}/${targetCount}` : `${targetCount}`;
+
+ return `
+
+
📋
+
+
${escapeHtml(profile.name)}
+ ${condSummary ? `
${escapeHtml(condSummary)}
` : ''}
+
+ ${statusBadge}
+
+
+
+
${targetsInfo}
+
${t('dashboard.targets')}
+
+
+
+
+
+
`;
+}
+
+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 = `${error.message}
`;
+ }
+}
+
+function renderProfiles(profiles) {
+ const container = document.getElementById('profiles-content');
+
+ let html = '';
+ for (const p of profiles) {
+ html += createProfileCard(p);
+ }
+ html += `
`;
+ html += '
';
+
+ 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 = `${t('profiles.conditions.empty')}`;
+ } 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 `${t('profiles.condition.application')}: ${apps} (${matchLabel})`;
+ }
+ return `${c.condition_type}`;
+ });
+ const logicLabel = profile.condition_logic === 'and' ? ' AND ' : ' OR ';
+ condPills = parts.join(`${logicLabel}`);
+ }
+
+ // 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 = `🕐 ${ts.toLocaleString()}`;
+ }
+
+ return `
+
+
+
+
+
+
+ ${profile.condition_logic === 'and' ? 'ALL' : 'ANY'}
+ ⚡ ${targetCountText}
+ ${lastActivityMeta}
+
+
${condPills}
+
+
+
+
+
`;
+}
+
+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 = `${t('profiles.targets.empty')}`;
+ return;
+ }
+
+ container.innerHTML = targets.map(tgt => {
+ const checked = selectedIds.includes(tgt.id) ? 'checked' : '';
+ return ``;
+ }).join('');
+ } catch (e) {
+ container.innerHTML = `${e.message}`;
+ }
+}
+
+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 = `
+
+
+
+
+
+
+
+
+ `;
+
+ // 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 = `${t('common.loading')}
`;
+ 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 = `${e.message}
`;
+ }
+}
+
+function renderProcessPicker(picker, processes, existing) {
+ const listEl = picker.querySelector('.process-picker-list');
+ if (processes.length === 0) {
+ listEl.innerHTML = `${t('profiles.condition.application.no_processes')}
`;
+ return;
+ }
+ listEl.innerHTML = processes.map(p => {
+ const added = existing.has(p.toLowerCase());
+ return `${escapeHtml(p)}${added ? ' ✓' : ''}
`;
+ }).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');
+ }
+}
diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html
index a309c37..05ab0fc 100644
--- a/server/src/wled_controller/static/index.html
+++ b/server/src/wled_controller/static/index.html
@@ -36,6 +36,7 @@
+
@@ -46,6 +47,12 @@
+
+
@@ -650,6 +657,7 @@
@@ -982,6 +990,81 @@
+
+
+
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index b1b51eb..defefb9 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -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"
}
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index 9fe9e3c..5f53c64 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -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": "Последняя активация"
}
diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css
index 6d22092..15e41a7 100644
--- a/server/src/wled_controller/static/style.css
+++ b/server/src/wled_controller/static/style.css
@@ -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;
+}
+