mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-27 03:06:41 +00:00
Add more trace visualization (#8724)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
parent
f43c420d59
commit
c341a99b83
@ -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>
|
||||
`
|
||||
|
404
src/components/trace/hat-graph.ts
Normal file
404
src/components/trace/hat-graph.ts
Normal 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;
|
||||
}
|
||||
}
|
166
src/components/trace/hat-script-graph.ts
Normal file
166
src/components/trace/hat-script-graph.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
430
src/components/trace/script-to-graph.ts
Normal file
430
src/components/trace/script-to-graph.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -139,12 +139,21 @@ export class HaManualAutomationEditor extends LitElement {
|
||||
"ui.panel.config.automation.editor.enable_disable"
|
||||
)}
|
||||
</div>
|
||||
<mwc-button
|
||||
@click=${this._runActions}
|
||||
.stateObj=${this.stateObj}
|
||||
>
|
||||
${this.hass.localize("ui.card.automation.trigger")}
|
||||
</mwc-button>
|
||||
<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}
|
||||
>
|
||||
${this.hass.localize("ui.card.automation.trigger")}
|
||||
</mwc-button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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">
|
||||
${this._traces && this._traces.length > 0
|
||||
? html`
|
||||
<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>
|
||||
`
|
||||
: ""}
|
||||
<button @click=${this._loadTraces}>
|
||||
Refresh
|
||||
</button>
|
||||
<button @click=${this._downloadTrace}>
|
||||
Download
|
||||
</button>
|
||||
<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>
|
||||
`
|
||||
: ""}
|
||||
<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">
|
||||
${this._traces === undefined
|
||||
? "Loading…"
|
||||
: this._traces.length === 0
|
||||
? "No traces found"
|
||||
: this._trace === undefined
|
||||
? "Loading…"
|
||||
: html`
|
||||
<hat-trace
|
||||
.hass=${this.hass}
|
||||
.trace=${this._trace}
|
||||
.logbookEntries=${this._logbookEntries}
|
||||
@value-changed=${this._pickPath}
|
||||
></hat-trace>
|
||||
`}
|
||||
</div>
|
||||
</ha-card>
|
||||
${!this._path || !this._trace
|
||||
</div>
|
||||
|
||||
${this._traces === undefined
|
||||
? "Loading…"
|
||||
: this._traces.length === 0
|
||||
? "No traces found"
|
||||
: this._trace === undefined
|
||||
? ""
|
||||
: 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 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}
|
||||
.selected=${this._selected}
|
||||
@value-changed=${this._timelinePathPicked}
|
||||
></ha-automation-trace-timeline>
|
||||
`}
|
||||
</div>
|
||||
</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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
28
src/panels/config/automation/trace/styles.ts
Normal file
28
src/panels/config/automation/trace/styles.ts
Normal 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);
|
||||
}
|
||||
`;
|
@ -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.",
|
||||
|
Loading…
x
Reference in New Issue
Block a user