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"; } from "lit-element";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { import {
ActionTrace,
AutomationTraceExtended, AutomationTraceExtended,
ChooseActionTrace,
getDataFromPath, getDataFromPath,
} from "../../data/automation_debug"; } from "../../data/automation_debug";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
@ -24,16 +24,300 @@ import {
mdiStopCircleOutline, mdiStopCircleOutline,
} from "@mdi/js"; } from "@mdi/js";
import { LogbookEntry } from "../../data/logbook"; import { LogbookEntry } from "../../data/logbook";
import { describeAction } from "../../data/script"; import { getActionType } from "../../data/script";
import relativeTime from "../../common/datetime/relative_time"; import relativeTime from "../../common/datetime/relative_time";
const LOGBOOK_ENTRIES_BEFORE_FOLD = 2; const LOGBOOK_ENTRIES_BEFORE_FOLD = 2;
const pathToName = (path: string) => path.split("/").join(" "); const pathToName = (path: string) => path.split("/").join(" ");
/* eslint max-classes-per-file: "off" */
// Report time entry when more than this time has passed // Report time entry when more than this time has passed
const SIGNIFICANT_TIME_CHANGE = 5000; // 5 seconds 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") @customElement("hat-trace")
export class HaAutomationTracer extends LitElement { export class HaAutomationTracer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -77,84 +361,44 @@ export class HaAutomationTracer extends LitElement {
} }
if (this.trace.action_trace && this.logbookEntries) { if (this.trace.action_trace && this.logbookEntries) {
const actionTraces = Object.values(this.trace.action_trace); const timeTracker = new RenderedTimeTracker(
this.hass,
let logbookIndex = 0; entries,
let actionTraceIndex = 0; this.trace
let lastReportedTime = new Date(this.trace.timestamp.start); );
const logbookRenderer = new LogbookRenderer(
const maybeRenderTime = (nextItemTimestamp: Date) => { entries,
if ( timeTracker,
nextItemTimestamp.getTime() - lastReportedTime.getTime() < this.logbookEntries
SIGNIFICANT_TIME_CHANGE );
) { const actionRenderer = new ActionRenderer(
return; entries,
} this.trace,
timeTracker
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;
}
while (logbookRenderer.hasNext && actionRenderer.hasNext) {
// Find next item time-wise. // Find next item time-wise.
const logbookItem = this.logbookEntries[logbookIndex]; const logbookItem = logbookRenderer.curItem;
const actionTrace = actionTraces[actionTraceIndex]; const actionTrace = actionRenderer.curItem;
const actionTimestamp = new Date(actionTrace[0].timestamp); const actionTimestamp = new Date(actionTrace[0].timestamp);
if (new Date(logbookItem.when) > actionTimestamp) { if (new Date(logbookItem.when) > actionTimestamp) {
actionTraceIndex++; logbookRenderer.flush();
if (groupedLogbookItems.length > 0) { actionRenderer.renderItem();
maybeRenderTime(new Date(groupedLogbookItems[0].when));
entries.push(this._renderLogbookEntries(groupedLogbookItems));
groupedLogbookItems = [];
}
maybeRenderTime(actionTimestamp);
entries.push(this._renderActionTrace(actionTrace));
} else { } else {
logbookIndex++; logbookRenderer.maybeRenderItem();
groupedLogbookItems.push(logbookItem);
} }
} }
while (logbookIndex < this.logbookEntries.length) { while (logbookRenderer.hasNext) {
groupedLogbookItems.push(this.logbookEntries[logbookIndex]); logbookRenderer.maybeRenderItem();
logbookIndex++;
} }
if (groupedLogbookItems.length > 0) { logbookRenderer.flush();
maybeRenderTime(new Date(groupedLogbookItems[0].when));
entries.push(this._renderLogbookEntries(groupedLogbookItems));
}
while (actionTraceIndex < actionTraces.length) { while (actionRenderer.hasNext) {
const trace = actionTraces[actionTraceIndex]; actionRenderer.renderItem();
maybeRenderTime(new Date(trace[0].timestamp));
entries.push(this._renderActionTrace(trace));
actionTraceIndex++;
} }
} }
@ -188,62 +432,6 @@ export class HaAutomationTracer extends LitElement {
return html`${entries}`; 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[] { static get styles(): CSSResult[] {
return [ return [
css` css`

View File

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

View File

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