v0.11.1
This commit is contained in:
+5
-2
@@ -704,7 +704,7 @@
|
||||
<div class="flow-palette-group flow-palette-time"><span data-i18n="flow.time">Czas</span>
|
||||
<button type="button" data-flow-add="weekday" data-i18n="flow.node.weekday">Dni tygodnia</button><button type="button" data-flow-add="time_range" data-i18n="flow.node.timeRange">Przedział godzin</button><button type="button" data-flow-add="date_range" data-i18n="flow.node.dateRange">Zakres dat</button><button type="button" data-flow-add="night_mode" data-i18n="flow.node.nightMode">Tryb nocny</button></div>
|
||||
<div class="flow-palette-group flow-palette-timeop"><span data-i18n="flow.timeOps">Operacje czasu</span>
|
||||
<button type="button" data-flow-add="stable_for" data-i18n="flow.node.stableFor">Utrzymuje się przez…</button><button type="button" data-flow-add="delay" data-i18n="flow.node.delay">Odczekaj…</button></div>
|
||||
<button type="button" data-flow-add="stable_for" data-i18n="flow.node.stableFor">Utrzymuje się przez…</button><button type="button" data-flow-add="state_duration" data-i18n="flow.node.stateDuration">Czas trwania stanu</button><button type="button" data-flow-add="on_change" data-i18n="flow.node.onChange">Tylko przy zmianie</button><button type="button" data-flow-add="rate_limit" data-i18n="flow.node.rateLimit">Maks. X razy / okres</button><button type="button" data-flow-add="delay" data-i18n="flow.node.delay">Odczekaj…</button></div>
|
||||
<div class="flow-palette-group flow-palette-sensor"><span data-i18n="flow.sensors">Sensory / wartości</span>
|
||||
<button type="button" data-flow-add="outdoor_temperature" data-i18n="flow.node.outdoorTemperature">Temperatura zewn.</button><button type="button" data-flow-add="device_temperature" data-i18n="flow.node.deviceTemperature">Temperatura urządzenia</button><button type="button" data-flow-add="zone_temperature" data-i18n="flow.node.zoneTemperature">Temperatura strefy</button><button type="button" data-flow-add="house_mode" data-i18n="flow.node.houseMode">Tryb domu</button><button type="button" data-flow-add="device_state" data-i18n="flow.node.deviceState">Stan urządzenia</button><button type="button" data-flow-add="zone_state" data-i18n="flow.node.zoneState">Stan termostatu</button><button type="button" data-flow-add="group_state" data-i18n="flow.node.groupState">Stan grupy</button><button type="button" data-flow-add="ha_state" data-i18n="flow.node.haState">HA: stan</button><button type="button" data-flow-add="ha_numeric" data-i18n="flow.node.haNumeric">HA: wartość</button><button type="button" data-flow-add="ha_attribute" data-i18n="flow.node.haAttribute">HA: atrybut</button><button type="button" data-flow-add="ha_available" data-i18n="flow.node.haAvailable">HA: dostępność</button><button type="button" data-flow-add="constant" data-i18n="flow.node.constant">Stała testowa</button><button type="button" data-flow-add="shared_input" data-i18n="flow.node.sharedInput">Wspólne wejście</button><button type="button" data-flow-add="rolling_stat" data-i18n="flow.node.rollingStat">Średnia / mediana</button><button type="button" data-flow-add="oscillates" data-i18n="flow.node.oscillates">Wartość oscyluje</button></div>
|
||||
<div class="flow-palette-group flow-palette-logic"><span data-i18n="flow.logic">Logika</span><button type="button" data-flow-add="logic_and" data-i18n="flow.node.and">AND</button><button type="button" data-flow-add="logic_or" data-i18n="flow.node.or">OR</button><button type="button" data-flow-add="logic_not" data-i18n="flow.node.not">NOT</button></div>
|
||||
@@ -719,7 +719,10 @@
|
||||
<svg id="flowEdges" class="flow-edges" aria-hidden="true"></svg><div id="flowNodes" class="flow-nodes"></div>
|
||||
<div id="flowEmptyHint" class="flow-empty-hint"><strong data-i18n="flow.emptyCanvas">Start by adding blocks</strong><span data-i18n="flow.emptyCanvasHint">Conditions go on the left, actions on the right.</span></div>
|
||||
</div>
|
||||
<div class="flow-natural-preview"><strong data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
|
||||
<div class="flow-natural-preview">
|
||||
<div class="flow-preview-item flow-preview-interpretation"><strong data-i18n="flow.interpretation">Interpretation</strong><span id="flowInterpretation"></span></div>
|
||||
<div class="flow-preview-item flow-preview-runtime" id="flowRuntimeInfo"><strong data-i18n="flow.runtimeInfo">Flow cycle</strong><span id="flowRuntimeSummary"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<aside id="flowInspector" class="flow-inspector"><div class="empty compact"><strong data-i18n="flow.selectBlock">Select a block</strong><span data-i18n="flow.selectBlockHint">Its settings will appear here.</span></div></aside>
|
||||
</div>
|
||||
|
||||
+61
-1
@@ -4,6 +4,9 @@ const FLOW_NODE_META = Object.freeze({
|
||||
date_range: { titleKey: 'flow.node.dateRange', category: 'time' },
|
||||
cron_trigger: { titleKey: 'flow.node.cronTrigger', category: 'trigger' },
|
||||
stable_for: { titleKey: 'flow.node.stableFor', category: 'timeop' },
|
||||
state_duration: { titleKey: 'flow.node.stateDuration', category: 'timeop' },
|
||||
on_change: { titleKey: 'flow.node.onChange', category: 'timeop' },
|
||||
rate_limit: { titleKey: 'flow.node.rateLimit', category: 'timeop' },
|
||||
delay: { titleKey: 'flow.node.delay', category: 'timeop' },
|
||||
rolling_stat: { titleKey: 'flow.node.rollingStat', category: 'sensor' },
|
||||
oscillates: { titleKey: 'flow.node.oscillates', category: 'sensor' },
|
||||
@@ -64,6 +67,9 @@ function flowDefaultConfig(kind) {
|
||||
if (kind === 'date_range') { const today = new Date().toISOString().slice(0, 10); return { start: today, end: today }; }
|
||||
if (kind === 'cron_trigger') return { expression: '*/5 * * * *' };
|
||||
if (kind === 'stable_for') return { seconds: 180 };
|
||||
if (kind === 'state_duration') return { min_seconds: 60, max_seconds: 600 };
|
||||
if (kind === 'on_change') return { mode: 'result' };
|
||||
if (kind === 'rate_limit') return { max_count: 3, period_seconds: 3600 };
|
||||
if (kind === 'delay') return { seconds: 30 };
|
||||
if (kind === 'rolling_stat') return { source: 'outdoor_temperature', statistic: 'mean', window_seconds: 300, operator: 'lt', value: 20, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' };
|
||||
if (kind === 'oscillates') return { source: 'outdoor_temperature', window_seconds: 300, min_span: 1.0, min_direction_changes: 2, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' };
|
||||
@@ -260,6 +266,9 @@ function flowNodeSummary(node) {
|
||||
if (node.kind === 'date_range') return `${c.start || '—'} → ${c.end || '—'}`;
|
||||
if (node.kind === 'cron_trigger') return c.expression || '—';
|
||||
if (node.kind === 'stable_for') return flowDurationLabel(c.seconds);
|
||||
if (node.kind === 'state_duration') return `${flowDurationLabel(c.min_seconds || 0)}–${c.max_seconds == null ? '∞' : flowDurationLabel(c.max_seconds)}`;
|
||||
if (node.kind === 'on_change') return c.mode === 'value' ? tr('flow.changeModeValue') : tr('flow.changeModeResult');
|
||||
if (node.kind === 'rate_limit') return `${Number(c.max_count || 1)} × / ${flowDurationLabel(c.period_seconds || 3600)}`;
|
||||
if (node.kind === 'delay') return flowDurationLabel(c.seconds);
|
||||
if (node.kind === 'rolling_stat') return `${c.statistic === 'median' ? tr('flow.median') : tr('flow.mean')} · ${flowDurationLabel(c.window_seconds)} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
|
||||
if (node.kind === 'oscillates') return `${flowDurationLabel(c.window_seconds)} · Δ≥${Number(c.min_span ?? 1)} · ↕≥${Number(c.min_direction_changes ?? 2)}`;
|
||||
@@ -315,7 +324,7 @@ function renderFlowEditor() {
|
||||
}).join('');
|
||||
$('#flowEmptyHint').hidden = draft.nodes.length > 0;
|
||||
const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : '';
|
||||
renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); refreshFlowSharedInputCurrentValues();
|
||||
renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); refreshFlowSharedInputCurrentValues();
|
||||
const status = $('#flowCompileStatus');
|
||||
status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
|
||||
}
|
||||
@@ -363,6 +372,9 @@ function renderFlowInspector() {
|
||||
else if (node.kind === 'date_range') fields = `<div class="two"><label><span>${esc(tr('common.from'))}</span><input type="date" data-flow-config="start" value="${esc(c.start || '')}"></label><label><span>${esc(tr('common.to'))}</span><input type="date" data-flow-config="end" value="${esc(c.end || '')}"></label></div>`;
|
||||
else if (node.kind === 'cron_trigger') fields = `<label><span>CRON</span><input data-flow-config="expression" value="${esc(c.expression || '*/5 * * * *')}" placeholder="*/5 * * * *"></label><p class="field-note">${esc(tr('flow.cronHint'))}</p>`;
|
||||
else if (node.kind === 'stable_for') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 180)}"></label><p class="field-note">${esc(tr('flow.stableForHint'))}</p>`;
|
||||
else if (node.kind === 'state_duration') fields = `<div class="two"><label><span>${esc(tr('flow.minDurationSeconds'))}</span><input type="number" min="0" max="604800" data-flow-config="min_seconds" value="${Number(c.min_seconds || 0)}"></label><label><span>${esc(tr('flow.maxDurationSeconds'))}</span><input type="number" min="0" max="604800" data-flow-config="max_seconds" value="${c.max_seconds == null ? '' : Number(c.max_seconds)}" placeholder="∞"></label></div><p class="field-note">${esc(tr('flow.stateDurationHint'))}</p>`;
|
||||
else if (node.kind === 'on_change') fields = `<label><span>${esc(tr('flow.changeMode'))}</span><select data-flow-config="mode"><option value="result" ${c.mode !== 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeResult'))}</option><option value="value" ${c.mode === 'value' ? 'selected' : ''}>${esc(tr('flow.changeModeValue'))}</option></select></label><p class="field-note">${esc(tr('flow.onChangeHint'))}</p>`;
|
||||
else if (node.kind === 'rate_limit') fields = `<div class="two"><label><span>${esc(tr('flow.maxExecutions'))}</span><input type="number" min="1" max="1000" data-flow-config="max_count" value="${Number(c.max_count || 1)}"></label><label><span>${esc(tr('flow.periodSeconds'))}</span><input type="number" min="1" max="2678400" data-flow-config="period_seconds" value="${Number(c.period_seconds || 3600)}"></label></div><p class="field-note">${esc(tr('flow.rateLimitHint'))}</p>`;
|
||||
else if (node.kind === 'delay') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 30)}"></label><p class="field-note">${esc(tr('flow.delayHint'))}</p>`;
|
||||
else if (node.kind === 'rolling_stat') { const source=c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature',tr('flow.node.outdoorTemperature')],['device_temperature',tr('flow.node.deviceTemperature')],['zone_temperature',tr('flow.node.zoneTemperature')],['ha_numeric',tr('flow.node.haNumeric')]].map(([v,l])=>`<option value="${v}" ${source===v?'selected':''}>${esc(l)}</option>`).join('')}</select></label>${source==='device_temperature'?`<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices,c.device_id)}</select></label>`:''}${source==='zone_temperature'?`<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones,c.zone_id)}</select></label>`:''}${source==='ha_numeric'?`<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id||'')}" placeholder="sensor.temperature"></label>`:''}<div class="two"><label><span>${esc(tr('flow.statistic'))}</span><select data-flow-config="statistic"><option value="mean" ${c.statistic!=='median'?'selected':''}>${esc(tr('flow.mean'))}</option><option value="median" ${c.statistic==='median'?'selected':''}>${esc(tr('flow.median'))}</option></select></label><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds||300)}"></label></div>${flowComparisonFields(c,false)}`; }
|
||||
else if (node.kind === 'oscillates') { const source=c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature',tr('flow.node.outdoorTemperature')],['device_temperature',tr('flow.node.deviceTemperature')],['zone_temperature',tr('flow.node.zoneTemperature')],['ha_numeric',tr('flow.node.haNumeric')]].map(([v,l])=>`<option value="${v}" ${source===v?'selected':''}>${esc(l)}</option>`).join('')}</select></label>${source==='device_temperature'?`<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices,c.device_id)}</select></label>`:''}${source==='zone_temperature'?`<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones,c.zone_id)}</select></label>`:''}${source==='ha_numeric'?`<label><span>entity_id</span><input data-flow-config="entity_id" value="${esc(c.entity_id||'')}" placeholder="sensor.temperature"></label>`:''}<div class="two"><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds||300)}"></label><label><span>${esc(tr('flow.minSpan'))}</span><input type="number" min="0.001" step="0.1" data-flow-config="min_span" value="${Number(c.min_span??1)}"></label></div><label><span>${esc(tr('flow.minDirectionChanges'))}</span><input type="number" min="1" max="1000" data-flow-config="min_direction_changes" value="${Number(c.min_direction_changes||2)}"></label><p class="field-note">${esc(tr('flow.oscillatesHint'))}</p>`; }
|
||||
@@ -437,6 +449,50 @@ function renderFlowInterpretation() {
|
||||
target.textContent = actions.map(action => `${flowExpressionSummary(action.id)} → ${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · ');
|
||||
}
|
||||
|
||||
function flowCronHumanSummary(expression) {
|
||||
const raw = String(expression || '').trim();
|
||||
const parts = raw.split(/\s+/);
|
||||
if (parts.length !== 5) return tr('flow.cronCustom', { expression: raw || '—' });
|
||||
const [minute, hour, day, month, weekday] = parts;
|
||||
const allDays = day === '*' && month === '*' && weekday === '*';
|
||||
if (minute === '*' && hour === '*' && allDays) return tr('flow.cronEveryMinute');
|
||||
const everyMinutes = minute.match(/^\*\/(\d+)$/);
|
||||
if (everyMinutes && hour === '*' && allDays) return tr('flow.cronEveryMinutes', { minutes: everyMinutes[1] });
|
||||
if (/^\d{1,2}$/.test(minute) && hour === '*' && allDays) return tr('flow.cronHourlyAt', { minute: String(Number(minute)).padStart(2, '0') });
|
||||
if (/^\d{1,2}$/.test(minute) && /^\d{1,2}$/.test(hour) && allDays) return tr('flow.cronDailyAt', { time: `${String(Number(hour)).padStart(2, '0')}:${String(Number(minute)).padStart(2, '0')}` });
|
||||
return tr('flow.cronCustom', { expression: raw });
|
||||
}
|
||||
|
||||
function renderFlowRuntimeInfo() {
|
||||
const target = $('#flowRuntimeSummary');
|
||||
const container = $('#flowRuntimeInfo');
|
||||
if (!target || !app.flowDraft) return;
|
||||
const seconds = Math.max(2, Number(app.settings?.zone_interval_seconds || 5));
|
||||
const actions = app.flowDraft.nodes.filter(node => ['action','haaction'].includes(FLOW_NODE_META[node.kind]?.category));
|
||||
if (!actions.length) {
|
||||
target.textContent = tr('flow.runtimeNoActions', { seconds });
|
||||
if (container) container.title = tr('flow.runtimeHint');
|
||||
return;
|
||||
}
|
||||
const cronExpressions = new Set();
|
||||
let hasActionWithoutCron = false;
|
||||
actions.forEach(action => {
|
||||
const crons = flowUpstreamNodes(action.id).filter(node => node.kind === 'cron_trigger');
|
||||
if (!crons.length) hasActionWithoutCron = true;
|
||||
crons.forEach(node => cronExpressions.add(String(node.config?.expression || '').trim()));
|
||||
});
|
||||
if (!cronExpressions.size) {
|
||||
target.textContent = tr('flow.runtimeWithoutCron', { seconds });
|
||||
} else if (!hasActionWithoutCron && cronExpressions.size === 1) {
|
||||
target.textContent = tr('flow.runtimeWithCron', { seconds, cron: flowCronHumanSummary([...cronExpressions][0]) });
|
||||
} else if (!hasActionWithoutCron) {
|
||||
target.textContent = tr('flow.runtimeWithMultipleCrons', { seconds, count: cronExpressions.size });
|
||||
} else {
|
||||
target.textContent = tr('flow.runtimeMixed', { seconds, count: cronExpressions.size });
|
||||
}
|
||||
if (container) container.title = tr('flow.runtimeHint');
|
||||
}
|
||||
|
||||
function flowUpstreamNodes(id) {
|
||||
if (!app.flowDraft) return [];
|
||||
const byId = new Map(app.flowDraft.nodes.map(node => [node.id, node])); const seen = new Set(), stack = [id], out = [];
|
||||
@@ -921,6 +977,10 @@ function updateFlowConfig(input) {
|
||||
else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true';
|
||||
else if (key === 'data_json' && node.kind === 'ha_service_action') { try { node.config.data = JSON.parse(input.value || '{}'); } catch { toast(tr('flow.invalidJson'), true); return; } }
|
||||
else if (key === 'seconds' && ['stable_for','delay'].includes(node.kind)) node.config.seconds = Math.max(1, Number(input.value || 1));
|
||||
else if (key === 'min_seconds' && node.kind === 'state_duration') node.config.min_seconds = Math.max(0, Number(input.value || 0));
|
||||
else if (key === 'max_seconds' && node.kind === 'state_duration') node.config.max_seconds = input.value === '' ? null : Math.max(0, Number(input.value));
|
||||
else if (key === 'max_count' && node.kind === 'rate_limit') node.config.max_count = Math.max(1, Math.floor(Number(input.value || 1)));
|
||||
else if (key === 'period_seconds' && node.kind === 'rate_limit') node.config.period_seconds = Math.max(1, Math.floor(Number(input.value || 1)));
|
||||
else if (key === 'window_seconds' && ['rolling_stat','oscillates'].includes(node.kind)) node.config.window_seconds = Math.max(10, Number(input.value || 10));
|
||||
else if (key === 'value' && node.kind === 'rolling_stat') node.config.value = Number(input.value || 0);
|
||||
else if (key === 'min_span' && node.kind === 'oscillates') node.config.min_span = Math.max(0.001, Number(input.value || 0.001));
|
||||
|
||||
+9
-2
@@ -6213,8 +6213,12 @@ body.flow-editor-open { overflow:hidden; }
|
||||
.flow-editor .flow-port-out { right:-8px; }
|
||||
.flow-editor .flow-port:hover,.flow-editor .flow-port.armed { background:var(--accent); transform:scale(1.15); }
|
||||
.flow-empty-hint { position:absolute; z-index:0; left:50%; top:42%; transform:translate(-50%,-50%); text-align:center; display:grid; gap:5px; color:var(--muted); pointer-events:none; }
|
||||
.flow-natural-preview { display:grid; grid-template-columns:auto 1fr; gap:14px; align-items:center; min-height:58px; padding:10px 18px; border-top:1px solid var(--line); background:var(--surface); }
|
||||
.flow-natural-preview { display:flex; justify-content:space-between; gap:24px; align-items:center; min-height:58px; padding:10px 18px; border-top:1px solid var(--line); background:var(--surface); }
|
||||
.flow-preview-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:baseline; min-width:0; }
|
||||
.flow-preview-interpretation { flex:1 1 auto; }
|
||||
.flow-preview-runtime { flex:0 0 auto; justify-content:end; text-align:right; }
|
||||
.flow-natural-preview span { color:var(--muted); font-size:.86rem; line-height:1.4; }
|
||||
.flow-preview-runtime span { white-space:nowrap; }
|
||||
.flow-inspector-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-bottom:18px; }
|
||||
.flow-inspector-head h3 { margin:2px 0 0; }
|
||||
.flow-inspector label { display:grid; gap:6px; margin-bottom:13px; color:var(--muted); font-size:.82rem; }
|
||||
@@ -6237,7 +6241,10 @@ body.flow-editor-open { overflow:hidden; }
|
||||
.flow-inspector { position:absolute; right:0; top:138px; bottom:0; width:min(310px,86vw); z-index:8; box-shadow:-12px 0 30px rgba(0,0,0,.28); }
|
||||
.flow-palette { padding:10px; }
|
||||
.flow-palette-group button { font-size:.76rem; padding:8px; }
|
||||
.flow-natural-preview { grid-template-columns:1fr; gap:4px; }
|
||||
.flow-natural-preview { flex-direction:column; align-items:stretch; gap:6px; }
|
||||
.flow-preview-item { grid-template-columns:1fr; gap:2px; }
|
||||
.flow-preview-runtime { justify-content:start; text-align:left; }
|
||||
.flow-preview-runtime span { white-space:normal; }
|
||||
}
|
||||
|
||||
/* Flow diagnostics, templates and portable Flow files */
|
||||
|
||||
Reference in New Issue
Block a user