Add more trace visualization (#8724)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Paulus Schoutsen 2021-03-28 11:31:59 -07:00 committed by GitHub
parent f43c420d59
commit c341a99b83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1596 additions and 98 deletions

View File

@ -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`
<ha-card .heading=${trace.trace.config.alias}>
<div class="card-content">
<hat-trace
<hat-trace-timeline
.hass=${this.hass}
.trace=${trace.trace}
.logbookEntries=${trace.logbookEntries}
></hat-trace>
></hat-trace-timeline>
</div>
</ha-card>
`

View File

@ -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> = T | ValueOrArray<T>[];
// Return value is undefined if it's an empty array
const extractFirstValue = <T>(val: ValueOrArray<T>): T | undefined =>
Array.isArray(val) ? extractFirstValue(val[0]) : val;
const extractLastValue = <T>(val: ValueOrArray<T>): 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<ValueOrArray<TreeNode>>;
clickCallback?: () => void;
addCallback?: () => void;
isActive: boolean;
isTracked: boolean | undefined;
isNew?: boolean;
}
export function* bfsIterateTreeNodes(
nodeOrNodes: ValueOrArray<TreeNode>
): IterableIterator<TreeNode> {
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`
<circle
cx="${x}"
cy="${y + this.nodeSize / 2}"
r="${this.nodeSize / 2}"
class="node ${classMap({
active: node.isActive || false,
track: node.isTracked || false,
new: node.isNew || false,
click: !!node.clickCallback,
})}"
@click=${node.clickCallback}
/>
<g style="pointer-events: none" transform="translate(${x - 12} ${
y + this.nodeSize / 2 - 12
})">
${node.icon ? svg`<path d="${node.icon}"/>` : ""}
${
node.number
? svg`<g class="number"><circle r="8" cx="27" cy=0></circle>
<text x="27" y="1" text-anchor="middle" alignment-baseline="middle">${node.number}</text></g>`
: ""
}
</g>
`;
}
private _draw_new_node(x, y, node) {
return svg`
<circle
cx="${x}"
cy="${y + this.nodeSize / 4}"
r="${this.nodeSize / 4}"
class="newnode"
@click=${node.addCallback}
/>
`;
}
private _draw_connector(x1, y1, x2, y2, track) {
return svg`
<line
class=${classMap({ track })}
x1=${x1}
y1=${y1}
x2=${x2}
y2=${y2}
/>
`;
}
private _draw_tree(
tree: ValueOrArray<TreeNode>,
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`
<g class="a" transform="translate(${x} ${
this.nodeSize + this.nodeSeparation
})">
${child.svg}
</g>
`);
}
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<TreeNode>[],
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`
<g class="b" transform="translate(0, ${height})">
${node.svg}
</g>
`);
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`
<svg width=${tree.width + 32} height=${tree.height + 64}>
<g transform="translate(${tree.width / 2 + 16} 16)">
${tree.svg}
</g>
</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;
}
}

View File

@ -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`
<hat-graph .tree=${actionHandler.createGraph()}></hat-graph>
<div class="actions">
<mwc-icon-button
.disabled=${paths.length === 0 || paths[0] === this.selected}
@click=${this.previousTrackedNode}
>
<ha-svg-icon .path=${mdiChevronUp}></ha-svg-icon>
</mwc-icon-button>
<mwc-icon-button
.disabled=${paths.length === 0 ||
paths[paths.length - 1] === this.selected}
@click=${this.nextTrackedNode}
>
<ha-svg-icon .path=${mdiChevronDown}></ha-svg-icon>
</mwc-icon-button>
</div>
`;
}
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<this>) {
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<string, TreeNode> = {};
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;
}
}

View File

@ -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}<br />`;
return html`${entry.name} (${entry.entity_id})
${entry.message || `turned ${entry.state}`}<br />`;
}
}
@ -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<HaTimeline>(
"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<HaTimeline>(
"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;
}
}

View File

