diff --git a/web/js-dynamic/flows.js b/web/js-dynamic/flows.js
index 3a36251..68c052c 100644
--- a/web/js-dynamic/flows.js
+++ b/web/js-dynamic/flows.js
@@ -108,12 +108,55 @@ function renderFlows() {
const host = $('#flowList'); if (!host) return;
const count = $('#flowListCount'); if (count) count.textContent = tr('flow.listCount', { count: app.flows.length });
host.innerHTML = app.flows.length ? app.flows.map(flow => `
- ${esc(flow.name)}${flow.draft ? ` ${esc(tr('flow.draft'))}` : ''}
${esc(flow.description || tr('flow.defaultDescription'))}
${flow.draft ? `
` : `
`}
+ ${esc(flow.name)}${flow.draft ? ` ${esc(tr('flow.draft'))}` : ''}
${esc(flow.description || tr('flow.defaultDescription'))}
${flow.draft ? `
` : `
`}
${esc(tr('flow.blocks'))}${flow.nodes?.length || 0}
${esc(tr('nav.schedules'))}${flow.compiled_schedule_ids?.length || 0}
${esc(tr('nav.automations'))}${flow.compiled_automation_ids?.length || 0}
-
+
`).join('') : `
${esc(tr('flow.emptyTitle'))}${esc(tr('flow.emptyText'))}
`;
}
+let flowDescriptionTargetId = null;
+let flowSimpleSimulationFlow = null;
+
+function flowSavedPayload(flow, changes = {}) {
+ return {
+ name: flow.name,
+ enabled: flow.enabled === true,
+ draft: flow.draft === true,
+ description: flow.description || '',
+ nodes: flow.nodes || [],
+ edges: flow.edges || [],
+ expected_revision: Number(flow.revision || 0),
+ ...changes,
+ };
+}
+
+function openFlowDescriptionEditor(id) {
+ const flow = app.flows.find(item => item.id === id); if (!flow) return toast(tr('flow.notFound'), true);
+ flowDescriptionTargetId = id;
+ const form = $('#flowDescriptionForm'); if (!form) return;
+ $('#flowDescriptionName').textContent = flow.name;
+ form.elements.description.value = flow.description || '';
+ $('#flowDescriptionDialog')?.showModal();
+ requestAnimationFrame(() => form.elements.description?.focus());
+}
+
+async function saveFlowDescription() {
+ const flow = app.flows.find(item => item.id === flowDescriptionTargetId); if (!flow) return toast(tr('flow.notFound'), true);
+ const form = $('#flowDescriptionForm'); if (!form) return;
+ const button = form.querySelector('button[type="submit"]');
+ const description = String(form.elements.description?.value || '').trim();
+ if (button) button.disabled = true;
+ try {
+ const saved = await api(`/api/flows/${encodeURIComponent(flow.id)}`, { method: 'PUT', body: flowSavedPayload(flow, { description }) });
+ const index = app.flows.findIndex(item => item.id === saved.id); if (index >= 0) app.flows[index] = saved;
+ $('#flowDescriptionDialog')?.close(); flowDescriptionTargetId = null;
+ renderFlows(); await loadBootstrap(); toast(tr('flow.descriptionSaved'));
+ } catch (error) {
+ toast(error.message, true);
+ if (error.status === 409) await loadBootstrap();
+ } finally { if (button) button.disabled = false; }
+}
+
function flowDraftFrom(flow) {
return flow ? JSON.parse(JSON.stringify(flow)) : {
@@ -1165,8 +1208,8 @@ function localDateTimeInputValue(date = new Date()) {
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
-function flowSimulationOverrideNodes() {
- return (app.flowDraft?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind));
+function flowSimulationOverrideNodes(flow = app.flowDraft) {
+ return (flow?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind));
}
function flowSimulationLouverAxis(node) {
if (node?.kind === 'device_state') {
@@ -1204,6 +1247,134 @@ function collectFlowSimulationOverrides() {
});
return result;
}
+
+function flowSimulationEffectiveSource(node) {
+ if (node?.kind !== 'shared_input') return { kind: node?.kind || '', config: node?.config || {} };
+ const item = (app.flowSharedInputs || []).find(value => value.id === node.config?.input_id);
+ return { kind: item?.kind || 'shared_input', config: item?.config || {}, item };
+}
+
+function flowSimpleBooleanSource(node) {
+ const source = flowSimulationEffectiveSource(node);
+ if (['ha_available', 'night_mode'].includes(source.kind)) return true;
+ if (source.kind === 'group_state') return source.config.field === 'power_enabled';
+ if (source.kind === 'device_state') return ['enabled', 'online', 'power', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].includes(source.config.field);
+ if (source.kind === 'zone_state') return ['enabled', 'demand', 'device_manual_override', 'local_thermostat_power'].includes(source.config.field);
+ return false;
+}
+
+function flowSimpleSimulationControl(node) {
+ const source = flowSimulationEffectiveSource(node);
+ const louverAxis = flowSimulationLouverAxis(node);
+ const live = `
`;
+ if (louverAxis) return `
`;
+ if (['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(source.kind)) {
+ return `
`;
+ }
+ if (source.kind === 'house_mode') {
+ return `
`;
+ }
+ if (flowSimpleBooleanSource(node)) {
+ return `
`;
+ }
+ return `
`;
+}
+
+function renderFlowSimpleSimulationOverrides(flow) {
+ const host = $('#flowSimpleSimulationOverrides'); if (!host) return;
+ const nodes = flowSimulationOverrideNodes(flow);
+ host.innerHTML = nodes.length ? nodes.map(node => `
`).join('') : `
${esc(tr('flow.noSimulationOverrides'))}
`;
+}
+
+function collectFlowSimpleSimulationOverrides(flow) {
+ const byId = new Map((flow?.nodes || []).map(node => [node.id, node]));
+ const result = {};
+ $$('[data-flow-simple-sim-node]', $('#flowSimpleSimulationOverrides')).forEach(input => {
+ const raw = String(input.value || '').trim(); if (raw === '') return;
+ const node = byId.get(input.dataset.flowSimpleSimNode); if (!node) return;
+ const source = flowSimulationEffectiveSource(node);
+ let value = raw;
+ if (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(source.kind)) value = Number(raw);
+ else if (/^(true|false)$/i.test(raw)) value = raw.toLowerCase() === 'true';
+ result[node.id] = value;
+ });
+ return result;
+}
+
+function flowSimpleSimulationActualText(node, actual) {
+ if (actual === null || actual === undefined) return tr('flow.simpleNoData');
+ if (typeof actual === 'object') {
+ if (actual.error) return tr('flow.simpleNoData');
+ return '';
+ }
+ const source = flowSimulationEffectiveSource(node);
+ const louverAxis = flowSimulationLouverAxis(node);
+ if (louverAxis && Number.isFinite(Number(actual))) return louverPositionLabel(louverAxis, Number(actual));
+ if (['outdoor_temperature', 'device_temperature', 'zone_temperature'].includes(source.kind) && Number.isFinite(Number(actual))) return fmtTemp(Number(actual));
+ if (source.kind === 'house_mode') return houseModeLabel(String(actual));
+ if (typeof actual === 'boolean') return actual ? tr('common.yes') : tr('common.no');
+ if (node?.kind === 'weekday' && Number.isFinite(Number(actual))) return tr(`day.${Number(actual)}`);
+ return String(actual);
+}
+
+function flowSimpleBlockedReason(reason) {
+ const key = {
+ flow_disabled: 'flow.simpleBlockedFlowDisabled', missing_zone: 'flow.simpleBlockedMissingZone', missing_device: 'flow.simpleBlockedMissingDevice',
+ missing_group: 'flow.simpleBlockedMissingGroup', device_manual_override: 'flow.simpleBlockedManual', local_thermostat_override: 'flow.simpleBlockedLocalThermostat',
+ temporary_quick_thermostat: 'flow.simpleBlockedTemporary', zone_disabled: 'flow.simpleBlockedZoneDisabled', device_disabled: 'flow.simpleBlockedDeviceDisabled',
+ thermostat_owner_conflict: 'flow.simpleBlockedThermostatConflict', group_control_disabled: 'flow.simpleBlockedGroupDisabled', unsupported_action: 'flow.simpleBlockedUnsupported',
+ }[reason];
+ return key ? tr(key) : (reason || tr('flow.simpleBlockedUnknown'));
+}
+
+function renderFlowSimpleSimulationResult(result) {
+ const host = $('#flowSimpleSimulationResults'); const flow = flowSimpleSimulationFlow;
+ if (!host || !flow) return;
+ const byId = new Map((flow.nodes || []).map(node => [node.id, node]));
+ if (!(result.actions || []).length) {
+ host.innerHTML = `
${esc(tr('flow.simpleNoActions'))}${esc(tr('flow.simpleNoActionsHint'))}
`;
+ return;
+ }
+ host.innerHTML = (result.actions || []).map(action => {
+ const actionNode = byId.get(action.node_id);
+ const state = action.would_execute ? 'pass' : action.matched ? 'blocked' : 'skip';
+ const stateLabel = action.would_execute ? tr('flow.simpleWillRun') : action.matched ? tr('flow.simpleBlocked') : tr('flow.simpleWillNotRun');
+ const explanation = action.would_execute ? tr('flow.simpleWillRunHint') : action.matched ? tr('flow.simpleBlockedHint', { reason: flowSimpleBlockedReason(action.blocked_reason) }) : tr('flow.simpleWillNotRunHint');
+ const trace = (action.trace || []).map(item => {
+ const node = byId.get(item.node_id); if (!node || ['logic_and', 'logic_or', 'logic_not'].includes(node.kind)) return '';
+ const actual = flowSimpleSimulationActualText(node, item.actual);
+ return `
${uiIcon(item.matched ? 'check' : 'close')}${esc(flowNodeTitle(FLOW_NODE_META[node.kind] || { title: node.kind }))}${esc(flowNodeSummary(node))}${actual ? `${esc(tr('flow.simpleCurrentValue', { value: actual }))}` : ''}
`;
+ }).join('');
+ const actionType = actionNode ? flowNodeTitle(FLOW_NODE_META[actionNode.kind] || { title: actionNode.kind }) : tr('flow.simpleEffect');
+ return `
${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}
${esc(stateLabel)}${esc(explanation)}
${trace ? `${esc(tr('flow.simpleConditions'))}
${trace}
` : ''}`;
+ }).join('');
+}
+
+async function runFlowSimpleSimulation() {
+ const flow = flowSimpleSimulationFlow; if (!flow) return;
+ const input = $('#flowSimpleSimulationAt')?.value;
+ const at = input ? new Date(input).toISOString() : new Date().toISOString();
+ const host = $('#flowSimpleSimulationResults'); if (host) host.innerHTML = `
${esc(tr('flow.runningSimulation'))}
`;
+ const button = $('[data-action="run-flow-simple-simulation"]', $('#flowSimpleSimulationDialog')); if (button) button.disabled = true;
+ try {
+ const result = await api('/api/flows/simulate', { method: 'POST', body: { flow: flowSavedPayload(flow, { expected_revision: undefined }), flow_id: flow.id, at, overrides: collectFlowSimpleSimulationOverrides(flow), log: false } });
+ renderFlowSimpleSimulationResult(result);
+ } catch (error) {
+ if (host) host.innerHTML = `
${esc(tr('flow.simulationFailed'))}${esc(error.message)}
`;
+ } finally { if (button) button.disabled = false; }
+}
+
+function openFlowSimpleSimulation(id) {
+ const flow = app.flows.find(item => item.id === id); if (!flow) return toast(tr('flow.notFound'), true);
+ if (flow.draft) return toast(tr('flow.simulatorDraftHint'), true);
+ flowSimpleSimulationFlow = JSON.parse(JSON.stringify(flow));
+ $('#flowSimpleSimulationName').textContent = flow.name;
+ $('#flowSimpleSimulationAt').value = localDateTimeInputValue();
+ $('#flowSimpleSimulationResults').innerHTML = '';
+ renderFlowSimpleSimulationOverrides(flowSimpleSimulationFlow);
+ $('#flowSimpleSimulationDialog')?.showModal();
+ runFlowSimpleSimulation();
+}
function openFlowDryRun() {
if (!app.flowDraft) return;
$('#flowTestTitle').textContent = tr('flow.dryRun'); $('#flowSimulationControls').hidden = false; $('#flowTestResults').innerHTML = '';
@@ -1702,6 +1873,8 @@ document.addEventListener('click', event => {
if (mobileActionsDialog?.open && action !== 'flow-mobile-actions') mobileActionsDialog.close();
if (action === 'new-flow') openFlowEditor();
else if (action === 'edit-flow') openFlowEditor(actionButton.dataset.id);
+ else if (action === 'edit-flow-description') openFlowDescriptionEditor(actionButton.dataset.id);
+ else if (action === 'simulate-flow') openFlowSimpleSimulation(actionButton.dataset.id);
else if (action === 'delete-flow') deleteFlow(actionButton.dataset.id);
else if (action === 'export-flow-by-id') exportFlowById(actionButton.dataset.id);
else if (action === 'toggle-flow-enabled') toggleFlowEnabled(actionButton.dataset.id, actionButton.dataset.value === 'true');
@@ -1722,6 +1895,7 @@ document.addEventListener('click', event => {
else if (action === 'flow-templates') openFlowTemplates();
else if (action === 'flow-dry-run') openFlowDryRun();
else if (action === 'run-flow-dry-run') runFlowDryRun();
+ else if (action === 'run-flow-simple-simulation') runFlowSimpleSimulation();
else if (action === 'flow-logs') openFlowLogs();
else if (action === 'flow-select-all') selectAllFlowNodes();
else if (action === 'flow-duplicate-selection') duplicateSelectedFlowNodes();
@@ -1743,6 +1917,7 @@ document.addEventListener('click', event => {
document.addEventListener('change', event => {
if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target);
});
+$('#flowDescriptionForm')?.addEventListener('submit', event => { event.preventDefault(); saveFlowDescription(); });
$('#flowImportFile')?.addEventListener('change', event => importFlowFile(event.target.files?.[0]));
$('#flowTemplateSearch')?.addEventListener('input', event => { flowPresetSearch = event.target.value || ''; renderFlowPresetBrowser(flowPresetActiveCategory); });
$('#flowBlockSearch')?.addEventListener('input', event => renderFlowBlockLibrary(event.target.value));