Refactor trace rendering (#8693)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Paulus Schoutsen 2021-03-23 09:06:59 -07:00 committed by GitHub
parent 4d48fc3d85
commit 5156c67226
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 326 additions and 139 deletions

View File

@ -9,8 +9,8 @@ import {
} from "lit-element";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
ActionTrace,
AutomationTraceExtended,
ChooseActionTrace,
getDataFromPath,
} from "../../data/automation_debug";
import { HomeAssistant } from "../../types";
@ -24,16 +24,300 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import { LogbookEntry } from "../../data/logbook";
import { describeAction } from "../../data/script";
import { getActionType } from "../../data/script";
import relativeTime from "../../common/datetime/relative_time";
const LOGBOOK_ENTRIES_BEFORE_FOLD = 2;
const pathToName = (path: string) => path.split("/").join(" ");
/* eslint max-classes-per-file: "off" */
// Report time entry when more than this time has passed
const SIGNIFICANT_TIME_CHANGE = 5000; // 5 seconds
const isSignificantTimeChange = (a: Date, b: Date) =>
Math.abs(b.getTime() - a.getTime()) > SIGNIFICANT_TIME_CHANGE;
class RenderedTimeTracker {
private lastReportedTime: Date;
constructor(
private hass: HomeAssistant,
private entries: TemplateResult[],
trace: AutomationTraceExtended
) {
this.lastReportedTime = new Date(trace.timestamp.start);
}
setLastReportedTime(date: Date) {
this.lastReportedTime = date;
}
renderTime(from: Date, to: Date): void {
this.entries.push(html`
<ha-timeline label>
${relativeTime(from, this.hass.localize, {
compareTime: to,
includeTense: false,
})}
later
</ha-timeline>
`);
this.lastReportedTime = to;
}
maybeRenderTime(timestamp: Date): boolean {
if (!isSignificantTimeChange(timestamp, this.lastReportedTime)) {
this.lastReportedTime = timestamp;
return false;
}
this.renderTime(this.lastReportedTime, timestamp);
return true;
}
}
class LogbookRenderer {
private curIndex: number;
private pendingItems: Array<[Date, LogbookEntry]> = [];
constructor(
private entries: TemplateResult[],
private timeTracker: RenderedTimeTracker,
private logbookEntries: LogbookEntry[]
) {
// Skip the "automation got triggered item"
this.curIndex =
logbookEntries.length > 0 && logbookEntries[0].domain === "automation"
? 1
: 0;
}
get curItem() {
return this.logbookEntries[this.curIndex];
}
get hasNext() {
return this.curIndex !== this.logbookEntries.length;
}
maybeRenderItem() {
const logbookEntry = this.curItem;
this.curIndex++;
const entryDate = new Date(logbookEntry.when);
if (this.pendingItems.length === 0) {
this.pendingItems.push([entryDate, logbookEntry]);
return;
}
const previousEntryDate = this.pendingItems[
this.pendingItems.length - 1
][0];
// If logbook entry is too long after the last one,
// add a time passed label
if (isSignificantTimeChange(previousEntryDate, entryDate)) {
this._renderLogbookEntries();
this.timeTracker.renderTime(previousEntryDate, entryDate);
}
this.pendingItems.push([entryDate, logbookEntry]);
}
flush() {
if (this.pendingItems.length > 0) {
this._renderLogbookEntries();
}
}
private _renderLogbookEntries() {
this.timeTracker.maybeRenderTime(this.pendingItems[0][0]);
const parts: TemplateResult[] = [];
let i;
for (
i = 0;
i < Math.min(this.pendingItems.length, LOGBOOK_ENTRIES_BEFORE_FOLD);
i++
) {
parts.push(this._renderLogbookEntryHelper(this.pendingItems[i][1]));
}
let moreItems: TemplateResult[] | undefined;
// If we didn't render all items, push rest into `moreItems`
if (i < this.pendingItems.length) {
moreItems = [];
for (; i < this.pendingItems.length; i++) {
moreItems.push(this._renderLogbookEntryHelper(this.pendingItems[i][1]));
}
}
this.entries.push(html`
<ha-timeline .icon=${mdiCircleOutline} .moreItems=${moreItems}>
${parts}
</ha-timeline>
`);
// Clear rendered items.
this.timeTracker.setLastReportedTime(
this.pendingItems[this.pendingItems.length - 1][0]
);
this.pendingItems = [];
}
private _renderLogbookEntryHelper(entry: LogbookEntry) {
return html`${entry.name} (${entry.entity_id}) turned ${entry.state}<br />`;
}
}
class ActionRenderer {
private curIndex = 0;
private keys: string[];
constructor(
private entries: TemplateResult[],
private trace: AutomationTraceExtended,
private timeTracker: RenderedTimeTracker
) {
this.keys = Object.keys(trace.action_trace);
}
get curItem() {
return this._getItem(this.curIndex);
}
get hasNext() {
return this.curIndex !== this.keys.length;
}
renderItem() {
this.curIndex = this._renderItem(this.curIndex);
}
private _getItem(index: number) {
return this.trace.action_trace[this.keys[index]];
}
private _renderItem(
index: number,
actionType?: ReturnType<typeof getActionType>
): number {
const value = this._getItem(index);
const timestamp = new Date(value[0].timestamp);
this.timeTracker.maybeRenderTime(timestamp);
const path = value[0].path;
let data;
try {
data = getDataFromPath(this.trace.config, path);
} catch (err) {
this.entries.push(
html`Unable to extract path ${path}. Download trace and report as bug`
);
return index + 1;
}
const isTopLevel = path.split("/").length === 2;
if (!isTopLevel && !actionType) {
this._renderEntry(path.replace(/\//g, " "));
return index + 1;
}
if (!actionType) {
actionType = getActionType(data);
}
if (actionType === "choose") {
return this._handleChoose(index);
}
this._renderEntry(data.alias || actionType);
return index + 1;
}
private _handleChoose(index: number): number {
// startLevel: choose root config
// +1: 'default
// +2: executed sequence
// +1: 'choose'
// +2: current choice
// +3: 'conditions'
// +4: evaluated condition
// +3: 'sequence'
// +4: executed sequence
const startLevel = this.keys[index].split("/").length - 1;
const chooseTrace = this._getItem(index)[0] as ChooseActionTrace;
const defaultExecuted = chooseTrace.result.choice === "default";
if (defaultExecuted) {
this._renderEntry(`Choose: Default action executed`);
} else {
this._renderEntry(`Choose: Choice ${chooseTrace.result.choice} executed`);
}
let i;
// Skip over conditions
for (i = index + 1; i < this.keys.length; i++) {
const parts = this.keys[i].split("/");
// We're done if no more sequence in current level
if (parts.length <= startLevel) {
return i;
}
// We're going to skip all conditions
if (parts[startLevel + 3] === "sequence") {
break;
}
}
// Render choice
for (; i < this.keys.length; i++) {
const path = this.keys[i];
const parts = path.split("/");
// We're done if no more sequence in current level
if (parts.length <= startLevel) {
return i;
}
// We know it's an action sequence, so force the type like that
// for rendering.
this._renderItem(i, getActionType(this._getDataFromPath(path)));
}
return i;
}
private _renderEntry(description: string) {
this.entries.push(html`
<ha-timeline .icon=${mdiRecordCircleOutline}>
${description}
</ha-timeline>
`);
}
private _getDataFromPath(path: string) {
return getDataFromPath(this.trace.config, path);
}
}
@customElement("hat-trace")
export class HaAutomationTracer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -77,84 +361,44 @@ export class HaAutomationTracer extends LitElement {
}
if (this.trace.action_trace && this.logbookEntries) {
const actionTraces = Object.values(this.trace.action_trace);
let logbookIndex = 0;
let actionTraceIndex = 0;
let lastReportedTime = new Date(this.trace.timestamp.start);
const maybeRenderTime = (nextItemTimestamp: Date) => {
if (
nextItemTimestamp.getTime() - lastReportedTime.getTime() <
SIGNIFICANT_TIME_CHANGE
) {
return;
}
entries.push(html`
<ha-timeline label>
${relativeTime(lastReportedTime, this.hass.localize, {
compareTime: nextItemTimestamp,
includeTense: false,
})}
later
</ha-timeline>
`);
lastReportedTime = nextItemTimestamp;
};
let groupedLogbookItems: LogbookEntry[] = [];
while (
logbookIndex < this.logbookEntries.length &&
actionTraceIndex < actionTraces.length
) {
// Find next item.
// Skip the "automation got triggered item"
if (
logbookIndex === 0 &&
this.logbookEntries[0].domain === "automation"
) {
logbookIndex++;
continue;
}
const timeTracker = new RenderedTimeTracker(
this.hass,
entries,
this.trace
);
const logbookRenderer = new LogbookRenderer(
entries,
timeTracker,
this.logbookEntries
);
const actionRenderer = new ActionRenderer(
entries,
this.trace,
timeTracker
);
while (logbookRenderer.hasNext && actionRenderer.hasNext) {
// Find next item time-wise.
const logbookItem = this.logbookEntries[logbookIndex];
const actionTrace = actionTraces[actionTraceIndex];
const logbookItem = logbookRenderer.curItem;
const actionTrace = actionRenderer.curItem;
const actionTimestamp = new Date(actionTrace[0].timestamp);
if (new Date(logbookItem.when) > actionTimestamp) {
actionTraceIndex++;
if (groupedLogbookItems.length > 0) {
maybeRenderTime(new Date(groupedLogbookItems[0].when));
entries.push(this._renderLogbookEntries(groupedLogbookItems));
groupedLogbookItems = [];
}
maybeRenderTime(actionTimestamp);
entries.push(this._renderActionTrace(actionTrace));
logbookRenderer.flush();
actionRenderer.renderItem();
} else {
logbookIndex++;
groupedLogbookItems.push(logbookItem);
logbookRenderer.maybeRenderItem();
}
}
while (logbookIndex < this.logbookEntries.length) {
groupedLogbookItems.push(this.logbookEntries[logbookIndex]);
logbookIndex++;
while (logbookRenderer.hasNext) {
logbookRenderer.maybeRenderItem();
}
if (groupedLogbookItems.length > 0) {
maybeRenderTime(new Date(groupedLogbookItems[0].when));
entries.push(this._renderLogbookEntries(groupedLogbookItems));
}
logbookRenderer.flush();
while (actionTraceIndex < actionTraces.length) {
const trace = actionTraces[actionTraceIndex];
maybeRenderTime(new Date(trace[0].timestamp));
entries.push(this._renderActionTrace(trace));
actionTraceIndex++;
while (actionRenderer.hasNext) {
actionRenderer.renderItem();
}
}
@ -188,62 +432,6 @@ export class HaAutomationTracer extends LitElement {
return html`${entries}`;
}
private _renderLogbookEntryHelper(entry: LogbookEntry) {
return html`${entry.name} (${entry.entity_id}) turned ${entry.state}<br />`;
}
private _renderLogbookEntries(entries: LogbookEntry[]) {
const parts: TemplateResult[] = [];
let i;
for (
i = 0;
i < Math.min(entries.length, LOGBOOK_ENTRIES_BEFORE_FOLD);
i++
) {
parts.push(this._renderLogbookEntryHelper(entries[i]));
}
let moreItems: TemplateResult[] | undefined;
if (i < entries.length) {
moreItems = [];
for (i = 0; i < entries.length; i++) {
moreItems.push(this._renderLogbookEntryHelper(entries[i]));
}
}
return html`
<ha-timeline .icon=${mdiCircleOutline} .moreItems=${moreItems}>
${parts}
</ha-timeline>
`;
}
private _renderActionTrace(value: ActionTrace[]) {
const path = value[0].path;
let data;
try {
data = getDataFromPath(this.trace!.config, path);
} catch (err) {
return html`Unable to extract path ${path}. Download trace and report as
bug`;
}
const description =
// Top-level we know it's an action
path.split("/").length === 2
? data.alias || describeAction(data, this.hass.localize)
: path.replace(/\//g, " ");
return html`
<ha-timeline .icon=${mdiRecordCircleOutline}>
${description}
</ha-timeline>
`;
}
static get styles(): CSSResult[] {
return [
css`

View File

@ -27,7 +27,7 @@ export interface CallServiceActionTrace extends BaseTrace {
}
export interface ChooseActionTrace extends BaseTrace {
result: { choice: number };
result: { choice: number | "default" };
}
export interface ChooseChoiceActionTrace extends BaseTrace {

View File

@ -5,7 +5,6 @@ import {
} from "home-assistant-js-websocket";
import { computeObjectId } from "../common/entity/compute_object_id";
import { navigate } from "../common/navigate";
import { LocalizeFunc } from "../common/translations/localize";
import { HomeAssistant } from "../types";
import { Condition, Trigger } from "./automation";
@ -165,40 +164,40 @@ export const getScriptEditorInitData = () => {
return data;
};
export const describeAction = (action: Action, _localize: LocalizeFunc) => {
export const getActionType = (action: Action) => {
// Check based on config_validation.py#determine_script_action
if ("delay" in action) {
return "Delay";
return "delay";
}
if ("wait_template" in action) {
return "Wait";
return "wait_template";
}
if ("condition" in action) {
return "Check condition";
return "check_condition";
}
if ("event" in action) {
return "Fire event";
return "fire_event";
}
if ("device_id" in action) {
return "Run Device Action";
return "device_action";
}
if ("scene" in action) {
return "Activate a scene";
return "activate_scene";
}
if ("repeat" in action) {
return "Repeat an action multiple times";
return "repeat";
}
if ("choose" in action) {
return "Choose an action";
return "choose";
}
if ("wait_for_trigger" in action) {
return "Wait for a trigger";
return "wait_for_trigger";
}
if ("variables" in action) {
return "Define variables";
return "variables";
}
if ("service" in action) {
return "Call service";
return "service";
}
return "Unknown action";
return "unknown";
};