From e714f32737b7146c96a60264fb172cd3e4657380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Lov=C3=A9n?= Date: Wed, 31 Mar 2021 15:09:00 +0200 Subject: [PATCH] Refactoring automation trace graphs (#8763) Co-authored-by: Paulus Schoutsen Co-authored-by: Bram Kragten --- src/common/string/starts-with.ts | 2 + src/components/trace/hat-graph-node.ts | 187 +++++++ src/components/trace/hat-graph.ts | 523 ++++++------------ src/components/trace/hat-script-graph.ts | 503 +++++++++++++++-- src/components/trace/hat-trace-timeline.ts | 12 +- src/components/trace/script-to-graph.ts | 443 --------------- src/data/trace.ts | 9 + .../trace/ha-automation-trace-path-details.ts | 7 +- .../trace/ha-automation-trace-timeline.ts | 6 - .../automation/trace/ha-automation-trace.ts | 4 +- 10 files changed, 828 insertions(+), 868 deletions(-) create mode 100644 src/common/string/starts-with.ts create mode 100644 src/components/trace/hat-graph-node.ts delete mode 100644 src/components/trace/script-to-graph.ts diff --git a/src/common/string/starts-with.ts b/src/common/string/starts-with.ts new file mode 100644 index 0000000000..52a607436f --- /dev/null +++ b/src/common/string/starts-with.ts @@ -0,0 +1,2 @@ +export const strStartsWith = (value: string, search: string) => + value.substring(0, search.length) === search; diff --git a/src/components/trace/hat-graph-node.ts b/src/components/trace/hat-graph-node.ts new file mode 100644 index 0000000000..80bba78d74 --- /dev/null +++ b/src/components/trace/hat-graph-node.ts @@ -0,0 +1,187 @@ +import { css, customElement, LitElement, property, svg } from "lit-element"; + +import { NODE_SIZE, SPACING } from "./hat-graph"; + +@customElement("hat-graph-node") +export class HatGraphNode extends LitElement { + @property() iconPath?: string; + + @property({ reflect: true, type: Boolean }) disabled?: boolean; + + @property({ reflect: true, type: Boolean }) graphstart?: boolean; + + @property({ reflect: true, type: Boolean }) nofocus?: boolean; + + @property({ reflect: true, type: Number }) badge?: number; + + connectedCallback() { + super.connectedCallback(); + if (!this.hasAttribute("tabindex") && !this.nofocus) + this.setAttribute("tabindex", "0"); + } + + updated() { + const svgEl = this.shadowRoot?.querySelector("svg"); + if (!svgEl) { + return; + } + const bbox = svgEl.getBBox(); + const extra_height = this.graphstart ? 2 : 1; + const extra_width = SPACING; + svgEl.setAttribute("width", `${bbox.width + extra_width}px`); + svgEl.setAttribute("height", `${bbox.height + extra_height}px`); + svgEl.setAttribute( + "viewBox", + `${Math.ceil(bbox.x - extra_width / 2)} + ${Math.ceil(bbox.y - extra_height / 2)} + ${bbox.width + extra_width} + ${bbox.height + extra_height}` + ); + } + + render() { + return svg` + + ${ + this.graphstart + ? `` + : svg` + + ` + } + + + ${ + this.badge + ? svg` + + + ${this.badge > 9 ? "9+" : this.badge} + + ` + : "" + } + + ${this.iconPath ? svg`` : ""} + + + + `; + } + + static get styles() { + return css` + :host { + display: flex; + flex-direction: column; + --stroke-clr: var(--stroke-color, var(--secondary-text-color)); + --active-clr: var(--active-color, var(--primary-color)); + --track-clr: var(--track-color, var(--accent-color)); + --hover-clr: var(--hover-color, var(--primary-color)); + --disabled-clr: var(--disabled-color, var(--disabled-text-color)); + --default-trigger-color: 3, 169, 244; + --rgb-trigger-color: var(--trigger-color, var(--default-trigger-color)); + --background-clr: var(--background-color, white); + --default-icon-clr: var(--icon-color, black); + --icon-clr: var(--stroke-clr); + } + :host(.track) { + --stroke-clr: var(--track-clr); + --icon-clr: var(--default-icon-clr); + } + :host(.active) circle { + --stroke-clr: var(--active-clr); + --icon-clr: var(--default-icon-clr); + } + :host(:focus) { + outline: none; + } + :host(:hover) circle { + --stroke-clr: var(--hover-clr); + --icon-clr: var(--default-icon-clr); + } + :host([disabled]) circle { + stroke: var(--disabled-clr); + } + :host-context([disabled]) { + --stroke-clr: var(--disabled-clr); + } + + :host([nofocus]):host-context(.active), + :host([nofocus]):host-context(:focus) { + --stroke-clr: var(--active-clr); + --icon-clr: var(--default-icon-clr); + } + + circle, + path.connector { + stroke: var(--stroke-clr); + stroke-width: 2; + fill: none; + } + circle { + fill: var(--background-clr); + stroke: var(--circle-clr, var(--stroke-clr)); + } + .number circle { + fill: var(--track-clr); + stroke: none; + stroke-width: 0; + } + .number text { + font-size: smaller; + } + path.icon { + fill: var(--icon-clr); + } + + :host(.triggered) svg { + overflow: visible; + } + :host(.triggered) circle { + animation: glow 10s; + } + @keyframes glow { + 0% { + filter: drop-shadow(0px 0px 5px rgba(var(--rgb-trigger-color), 0)); + } + 10% { + filter: drop-shadow(0px 0px 10px rgba(var(--rgb-trigger-color), 1)); + } + 100% { + filter: drop-shadow(0px 0px 5px rgba(var(--rgb-trigger-color), 0)); + } + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hat-graph-node": HatGraphNode; + } +} diff --git a/src/components/trace/hat-graph.ts b/src/components/trace/hat-graph.ts index e1a10ca372..fc8c3f28b2 100644 --- a/src/components/trace/hat-graph.ts +++ b/src/components/trace/hat-graph.ts @@ -1,397 +1,218 @@ import { - LitElement, - html, - svg, - property, - customElement, - SVGTemplateResult, css, + customElement, + html, + LitElement, + property, + svg, } from "lit-element"; import { classMap } from "lit-html/directives/class-map"; -const SIZE = 35; -const DIST = 20; +export const BRANCH_HEIGHT = 20; +export const SPACING = 10; +export const NODE_SIZE = 30; -type ValueOrArray = T | ValueOrArray[]; - -// Return value is undefined if it's an empty array -const extractFirstValue = (val: ValueOrArray): T | undefined => - Array.isArray(val) ? extractFirstValue(val[0]) : val; -const extractLastValue = (val: ValueOrArray): T | undefined => - Array.isArray(val) ? extractLastValue(val[val.length - 1]) : val; +const track_converter = { + fromAttribute: (value) => value.split(",").map((v) => parseInt(v)), + toAttribute: (value) => + value instanceof Array ? value.join(",") : `${value}`, +}; export interface NodeInfo { path: string; config: any; - update?: (conf: any) => void; } -export interface TreeNode { - icon: string; - number?: number; - end?: boolean; - nodeInfo?: NodeInfo; - children?: Array>; - clickCallback?: () => void; - addCallback?: () => void; - isActive: boolean; - isTracked: boolean | undefined; - isNew?: boolean; -} - -export function* bfsIterateTreeNodes( - nodeOrNodes: ValueOrArray -): IterableIterator { - if (Array.isArray(nodeOrNodes)) { - for (const node of nodeOrNodes) { - yield* bfsIterateTreeNodes(node); - } - return; - } - yield nodeOrNodes; - - if (nodeOrNodes.children) { - yield* bfsIterateTreeNodes(nodeOrNodes.children); - } -} - -interface RenderedTree { - svg: SVGTemplateResult[]; - width: number; +interface BranchConfig { + x: number; height: number; - // These are the parts rendered before/after this tree - previousPartTracked: boolean | undefined; - nextPartTracked: boolean | undefined; - // These are the parts inside the tree - firstNodeTracked: boolean | undefined; - lastNodeTracked: boolean | undefined; + start: boolean; + end: boolean; } @customElement("hat-graph") -class HatGraph extends LitElement { - @property() tree!: TreeNode[]; +export class HatGraph extends LitElement { + @property({ type: Number }) _num_items = 0; - @property() finishedActive = false; + @property({ reflect: true, type: Boolean }) branching?: boolean; - @property() nodeSize = SIZE; + @property({ reflect: true, converter: track_converter }) + track_start?: number[]; - @property() nodeSeparation = DIST; + @property({ reflect: true, converter: track_converter }) track_end?: number[]; - private _draw_node(x: number, y: number, node: TreeNode) { - return svg` - - - ${node.icon ? svg`` : ""} - ${ - node.number - ? svg` - ${node.number}` - : "" - } - - `; - } + @property({ reflect: true, type: Boolean }) disabled?: boolean; - private _draw_new_node(x, y, node) { - return svg` - - `; - } + @property({ reflect: true, type: Boolean }) selected?: boolean; - private _draw_connector(x1, y1, x2, y2, track) { - return svg` - - `; - } + @property({ reflect: true, type: Boolean }) short = false; - private _draw_tree( - tree: ValueOrArray, - previousPartTracked: boolean | undefined, - nextPartTracked: boolean | undefined - ): RenderedTree { - if (!tree) { - return { - svg: [], - width: 0, - height: 0, - previousPartTracked, - nextPartTracked, - firstNodeTracked: false, - lastNodeTracked: false, - }; - } - - if (!Array.isArray(tree)) { - return this._draw_tree_single(tree, previousPartTracked, nextPartTracked); - } - - if (tree.length === 0) { - return { - svg: [], - width: 0, - height: 0, - previousPartTracked, - nextPartTracked, - firstNodeTracked: false, - lastNodeTracked: false, - }; - } - - return this._draw_tree_array(tree, previousPartTracked, nextPartTracked); - } - - private _draw_tree_single( - tree: TreeNode, - previousPartTracked: boolean | undefined, - nextPartTracked: boolean | undefined - ): RenderedTree { - let height = this.nodeSize; - let width = this.nodeSize; - const pieces: SVGTemplateResult[] = []; - - let lastNodeTracked = tree.isTracked; - - // These children are drawn in parallel to one another. - if (tree.children && tree.children.length > 0) { - lastNodeTracked = extractFirstValue( - tree.children[tree.children.length - 1] - )?.isTracked; - - const childTrees: RenderedTree[] = []; - tree.children.forEach((child) => { - childTrees.push( - this._draw_tree(child, previousPartTracked, nextPartTracked) - ); - }); - height += childTrees.reduce((a, i) => Math.max(a, i.height), 0); - width = - childTrees.reduce((a, i) => a + i.width, 0) + - this.nodeSeparation * (tree.children.length - 1); - const offsets = childTrees.map( - ((sum) => (value) => { - return sum + value.width + this.nodeSeparation; - })(0) - ); - - let bottomConnectors = false; - - let prevOffset = 0; - - for (const [idx, child] of childTrees.entries()) { - prevOffset += idx ? offsets[idx - 1] : 0; - const x = -width / 2 + prevOffset + child.width / 2; - // Draw top connectors - pieces.push( - this._draw_connector( - 0, - this.nodeSize / 2, - x, - this.nodeSize + this.nodeSeparation, - child.previousPartTracked && child.firstNodeTracked - ) - ); - - const endNode = extractLastValue(tree.children[idx])!; - - if (endNode.end !== true) { - // Draw bottom fill - pieces.push( - this._draw_connector( - x, - this.nodeSeparation + child.height, - x, - this.nodeSeparation + height, - child.lastNodeTracked && child.nextPartTracked - ) - ); - - // Draw bottom connectors - pieces.push( - this._draw_connector( - x, - this.nodeSeparation + height - 1, - 0, - this.nodeSeparation + - height + - this.nodeSize / 2 + - this.nodeSeparation - - 1, - child.lastNodeTracked && child.nextPartTracked - ) - ); - bottomConnectors = true; - } - - // Draw child tree - pieces.push(svg` - - ${child.svg} - - `); - } - if (bottomConnectors) { - height += this.nodeSize + this.nodeSeparation; - } - } - if (tree.addCallback) { - pieces.push( - this._draw_connector( - 0, - height, - 0, - height + this.nodeSeparation, - tree.isTracked && nextPartTracked - ) - ); - pieces.push(this._draw_new_node(0, height + this.nodeSeparation, tree)); - height += this.nodeSeparation + this.nodeSize / 2; - } - if (tree.end !== true) { - // Draw bottom connector - pieces.push( - this._draw_connector( - 0, - height, - 0, - height + this.nodeSeparation, - tree.isTracked && nextPartTracked - ) - ); - height += this.nodeSeparation; - } - - // Draw the node itself - pieces.push(this._draw_node(0, 0, tree)); - - return { - svg: pieces, - width, - height, - previousPartTracked, - nextPartTracked, - firstNodeTracked: tree.isTracked, - lastNodeTracked, - }; - } - - private _draw_tree_array( - tree: ValueOrArray[], - previousPartTracked: boolean | undefined, - nextPartTracked: boolean | undefined - ): RenderedTree { - const pieces: SVGTemplateResult[] = []; - let height = 0; - - // Render each entry while keeping track of the "track" variable. - const childTrees: RenderedTree[] = []; - let lastChildTracked: boolean | undefined = previousPartTracked; - tree.forEach((child, idx) => { - const lastNodeTracked = extractLastValue(child)?.isTracked; - const nextChildTracked = - idx < tree.length - 1 - ? extractFirstValue(tree[idx + 1])?.isTracked - : lastNodeTracked && nextPartTracked; - childTrees.push( - this._draw_tree(child, lastChildTracked, nextChildTracked) - ); - lastChildTracked = lastNodeTracked; - }); - - const width = childTrees.reduce((a, i) => Math.max(a, i.width), 0); - for (const [_, node] of childTrees.entries()) { - pieces.push(svg` - - ${node.svg} - - `); - height += node.height; - } - - return { - svg: pieces, - width, - height, - previousPartTracked, - nextPartTracked, - firstNodeTracked: extractFirstValue(tree[0])?.isTracked, - lastNodeTracked: extractFirstValue(tree[tree.length - 1])?.isTracked, - }; + async updateChildren() { + this._num_items = this.children.length; } render() { - const tree = this._draw_tree( - this.tree, - this.tree.length > 0 && this.tree[0].isTracked, - this.finishedActive - ); + const branches: BranchConfig[] = []; + let total_width = 0; + let max_height = 0; + let min_height = Number.POSITIVE_INFINITY; + if (this.branching) { + for (const c of Array.from(this.children)) { + if (c.slot === "head") continue; + const rect = c.getBoundingClientRect(); + branches.push({ + x: rect.width / 2 + total_width, + height: rect.height, + start: c.getAttribute("graphStart") != null, + end: c.getAttribute("graphEnd") != null, + }); + total_width += rect.width; + max_height = Math.max(max_height, rect.height); + min_height = Math.min(min_height, rect.height); + } + } + return html` - - - ${tree.svg} - - + + ${this.branching + ? svg` + + ${branches.map((branch, i) => { + if (branch.start) return ""; + return svg` + + `; + })} + + + ` + : ""} +
+ ${this.branching + ? svg` + + ${branches.map((branch, i) => { + if (branch.end) return ""; + return svg` + + `; + })} + + ` + : ""} + +
+ + ${this.branching && !this.short + ? svg` + + ${branches.map((branch, i) => { + if (branch.end) return ""; + return svg` + + `; + })} + + + ` + : ""} `; } static get styles() { return css` :host { + position: relative; + display: flex; + flex-direction: column; + align-items: center; --stroke-clr: var(--stroke-color, var(--secondary-text-color)); --active-clr: var(--active-color, var(--primary-color)); - --hover-clr: var(--hover-color, var(--primary-color)); --track-clr: var(--track-color, var(--accent-color)); + --disabled-clr: var(--disabled-color, gray); } - circle, - line { + :host(:focus) { + outline: none; + } + #branches { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + } + :host([branching]) #branches { + flex-direction: row; + align-items: start; + } + :host([branching]) ::slotted(*) { + z-index: 1; + } + :host([branching]) ::slotted([slot="head"]) { + margin-bottom: ${-BRANCH_HEIGHT / 2}px; + } + + #lines { + position: absolute; + } + + path.line { stroke: var(--stroke-clr); - stroke-width: 3px; - fill: white; + stroke-width: 2; + fill: none; } - .click { - cursor: pointer; - } - .click:hover { - stroke: var(--hover-clr); - } - .track { + path.line.track { stroke: var(--track-clr); } - .active { + :host([disabled]) path.line { + stroke: var(--disabled-clr); + } + :host(.active) #top path.line { stroke: var(--active-clr); } - .number circle { - fill: var(--track-clr); - stroke: none; - } - .number text { - font-size: smaller; + :host(:focus) #top path.line { + stroke: var(--active-clr); } `; } diff --git a/src/components/trace/hat-script-graph.ts b/src/components/trace/hat-script-graph.ts index 5e8e1e7d53..156be802a7 100644 --- a/src/components/trace/hat-script-graph.ts +++ b/src/components/trace/hat-script-graph.ts @@ -7,13 +7,47 @@ import { css, } from "lit-element"; import "@material/mwc-icon-button/mwc-icon-button"; -import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import "../ha-svg-icon"; -import { AutomationTraceExtended } from "../../data/trace"; -import { bfsIterateTreeNodes, NodeInfo, TreeNode } from "./hat-graph"; -import { ActionHandler } from "./script-to-graph"; -import { mdiChevronDown, mdiChevronUp } from "@mdi/js"; +import { + AutomationTraceExtended, + ChooseActionTraceStep, + ConditionTraceStep, +} from "../../data/trace"; +import { + mdiAbTesting, + mdiArrowUp, + mdiAsterisk, + mdiCallSplit, + mdiCheckboxBlankOutline, + mdiCheckBoxOutline, + mdiChevronDown, + mdiChevronRight, + mdiChevronUp, + mdiClose, + mdiCodeBrackets, + mdiDevices, + mdiExclamation, + mdiRefresh, + mdiTimerOutline, + mdiTrafficLight, +} from "@mdi/js"; +import "./hat-graph-node"; +import { classMap } from "lit-html/directives/class-map"; +import { NODE_SIZE, SPACING, NodeInfo } from "./hat-graph"; +import { Condition, Trigger } from "../../data/automation"; +import { + Action, + ChooseAction, + DelayAction, + DeviceAction, + EventAction, + RepeatAction, + SceneAction, + ServiceAction, + WaitAction, + WaitForTriggerAction, +} from "../../data/script"; declare global { interface HASSDomEvents { @@ -27,27 +61,390 @@ class HatScriptGraph extends LitElement { @property({ attribute: false }) public selected; - private getActionHandler = memoizeOne((trace: AutomationTraceExtended) => { - return new ActionHandler( - trace.config.action, - false, - undefined, - (nodeInfo) => { - // eslint-disable-next-line no-console - console.log(nodeInfo); - fireEvent(this, "graph-node-selected", nodeInfo); - this.requestUpdate(); - }, - this.selected, - this.trace - ); - }); + @property() trackedNodes: Record = {}; + + private selectNode(config, path) { + return () => { + fireEvent(this, "graph-node-selected", { config, path }); + }; + } + + private render_trigger(config: Trigger, i: number) { + const path = `trigger/${i}`; + const tracked = this.trace && path in this.trace.trace; + if (tracked) { + this.trackedNodes[path] = { config, path }; + } + return html` + + `; + } + + private render_condition(config: Condition, i: number) { + const path = `condition/${i}`; + const trace = this.trace.trace[path] as ConditionTraceStep[] | undefined; + const track_path = + trace === undefined ? 0 : trace![0].result.result ? 1 : 2; + if (trace) { + this.trackedNodes[path] = { config, path }; + } + return html` + + +
+
+ +
+ `; + } + + private render_choose_node(config: ChooseAction, path: string) { + const trace = this.trace.trace[path] as ChooseActionTraceStep[] | undefined; + const trace_path = trace + ? trace[0].result.choice === "default" + ? [config.choose.length] + : [trace[0].result.choice] + : []; + return html` + + + + ${config.choose.map((branch, i) => { + const branch_path = `${path}/choose/${i}`; + return html` + + + ${branch.sequence.map((action, j) => + this.render_node(action, `${branch_path}/sequence/${j}`) + )} + + `; + })} + + + ${config.default?.map((action, i) => + this.render_node(action, `${path}/default/${i}`) + )} + + + `; + } + + private render_condition_node(node: Condition, path: string) { + const trace: any = this.trace.trace[path]; + const track_path = trace === undefined ? 0 : trace[0].result.result ? 1 : 2; + return html` + + +
+
+ +
+ `; + } + + private render_delay_node(node: DelayAction, path: string) { + return html` + + `; + } + + private render_device_node(node: DeviceAction, path: string) { + return html` + + `; + } + + private render_event_node(node: EventAction, path: string) { + return html` + + `; + } + + private render_repeat_node(node: RepeatAction, path: string) { + const trace: any = this.trace.trace[path]; + const track_path = trace ? [0, 1] : []; + const repeats = this.trace?.trace[`${path}/repeat/sequence/0`]?.length; + return html` + + + + + ${node.repeat.sequence.map((action, i) => + this.render_node(action, `${path}/repeat/sequence/${i}`) + )} + + + `; + } + + private render_scene_node(node: SceneAction, path: string) { + return html` + + `; + } + + private render_service_node(node: ServiceAction, path: string) { + return html` + + `; + } + + private render_wait_node( + node: WaitAction | WaitForTriggerAction, + path: string + ) { + return html` + + `; + } + + private render_other_node(node: Action, path: string) { + return html` + + `; + } + + private render_node(node: Action, path: string) { + const NODE_TYPES = { + choose: this.render_choose_node, + condition: this.render_condition_node, + delay: this.render_delay_node, + device_id: this.render_device_node, + event: this.render_event_node, + repeat: this.render_repeat_node, + scene: this.render_scene_node, + service: this.render_service_node, + wait_template: this.render_wait_node, + wait_for_trigger: this.render_wait_node, + other: this.render_other_node, + }; + + const type = Object.keys(NODE_TYPES).find((key) => key in node) || "other"; + const nodeEl = NODE_TYPES[type].bind(this)(node, path); + if (this.trace && path in this.trace.trace) { + this.trackedNodes[path] = { config: node, path }; + } + return nodeEl; + } protected render() { - const actionHandler = this.getActionHandler(this.trace); - const paths = Object.keys(this.getTrackedNodes()); + const paths = Object.keys(this.trackedNodes); + + const manual_triggered = this.trace && "trigger" in this.trace.trace; + let track_path = manual_triggered ? undefined : [0]; + const trigger_nodes = (Array.isArray(this.trace.config.trigger) + ? this.trace.config.trigger + : [this.trace.config.trigger] + ).map((trigger, i) => { + if (this.trace && `trigger/${i}` in this.trace.trace) { + track_path = [i]; + } + return this.render_trigger(trigger, i); + }); + return html` - + +
+ + ${trigger_nodes} + + + ${(!this.trace.config.condition || + Array.isArray(this.trace.config.condition) + ? this.trace.config.condition + : [this.trace.config.condition] + )?.map((condition, i) => this.render_condition(condition, i))} + + ${(Array.isArray(this.trace.config.action) + ? this.trace.config.action + : [this.trace.config.action] + ).map((action, i) => this.render_node(action, `action/${i}`))} +
) { + if (changedProps.has("trace")) { + this.trackedNodes = {}; } - - actionHandler.selected = selected?.path || ""; - this.requestUpdate(); + super.update(changedProps); } protected updated(changedProps: PropertyValues) { @@ -93,22 +482,29 @@ class HatScriptGraph extends LitElement { if (this.selected === "" || !(this.selected in paths)) { // Find first tracked node with node info for (const path of paths) { - if (tracked[path].nodeInfo) { - fireEvent(this, "graph-node-selected", tracked[path].nodeInfo); + if (tracked[path]) { + fireEvent(this, "graph-node-selected", tracked[path]); break; } } } - } - if (changedProps.has("selected")) { - this.getActionHandler(this.trace).selected = this.selected; - this.requestUpdate(); + if (this.trace) { + const sortKeys = Object.keys(this.trace.trace); + const keys = Object.keys(this.trackedNodes).sort( + (a, b) => sortKeys.indexOf(a) - sortKeys.indexOf(b) + ); + const sortedTrackedNodes = keys.reduce((obj, key) => { + obj[key] = this.trackedNodes[key]; + return obj; + }, {}); + this.trackedNodes = sortedTrackedNodes; + } } } public getTrackedNodes() { - return this._getTrackedNodes(this.trace); + return this.trackedNodes; } public previousTrackedNode() { @@ -116,8 +512,8 @@ class HatScriptGraph extends LitElement { const nodes = Object.keys(tracked); for (let i = nodes.indexOf(this.selected) - 1; i >= 0; i--) { - if (tracked[nodes[i]].nodeInfo) { - fireEvent(this, "graph-node-selected", tracked[nodes[i]].nodeInfo); + if (tracked[nodes[i]]) { + fireEvent(this, "graph-node-selected", tracked[nodes[i]]); break; } } @@ -127,25 +523,13 @@ class HatScriptGraph extends LitElement { const tracked = this.getTrackedNodes(); const nodes = Object.keys(tracked); for (let i = nodes.indexOf(this.selected) + 1; i < nodes.length; i++) { - if (tracked[nodes[i]].nodeInfo) { - fireEvent(this, "graph-node-selected", tracked[nodes[i]].nodeInfo); + if (tracked[nodes[i]]) { + fireEvent(this, "graph-node-selected", tracked[nodes[i]]); break; } } } - private _getTrackedNodes = memoizeOne((trace) => { - const tracked: Record = {}; - for (const node of bfsIterateTreeNodes( - this.getActionHandler(trace).createGraph() - )) { - if (node.isTracked && node.nodeInfo) { - tracked[node.nodeInfo.path] = node; - } - } - return tracked; - }); - static get styles() { return css` :host { @@ -155,6 +539,9 @@ class HatScriptGraph extends LitElement { display: flex; flex-direction: column; } + .parent { + margin-left: 8px; + } `; } } diff --git a/src/components/trace/hat-trace-timeline.ts b/src/components/trace/hat-trace-timeline.ts index 1aef30f3ae..4ba3e531af 100644 --- a/src/components/trace/hat-trace-timeline.ts +++ b/src/components/trace/hat-trace-timeline.ts @@ -14,6 +14,7 @@ import { ChooseActionTraceStep, getDataFromPath, TriggerTraceStep, + isTriggerPath, } from "../../data/trace"; import { HomeAssistant } from "../../types"; import "./ha-timeline"; @@ -218,7 +219,7 @@ class ActionRenderer { ): number { const value = this._getItem(index); - if (value[0].path === "trigger") { + if (isTriggerPath(value[0].path)) { return this._handleTrigger(index, value[0] as TriggerTraceStep); } @@ -267,9 +268,12 @@ class ActionRenderer { private _handleTrigger(index: number, triggerStep: TriggerTraceStep): number { this._renderEntry( - "trigger", - `Triggered by the - ${triggerStep.changed_variables.trigger.description} at + triggerStep.path, + `Triggered ${ + triggerStep.path === "trigger" + ? "manually" + : `by the ${triggerStep.changed_variables.trigger.description}` + } at ${formatDateTimeWithSeconds( new Date(triggerStep.timestamp), this.hass.locale diff --git a/src/components/trace/script-to-graph.ts b/src/components/trace/script-to-graph.ts deleted file mode 100644 index 318c78d861..0000000000 --- a/src/components/trace/script-to-graph.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { - mdiCallSplit, - mdiAbTesting, - mdiCheck, - mdiClose, - mdiChevronRight, - mdiExclamation, - mdiTimerOutline, - mdiTrafficLight, - mdiRefresh, - mdiArrowUp, - mdiCodeJson, - mdiCheckBoxOutline, - mdiCheckboxBlankOutline, - mdiAsterisk, - mdiDevices, - mdiFlare, -} from "@mdi/js"; -import memoizeOne from "memoize-one"; -import { Condition } from "../../data/automation"; -import { Action, ChooseAction, RepeatAction } from "../../data/script"; -import { - AutomationTraceExtended, - ChooseActionTraceStep, - ChooseChoiceActionTraceStep, - ConditionTraceStep, -} from "../../data/trace"; - -import { NodeInfo, TreeNode } from "./hat-graph"; - -const ICONS = { - new: mdiAsterisk, - device_id: mdiDevices, - service: mdiChevronRight, - condition: mdiAbTesting, - TRUE: mdiCheck, - FALSE: mdiClose, - delay: mdiTimerOutline, - wait_template: mdiTrafficLight, - event: mdiExclamation, - repeat: mdiRefresh, - repeatReturn: mdiArrowUp, - choose: mdiCallSplit, - chooseChoice: mdiCheckBoxOutline, - chooseDefault: mdiCheckboxBlankOutline, - YAML: mdiCodeJson, -}; - -const OPTIONS = [ - "condition", - "delay", - "device_id", - "event", - "scene", - "service", - "wait_template", - "repeat", - "choose", -]; - -// eslint-disable-next-line @typescript-eslint/no-empty-interface -interface NoAction {} - -// const cmpLists = (a: Array, b: Array) => -// a.length === b.length && a.every((itm, idx) => b[idx] === itm); - -const TRACE_ACTION_PREFIX = "action/"; - -export class ActionHandler { - public pathPrefix: string; - - constructor( - public actions: Array, - /** - * Do we allow adding new nodes - */ - private allowAdd: boolean, - /** - * Called when the data has changed - */ - private updateCallback?: (actions: ActionHandler["actions"]) => void, - /** - * Called when a node is clicked. - */ - private selectCallback?: (params: NodeInfo) => void, - - public selected: string = "", - - public trace?: AutomationTraceExtended, - - pathPrefix?: string, - - public end?: boolean - ) { - if (pathPrefix !== undefined) { - this.pathPrefix = pathPrefix; - } else if (this.trace) { - this.pathPrefix = TRACE_ACTION_PREFIX; - } else { - this.pathPrefix = ""; - } - } - - createGraph(): TreeNode[] { - return this._createGraph(this.actions, this.selected, this.trace); - } - - _createGraph = memoizeOne((_actions, _selected, _trace) => - this._renderTraceHead().concat( - this.actions.map((action, idx) => - this._createTreeNode( - idx, - action, - this.actions.length === idx + 1 && - (this.end === undefined || this.end === true) - ) - ) - ) - ); - - _renderTraceHead(): TreeNode[] { - if (this.pathPrefix !== TRACE_ACTION_PREFIX) { - return []; - } - - const triggerNodeInfo = { - path: "trigger", - // Just all triggers for now. - config: this.trace!.config.trigger, - }; - - const nodes: TreeNode[] = [ - { - icon: mdiFlare, - nodeInfo: triggerNodeInfo, - clickCallback: () => { - this._selectNode(triggerNodeInfo); - }, - isActive: this.selected === "trigger", - isTracked: true, - }, - ]; - - if (this.trace!.config.condition) { - this.trace!.config.condition.forEach((condition, idx) => - nodes.push( - this._createConditionNode( - "condition/", - idx, - condition, - !this.actions.length && - this.trace!.config.condition!.length === idx + 1 - ) - ) - ); - } - - return nodes; - } - - _updateAction(idx: number, action) { - if (action === null) { - this.actions.splice(idx, 1); - } else { - this.actions[idx] = action; - } - if (this.updateCallback) this.updateCallback(this.actions); - } - - _addAction(idx: number) { - this.actions.splice(idx, 0, {}); - if (this.updateCallback) { - this.updateCallback(this.actions); - } - this._selectNode({ - path: `${this.pathPrefix}${idx}`, - config: {}, - update: (a) => this._updateAction(idx, a), - }); - } - - _selectNode(nodeInfo: NodeInfo) { - this.selected = nodeInfo.path; - if (this.selectCallback) { - this.selectCallback(nodeInfo); - } - } - - _createTreeNode(idx: number, action, end: boolean): TreeNode { - let _type = "yaml"; - - if (Object.keys(action).length === 0) { - _type = "new"; - } else { - _type = OPTIONS.find((option) => option in action) || "YAML"; - } - - let node: TreeNode; - - if (_type in this.SPECIAL) { - node = this.SPECIAL[_type](idx, action, end); - } else { - const path = `${this.pathPrefix}${idx}`; - const nodeInfo: NodeInfo = { - path, - config: action, - update: (a) => this._updateAction(idx, a), - }; - node = { - icon: ICONS[_type], - nodeInfo, - clickCallback: () => { - this._selectNode(nodeInfo); - }, - isActive: path === this.selected, - isTracked: this.trace && path in this.trace.trace, - end, - }; - } - - if (this.allowAdd) { - node.addCallback = () => this._addAction(idx + 1); - } - if (_type === "new") { - node.isNew = true; - } - - return node; - } - - SPECIAL: Record< - string, - (idx: number, action: any, end: boolean) => TreeNode - > = { - condition: (idx, action: Condition, end: boolean): TreeNode => - this._createConditionNode(this.pathPrefix, idx, action, end), - - repeat: (idx, action: RepeatAction, end: boolean): TreeNode => { - let seq: Array = action.repeat.sequence; - if (!seq || !seq.length) { - seq = [{}]; - } - - const path = `${this.pathPrefix}${idx}`; - const isTracked = this.trace && path in this.trace.trace; - - const repeats = - this.trace && this.trace.trace[`${path}/repeat/sequence/0`]?.length; - - const nodeInfo: NodeInfo = { - path, - config: action, - update: (conf) => this._updateAction(idx, conf), - }; - return { - icon: ICONS.repeat, - number: repeats, - nodeInfo, - clickCallback: () => this._selectNode(nodeInfo), - isActive: path === this.selected, - isTracked, - end, - children: [ - new ActionHandler( - seq, - this.allowAdd, - (a) => { - action.repeat.sequence = a as Action[]; - this._updateAction(idx, action); - }, - (params) => this._selectNode(params), - this.selected, - this.trace, - `${path}/repeat/sequence/`, - end - ).createGraph(), - ], - }; - }, - - choose: (idx, action: ChooseAction, end: boolean): TreeNode => { - const choosePath = `${this.pathPrefix}${idx}`; - let choice: number | "default" | undefined; - - if (this.trace?.trace && choosePath in this.trace.trace) { - const chooseResult = this.trace.trace[ - choosePath - ] as ChooseActionTraceStep[]; - choice = chooseResult[0].result.choice; - } - - const children = action.choose.map( - (b, choiceIdx): NonNullable => { - // If we have a trace, highlight the chosen track here. - const choicePath = `${this.pathPrefix}${idx}/choose/${choiceIdx}`; - let chosen = false; - if (this.trace && choicePath in this.trace.trace) { - const choiceResult = this.trace.trace[ - choicePath - ] as ChooseChoiceActionTraceStep[]; - chosen = choiceResult[0].result.result; - } - const choiceNodeInfo: NodeInfo = { - path: choicePath, - config: b, - update: (conf) => { - action.choose[choiceIdx] = conf; - this._updateAction(idx, action); - }, - }; - - return [ - { - icon: ICONS.chooseChoice, - nodeInfo: choiceNodeInfo, - clickCallback: () => this._selectNode(choiceNodeInfo), - isActive: choicePath === this.selected, - isTracked: chosen, - }, - new ActionHandler( - b.sequence || [{}], - this.allowAdd, - (actions) => { - b.sequence = actions as Action[]; - action.choose[choiceIdx] = b; - this._updateAction(idx, action); - }, - (params) => { - this._selectNode(params); - }, - this.selected, - this.trace, - `${this.pathPrefix}${idx}/choose/${choiceIdx}/sequence/`, - !chosen || end - ).createGraph(), - ]; - } - ); - - if (action.default || this.allowAdd) { - const defaultConfig = action.default || [{}]; - - const updateDefault = (actions) => { - action.default = actions as Action[]; - this._updateAction(idx, action); - }; - - const defaultPath = `${this.pathPrefix}${idx}/default`; - - const defaultNodeInfo: NodeInfo = { - path: defaultPath, - config: defaultConfig, - update: updateDefault, - }; - - children.push([ - { - icon: ICONS.chooseDefault, - nodeInfo: defaultNodeInfo, - clickCallback: () => this._selectNode(defaultNodeInfo), - isActive: defaultPath === this.selected, - isTracked: choice === "default", - }, - new ActionHandler( - defaultConfig, - this.allowAdd, - updateDefault, - (params) => this._selectNode(params), - this.selected, - this.trace, - `${this.pathPrefix}${idx}/default/`, - choice !== "default" || end - ).createGraph(), - ]); - } - - const chooseNodeInfo: NodeInfo = { - path: choosePath, - config: action, - update: (conf) => this._updateAction(idx, conf), - }; - - return { - icon: ICONS.choose, - nodeInfo: chooseNodeInfo, - end, - clickCallback: () => this._selectNode(chooseNodeInfo), - isActive: choosePath === this.selected, - isTracked: choice !== undefined, - children, - }; - }, - }; - - private _createConditionNode( - pathPrefix: string, - idx: number, - action: Condition, - end: boolean - ): TreeNode { - const path = `${pathPrefix}${idx}`; - let result: boolean | undefined; - let isTracked = false; - - if (this.trace && path in this.trace.trace) { - const conditionResult = this.trace.trace[path] as ConditionTraceStep[]; - result = conditionResult[0].result.result; - isTracked = true; - } - - const nodeInfo: NodeInfo = { - path, - config: action, - update: (conf) => this._updateAction(idx, conf), - }; - - const isActive = path === this.selected; - - return { - icon: ICONS.condition, - nodeInfo, - clickCallback: () => this._selectNode(nodeInfo), - isActive, - isTracked, - end, - children: [ - { - icon: ICONS.TRUE, - clickCallback: () => this._selectNode(nodeInfo), - isActive, - isTracked: result === true, - }, - { - icon: ICONS.FALSE, - clickCallback: () => this._selectNode(nodeInfo), - isActive, - isTracked: result === false, - end: true, - }, - ], - }; - } -} diff --git a/src/data/trace.ts b/src/data/trace.ts index 8439f332b1..3ad16845c0 100644 --- a/src/data/trace.ts +++ b/src/data/trace.ts @@ -1,3 +1,4 @@ +import { strStartsWith } from "../common/string/starts-with"; import { HomeAssistant, Context } from "../types"; import { AutomationConfig } from "./automation"; @@ -146,3 +147,11 @@ export const getDataFromPath = ( return result; }; + +// It is 'trigger' if manually triggered by the user via UI +export const isTriggerPath = (path: string): boolean => + path === "trigger" || strStartsWith(path, "trigger/"); + +export const getTriggerPathFromTrace = ( + steps: Record +): string | undefined => Object.keys(steps).find((path) => isTriggerPath(path)); diff --git a/src/panels/config/automation/trace/ha-automation-trace-path-details.ts b/src/panels/config/automation/trace/ha-automation-trace-path-details.ts index 68c42eed1b..9a3f628aee 100644 --- a/src/panels/config/automation/trace/ha-automation-trace-path-details.ts +++ b/src/panels/config/automation/trace/ha-automation-trace-path-details.ts @@ -17,10 +17,7 @@ import { } from "../../../../data/trace"; import "../../../../components/ha-icon-button"; import "../../../../components/ha-code-editor"; -import type { - NodeInfo, - TreeNode, -} from "../../../../components/trace/hat-graph"; +import type { NodeInfo } from "../../../../components/trace/hat-graph"; import { HomeAssistant } from "../../../../types"; import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time"; import { LogbookEntry } from "../../../../data/logbook"; @@ -39,7 +36,7 @@ export class HaAutomationTracePathDetails extends LitElement { @property() public logbookEntries!: LogbookEntry[]; - @property() public trackedNodes!: Record; + @property() public trackedNodes!: Record; @internalProperty() private _view: | "config" diff --git a/src/panels/config/automation/trace/ha-automation-trace-timeline.ts b/src/panels/config/automation/trace/ha-automation-trace-timeline.ts index fd8f77aa97..63599b2db5 100644 --- a/src/panels/config/automation/trace/ha-automation-trace-timeline.ts +++ b/src/panels/config/automation/trace/ha-automation-trace-timeline.ts @@ -10,7 +10,6 @@ import { import { AutomationTraceExtended } from "../../../../data/trace"; import { HomeAssistant } from "../../../../types"; import { LogbookEntry } from "../../../../data/logbook"; -import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/trace/hat-trace-timeline"; import { NodeInfo } from "../../../../components/trace/hat-graph"; @@ -32,16 +31,11 @@ export class HaAutomationTraceTimeline extends LitElement { .logbookEntries=${this.logbookEntries} .selectedPath=${this.selected.path} allowPick - @value-changed=${this._timelinePathPicked} > `; } - private _timelinePathPicked(ev) { - fireEvent(this, "value-changed", ev.detail); - } - static get styles(): CSSResult[] { return [ css` diff --git a/src/panels/config/automation/trace/ha-automation-trace.ts b/src/panels/config/automation/trace/ha-automation-trace.ts index 2c86617ce2..f99503e3b5 100644 --- a/src/panels/config/automation/trace/ha-automation-trace.ts +++ b/src/panels/config/automation/trace/ha-automation-trace.ts @@ -384,7 +384,9 @@ export class HaAutomationTrace extends LitElement { const nodes = this.shadowRoot!.querySelector( "hat-script-graph" )!.getTrackedNodes(); - this._selected = nodes[path]?.nodeInfo; + if (nodes[path]) { + this._selected = nodes[path]; + } } static get styles(): CSSResult[] {