diff --git a/gallery/src/demos/demo-automation-trace.ts b/gallery/src/demos/demo-automation-trace.ts index bdf2135a21..a7124ab527 100644 --- a/gallery/src/demos/demo-automation-trace.ts +++ b/gallery/src/demos/demo-automation-trace.ts @@ -7,7 +7,7 @@ import { property, } from "lit-element"; import "../../../src/components/ha-card"; -import "../../../src/components/trace/hat-trace"; +import "../../../src/components/trace/hat-trace-timeline"; import { provideHass } from "../../../src/fake_data/provide_hass"; import { HomeAssistant } from "../../../src/types"; import { DemoTrace } from "../data/traces/types"; @@ -29,11 +29,11 @@ export class DemoAutomationTrace extends LitElement { (trace) => html`
- + >
` diff --git a/src/components/trace/hat-graph.ts b/src/components/trace/hat-graph.ts new file mode 100644 index 0000000000..e1a10ca372 --- /dev/null +++ b/src/components/trace/hat-graph.ts @@ -0,0 +1,404 @@ +import { + LitElement, + html, + svg, + property, + customElement, + SVGTemplateResult, + css, +} from "lit-element"; +import { classMap } from "lit-html/directives/class-map"; + +const SIZE = 35; +const DIST = 20; + +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; + +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; + 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; +} + +@customElement("hat-graph") +class HatGraph extends LitElement { + @property() tree!: TreeNode[]; + + @property() finishedActive = false; + + @property() nodeSize = SIZE; + + @property() nodeSeparation = DIST; + + private _draw_node(x: number, y: number, node: TreeNode) { + return svg` + + + ${node.icon ? svg`` : ""} + ${ + node.number + ? svg` + ${node.number}` + : "" + } + + `; + } + + private _draw_new_node(x, y, node) { + return svg` + + `; + } + + private _draw_connector(x1, y1, x2, y2, track) { + return svg` + + `; + } + + 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, + }; + } + + render() { + const tree = this._draw_tree( + this.tree, + this.tree.length > 0 && this.tree[0].isTracked, + this.finishedActive + ); + return html` + + + ${tree.svg} + + + `; + } + + static get styles() { + return css` + :host { + --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)); + } + circle, + line { + stroke: var(--stroke-clr); + stroke-width: 3px; + fill: white; + } + .click { + cursor: pointer; + } + .click:hover { + stroke: var(--hover-clr); + } + .track { + stroke: var(--track-clr); + } + .active { + stroke: var(--active-clr); + } + .number circle { + fill: var(--track-clr); + stroke: none; + } + .number text { + font-size: smaller; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hat-graph": HatGraph; + } +} diff --git a/src/components/trace/hat-script-graph.ts b/src/components/trace/hat-script-graph.ts new file mode 100644 index 0000000000..5e8e1e7d53 --- /dev/null +++ b/src/components/trace/hat-script-graph.ts @@ -0,0 +1,166 @@ +import { + html, + LitElement, + property, + customElement, + PropertyValues, + 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"; + +declare global { + interface HASSDomEvents { + "graph-node-selected": NodeInfo; + } +} + +@customElement("hat-script-graph") +class HatScriptGraph extends LitElement { + @property({ attribute: false }) public trace!: AutomationTraceExtended; + + @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 + ); + }); + + protected render() { + const actionHandler = this.getActionHandler(this.trace); + const paths = Object.keys(this.getTrackedNodes()); + return html` + +
+ + + + + + +
+ `; + } + + public selectPath(path: string) { + const actionHandler = this.getActionHandler(this.trace!); + let selected: NodeInfo | undefined; + + for (const node of actionHandler.createGraph()) { + if (node.nodeInfo?.path === path) { + selected = node.nodeInfo; + break; + } + } + + actionHandler.selected = selected?.path || ""; + this.requestUpdate(); + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + + // Select first node if new trace loaded but no selection given. + if (changedProps.has("trace")) { + const tracked = this.getTrackedNodes(); + const paths = Object.keys(tracked); + + // If trace changed and we have no or an invalid selection, select first option. + 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); + break; + } + } + } + } + + if (changedProps.has("selected")) { + this.getActionHandler(this.trace).selected = this.selected; + this.requestUpdate(); + } + } + + public getTrackedNodes() { + return this._getTrackedNodes(this.trace); + } + + public previousTrackedNode() { + const tracked = this.getTrackedNodes(); + 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); + break; + } + } + } + + public nextTrackedNode() { + 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); + 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 { + display: flex; + } + .actions { + display: flex; + flex-direction: column; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hat-script-graph": HatScriptGraph; + } +} diff --git a/src/components/trace/hat-trace.ts b/src/components/trace/hat-trace-timeline.ts similarity index 95% rename from src/components/trace/hat-trace.ts rename to src/components/trace/hat-trace-timeline.ts index da6ae4f372..fccb2e6023 100644 --- a/src/components/trace/hat-trace.ts +++ b/src/components/trace/hat-trace-timeline.ts @@ -3,7 +3,6 @@ import { CSSResult, customElement, html, - internalProperty, LitElement, property, PropertyValues, @@ -180,7 +179,8 @@ class LogbookRenderer { } private _renderLogbookEntryHelper(entry: LogbookEntry) { - return html`${entry.name} (${entry.entity_id}) turned ${entry.state}
`; + return html`${entry.name} (${entry.entity_id}) + ${entry.message || `turned ${entry.state}`}
`; } } @@ -346,7 +346,7 @@ class ActionRenderer { } } -@customElement("hat-trace") +@customElement("hat-trace-timeline") export class HaAutomationTracer extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -354,7 +354,9 @@ export class HaAutomationTracer extends LitElement { @property({ attribute: false }) public logbookEntries?: LogbookEntry[]; - @internalProperty() private _selectedPath?: string; + @property({ attribute: false }) public selectedPath?: string; + + @property({ type: Boolean }) public allowPick = false; protected render(): TemplateResult { if (!this.trace) { @@ -454,30 +456,30 @@ export class HaAutomationTracer extends LitElement { super.updated(props); // Pick first path when we load a new trace. - if (props.has("trace")) { + if (this.allowPick && props.has("trace")) { const element = this.shadowRoot!.querySelector( "ha-timeline[data-path]" ); if (element) { fireEvent(this, "value-changed", { value: element.dataset.path }); - this._selectedPath = element.dataset.path; + this.selectedPath = element.dataset.path; } } - if (props.has("trace") || props.has("_selectedPath")) { + if (props.has("trace") || props.has("selectedPath")) { this.shadowRoot!.querySelectorAll( "ha-timeline[data-path]" ).forEach((el) => { el.style.setProperty( "--timeline-ball-color", - this._selectedPath === el.dataset.path ? "var(--primary-color)" : null + this.selectedPath === el.dataset.path ? "var(--primary-color)" : null ); - if (el.dataset.upgraded) { + if (!this.allowPick || el.dataset.upgraded) { return; } el.dataset.upgraded = "1"; el.addEventListener("click", () => { - this._selectedPath = el.dataset.path; + this.selectedPath = el.dataset.path; fireEvent(this, "value-changed", { value: el.dataset.path }); }); el.addEventListener("mouseover", () => { @@ -506,6 +508,6 @@ export class HaAutomationTracer extends LitElement { declare global { interface HTMLElementTagNameMap { - "hat-trace": HaAutomationTracer; + "hat-trace-timeline": HaAutomationTracer; } } diff --git a/src/components/trace/script-to-graph.ts b/src/components/trace/script-to-graph.ts new file mode 100644 index 0000000000..2798cdee9c --- /dev/null +++ b/src/components/trace/script-to-graph.ts @@ -0,0 +1,430 @@ +import { + mdiCallSplit, + mdiAbTesting, + mdiCheck, + mdiClose, + mdiChevronRight, + mdiExclamation, + mdiTimerOutline, + mdiTrafficLight, + mdiRefresh, + mdiArrowUp, + mdiCodeJson, + mdiCheckBoxOutline, + mdiCheckboxBlankOutline, + mdiAsterisk, + mdiDevices, +} from "@mdi/js"; +import memoizeOne from "memoize-one"; +import { Condition } from "../../data/automation"; +import { Action, ChooseAction, RepeatAction } from "../../data/script"; +import { + ActionTrace, + AutomationTraceExtended, + ChooseActionTrace, + ChooseChoiceActionTrace, + ConditionTrace, +} 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._renderConditions().concat( + this.actions.map((action, idx) => + this._createTreeNode( + idx, + action, + this.actions.length === idx + 1 && + (this.end === undefined || this.end === true) + ) + ) + ) + ); + + _renderConditions(): TreeNode[] { + // action/ = default pathPrefix for trace-based actions + if ( + this.pathPrefix !== TRACE_ACTION_PREFIX || + !this.trace?.config.condition + ) { + return []; + } + return this.trace.config.condition.map((condition, idx) => + this._createConditionNode( + "condition/", + this.trace?.condition_trace, + idx, + condition, + !this.actions.length && this.trace!.config.condition!.length === idx + 1 + ) + ); + } + + _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.action_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, + this.trace?.action_trace, + 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.action_trace; + + const repeats = + this.trace && + this.trace.action_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?.action_trace && choosePath in this.trace.action_trace) { + const chooseResult = this.trace.action_trace[ + choosePath + ] as ChooseActionTrace[]; + 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.action_trace) { + const choiceResult = this.trace.action_trace[ + choicePath + ] as ChooseChoiceActionTrace[]; + 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, + tracePaths: Record | undefined, + idx: number, + action: Condition, + end: boolean + ): TreeNode { + const path = `${pathPrefix}${idx}`; + let result: boolean | undefined; + let isTracked = false; + + if (tracePaths && path in tracePaths) { + const conditionResult = tracePaths[path] as ConditionTrace[]; + 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/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index 3beebba9cd..a7a4df3e61 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -130,7 +130,7 @@ class HaAutomationPicker extends LitElement { )} > - - ${this.hass.localize("ui.card.automation.trigger")} - +
+ + + ${this.hass.localize( + "ui.panel.config.automation.editor.show_trace" + )} + + + + ${this.hass.localize("ui.card.automation.trigger")} + +
` : ""} diff --git a/src/panels/config/automation/trace/ha-automation-trace-config.ts b/src/panels/config/automation/trace/ha-automation-trace-config.ts new file mode 100644 index 0000000000..f579e30815 --- /dev/null +++ b/src/panels/config/automation/trace/ha-automation-trace-config.ts @@ -0,0 +1,40 @@ +import { safeDump } from "js-yaml"; +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import { AutomationTraceExtended } from "../../../../data/trace"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-code-editor"; +import { HomeAssistant } from "../../../../types"; + +@customElement("ha-automation-trace-config") +export class HaAutomationTraceConfig extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public trace!: AutomationTraceExtended; + + protected render(): TemplateResult { + return html` + + `; + } + + static get styles(): CSSResult[] { + return [css``]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-trace-config": HaAutomationTraceConfig; + } +} 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 new file mode 100644 index 0000000000..1a69affc34 --- /dev/null +++ b/src/panels/config/automation/trace/ha-automation-trace-path-details.ts @@ -0,0 +1,258 @@ +import { safeDump } from "js-yaml"; +import { + css, + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import { + ActionTrace, + AutomationTraceExtended, + ChooseActionTrace, + getDataFromPath, +} from "../../../../data/trace"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-code-editor"; +import type { + NodeInfo, + TreeNode, +} from "../../../../components/trace/hat-graph"; +import { HomeAssistant } from "../../../../types"; +import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time"; +import { LogbookEntry } from "../../../../data/logbook"; +import { traceTabStyles } from "./styles"; +import { classMap } from "lit-html/directives/class-map"; + +@customElement("ha-automation-trace-path-details") +export class HaAutomationTracePathDetails extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() private selected!: NodeInfo; + + @property() public trace!: AutomationTraceExtended; + + @property() public logbookEntries!: LogbookEntry[]; + + @property() public trackedNodes!: Record; + + @internalProperty() private _view: + | "config" + | "changed_variables" + | "logbook" = "config"; + + protected render(): TemplateResult { + return html` +
+ ${this._renderSelectedTraceInfo()} +
+ +
+ ${[ + ["config", "Step Config"], + ["changed_variables", "Changed Variables"], + ["logbook", "Related logbook entries"], + ].map( + ([view, label]) => html` +
+ ${label} +
+ ` + )} +
+ ${this._view === "config" + ? this._renderSelectedConfig() + : this._view === "changed_variables" + ? this._renderChangedVars() + : this._renderLogbook()} + `; + } + + private _getPaths() { + return this.selected.path.split("/")[0] === "condition" + ? this.trace!.condition_trace + : this.trace!.action_trace; + } + + private _renderSelectedTraceInfo() { + const paths = this._getPaths(); + + if (!this.selected?.path) { + return "Select a node on the left for more information."; + } + + // HACK: default choice node is not part of paths. We filter them out here by checking parent. + const pathParts = this.selected.path.split("/"); + if (pathParts[pathParts.length - 1] === "default") { + const parentTraceInfo = paths[ + pathParts.slice(0, pathParts.length - 1).join("/") + ] as ChooseActionTrace[]; + + if (parentTraceInfo && parentTraceInfo[0]?.result?.choice === "default") { + return "The default node was executed because no choices matched."; + } + } + + if (!(this.selected.path in paths)) { + return "This node was not executed and so no further trace information is available."; + } + + const data: ActionTrace[] = paths[this.selected.path]; + + return data.map((trace, idx) => { + const { + path, + timestamp, + result, + changed_variables, + ...rest + } = trace as any; + + return html` + ${data.length === 1 ? "" : html`

Iteration ${idx + 1}

`} + Executed: + ${formatDateTimeWithSeconds(new Date(timestamp), this.hass.locale)}
+ ${result + ? html`Result: +
${safeDump(result)}
` + : ""} + ${Object.keys(rest).length === 0 + ? "" + : html`
${safeDump(rest)}
`} + `; + }); + } + + private _renderSelectedConfig() { + if (!this.selected?.path) { + return ""; + } + const config = getDataFromPath(this.trace!.config, this.selected.path); + return config + ? html`` + : "Unable to find config"; + } + + private _renderChangedVars() { + const paths = this._getPaths(); + const data: ActionTrace[] = paths[this.selected.path]; + + return html` +
+

+ The following variables have changed while the step ran. If this is + the first condition or action, this will include the trigger + variables. +

+ ${data.map( + (trace, idx) => html` + ${idx > 0 ? html`

Iteration ${idx + 1}

` : ""} + ${Object.keys(trace.changed_variables || {}).length === 0 + ? "No variables changed" + : html`
+${safeDump(trace.changed_variables).trimRight()}
`} + ` + )} +
+ `; + } + + private _renderLogbook() { + const paths = { + ...this.trace.condition_trace, + ...this.trace.action_trace, + }; + + const startTrace = paths[this.selected.path]; + + const trackedPaths = Object.keys(this.trackedNodes); + + const index = trackedPaths.indexOf(this.selected.path); + + if (index === -1) { + return html`
Node not tracked.
`; + } + + let entries: LogbookEntry[]; + + if (index === trackedPaths.length - 1) { + // it's the last entry. Find all logbook entries after start. + const startTime = new Date(startTrace[0].timestamp); + const idx = this.logbookEntries.findIndex( + (entry) => new Date(entry.when) >= startTime + ); + if (idx === -1) { + entries = []; + } else { + entries = this.logbookEntries.slice(idx); + } + } else { + const nextTrace = paths[trackedPaths[index + 1]]; + + const startTime = new Date(startTrace[0].timestamp); + const endTime = new Date(nextTrace[0].timestamp); + + entries = []; + + for (const entry of this.logbookEntries || []) { + const entryDate = new Date(entry.when); + if (entryDate >= startTime) { + if (entryDate < endTime) { + entries.push(entry); + } else { + // All following entries are no longer valid. + break; + } + } + } + } + + return html`
+ ${entries.map( + (entry) => + html`${entry.name} (${entry.entity_id}) + ${entry.message || `turned ${entry.state}`}
` + )} +
`; + } + + private _showTab(ev) { + this._view = ev.target.view; + } + + static get styles(): CSSResult[] { + return [ + traceTabStyles, + css` + .padded-box { + margin: 16px; + } + + .trace-info { + min-height: 250px; + } + + pre { + margin: 0; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-trace-path-details": HaAutomationTracePathDetails; + } +} diff --git a/src/panels/config/automation/trace/ha-automation-trace-timeline.ts b/src/panels/config/automation/trace/ha-automation-trace-timeline.ts new file mode 100644 index 0000000000..fd8f77aa97 --- /dev/null +++ b/src/panels/config/automation/trace/ha-automation-trace-timeline.ts @@ -0,0 +1,61 @@ +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + TemplateResult, +} from "lit-element"; +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"; + +@customElement("ha-automation-trace-timeline") +export class HaAutomationTraceTimeline extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public trace!: AutomationTraceExtended; + + @property() public logbookEntries!: LogbookEntry[]; + + @property() public selected!: NodeInfo; + + protected render(): TemplateResult { + return html` + + + `; + } + + private _timelinePathPicked(ev) { + fireEvent(this, "value-changed", ev.detail); + } + + static get styles(): CSSResult[] { + return [ + css` + :host { + display: block; + padding: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-trace-timeline": HaAutomationTraceTimeline; + } +} diff --git a/src/panels/config/automation/trace/ha-automation-trace.ts b/src/panels/config/automation/trace/ha-automation-trace.ts index b54a773009..e6b3431f02 100644 --- a/src/panels/config/automation/trace/ha-automation-trace.ts +++ b/src/panels/config/automation/trace/ha-automation-trace.ts @@ -1,4 +1,3 @@ -import { safeDump } from "js-yaml"; import { css, CSSResult, @@ -13,12 +12,12 @@ import { AutomationEntity } from "../../../../data/automation"; import { AutomationTrace, AutomationTraceExtended, - getDataFromPath, loadTrace, loadTraces, } from "../../../../data/trace"; -import "../../../../components/ha-card"; -import "../../../../components/trace/hat-trace"; +import "../../../../components/ha-icon-button"; +import "../../../../components/trace/hat-script-graph"; +import type { NodeInfo } from "../../../../components/trace/hat-graph"; import { haStyle } from "../../../../resources/styles"; import { HomeAssistant, Route } from "../../../../types"; import { configSections } from "../../ha-panel-config"; @@ -29,6 +28,11 @@ import { import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time"; import { repeat } from "lit-html/directives/repeat"; import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box"; +import "./ha-automation-trace-path-details"; +import "./ha-automation-trace-timeline"; +import "./ha-automation-trace-config"; +import { classMap } from "lit-html/directives/class-map"; +import { traceTabStyles } from "./styles"; @customElement("ha-automation-trace") export class HaAutomationTrace extends LitElement { @@ -50,17 +54,24 @@ export class HaAutomationTrace extends LitElement { @internalProperty() private _runId?: string; - @internalProperty() private _path?: string; + @internalProperty() private _selected?: NodeInfo; @internalProperty() private _trace?: AutomationTraceExtended; @internalProperty() private _logbookEntries?: LogbookEntry[]; + @internalProperty() private _view: "details" | "config" | "timeline" = + "details"; + protected render(): TemplateResult { const stateObj = this._entityId ? this.hass.states[this._entityId] : undefined; + const trackedNodes = this.shadowRoot!.querySelector( + "hat-script-graph" + )?.getTrackedNodes(); + return html` this._backTapped()} .tabs=${configSections.automation} > - -
- ${this._traces && this._traces.length > 0 - ? html` +
+
+ ${stateObj?.attributes.friendly_name || this._entityId} +
+ ${this._traces && this._traces.length > 0 + ? html` +
+ - ` - : ""} - - + +
+ ` + : ""} +
+ this._loadTraces()} + > +
-
- ${this._traces === undefined - ? "Loading…" - : this._traces.length === 0 - ? "No traces found" - : this._trace === undefined - ? "Loading…" - : html` - - `} -
- - ${!this._path || !this._trace +
+ + ${this._traces === undefined + ? "Loading…" + : this._traces.length === 0 + ? "No traces found" + : this._trace === undefined ? "" : html` -
- -
-${safeDump(getDataFromPath(this._trace.config, this._path))}
-
- -
-${safeDump(
-                      (this._path.split("/")[0] === "condition"
-                        ? this._trace.condition_trace
-                        : this._trace.action_trace)[this._path]
-                    )}
-
+
+
+ +
+ +
+
+ ${[ + ["details", "Step Details"], + ["timeline", "Trace Timeline"], + ["config", "Automation Config"], + ].map( + ([view, label]) => html` +
+ ${label} +
+ ` + )} +
+ ${this._selected === undefined || + this._logbookEntries === undefined || + trackedNodes === undefined + ? "" + : this._view === "details" + ? html` + + ` + : this._view === "config" + ? html` + + ` + : html` + + `} +
`} @@ -185,13 +247,25 @@ ${safeDump( } } - private _pickTrace(ev) { - this._runId = ev.target.value; - this._path = undefined; + private _pickOlderTrace() { + const curIndex = this._traces!.findIndex((tr) => tr.run_id === this._runId); + this._runId = this._traces![curIndex + 1].run_id; + this._selected = undefined; } - private _pickPath(ev) { - this._path = ev.detail.value; + private _pickNewerTrace() { + const curIndex = this._traces!.findIndex((tr) => tr.run_id === this._runId); + this._runId = this._traces![curIndex - 1].run_id; + this._selected = undefined; + } + + private _pickTrace(ev) { + this._runId = ev.target.value; + this._selected = undefined; + } + + private _pickNode(ev) { + this._selected = ev.detail; } private async _loadTraces(runId?: string) { @@ -209,7 +283,7 @@ ${safeDump( !this._traces.some((trace) => trace.run_id === this._runId) ) { this._runId = undefined; - this._path = undefined; + this._selected = undefined; // If we came here from a trace passed into the url, clear it. if (runId) { @@ -271,30 +345,55 @@ ${safeDump( aEl.click(); } + private _showTab(ev) { + this._view = (ev.target as any).view; + } + + private _timelinePathPicked(ev) { + const path = ev.detail.value; + const nodes = this.shadowRoot!.querySelector( + "hat-script-graph" + )!.getTrackedNodes(); + this._selected = nodes[path].nodeInfo; + } + static get styles(): CSSResult[] { return [ haStyle, + traceTabStyles, css` - ha-card { - max-width: 800px; - margin: 24px auto; - } - - .actions { - position: absolute; - top: 8px; - right: 8px; - } - - .details { + .toolbar { display: flex; - margin: 0 16px; + align-items: center; + justify-content: space-between; + font-size: 20px; + height: var(--header-height); + padding: 0 16px; + background-color: var(--primary-background-color); + font-weight: 400; + color: var(--app-header-text-color, white); + border-bottom: var(--app-header-border-bottom, none); + box-sizing: border-box; } - .details > * { - flex: 1 1 0px; + + .toolbar > * { + display: flex; + align-items: center; } - .details > *:first-child { - margin-right: 16px; + + .main { + height: calc(100% - 56px); + display: flex; + background-color: var(--card-background-color); + } + + .graph { + border-right: 1px solid var(--divider-color); + } + + .info { + flex: 1; + background-color: var(--card-background-color); } `, ]; diff --git a/src/panels/config/automation/trace/styles.ts b/src/panels/config/automation/trace/styles.ts new file mode 100644 index 0000000000..87d1375939 --- /dev/null +++ b/src/panels/config/automation/trace/styles.ts @@ -0,0 +1,28 @@ +import { css } from "lit-element"; + +export const traceTabStyles = css` + .tabs { + background-color: var(--primary-background-color); + border-top: 1px solid var(--divider-color); + border-bottom: 1px solid var(--divider-color); + display: flex; + padding-left: 4px; + } + + .tabs.top { + border-top: none; + } + + .tabs > * { + padding: 2px 16px; + cursor: pointer; + position: relative; + bottom: -1px; + border-bottom: 2px solid transparent; + user-select: none; + } + + .tabs > *.active { + border-bottom-color: var(--accent-color); + } +`; diff --git a/src/translations/en.json b/src/translations/en.json index fd2fb79fba..d411fffc92 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1211,6 +1211,7 @@ }, "editor": { "enable_disable": "Enable/Disable automation", + "show_trace": "Show trace", "introduction": "Use automations to bring your home to life.", "default_name": "New Automation", "load_error_not_editable": "Only automations in automations.yaml are editable.",