v0.15.12
This commit is contained in:
@@ -10534,3 +10534,258 @@ select.select-native-proxy[aria-invalid="true"]+.custom-select .custom-select-tr
|
||||
@media (max-width: 760px) {
|
||||
.flow-editor-notice { max-width: 42vw; }
|
||||
}
|
||||
|
||||
/* Flow quick actions: description editing and simple simulator */
|
||||
.flow-card-copy {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flow-card-description {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.flow-card-description p {
|
||||
flex: 1 1 auto;
|
||||
margin: .35rem 0 0;
|
||||
}
|
||||
|
||||
.flow-description-edit {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
.flow-description-edit:hover,
|
||||
.flow-description-edit:focus-visible {
|
||||
color: var(--text);
|
||||
opacity: 1;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.flow-card .flow-simulate-button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.flow-card .flow-simulate-button:hover:not(:disabled) {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.flow-card .flow-simulate-button:disabled {
|
||||
opacity: .45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.flow-simple-simulation-dialog .dialog-form {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.flow-simple-intro {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.flow-simple-intro > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.flow-simple-intro > div > span {
|
||||
color: var(--muted);
|
||||
font-size: .88rem;
|
||||
}
|
||||
|
||||
.flow-simple-scenario,
|
||||
.flow-simple-results-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-simple-results-section {
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.flow-simple-section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-simple-section-head p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.flow-simple-time {
|
||||
max-width: 340px;
|
||||
}
|
||||
|
||||
.flow-simple-simulation-overrides {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.flow-simple-scenario-field {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
padding: 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.flow-simple-scenario-field > span {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.flow-simple-scenario-field small {
|
||||
color: var(--muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.flow-simple-results {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.flow-simple-result {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 13px;
|
||||
border: 1px solid var(--line);
|
||||
border-left-width: 4px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.flow-simple-result.pass {
|
||||
border-left-color: var(--teal);
|
||||
}
|
||||
|
||||
.flow-simple-result.blocked,
|
||||
.flow-simple-result.skip {
|
||||
border-left-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.flow-simple-result-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.flow-simple-result-head > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.flow-simple-result-head > div > small {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
|
||||
.flow-simple-result > p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.flow-simple-details summary {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.flow-simple-condition-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.flow-simple-condition {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 7px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.flow-simple-condition > span:nth-child(2) {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flow-simple-condition small,
|
||||
.flow-simple-condition em {
|
||||
color: var(--muted);
|
||||
font-size: .82rem;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.flow-simple-condition-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.flow-simple-condition.pass .flow-simple-condition-mark {
|
||||
color: var(--teal);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.flow-card .card-footer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.flow-card .card-footer > small {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.flow-card .card-menu {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flow-simple-intro,
|
||||
.flow-simple-result-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.flow-simple-simulation-overrides {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.flow-simple-condition {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.flow-simple-condition em {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1162,6 +1162,50 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="flowDescriptionDialog">
|
||||
<form id="flowDescriptionForm" class="dialog-form">
|
||||
<div class="dialog-head">
|
||||
<div><span class="eyebrow" data-i18n="flow.quickEdit">Quick edit</span>
|
||||
<h2 data-i18n="flow.editDescription">Edit description</h2>
|
||||
</div><button type="button" data-close data-ui-icon="close"></button>
|
||||
</div>
|
||||
<p class="field-note"><strong id="flowDescriptionName"></strong></p>
|
||||
<label><span data-i18n="flow.description">Description</span><textarea name="description" rows="6" maxlength="1200"
|
||||
data-i18n-placeholder="flow.descriptionPlaceholder" placeholder="Describe what this Flow does…"></textarea></label>
|
||||
<p class="field-note" data-i18n="flow.descriptionQuickHint">Only the description is changed. You do not need to open the Flow editor.</p>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close
|
||||
data-i18n="actions.cancel">Cancel</button><button type="submit" class="primary"
|
||||
data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="flowSimpleSimulationDialog" class="dialog-wide flow-simple-simulation-dialog">
|
||||
<div class="dialog-form">
|
||||
<div class="dialog-head">
|
||||
<div><span class="eyebrow" data-i18n="flow.simpleSimulatorEyebrow">Simple preview</span>
|
||||
<h2 data-i18n="flow.simpleSimulator">Simulator</h2>
|
||||
</div><button type="button" data-close data-ui-icon="close"></button>
|
||||
</div>
|
||||
<div class="flow-simple-intro">
|
||||
<div><strong id="flowSimpleSimulationName"></strong><span data-i18n="flow.simpleSimulatorHint">Set a situation and see whether each Flow action would run.</span></div>
|
||||
<span class="badge" data-i18n="flow.simpleSimulatorSafe">Preview only — nothing is changed</span>
|
||||
</div>
|
||||
<section class="flow-simple-scenario">
|
||||
<div class="flow-simple-section-head"><div><strong data-i18n="flow.simpleScenario">Situation to check</strong><p class="field-note"
|
||||
data-i18n="flow.simpleScenarioHint">Leave values unchanged to use the current application and Home Assistant state.</p></div></div>
|
||||
<label class="flow-simple-time"><span data-i18n="flow.simulationTime">Simulation date and time</span><input
|
||||
id="flowSimpleSimulationAt" type="datetime-local"></label>
|
||||
<div id="flowSimpleSimulationOverrides" class="flow-simple-simulation-overrides"></div>
|
||||
<div class="form-actions"><button class="primary" type="button" data-action="run-flow-simple-simulation"
|
||||
data-i18n="flow.simpleCheck">Check what happens</button></div>
|
||||
</section>
|
||||
<section class="flow-simple-results-section">
|
||||
<div class="flow-simple-section-head"><strong data-i18n="flow.simpleResult">What will happen?</strong></div>
|
||||
<div id="flowSimpleSimulationResults" class="flow-simple-results"></div>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="flowTestDialog" class="dialog-wide">
|
||||
<div class="dialog-form">
|
||||
<div class="dialog-head">
|
||||
|
||||
+179
-4
@@ -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 => `<article class="list-card flow-card ${flow.enabled && !flow.draft ? '' : 'is-disabled'} ${flow.draft ? 'is-draft' : ''}" data-flow-card-id="${esc(flow.id)}" tabindex="0" aria-label="${esc(`${tr('flow.openEditor')}: ${flow.name}`)}">
|
||||
<div class="list-card-head"><div><h3>${esc(flow.name)}${flow.draft ? ` <span class="badge flow-draft-badge">${esc(tr('flow.draft'))}</span>` : ''}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div>${flow.draft ? `<button type="button" class="enable-toggle" disabled title="${esc(tr('flow.draftDisabledHint'))}"><span>${uiIcon('edit')}</span>${esc(tr('flow.disabledState'))}</button>` : `<button type="button" class="enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${uiIcon(flow.enabled ? 'check' : 'circle')}</span>${esc(flow.enabled ? tr('flow.enabledState') : tr('flow.disabledState'))}</button>`}</div>
|
||||
<div class="list-card-head"><div class="flow-card-copy"><h3>${esc(flow.name)}${flow.draft ? ` <span class="badge flow-draft-badge">${esc(tr('flow.draft'))}</span>` : ''}</h3><div class="flow-card-description"><p>${esc(flow.description || tr('flow.defaultDescription'))}</p><button type="button" class="flow-description-edit" data-action="edit-flow-description" data-id="${esc(flow.id)}" aria-label="${esc(tr('flow.editDescription'))}" title="${esc(tr('flow.editDescription'))}">${uiIcon('edit')}</button></div></div>${flow.draft ? `<button type="button" class="enable-toggle" disabled title="${esc(tr('flow.draftDisabledHint'))}"><span>${uiIcon('edit')}</span>${esc(tr('flow.disabledState'))}</button>` : `<button type="button" class="enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${uiIcon(flow.enabled ? 'check' : 'circle')}</span>${esc(flow.enabled ? tr('flow.enabledState') : tr('flow.disabledState'))}</button>`}</div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('flow.blocks'))}</small><strong>${flow.nodes?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.schedules'))}</small><strong>${flow.compiled_schedule_ids?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.automations'))}</small><strong>${flow.compiled_automation_ids?.length || 0}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(flow.draft ? tr('flow.draftNoExecution') : tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button class="card-primary-action" data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button data-action="export-flow-by-id" data-id="${esc(flow.id)}">${esc(tr('flow.export'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>
|
||||
<div class="card-footer"><small>${esc(flow.draft ? tr('flow.draftNoExecution') : tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button class="flow-simulate-button" data-action="simulate-flow" data-id="${esc(flow.id)}" ${flow.draft ? `disabled title="${esc(tr('flow.simulatorDraftHint'))}"` : ''}>${esc(tr('flow.simpleSimulator'))}</button><button class="card-primary-action" data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><details class="card-overflow"><summary aria-label="${esc(tr('actions.more'))}" title="${esc(tr('actions.more'))}">${uiIcon('more-horizontal')}</summary><div class="card-overflow-menu"><button data-action="export-flow-by-id" data-id="${esc(flow.id)}">${esc(tr('flow.export'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></details></div></div>
|
||||
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
|
||||
}
|
||||
|
||||
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 = `<option value="">${esc(tr('flow.useLiveValue'))}</option>`;
|
||||
if (louverAxis) return `<select data-flow-simple-sim-node="${esc(node.id)}">${live}${louverOptions(louverAxis, null)}</select>`;
|
||||
if (['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(source.kind)) {
|
||||
return `<input type="number" step="0.1" data-flow-simple-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`;
|
||||
}
|
||||
if (source.kind === 'house_mode') {
|
||||
return `<select data-flow-simple-sim-node="${esc(node.id)}">${live}${['cool', 'heat', 'off'].map(value => `<option value="${value}">${esc(value === 'off' ? tr('common.off') : tr(`mode.${value}`))}</option>`).join('')}</select>`;
|
||||
}
|
||||
if (flowSimpleBooleanSource(node)) {
|
||||
return `<select data-flow-simple-sim-node="${esc(node.id)}">${live}<option value="true">${esc(tr('common.yes'))}</option><option value="false">${esc(tr('common.no'))}</option></select>`;
|
||||
}
|
||||
return `<input type="text" data-flow-simple-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`;
|
||||
}
|
||||
|
||||
function renderFlowSimpleSimulationOverrides(flow) {
|
||||
const host = $('#flowSimpleSimulationOverrides'); if (!host) return;
|
||||
const nodes = flowSimulationOverrideNodes(flow);
|
||||
host.innerHTML = nodes.length ? nodes.map(node => `<label class="flow-simple-scenario-field"><span><strong>${esc(flowNodeTitle(FLOW_NODE_META[node.kind]))}</strong><small>${esc(flowNodeSummary(node))}</small></span>${flowSimpleSimulationControl(node)}</label>`).join('') : `<p class="field-note">${esc(tr('flow.noSimulationOverrides'))}</p>`;
|
||||
}
|
||||
|
||||
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 = `<div class="empty compact"><strong>${esc(tr('flow.simpleNoActions'))}</strong><span>${esc(tr('flow.simpleNoActionsHint'))}</span></div>`;
|
||||
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 `<div class="flow-simple-condition ${item.matched ? 'pass' : 'fail'}"><span class="flow-simple-condition-mark">${uiIcon(item.matched ? 'check' : 'close')}</span><span><strong>${esc(flowNodeTitle(FLOW_NODE_META[node.kind] || { title: node.kind }))}</strong><small>${esc(flowNodeSummary(node))}</small></span>${actual ? `<em>${esc(tr('flow.simpleCurrentValue', { value: actual }))}</em>` : ''}</div>`;
|
||||
}).join('');
|
||||
const actionType = actionNode ? flowNodeTitle(FLOW_NODE_META[actionNode.kind] || { title: actionNode.kind }) : tr('flow.simpleEffect');
|
||||
return `<article class="flow-simple-result ${state}"><div class="flow-simple-result-head"><div><small>${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}</small><strong>${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}</strong></div><span class="badge ${action.would_execute ? 'active' : ''}">${esc(stateLabel)}</span></div><p>${esc(explanation)}</p>${trace ? `<details class="flow-simple-details" ${action.would_execute ? '' : 'open'}><summary>${esc(tr('flow.simpleConditions'))}</summary><div class="flow-simple-condition-list">${trace}</div></details>` : ''}</article>`;
|
||||
}).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 = `<p class="field-note">${esc(tr('flow.runningSimulation'))}</p>`;
|
||||
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 = `<div class="empty compact"><strong>${esc(tr('flow.simulationFailed'))}</strong><span>${esc(error.message)}</span></div>`;
|
||||
} 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));
|
||||
|
||||
Reference in New Issue
Block a user