- CardSection._animateEntrance: skip after first render to prevent card fade-in replaying on every data refresh - automations: use reconcile() on subsequent renders instead of full innerHTML replacement that destroyed and recreated all cards - streams: same reconcile() approach for all 9 CardSections - targets/dashboard/streams: only show setTabRefreshing loading bar on first render when the tab is empty Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
697 lines
33 KiB
JavaScript
697 lines
33 KiB
JavaScript
/**
|
||
* Automations — automation cards, editor, condition builder, process picker, scene selector.
|
||
*/
|
||
|
||
import { apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, scenePresetsCache } from '../core/state.js';
|
||
import { fetchWithAuth, escapeHtml } from '../core/api.js';
|
||
import { t } from '../core/i18n.js';
|
||
import { showToast, showConfirm, setTabRefreshing } from '../core/ui.js';
|
||
import { Modal } from '../core/modal.js';
|
||
import { CardSection } from '../core/card-sections.js';
|
||
import { updateTabBadge } from './tabs.js';
|
||
import { ICON_SETTINGS, ICON_START, ICON_PAUSE, ICON_CLOCK, ICON_AUTOMATION, ICON_HELP, ICON_OK, ICON_TIMER, ICON_MONITOR, ICON_RADIO, ICON_SCENE } from '../core/icons.js';
|
||
import { wrapCard } from '../core/card-colors.js';
|
||
import { csScenes, createSceneCard } from './scene-presets.js';
|
||
|
||
class AutomationEditorModal extends Modal {
|
||
constructor() { super('automation-editor-modal'); }
|
||
|
||
snapshotValues() {
|
||
return {
|
||
name: document.getElementById('automation-editor-name').value,
|
||
enabled: document.getElementById('automation-editor-enabled').checked.toString(),
|
||
logic: document.getElementById('automation-editor-logic').value,
|
||
conditions: JSON.stringify(getAutomationEditorConditions()),
|
||
scenePresetId: document.getElementById('automation-scene-id').value,
|
||
deactivationMode: document.getElementById('automation-deactivation-mode').value,
|
||
deactivationScenePresetId: document.getElementById('automation-fallback-scene-id').value,
|
||
};
|
||
}
|
||
}
|
||
|
||
const automationModal = new AutomationEditorModal();
|
||
const csAutomations = new CardSection('automations', { titleKey: 'automations.title', gridClass: 'devices-grid', addCardOnclick: "openAutomationEditor()", keyAttr: 'data-automation-id' });
|
||
|
||
// Re-render automations when language changes (only if tab is active)
|
||
document.addEventListener('languageChanged', () => {
|
||
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') loadAutomations();
|
||
});
|
||
|
||
// React to real-time automation state changes from global events WS
|
||
document.addEventListener('server:automation_state_changed', () => {
|
||
if (apiKey && (localStorage.getItem('activeTab') || 'dashboard') === 'automations') {
|
||
loadAutomations();
|
||
}
|
||
});
|
||
|
||
export async function loadAutomations() {
|
||
if (_automationsLoading) return;
|
||
set_automationsLoading(true);
|
||
const container = document.getElementById('automations-content');
|
||
if (!container) { set_automationsLoading(false); return; }
|
||
if (!csAutomations.isMounted()) setTabRefreshing('automations-content', true);
|
||
|
||
try {
|
||
const [automations, scenes] = await Promise.all([
|
||
automationsCacheObj.fetch(),
|
||
scenePresetsCache.fetch(),
|
||
]);
|
||
|
||
const sceneMap = new Map(scenes.map(s => [s.id, s]));
|
||
const activeCount = automations.filter(a => a.is_active).length;
|
||
updateTabBadge('automations', activeCount);
|
||
renderAutomations(automations, sceneMap);
|
||
} catch (error) {
|
||
if (error.isAuth) return;
|
||
console.error('Failed to load automations:', error);
|
||
container.innerHTML = `<p class="error-message">${error.message}</p>`;
|
||
} finally {
|
||
set_automationsLoading(false);
|
||
setTabRefreshing('automations-content', false);
|
||
}
|
||
}
|
||
|
||
export function expandAllAutomationSections() {
|
||
CardSection.expandAll([csAutomations, csScenes]);
|
||
}
|
||
|
||
export function collapseAllAutomationSections() {
|
||
CardSection.collapseAll([csAutomations, csScenes]);
|
||
}
|
||
|
||
function renderAutomations(automations, sceneMap) {
|
||
const container = document.getElementById('automations-content');
|
||
|
||
const autoItems = csAutomations.applySortOrder(automations.map(a => ({ key: a.id, html: createAutomationCard(a, sceneMap) })));
|
||
const sceneItems = csScenes.applySortOrder(scenePresetsCache.data.map(s => ({ key: s.id, html: createSceneCard(s) })));
|
||
|
||
if (csAutomations.isMounted()) {
|
||
csAutomations.reconcile(autoItems);
|
||
csScenes.reconcile(sceneItems);
|
||
} else {
|
||
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group"><button class="btn-expand-collapse" onclick="expandAllAutomationSections()" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllAutomationSections()" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startAutomationsTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
|
||
container.innerHTML = toolbar + csAutomations.render(autoItems) + csScenes.render(sceneItems);
|
||
csAutomations.bind();
|
||
csScenes.bind();
|
||
|
||
// Localize data-i18n elements within the container
|
||
container.querySelectorAll('[data-i18n]').forEach(el => {
|
||
el.textContent = t(el.getAttribute('data-i18n'));
|
||
});
|
||
}
|
||
}
|
||
|
||
function createAutomationCard(automation, sceneMap = new Map()) {
|
||
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
|
||
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
|
||
|
||
let condPills = '';
|
||
if (automation.conditions.length === 0) {
|
||
condPills = `<span class="stream-card-prop">${t('automations.conditions.empty')}</span>`;
|
||
} else {
|
||
const parts = automation.conditions.map(c => {
|
||
if (c.condition_type === 'always') {
|
||
return `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`;
|
||
}
|
||
if (c.condition_type === 'application') {
|
||
const apps = (c.apps || []).join(', ');
|
||
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
|
||
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
|
||
}
|
||
if (c.condition_type === 'time_of_day') {
|
||
return `<span class="stream-card-prop">${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}</span>`;
|
||
}
|
||
if (c.condition_type === 'system_idle') {
|
||
const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
|
||
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
|
||
}
|
||
if (c.condition_type === 'display_state') {
|
||
const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
|
||
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}</span>`;
|
||
}
|
||
if (c.condition_type === 'mqtt') {
|
||
return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`;
|
||
}
|
||
return `<span class="stream-card-prop">${c.condition_type}</span>`;
|
||
});
|
||
const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
|
||
condPills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
|
||
}
|
||
|
||
// Scene info
|
||
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
|
||
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
|
||
const sceneColor = scene ? scene.color || '#4fc3f7' : '#888';
|
||
|
||
// Deactivation mode label
|
||
let deactivationLabel = '';
|
||
if (automation.deactivation_mode === 'revert') {
|
||
deactivationLabel = t('automations.deactivation_mode.revert');
|
||
} else if (automation.deactivation_mode === 'fallback_scene') {
|
||
const fallback = automation.deactivation_scene_preset_id ? sceneMap.get(automation.deactivation_scene_preset_id) : null;
|
||
deactivationLabel = fallback ? `${t('automations.deactivation_mode.fallback_scene')}: ${escapeHtml(fallback.name)}` : t('automations.deactivation_mode.fallback_scene');
|
||
}
|
||
|
||
let lastActivityMeta = '';
|
||
if (automation.last_activated_at) {
|
||
const ts = new Date(automation.last_activated_at);
|
||
lastActivityMeta = `<span class="card-meta" title="${t('automations.last_activated')}">${ICON_CLOCK} ${ts.toLocaleString()}</span>`;
|
||
}
|
||
|
||
return wrapCard({
|
||
dataAttr: 'data-automation-id',
|
||
id: automation.id,
|
||
classes: !automation.enabled ? 'automation-status-disabled' : '',
|
||
removeOnclick: `deleteAutomation('${automation.id}', '${escapeHtml(automation.name)}')`,
|
||
removeTitle: t('common.delete'),
|
||
content: `
|
||
<div class="card-header">
|
||
<div class="card-title">
|
||
${escapeHtml(automation.name)}
|
||
<span class="badge badge-automation-${statusClass}">${statusText}</span>
|
||
</div>
|
||
</div>
|
||
<div class="card-subtitle">
|
||
<span class="card-meta">${automation.condition_logic === 'and' ? t('automations.logic.all') : t('automations.logic.any')}</span>
|
||
<span class="card-meta">${ICON_SCENE} <span style="color:${sceneColor}">●</span> ${sceneName}</span>
|
||
${deactivationLabel ? `<span class="card-meta">${deactivationLabel}</span>` : ''}
|
||
${lastActivityMeta}
|
||
</div>
|
||
<div class="stream-card-props">${condPills}</div>`,
|
||
actions: `
|
||
<button class="btn btn-icon btn-secondary" onclick="openAutomationEditor('${automation.id}')" title="${t('automations.edit')}">${ICON_SETTINGS}</button>
|
||
<button class="btn btn-icon ${automation.enabled ? 'btn-warning' : 'btn-success'}" onclick="toggleAutomationEnabled('${automation.id}', ${!automation.enabled})" title="${automation.enabled ? t('automations.action.disable') : t('automations.status.active')}">
|
||
${automation.enabled ? ICON_PAUSE : ICON_START}
|
||
</button>`,
|
||
});
|
||
}
|
||
|
||
export async function openAutomationEditor(automationId) {
|
||
const modal = document.getElementById('automation-editor-modal');
|
||
const titleEl = document.getElementById('automation-editor-title');
|
||
const idInput = document.getElementById('automation-editor-id');
|
||
const nameInput = document.getElementById('automation-editor-name');
|
||
const enabledInput = document.getElementById('automation-editor-enabled');
|
||
const logicSelect = document.getElementById('automation-editor-logic');
|
||
const condList = document.getElementById('automation-conditions-list');
|
||
const errorEl = document.getElementById('automation-editor-error');
|
||
|
||
errorEl.style.display = 'none';
|
||
condList.innerHTML = '';
|
||
|
||
// Fetch scenes for selector
|
||
try {
|
||
await scenePresetsCache.fetch();
|
||
} catch { /* use cached */ }
|
||
|
||
// Reset deactivation mode
|
||
document.getElementById('automation-deactivation-mode').value = 'none';
|
||
document.getElementById('automation-fallback-scene-group').style.display = 'none';
|
||
|
||
if (automationId) {
|
||
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.edit')}`;
|
||
try {
|
||
const resp = await fetchWithAuth(`/automations/${automationId}`);
|
||
if (!resp.ok) throw new Error('Failed to load automation');
|
||
const automation = await resp.json();
|
||
|
||
idInput.value = automation.id;
|
||
nameInput.value = automation.name;
|
||
enabledInput.checked = automation.enabled;
|
||
logicSelect.value = automation.condition_logic;
|
||
|
||
for (const c of automation.conditions) {
|
||
addAutomationConditionRow(c);
|
||
}
|
||
|
||
// Scene selector
|
||
_initSceneSelector('automation-scene', automation.scene_preset_id);
|
||
|
||
// Deactivation mode
|
||
document.getElementById('automation-deactivation-mode').value = automation.deactivation_mode || 'none';
|
||
_onDeactivationModeChange();
|
||
_initSceneSelector('automation-fallback-scene', automation.deactivation_scene_preset_id);
|
||
} catch (e) {
|
||
showToast(e.message, 'error');
|
||
return;
|
||
}
|
||
} else {
|
||
titleEl.innerHTML = `${ICON_AUTOMATION} ${t('automations.add')}`;
|
||
idInput.value = '';
|
||
nameInput.value = '';
|
||
enabledInput.checked = true;
|
||
logicSelect.value = 'or';
|
||
_initSceneSelector('automation-scene', null);
|
||
_initSceneSelector('automation-fallback-scene', null);
|
||
}
|
||
|
||
// Wire up deactivation mode change
|
||
document.getElementById('automation-deactivation-mode').onchange = _onDeactivationModeChange;
|
||
|
||
automationModal.open();
|
||
modal.querySelectorAll('[data-i18n]').forEach(el => {
|
||
el.textContent = t(el.getAttribute('data-i18n'));
|
||
});
|
||
modal.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
|
||
});
|
||
automationModal.snapshot();
|
||
}
|
||
|
||
function _onDeactivationModeChange() {
|
||
const mode = document.getElementById('automation-deactivation-mode').value;
|
||
document.getElementById('automation-fallback-scene-group').style.display = mode === 'fallback_scene' ? '' : 'none';
|
||
}
|
||
|
||
export async function closeAutomationEditorModal() {
|
||
await automationModal.close();
|
||
}
|
||
|
||
// ===== Scene selector logic =====
|
||
|
||
function _initSceneSelector(prefix, selectedId) {
|
||
const hiddenInput = document.getElementById(`${prefix}-id`);
|
||
const searchInput = document.getElementById(`${prefix}-search`);
|
||
const clearBtn = document.getElementById(`${prefix}-clear`);
|
||
const dropdown = document.getElementById(`${prefix}-dropdown`);
|
||
|
||
hiddenInput.value = selectedId || '';
|
||
|
||
// Set initial display text
|
||
if (selectedId) {
|
||
const scene = scenePresetsCache.data.find(s => s.id === selectedId);
|
||
searchInput.value = scene ? scene.name : '';
|
||
clearBtn.classList.toggle('visible', true);
|
||
} else {
|
||
searchInput.value = '';
|
||
clearBtn.classList.toggle('visible', false);
|
||
}
|
||
|
||
// Render dropdown items
|
||
function renderDropdown(filter) {
|
||
const query = (filter || '').toLowerCase();
|
||
const filtered = query ? scenePresetsCache.data.filter(s => s.name.toLowerCase().includes(query)) : scenePresetsCache.data;
|
||
|
||
if (filtered.length === 0) {
|
||
dropdown.innerHTML = `<div class="scene-selector-empty">${t('automations.scene.none_available')}</div>`;
|
||
} else {
|
||
dropdown.innerHTML = filtered.map(s => {
|
||
const selected = s.id === hiddenInput.value ? ' selected' : '';
|
||
return `<div class="scene-selector-item${selected}" data-scene-id="${s.id}"><span class="scene-color-dot" style="background:${escapeHtml(s.color || '#4fc3f7')}"></span>${escapeHtml(s.name)}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Attach click handlers
|
||
dropdown.querySelectorAll('.scene-selector-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const id = item.dataset.sceneId;
|
||
const scene = scenePresetsCache.data.find(s => s.id === id);
|
||
hiddenInput.value = id;
|
||
searchInput.value = scene ? scene.name : '';
|
||
clearBtn.classList.toggle('visible', true);
|
||
dropdown.classList.remove('open');
|
||
});
|
||
});
|
||
}
|
||
|
||
// Show dropdown on focus/click
|
||
searchInput.onfocus = () => {
|
||
renderDropdown(searchInput.value);
|
||
dropdown.classList.add('open');
|
||
};
|
||
|
||
searchInput.oninput = () => {
|
||
renderDropdown(searchInput.value);
|
||
dropdown.classList.add('open');
|
||
// If text doesn't match any scene, clear the hidden input
|
||
const exactMatch = scenePresetsCache.data.find(s => s.name.toLowerCase() === searchInput.value.toLowerCase());
|
||
if (!exactMatch) {
|
||
hiddenInput.value = '';
|
||
clearBtn.classList.toggle('visible', !!searchInput.value);
|
||
}
|
||
};
|
||
|
||
searchInput.onkeydown = (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
// Select first visible item
|
||
const first = dropdown.querySelector('.scene-selector-item');
|
||
if (first) first.click();
|
||
} else if (e.key === 'Escape') {
|
||
dropdown.classList.remove('open');
|
||
searchInput.blur();
|
||
}
|
||
};
|
||
|
||
// Clear button
|
||
clearBtn.onclick = () => {
|
||
hiddenInput.value = '';
|
||
searchInput.value = '';
|
||
clearBtn.classList.remove('visible');
|
||
dropdown.classList.remove('open');
|
||
};
|
||
|
||
// Close dropdown when clicking outside
|
||
const selectorEl = searchInput.closest('.scene-selector');
|
||
// Remove old listener if any (re-init)
|
||
if (selectorEl._outsideClickHandler) {
|
||
document.removeEventListener('click', selectorEl._outsideClickHandler);
|
||
}
|
||
selectorEl._outsideClickHandler = (e) => {
|
||
if (!selectorEl.contains(e.target)) {
|
||
dropdown.classList.remove('open');
|
||
}
|
||
};
|
||
document.addEventListener('click', selectorEl._outsideClickHandler);
|
||
}
|
||
|
||
// ===== Condition editor =====
|
||
|
||
export function addAutomationCondition() {
|
||
addAutomationConditionRow({ condition_type: 'application', apps: [], match_type: 'running' });
|
||
}
|
||
|
||
function addAutomationConditionRow(condition) {
|
||
const list = document.getElementById('automation-conditions-list');
|
||
const row = document.createElement('div');
|
||
row.className = 'automation-condition-row';
|
||
const condType = condition.condition_type || 'application';
|
||
|
||
row.innerHTML = `
|
||
<div class="condition-header">
|
||
<select class="condition-type-select">
|
||
<option value="always" ${condType === 'always' ? 'selected' : ''}>${t('automations.condition.always')}</option>
|
||
<option value="application" ${condType === 'application' ? 'selected' : ''}>${t('automations.condition.application')}</option>
|
||
<option value="time_of_day" ${condType === 'time_of_day' ? 'selected' : ''}>${t('automations.condition.time_of_day')}</option>
|
||
<option value="system_idle" ${condType === 'system_idle' ? 'selected' : ''}>${t('automations.condition.system_idle')}</option>
|
||
<option value="display_state" ${condType === 'display_state' ? 'selected' : ''}>${t('automations.condition.display_state')}</option>
|
||
<option value="mqtt" ${condType === 'mqtt' ? 'selected' : ''}>${t('automations.condition.mqtt')}</option>
|
||
</select>
|
||
<button type="button" class="btn-remove-condition" onclick="this.closest('.automation-condition-row').remove()" title="Remove">✕</button>
|
||
</div>
|
||
<div class="condition-fields-container"></div>
|
||
`;
|
||
|
||
const typeSelect = row.querySelector('.condition-type-select');
|
||
const container = row.querySelector('.condition-fields-container');
|
||
|
||
function renderFields(type, data) {
|
||
if (type === 'always') {
|
||
container.innerHTML = `<small class="condition-always-desc">${t('automations.condition.always.hint')}</small>`;
|
||
return;
|
||
}
|
||
if (type === 'time_of_day') {
|
||
const startTime = data.start_time || '00:00';
|
||
const endTime = data.end_time || '23:59';
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${t('automations.condition.time_of_day.start_time')}</label>
|
||
<input type="time" class="condition-start-time" value="${startTime}">
|
||
</div>
|
||
<div class="condition-field">
|
||
<label>${t('automations.condition.time_of_day.end_time')}</label>
|
||
<input type="time" class="condition-end-time" value="${endTime}">
|
||
</div>
|
||
<small class="condition-always-desc">${t('automations.condition.time_of_day.overnight_hint')}</small>
|
||
</div>`;
|
||
return;
|
||
}
|
||
if (type === 'system_idle') {
|
||
const idleMinutes = data.idle_minutes ?? 5;
|
||
const whenIdle = data.when_idle ?? true;
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${t('automations.condition.system_idle.idle_minutes')}</label>
|
||
<input type="number" class="condition-idle-minutes" min="1" max="999" value="${idleMinutes}">
|
||
</div>
|
||
<div class="condition-field">
|
||
<label>${t('automations.condition.system_idle.mode')}</label>
|
||
<select class="condition-when-idle">
|
||
<option value="true" ${whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_idle')}</option>
|
||
<option value="false" ${!whenIdle ? 'selected' : ''}>${t('automations.condition.system_idle.when_active')}</option>
|
||
</select>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
if (type === 'display_state') {
|
||
const dState = data.state || 'on';
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${t('automations.condition.display_state.state')}</label>
|
||
<select class="condition-display-state">
|
||
<option value="on" ${dState === 'on' ? 'selected' : ''}>${t('automations.condition.display_state.on')}</option>
|
||
<option value="off" ${dState === 'off' ? 'selected' : ''}>${t('automations.condition.display_state.off')}</option>
|
||
</select>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
if (type === 'mqtt') {
|
||
const topic = data.topic || '';
|
||
const payload = data.payload || '';
|
||
const matchMode = data.match_mode || 'exact';
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${t('automations.condition.mqtt.topic')}</label>
|
||
<input type="text" class="condition-mqtt-topic" value="${escapeHtml(topic)}" placeholder="home/status/power">
|
||
</div>
|
||
<div class="condition-field">
|
||
<label>${t('automations.condition.mqtt.payload')}</label>
|
||
<input type="text" class="condition-mqtt-payload" value="${escapeHtml(payload)}" placeholder="ON">
|
||
</div>
|
||
<div class="condition-field">
|
||
<label>${t('automations.condition.mqtt.match_mode')}</label>
|
||
<select class="condition-mqtt-match-mode">
|
||
<option value="exact" ${matchMode === 'exact' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.exact')}</option>
|
||
<option value="contains" ${matchMode === 'contains' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.contains')}</option>
|
||
<option value="regex" ${matchMode === 'regex' ? 'selected' : ''}>${t('automations.condition.mqtt.match_mode.regex')}</option>
|
||
</select>
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
const appsValue = (data.apps || []).join('\n');
|
||
const matchType = data.match_type || 'running';
|
||
container.innerHTML = `
|
||
<div class="condition-fields">
|
||
<div class="condition-field">
|
||
<label>${t('automations.condition.application.match_type')}</label>
|
||
<select class="condition-match-type">
|
||
<option value="running" ${matchType === 'running' ? 'selected' : ''}>${t('automations.condition.application.match_type.running')}</option>
|
||
<option value="topmost" ${matchType === 'topmost' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost')}</option>
|
||
<option value="topmost_fullscreen" ${matchType === 'topmost_fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.topmost_fullscreen')}</option>
|
||
<option value="fullscreen" ${matchType === 'fullscreen' ? 'selected' : ''}>${t('automations.condition.application.match_type.fullscreen')}</option>
|
||
</select>
|
||
</div>
|
||
<div class="condition-field">
|
||
<div class="condition-apps-header">
|
||
<label>${t('automations.condition.application.apps')}</label>
|
||
<button type="button" class="btn-browse-apps" title="${t('automations.condition.application.browse')}">${t('automations.condition.application.browse')}</button>
|
||
</div>
|
||
<textarea class="condition-apps" rows="3" placeholder="firefox.exe chrome.exe">${escapeHtml(appsValue)}</textarea>
|
||
<div class="process-picker" style="display:none">
|
||
<input type="text" class="process-picker-search" placeholder="${t('automations.condition.application.search')}" autocomplete="off">
|
||
<div class="process-picker-list"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
const browseBtn = container.querySelector('.btn-browse-apps');
|
||
const picker = container.querySelector('.process-picker');
|
||
browseBtn.addEventListener('click', () => toggleProcessPicker(picker, row));
|
||
const searchInput = container.querySelector('.process-picker-search');
|
||
searchInput.addEventListener('input', () => filterProcessPicker(picker));
|
||
}
|
||
|
||
renderFields(condType, condition);
|
||
typeSelect.addEventListener('change', () => {
|
||
renderFields(typeSelect.value, {});
|
||
});
|
||
|
||
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 fetchWithAuth('/system/processes');
|
||
if (!resp.ok) throw new Error('Failed to fetch processes');
|
||
const data = await resp.json();
|
||
|
||
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('automations.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 ? ' \u2713' : ''}</div>`;
|
||
}).join('');
|
||
|
||
listEl.querySelectorAll('.process-picker-item:not(.added)').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const proc = item.dataset.process;
|
||
const row = picker.closest('.automation-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 + ' \u2713';
|
||
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 getAutomationEditorConditions() {
|
||
const rows = document.querySelectorAll('#automation-conditions-list .automation-condition-row');
|
||
const conditions = [];
|
||
rows.forEach(row => {
|
||
const typeSelect = row.querySelector('.condition-type-select');
|
||
const condType = typeSelect ? typeSelect.value : 'application';
|
||
if (condType === 'always') {
|
||
conditions.push({ condition_type: 'always' });
|
||
} else if (condType === 'time_of_day') {
|
||
conditions.push({
|
||
condition_type: 'time_of_day',
|
||
start_time: row.querySelector('.condition-start-time').value || '00:00',
|
||
end_time: row.querySelector('.condition-end-time').value || '23:59',
|
||
});
|
||
} else if (condType === 'system_idle') {
|
||
conditions.push({
|
||
condition_type: 'system_idle',
|
||
idle_minutes: parseInt(row.querySelector('.condition-idle-minutes').value, 10) || 5,
|
||
when_idle: row.querySelector('.condition-when-idle').value === 'true',
|
||
});
|
||
} else if (condType === 'display_state') {
|
||
conditions.push({
|
||
condition_type: 'display_state',
|
||
state: row.querySelector('.condition-display-state').value || 'on',
|
||
});
|
||
} else if (condType === 'mqtt') {
|
||
conditions.push({
|
||
condition_type: 'mqtt',
|
||
topic: row.querySelector('.condition-mqtt-topic').value.trim(),
|
||
payload: row.querySelector('.condition-mqtt-payload').value,
|
||
match_mode: row.querySelector('.condition-mqtt-match-mode').value || 'exact',
|
||
});
|
||
} else {
|
||
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;
|
||
}
|
||
|
||
export async function saveAutomationEditor() {
|
||
const idInput = document.getElementById('automation-editor-id');
|
||
const nameInput = document.getElementById('automation-editor-name');
|
||
const enabledInput = document.getElementById('automation-editor-enabled');
|
||
const logicSelect = document.getElementById('automation-editor-logic');
|
||
|
||
const name = nameInput.value.trim();
|
||
if (!name) {
|
||
automationModal.showError(t('automations.error.name_required'));
|
||
return;
|
||
}
|
||
|
||
const body = {
|
||
name,
|
||
enabled: enabledInput.checked,
|
||
condition_logic: logicSelect.value,
|
||
conditions: getAutomationEditorConditions(),
|
||
scene_preset_id: document.getElementById('automation-scene-id').value || null,
|
||
deactivation_mode: document.getElementById('automation-deactivation-mode').value,
|
||
deactivation_scene_preset_id: document.getElementById('automation-fallback-scene-id').value || null,
|
||
};
|
||
|
||
const automationId = idInput.value;
|
||
const isEdit = !!automationId;
|
||
|
||
try {
|
||
const url = isEdit ? `/automations/${automationId}` : '/automations';
|
||
const resp = await fetchWithAuth(url, {
|
||
method: isEdit ? 'PUT' : 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.detail || 'Failed to save automation');
|
||
}
|
||
|
||
automationModal.forceClose();
|
||
showToast(isEdit ? t('automations.updated') : t('automations.created'), 'success');
|
||
loadAutomations();
|
||
} catch (e) {
|
||
if (e.isAuth) return;
|
||
automationModal.showError(e.message);
|
||
}
|
||
}
|
||
|
||
export async function toggleAutomationEnabled(automationId, enable) {
|
||
try {
|
||
const action = enable ? 'enable' : 'disable';
|
||
const resp = await fetchWithAuth(`/automations/${automationId}/${action}`, {
|
||
method: 'POST',
|
||
});
|
||
if (!resp.ok) throw new Error(`Failed to ${action} automation`);
|
||
loadAutomations();
|
||
} catch (e) {
|
||
if (e.isAuth) return;
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
export async function deleteAutomation(automationId, automationName) {
|
||
const msg = t('automations.delete.confirm').replace('{name}', automationName);
|
||
const confirmed = await showConfirm(msg);
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
const resp = await fetchWithAuth(`/automations/${automationId}`, {
|
||
method: 'DELETE',
|
||
});
|
||
if (!resp.ok) throw new Error('Failed to delete automation');
|
||
showToast(t('automations.deleted'), 'success');
|
||
loadAutomations();
|
||
} catch (e) {
|
||
if (e.isAuth) return;
|
||
showToast(e.message, 'error');
|
||
}
|
||
}
|