This commit is contained in:
Mateusz Gruszczyński committed 2026-09-23 09:24:15 +02:00
1 parent 0148daf410
commit f08613a254
23 files changed
+290 -70

No files matched your search

+93
View File
@@ -4895,6 +4895,90 @@ body.simulation-standalone [data-view="simulation"] {
line-height: 1.4;
}
#controlPlan .plan-recent-trigger {
align-items: start;
}
#controlPlan .plan-trigger-trace {
grid-column: 1 / -1;
min-width: 0;
margin-top: 2px;
border-top: 1px solid var(--line);
padding-top: 7px;
}
#controlPlan .plan-trigger-trace summary {
width: max-content;
max-width: 100%;
cursor: pointer;
color: var(--muted);
font-size: 10px;
font-weight: 800;
}
#controlPlan .plan-trigger-trace-list {
display: grid;
gap: 5px;
margin-top: 7px;
}
#controlPlan .plan-trigger-trace-row {
display: grid;
grid-template-columns: 18px minmax(150px, 1fr) minmax(110px, .85fr);
gap: 8px;
align-items: center;
padding: 5px 7px;
border-radius: 8px;
background: var(--surface);
}
#controlPlan .plan-trigger-trace-row>div {
display: grid;
gap: 1px;
min-width: 0;
}
#controlPlan .plan-trigger-trace-row strong {
font-size: 10px;
}
#controlPlan .plan-trigger-trace-row small {
overflow: hidden;
color: var(--muted);
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
#controlPlan .plan-trigger-trace-row code {
overflow: hidden;
color: var(--text-soft);
font-size: 9px;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
#controlPlan .plan-trigger-trace-mark {
display: grid;
place-items: center;
width: 18px;
height: 18px;
border-radius: 999px;
font-size: 11px;
font-weight: 900;
}
#controlPlan .plan-trigger-trace-row.pass .plan-trigger-trace-mark {
color: var(--accent-text);
background: var(--accent);
}
#controlPlan .plan-trigger-trace-row.fail .plan-trigger-trace-mark {
color: var(--warning);
background: var(--warning-soft);
}
#controlPlan .plan-reason-alert {
display: grid;
gap: 3px;
@@ -10939,4 +11023,13 @@ select.select-native-proxy[aria-invalid="true"]+.custom-select .custom-select-tr
grid-template-columns: 1fr;
gap: 5px;
}
#controlPlan .plan-trigger-trace-row {
grid-template-columns: 18px minmax(0, 1fr);
}
#controlPlan .plan-trigger-trace-row code {
grid-column: 2;
text-align: left;
}
}
+83 -2
View File
@@ -104,6 +104,86 @@ function planZoneReason(zone) {
return tr('plan.reasonWaiting');
}
function planFlowTraceNode(rule, item) {
const flow = rule.flow_id ? (app.flows || []).find(candidate => candidate.id === rule.flow_id) : null;
const current = flow?.nodes?.find(node => node.id === item.node_id);
return {
id: item.node_id || current?.id || '',
kind: item.kind || current?.kind || '',
config: item.config && typeof item.config === 'object' ? item.config : (current?.config || {}),
};
}
function planFlowTraceSourceLabel(node) {
const c = node?.config || {};
if (!['rolling_stat', 'oscillates'].includes(node?.kind)) return '';
if (c.source === 'outdoor_temperature') return tr('house.outdoor');
if (c.source === 'device_temperature') return (app.devices || []).find(device => device.id === c.device_id)?.name || c.device_id || '';
if (c.source === 'zone_temperature') return (app.zones || []).find(zone => zone.id === c.zone_id)?.name || c.zone_id || '';
if (c.source === 'ha_numeric') return c.entity_id ? haSensorLabel(c.entity_id) : '';
return c.source || '';
}
function planFlowTraceScalar(node, value) {
const formatted = flowSimpleSimulationActualText(node, value);
if (formatted) return formatted;
if (value === null || value === undefined) return '—';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function planFlowTraceActual(rule, item, node) {
const actual = item?.actual;
if (!actual || typeof actual !== 'object' || Array.isArray(actual)) return planFlowTraceScalar(node, actual);
if (actual.error) return `${actual.error}${actual.entity_id ? ` · ${actual.entity_id}` : ''}`;
if (node.kind === 'stable_for' || node.kind === 'delay') {
const since = actual.since ? new Date(actual.since).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—';
return `${actual.input ? tr('common.yes') : tr('common.no')} · ≥${flowDurationLabel(actual.seconds)} · ${since}`;
}
if (node.kind === 'state_duration') {
const max = actual.max_seconds == null ? '∞' : flowDurationLabel(actual.max_seconds);
return `${flowDurationLabel(actual.elapsed_seconds)} · ${flowDurationLabel(actual.min_seconds)}–${max}`;
}
if (node.kind === 'on_change') {
const previous = actual.previous === null || actual.previous === undefined ? '—' : planFlowTraceScalar(node, actual.previous);
const current = actual.current === null || actual.current === undefined ? '—' : planFlowTraceScalar(node, actual.current);
return `${previous} → ${current}`;
}
if (node.kind === 'rate_limit') return `${Number(actual.used || 0)}/${Number(actual.max_count || 0)} · ${flowDurationLabel(actual.period_seconds)}`;
if (node.kind === 'rolling_stat') {
const sourceKind = node.config?.source;
const tempSource = ['outdoor_temperature', 'device_temperature', 'zone_temperature'].includes(sourceKind);
const aggregate = actual.aggregate == null ? '—' : tempSource ? fmtTemp(Number(actual.aggregate)) : Number(actual.aggregate).toFixed(2);
const sample = actual.sample == null ? '—' : tempSource ? fmtTemp(Number(actual.sample)) : Number(actual.sample).toFixed(2);
return `${aggregate} · x=${sample} · n=${Number(actual.samples || 0)}`;
}
if (node.kind === 'oscillates') {
const span = actual.span == null ? '—' : Number(actual.span).toFixed(2);
return `Δ=${span} · ↕=${Number(actual.direction_changes || 0)} · n=${Number(actual.samples || 0)}`;
}
if (node.kind === 'logic_and' || node.kind === 'logic_or') {
return (Array.isArray(actual) ? actual : []).map(value => value ? tr('common.yes') : tr('common.no')).join(' · ') || '—';
}
if (node.kind === 'logic_not') return actual ? tr('common.yes') : tr('common.no');
return JSON.stringify(actual);
}
function planFlowTriggerTrace(rule, expanded = false) {
const trace = Array.isArray(rule.last_trigger_trace) ? rule.last_trigger_trace : [];
if (rule.trigger_kind !== 'flow' || !trace.length) return '';
const rows = trace.map(item => {
const node = planFlowTraceNode(rule, item);
const meta = FLOW_NODE_META[node.kind] || { title: node.kind || item.node_id || 'Flow' };
const source = planFlowTraceSourceLabel(node);
const summary = [source, flowNodeSummary(node)].filter(Boolean).join(' · ');
const actual = planFlowTraceActual(rule, item, node);
const matched = item.matched === true;
return `<div class="plan-trigger-trace-row ${matched ? 'pass' : 'fail'}"><span class="plan-trigger-trace-mark" aria-hidden="true">${matched ? '✓' : '×'}</span><div><strong>${esc(flowNodeTitle(meta))}</strong>${summary ? `<small>${esc(summary)}</small>` : ''}</div><code>${esc(actual)}</code></div>`;
}).join('');
return `<details class="plan-trigger-trace" ${expanded ? 'open' : ''}><summary>${esc(tr('plan.reasonTriggerDetails', { count: trace.length }))}</summary><div class="plan-trigger-trace-list">${rows}</div></details>`;
}
function planReasonCard(plan) {
const zones = (plan.zones || []).map(zone => {
const reason = planZoneReason(zone);
@@ -118,14 +198,15 @@ function planReasonCard(plan) {
.filter(rule => rule.enabled && rule.last_fired_at)
.sort((a, b) => new Date(b.last_fired_at) - new Date(a.last_fired_at))
.slice(0, 4)
.map(rule => {
.map((rule, index) => {
const when = new Date(rule.last_fired_at).toLocaleString(locale(), { weekday: 'short', hour: '2-digit', minute: '2-digit' });
const triggerDevice = rule.trigger_device_id ? (app.devices || []).find(device => device.id === rule.trigger_device_id) : null;
const current = triggerDevice?.current_temperature == null ? null : Number(triggerDevice.current_temperature);
const trigger = Number.isFinite(current)
? tr('plan.reasonTriggerReading', { trigger: automationTriggerLabel(rule), current: fmtTemp(current) })
: automationTriggerLabel(rule);
return `<li><div><strong>${esc(rule.name)}</strong><small>${esc(trigger)}</small></div><span>${esc(tr('plan.reasonLastTriggered', { when }))}</span></li>`;
const trace = planFlowTriggerTrace(rule, index === 0);
return `<li class="plan-recent-trigger"><div><strong>${esc(rule.name)}</strong><small>${esc(trigger)}</small></div><span>${esc(tr('plan.reasonLastTriggered', { when }))}</span>${trace}</li>`;
}).join('');
const emergency = plan.emergency_stop_active