mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-27 19:26:36 +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,
|
property,
|
||||||
} from "lit-element";
|
} from "lit-element";
|
||||||
import "../../../src/components/ha-card";
|
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 { provideHass } from "../../../src/fake_data/provide_hass";
|
||||||
import { HomeAssistant } from "../../../src/types";
|
import { HomeAssistant } from "../../../src/types";
|
||||||
import { DemoTrace } from "../data/traces/types";
|
import { DemoTrace } from "../data/traces/types";
|
||||||
@ -29,11 +29,11 @@ export class DemoAutomationTrace extends LitElement {
|
|||||||
(trace) => html`
|
(trace) => html`
|
||||||
<ha-card .heading=${trace.trace.config.alias}>
|
<ha-card .heading=${trace.trace.config.alias}>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<hat-trace
|
<hat-trace-timeline
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.trace=${trace.trace}
|
.trace=${trace.trace}
|
||||||
.logbookEntries=${trace.logbookEntries}
|
.logbookEntries=${trace.logbookEntries}
|
||||||
></hat-trace>
|
></hat-trace-timeline>
|
||||||
</div>
|
</div>
|
||||||
</ha-card>
|
</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,
|
CSSResult,
|
||||||
customElement,
|
customElement,
|
||||||
html,
|
html,
|
||||||
internalProperty,
|
|
||||||
LitElement,
|
LitElement,
|
||||||
property,
|
property,
|
||||||
PropertyValues,
|
PropertyValues,
|
||||||
@ -180,7 +179,8 @@ class LogbookRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _renderLogbookEntryHelper(entry: LogbookEntry) {
|
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 {
|
export class HaAutomationTracer extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
@ -354,7 +354,9 @@ export class HaAutomationTracer extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: false }) public logbookEntries?: LogbookEntry[];
|
@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 {
|
protected render(): TemplateResult {
|
||||||
if (!this.trace) {
|
if (!this.trace) {
|
||||||
@ -454,30 +456,30 @@ export class HaAutomationTracer extends LitElement {
|
|||||||
super.updated(props);
|
super.updated(props);
|
||||||
|
|
||||||
// Pick first path when we load a new trace.
|
// 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>(
|
const element = this.shadowRoot!.querySelector<HaTimeline>(
|
||||||
"ha-timeline[data-path]"
|
"ha-timeline[data-path]"
|
||||||
);
|
);
|
||||||
if (element) {
|
if (element) {
|
||||||
fireEvent(this, "value-changed", { value: element.dataset.path });
|
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>(
|
this.shadowRoot!.querySelectorAll<HaTimeline>(
|
||||||
"ha-timeline[data-path]"
|
"ha-timeline[data-path]"
|
||||||
).forEach((el) => {
|
).forEach((el) => {
|
||||||
el.style.setProperty(
|
el.style.setProperty(
|
||||||
"--timeline-ball-color",
|
"--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;
|
return;
|
||||||
}
|
}
|
||||||
el.dataset.upgraded = "1";
|
el.dataset.upgraded = "1";
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
this._selectedPath = el.dataset.path;
|
this.selectedPath = el.dataset.path;
|
||||||
fireEvent(this, "value-changed", { value: el.dataset.path });
|
fireEvent(this, "value-changed", { value: el.dataset.path });
|
||||||
});
|
});
|
||||||
el.addEventListener("mouseover", () => {
|
el.addEventListener("mouseover", () => {
|
||||||
@ -506,6 +508,6 @@ export class HaAutomationTracer extends LitElement {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
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
|
<ha-icon-button
|
||||||
icon="hass:hammer"
|
icon="hass:graph-outline"
|
||||||
.disabled=${!automation.attributes.id}
|
.disabled=${!automation.attributes.id}
|
||||||
title="${this.hass.localize(
|
title="${this.hass.localize(
|
||||||
"ui.panel.config.automation.picker.dev_automation"
|
"ui.panel.config.automation.picker.dev_automation"
|
||||||
|
@ -139,6 +139,14 @@ export class HaManualAutomationEditor extends LitElement {
|
|||||||
"ui.panel.config.automation.editor.enable_disable"
|
"ui.panel.config.automation.editor.enable_disable"
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<mwc-button
|
||||||
@click=${this._runActions}
|
@click=${this._runActions}
|
||||||
.stateObj=${this.stateObj}
|
.stateObj=${this.stateObj}
|
||||||
@ -146,6 +154,7 @@ export class HaManualAutomationEditor extends LitElement {
|
|||||||
${this.hass.localize("ui.card.automation.trigger")}
|
${this.hass.localize("ui.card.automation.trigger")}
|
||||||
</mwc-button>
|
</mwc-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`
|
`
|
||||||
: ""}
|
: ""}
|
||||||
</ha-card>
|
</ha-card>
|
||||||
|
@ -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 {
|
import {
|
||||||
css,
|
css,
|
||||||
CSSResult,
|
CSSResult,
|
||||||
@ -13,12 +12,12 @@ import { AutomationEntity } from "../../../../data/automation";
|
|||||||
import {
|
import {
|
||||||
AutomationTrace,
|
AutomationTrace,
|
||||||
AutomationTraceExtended,
|
AutomationTraceExtended,
|
||||||
getDataFromPath,
|
|
||||||
loadTrace,
|
loadTrace,
|
||||||
loadTraces,
|
loadTraces,
|
||||||
} from "../../../../data/trace";
|
} from "../../../../data/trace";
|
||||||
import "../../../../components/ha-card";
|
import "../../../../components/ha-icon-button";
|
||||||
import "../../../../components/trace/hat-trace";
|
import "../../../../components/trace/hat-script-graph";
|
||||||
|
import type { NodeInfo } from "../../../../components/trace/hat-graph";
|
||||||
import { haStyle } from "../../../../resources/styles";
|
import { haStyle } from "../../../../resources/styles";
|
||||||
import { HomeAssistant, Route } from "../../../../types";
|
import { HomeAssistant, Route } from "../../../../types";
|
||||||
import { configSections } from "../../ha-panel-config";
|
import { configSections } from "../../ha-panel-config";
|
||||||
@ -29,6 +28,11 @@ import {
|
|||||||
import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time";
|
import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time";
|
||||||
import { repeat } from "lit-html/directives/repeat";
|
import { repeat } from "lit-html/directives/repeat";
|
||||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
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")
|
@customElement("ha-automation-trace")
|
||||||
export class HaAutomationTrace extends LitElement {
|
export class HaAutomationTrace extends LitElement {
|
||||||
@ -50,17 +54,24 @@ export class HaAutomationTrace extends LitElement {
|
|||||||
|
|
||||||
@internalProperty() private _runId?: string;
|
@internalProperty() private _runId?: string;
|
||||||
|
|
||||||
@internalProperty() private _path?: string;
|
@internalProperty() private _selected?: NodeInfo;
|
||||||
|
|
||||||
@internalProperty() private _trace?: AutomationTraceExtended;
|
@internalProperty() private _trace?: AutomationTraceExtended;
|
||||||
|
|
||||||
@internalProperty() private _logbookEntries?: LogbookEntry[];
|
@internalProperty() private _logbookEntries?: LogbookEntry[];
|
||||||
|
|
||||||
|
@internalProperty() private _view: "details" | "config" | "timeline" =
|
||||||
|
"details";
|
||||||
|
|
||||||
protected render(): TemplateResult {
|
protected render(): TemplateResult {
|
||||||
const stateObj = this._entityId
|
const stateObj = this._entityId
|
||||||
? this.hass.states[this._entityId]
|
? this.hass.states[this._entityId]
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const trackedNodes = this.shadowRoot!.querySelector(
|
||||||
|
"hat-script-graph"
|
||||||
|
)?.getTrackedNodes();
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<hass-tabs-subpage
|
<hass-tabs-subpage
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@ -69,14 +80,20 @@ export class HaAutomationTrace extends LitElement {
|
|||||||
.backCallback=${() => this._backTapped()}
|
.backCallback=${() => this._backTapped()}
|
||||||
.tabs=${configSections.automation}
|
.tabs=${configSections.automation}
|
||||||
>
|
>
|
||||||
<ha-card
|
<div class="toolbar">
|
||||||
.header=${`Trace for ${
|
<div>
|
||||||
stateObj?.attributes.friendly_name || this._entityId
|
${stateObj?.attributes.friendly_name || this._entityId}
|
||||||
}`}
|
</div>
|
||||||
>
|
|
||||||
<div class="actions">
|
|
||||||
${this._traces && this._traces.length > 0
|
${this._traces && this._traces.length > 0
|
||||||
? html`
|
? 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}>
|
<select .value=${this._runId} @change=${this._pickTrace}>
|
||||||
${repeat(
|
${repeat(
|
||||||
this._traces,
|
this._traces,
|
||||||
@ -90,50 +107,95 @@ export class HaAutomationTrace extends LitElement {
|
|||||||
>`
|
>`
|
||||||
)}
|
)}
|
||||||
</select>
|
</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}>
|
<div>
|
||||||
Refresh
|
<ha-icon-button
|
||||||
</button>
|
label="Refresh"
|
||||||
<button @click=${this._downloadTrace}>
|
icon="hass:refresh"
|
||||||
Download
|
@click=${() => this._loadTraces()}
|
||||||
</button>
|
></ha-icon-button>
|
||||||
|
<ha-icon-button
|
||||||
|
.disabled=${!this._runId}
|
||||||
|
label="Download Trace"
|
||||||
|
icon="hass:download"
|
||||||
|
@click=${this._downloadTrace}
|
||||||
|
></ha-icon-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
</div>
|
||||||
|
|
||||||
${this._traces === undefined
|
${this._traces === undefined
|
||||||
? "Loading…"
|
? "Loading…"
|
||||||
: this._traces.length === 0
|
: this._traces.length === 0
|
||||||
? "No traces found"
|
? "No traces found"
|
||||||
: this._trace === undefined
|
: this._trace === undefined
|
||||||
? "Loading…"
|
? ""
|
||||||
: html`
|
: 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}
|
.hass=${this.hass}
|
||||||
.trace=${this._trace}
|
.trace=${this._trace}
|
||||||
.logbookEntries=${this._logbookEntries}
|
.logbookEntries=${this._logbookEntries}
|
||||||
@value-changed=${this._pickPath}
|
.selected=${this._selected}
|
||||||
></hat-trace>
|
@value-changed=${this._timelinePathPicked}
|
||||||
|
></ha-automation-trace-timeline>
|
||||||
`}
|
`}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`}
|
`}
|
||||||
</hass-tabs-subpage>
|
</hass-tabs-subpage>
|
||||||
@ -185,13 +247,25 @@ ${safeDump(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _pickTrace(ev) {
|
private _pickOlderTrace() {
|
||||||
this._runId = ev.target.value;
|
const curIndex = this._traces!.findIndex((tr) => tr.run_id === this._runId);
|
||||||
this._path = undefined;
|
this._runId = this._traces![curIndex + 1].run_id;
|
||||||
|
this._selected = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _pickPath(ev) {
|
private _pickNewerTrace() {
|
||||||
this._path = ev.detail.value;
|
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) {
|
private async _loadTraces(runId?: string) {
|
||||||
@ -209,7 +283,7 @@ ${safeDump(
|
|||||||
!this._traces.some((trace) => trace.run_id === this._runId)
|
!this._traces.some((trace) => trace.run_id === this._runId)
|
||||||
) {
|
) {
|
||||||
this._runId = undefined;
|
this._runId = undefined;
|
||||||
this._path = undefined;
|
this._selected = undefined;
|
||||||
|
|
||||||
// If we came here from a trace passed into the url, clear it.
|
// If we came here from a trace passed into the url, clear it.
|
||||||
if (runId) {
|
if (runId) {
|
||||||
@ -271,30 +345,55 @@ ${safeDump(
|
|||||||
aEl.click();
|
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[] {
|
static get styles(): CSSResult[] {
|
||||||
return [
|
return [
|
||||||
haStyle,
|
haStyle,
|
||||||
|
traceTabStyles,
|
||||||
css`
|
css`
|
||||||
ha-card {
|
.toolbar {
|
||||||
max-width: 800px;
|
|
||||||
margin: 24px auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details {
|
|
||||||
display: flex;
|
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": {
|
"editor": {
|
||||||
"enable_disable": "Enable/Disable automation",
|
"enable_disable": "Enable/Disable automation",
|
||||||
|
"show_trace": "Show trace",
|
||||||
"introduction": "Use automations to bring your home to life.",
|
"introduction": "Use automations to bring your home to life.",
|
||||||
"default_name": "New Automation",
|
"default_name": "New Automation",
|
||||||
"load_error_not_editable": "Only automations in automations.yaml are editable.",
|
"load_error_not_editable": "Only automations in automations.yaml are editable.",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user