@ -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<unknown>, b: Array<unknown>) =>
// 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<Action | NoAction>,
/**
* 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 | NoAction> = 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<TreeNode["children"]> => {
// 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<string, ActionTrace[]> | 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,
},
],
};
}
}

View File

@ -130,7 +130,7 @@ class HaAutomationPicker extends LitElement {
)}
>
<ha-icon-button
icon="hass:hammer"
icon="hass:graph-outline"
.disabled=${!automation.attributes.id}
title="${this.hass.localize(
"ui.panel.config.automation.picker.dev_automation"

View File

@ -139,6 +139,14 @@ export class HaManualAutomationEditor extends LitElement {
"ui.panel.config.automation.editor.enable_disable"
)}
</div>
<div>
<a href="/config/automation/trace/${this.config.id}">
<mwc-button>
${this.hass.localize(
"ui.panel.config.automation.editor.show_trace"
)}
</mwc-button>
</a>
<mwc-button
@click=${this._runActions}
.stateObj=${this.stateObj}
@ -146,6 +154,7 @@ export class HaManualAutomationEditor extends LitElement {
${this.hass.localize("ui.card.automation.trigger")}
</mwc-button>
</div>
</div>
`
: ""}
</ha-card>

View File

@ -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`
<ha-code-editor
.value=${safeDump(this.trace.config).trimRight()}
readOnly
></ha-code-editor>
`;
}
static get styles(): CSSResult[] {
return [css``];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-trace-config": HaAutomationTraceConfig;
}
}

View File

@ -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<string, TreeNode>;
@internalProperty() private _view:
| "config"
| "changed_variables"
| "logbook" = "config";
protected render(): TemplateResult {
return html`
<div class="padded-box trace-info">
${this._renderSelectedTraceInfo()}
</div>
<div class="tabs top">
${[
["config", "Step Config"],
["changed_variables", "Changed Variables"],
["logbook", "Related logbook entries"],
].map(
([view, label]) => html`
<div
.view=${view}
class=${classMap({ active: this._view === view })}
@click=${this._showTab}
>
${label}
</div>
`
)}
</div>
${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`<h3>Iteration ${idx + 1}</h3>`}
Executed:
${formatDateTimeWithSeconds(new Date(timestamp), this.hass.locale)}<br />
${result
? html`Result:
<pre>${safeDump(result)}</pre>`
: ""}
${Object.keys(rest).length === 0
? ""
: html`<pre>${safeDump(rest)}</pre>`}
`;
});
}
private _renderSelectedConfig() {
if (!this.selected?.path) {
return "";
}
const config = getDataFromPath(this.trace!.config, this.selected.path);
return config
? html`<ha-code-editor
.value=${safeDump(config).trimRight()}
readOnly
></ha-code-editor>`
: "Unable to find config";
}
private _renderChangedVars() {
const paths = this._getPaths();
const data: ActionTrace[] = paths[this.selected.path];
return html`
<div class="padded-box">
<p>
The following variables have changed while the step ran. If this is
the first condition or action, this will include the trigger
variables.
</p>
${data.map(
(trace, idx) => html`
${idx > 0 ? html`<p>Iteration ${idx + 1}</p>` : ""}
${Object.keys(trace.changed_variables || {}).length === 0
? "No variables changed"
: html`<pre>
${safeDump(trace.changed_variables).trimRight()}</pre
>`}
`
)}
</div>
`;
}
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`<div class="padded-box">Node not tracked.</div>`;
}
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`<div class="padded-box">
${entries.map(
(entry) =>
html`${entry.name} (${entry.entity_id})
${entry.message || `turned ${entry.state}`}<br />`
)}
</div>`;
}
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;
}
}

View File

@ -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`
<hat-trace-timeline
.hass=${this.hass}
.trace=${this.trace}
.logbookEntries=${this.logbookEntries}
.selectedPath=${this.selected.path}
allowPick
@value-changed=${this._timelinePathPicked}
>
</hat-trace-timeline>
`;
}
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;
}
}

View File

@ -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`
<hass-tabs-subpage
.hass=${this.hass}
@ -69,14 +80,20 @@ export class HaAutomationTrace extends LitElement {
.backCallback=${() => this._backTapped()}
.tabs=${configSections.automation}
>
<ha-card
.header=${`Trace for ${
stateObj?.attributes.friendly_name || this._entityId
}`}
>
<div class="actions">
<div class="toolbar">
<div>
${stateObj?.attributes.friendly_name || this._entityId}
</div>
${this._traces && this._traces.length > 0
? html`
<div>
<ha-icon-button
.disabled=${this._traces[this._traces.length - 1].run_id ===
this._runId}
label="Older trace"
icon="hass:ray-end-arrow"
@click=${this._pickOlderTrace}
></ha-icon-button>
<select .value=${this._runId} @change=${this._pickTrace}>
${repeat(
this._traces,
@ -90,50 +107,95 @@ export class HaAutomationTrace extends LitElement {
>`
)}
</select>
<ha-icon-button
.disabled=${this._traces[0].run_id === this._runId}
label="Newer trace"
icon="hass:ray-start-arrow"
@click=${this._pickNewerTrace}
></ha-icon-button>
</div>
`
: ""}
<button @click=${this._loadTraces}>
Refresh
</button>
<button @click=${this._downloadTrace}>
Download
</button>
<div>
<ha-icon-button
label="Refresh"
icon="hass:refresh"
@click=${() => this._loadTraces()}
></ha-icon-button>
<ha-icon-button
.disabled=${!this._runId}
label="Download Trace"
icon="hass:download"
@click=${this._downloadTrace}
></ha-icon-button>
</div>
<div class="card-content">
</div>
${this._traces === undefined
? "Loading…"
: this._traces.length === 0
? "No traces found"
: this._trace === undefined
? "Loading…"
? ""
: html`
<hat-trace
<div class="main">
<div class="graph">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info">
<div class="tabs top">
${[
["details", "Step Details"],
["timeline", "Trace Timeline"],
["config", "Automation Config"],
].map(
([view, label]) => html`
<div
.view=${view}
class=${classMap({ active: this._view === view })}
@click=${this._showTab}
>
${label}
</div>
`
)}
</div>
${this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? ""
: this._view === "details"
? html`
<ha-automation-trace-path-details
.hass=${this.hass}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
></ha-automation-trace-path-details>
`
: this._view === "config"
? html`
<ha-automation-trace-config
.hass=${this.hass}
.trace=${this._trace}
></ha-automation-trace-config>
`
: html`
<ha-automation-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
@value-changed=${this._pickPath}
></hat-trace>
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-automation-trace-timeline>
`}
</div>
</ha-card>
${!this._path || !this._trace
? ""
: html`
<div class="details">
<ha-card header="Config">
<pre class="config card-content">
${safeDump(getDataFromPath(this._trace.config, this._path))}</pre
>
</ha-card>
<ha-card header="Trace">
<pre class="trace card-content">
${safeDump(
(this._path.split("/")[0] === "condition"
? this._trace.condition_trace
: this._trace.action_trace)[this._path]
)}</pre
>
</ha-card>
</div>
`}
</hass-tabs-subpage>
@ -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);
}
`,
];

View File

@ -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);
}
`;

View File

@ -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.",