Refactor logbook data fetch logic into reusable class (#12701)

This commit is contained in:
Paulus Schoutsen 2022-05-17 08:53:22 -07:00 committed by GitHub
parent dd3a3ec586
commit 90c234ffad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 883 additions and 1004 deletions

View File

@ -13,7 +13,7 @@ export const throttle = <T extends any[]>(
) => { ) => {
let timeout: number | undefined; let timeout: number | undefined;
let previous = 0; let previous = 0;
return (...args: T): void => { const throttledFunc = (...args: T): void => {
const later = () => { const later = () => {
previous = leading === false ? 0 : Date.now(); previous = leading === false ? 0 : Date.now();
timeout = undefined; timeout = undefined;
@ -35,4 +35,10 @@ export const throttle = <T extends any[]>(
timeout = window.setTimeout(later, remaining); timeout = window.setTimeout(later, remaining);
} }
}; };
throttledFunc.cancel = () => {
clearTimeout(timeout);
timeout = undefined;
previous = 0;
};
return throttledFunc;
}; };

View File

@ -20,7 +20,7 @@ interface HassEntityWithCachedName extends HassEntity {
friendly_name: string; friendly_name: string;
} }
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
// eslint-disable-next-line lit/prefer-static-styles // eslint-disable-next-line lit/prefer-static-styles
const rowRenderer: ComboBoxLitRenderer<HassEntityWithCachedName> = (item) => const rowRenderer: ComboBoxLitRenderer<HassEntityWithCachedName> = (item) =>

View File

@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators";
import { LogbookEntry } from "../../data/logbook"; import { LogbookEntry } from "../../data/logbook";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "./hat-logbook-note"; import "./hat-logbook-note";
import "../../panels/logbook/ha-logbook"; import "../../panels/logbook/ha-logbook-renderer";
import { TraceExtended } from "../../data/trace"; import { TraceExtended } from "../../data/trace";
@customElement("ha-trace-logbook") @customElement("ha-trace-logbook")
@ -19,12 +19,12 @@ export class HaTraceLogbook extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return this.logbookEntries.length return this.logbookEntries.length
? html` ? html`
<ha-logbook <ha-logbook-renderer
relative-time relative-time
.hass=${this.hass} .hass=${this.hass}
.entries=${this.logbookEntries} .entries=${this.logbookEntries}
.narrow=${this.narrow} .narrow=${this.narrow}
></ha-logbook> ></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note> <hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
` `
: html`<div class="padded-box"> : html`<div class="padded-box">

View File

@ -13,7 +13,7 @@ import {
getDataFromPath, getDataFromPath,
TraceExtended, TraceExtended,
} from "../../data/trace"; } from "../../data/trace";
import "../../panels/logbook/ha-logbook"; import "../../panels/logbook/ha-logbook-renderer";
import { traceTabStyles } from "./trace-tab-styles"; import { traceTabStyles } from "./trace-tab-styles";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import type { NodeInfo } from "./hat-script-graph"; import type { NodeInfo } from "./hat-script-graph";
@ -224,12 +224,12 @@ export class HaTracePathDetails extends LitElement {
return entries.length return entries.length
? html` ? html`
<ha-logbook <ha-logbook-renderer
relative-time relative-time
.hass=${this.hass} .hass=${this.hass}
.entries=${entries} .entries=${entries}
.narrow=${this.narrow} .narrow=${this.narrow}
></ha-logbook> ></ha-logbook-renderer>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note> <hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
` `
: html`<div class="padded-box"> : html`<div class="padded-box">

View File

@ -1,17 +1,10 @@
import { startOfYesterday } from "date-fns"; import { startOfYesterday } from "date-fns";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { throttle } from "../../common/util/throttle";
import "../../components/ha-circular-progress";
import { getLogbookData, LogbookEntry } from "../../data/logbook";
import { loadTraceContexts, TraceContexts } from "../../data/trace";
import { fetchUsers } from "../../data/user";
import "../../panels/logbook/ha-logbook"; import "../../panels/logbook/ha-logbook";
import { haStyle } from "../../resources/styles"; import type { HomeAssistant } from "../../types";
import { HomeAssistant } from "../../types";
@customElement("ha-more-info-logbook") @customElement("ha-more-info-logbook")
export class MoreInfoLogbook extends LitElement { export class MoreInfoLogbook extends LitElement {
@ -19,26 +12,12 @@ export class MoreInfoLogbook extends LitElement {
@property() public entityId!: string; @property() public entityId!: string;
@state() private _logbookEntries?: LogbookEntry[];
@state() private _traceContexts?: TraceContexts;
@state() private _userIdToName = {};
private _lastLogbookDate?: Date;
private _fetchUserPromise?: Promise<void>;
private _error?: string;
private _showMoreHref = ""; private _showMoreHref = "";
private _throttleGetLogbookEntries = throttle(() => { private _time = { recent: 86400 };
this._getLogBookData();
}, 10000);
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.entityId) { if (!isComponentLoaded(this.hass, "logbook") || !this.entityId) {
return html``; return html``;
} }
const stateObj = this.hass.states[this.entityId]; const stateObj = this.hass.states[this.entityId];
@ -48,149 +27,34 @@ export class MoreInfoLogbook extends LitElement {
} }
return html` return html`
${isComponentLoaded(this.hass, "logbook") <div class="header">
? this._error <div class="title">
? html`<div class="no-entries"> ${this.hass.localize("ui.dialogs.more_info_control.logbook")}
${`${this.hass.localize( </div>
"ui.components.logbook.retrieval_error" <a href=${this._showMoreHref} @click=${this._close}
)}: ${this._error}`} >${this.hass.localize("ui.dialogs.more_info_control.show_more")}</a
</div>` >
: !this._logbookEntries </div>
? html` <ha-logbook
<ha-circular-progress .hass=${this.hass}
active .time=${this._time}
alt=${this.hass.localize("ui.common.loading")} .entityId=${this.entityId}
></ha-circular-progress> narrow
` no-icon
: this._logbookEntries.length no-name
? html` relative-time
<div class="header"> ></ha-logbook>
<div class="title">
${this.hass.localize("ui.dialogs.more_info_control.logbook")}
</div>
<a href=${this._showMoreHref} @click=${this._close}
>${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}</a
>
</div>
<ha-logbook
narrow
no-icon
no-name
relative-time
.hass=${this.hass}
.entries=${this._logbookEntries}
.traceContexts=${this._traceContexts}
.userIdToName=${this._userIdToName}
></ha-logbook>
`
: html`<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>`
: ""}
`; `;
} }
protected firstUpdated(): void { protected willUpdate(changedProps: PropertyValues): void {
this._fetchUserPromise = this._fetchUserNames(); super.willUpdate(changedProps);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("entityId")) {
this._lastLogbookDate = undefined;
this._logbookEntries = undefined;
if (!this.entityId) {
return;
}
if (changedProps.has("entityId") && this.entityId) {
this._showMoreHref = `/logbook?entity_id=${ this._showMoreHref = `/logbook?entity_id=${
this.entityId this.entityId
}&start_date=${startOfYesterday().toISOString()}`; }&start_date=${startOfYesterday().toISOString()}`;
this._throttleGetLogbookEntries();
return;
} }
if (!this.entityId || !changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
oldHass &&
this.hass.states[this.entityId] !== oldHass?.states[this.entityId]
) {
// wait for commit of data (we only account for the default setting of 1 sec)
setTimeout(this._throttleGetLogbookEntries, 1000);
}
}
private async _getLogBookData() {
if (!isComponentLoaded(this.hass, "logbook")) {
return;
}
const lastDate =
this._lastLogbookDate ||
new Date(new Date().getTime() - 24 * 60 * 60 * 1000);
const now = new Date();
let newEntries;
let traceContexts;
try {
[newEntries, traceContexts] = await Promise.all([
getLogbookData(
this.hass,
lastDate.toISOString(),
now.toISOString(),
this.entityId
),
this.hass.user?.is_admin ? loadTraceContexts(this.hass) : {},
this._fetchUserPromise,
]);
} catch (err: any) {
this._error = err.message;
}
this._logbookEntries = this._logbookEntries
? [...newEntries, ...this._logbookEntries]
: newEntries;
this._lastLogbookDate = now;
this._traceContexts = traceContexts;
}
private async _fetchUserNames() {
const userIdToName = {};
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
// Process persons
Object.values(this.hass.states).forEach((entity) => {
if (
entity.attributes.user_id &&
computeStateDomain(entity) === "person"
) {
this._userIdToName[entity.attributes.user_id] =
entity.attributes.friendly_name;
}
});
// Process users
if (userProm) {
const users = await userProm;
for (const user of users) {
if (!(user.id in userIdToName)) {
userIdToName[user.id] = user.name;
}
}
}
this._userIdToName = userIdToName;
} }
private _close(): void { private _close(): void {
@ -199,13 +63,7 @@ export class MoreInfoLogbook extends LitElement {
static get styles() { static get styles() {
return [ return [
haStyle,
css` css`
.no-entries {
text-align: center;
padding: 16px;
color: var(--secondary-text-color);
}
ha-logbook { ha-logbook {
--logbook-max-height: 250px; --logbook-max-height: 250px;
} }
@ -214,10 +72,6 @@ export class MoreInfoLogbook extends LitElement {
--logbook-max-height: unset; --logbook-max-height: unset;
} }
} }
ha-circular-progress {
display: flex;
justify-content: center;
}
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -0,0 +1,532 @@
import "@lit-labs/virtualizer";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../../common/const";
import { formatDate } from "../../common/datetime/format_date";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { domainIcon } from "../../common/entity/domain_icon";
import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-circular-progress";
import "../../components/ha-relative-time";
import { LogbookEntry } from "../../data/logbook";
import { TraceContexts } from "../../data/trace";
import {
haStyle,
haStyleScrollbar,
buttonLinkStyle,
} from "../../resources/styles";
import { HomeAssistant } from "../../types";
const EVENT_LOCALIZE_MAP = {
script_started: "from_script",
};
@customElement("ha-logbook-renderer")
class HaLogbookRenderer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public userIdToName = {};
@property({ attribute: false })
public traceContexts: TraceContexts = {};
@property({ attribute: false }) public entries: LogbookEntry[] = [];
@property({ type: Boolean, attribute: "narrow" })
public narrow = false;
@property({ attribute: "rtl", type: Boolean })
private _rtl = false;
@property({ type: Boolean, attribute: "virtualize", reflect: true })
public virtualize = false;
@property({ type: Boolean, attribute: "no-icon" })
public noIcon = false;
@property({ type: Boolean, attribute: "no-name" })
public noName = false;
@property({ type: Boolean, attribute: "relative-time" })
public relativeTime = false;
// @ts-ignore
@restoreScroll(".container") private _savedScrollPos?: number;
protected shouldUpdate(changedProps: PropertyValues<this>) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const languageChanged =
oldHass === undefined || oldHass.locale !== this.hass.locale;
return (
changedProps.has("entries") ||
changedProps.has("traceContexts") ||
languageChanged
);
}
protected updated(_changedProps: PropertyValues) {
const oldHass = _changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass === undefined || oldHass.language !== this.hass.language) {
this._rtl = computeRTL(this.hass);
}
}
protected render(): TemplateResult {
if (!this.entries?.length) {
return html`
<div class="container no-entries" .dir=${emitRTLDirection(this._rtl)}>
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>
`;
}
return html`
<div
class="container ha-scrollbar ${classMap({
narrow: this.narrow,
rtl: this._rtl,
"no-name": this.noName,
"no-icon": this.noIcon,
})}"
@scroll=${this._saveScrollPos}
>
${this.virtualize
? html`<lit-virtualizer
scroller
class="ha-scrollbar"
.items=${this.entries}
.renderItem=${this._renderLogbookItem}
>
</lit-virtualizer>`
: this.entries.map((item, index) =>
this._renderLogbookItem(item, index)
)}
</div>
`;
}
private _renderLogbookItem = (
item: LogbookEntry,
index: number
): TemplateResult => {
if (!item || index === undefined) {
return html``;
}
const seenEntityIds: string[] = [];
const previous = this.entries[index - 1];
const stateObj = item.entity_id
? this.hass.states[item.entity_id]
: undefined;
const item_username =
item.context_user_id && this.userIdToName[item.context_user_id];
const domain = item.entity_id
? computeDomain(item.entity_id)
: // Domain is there if there is no entity ID.
item.domain!;
return html`
<div class="entry-container">
${index === 0 ||
(item?.when &&
previous?.when &&
new Date(item.when * 1000).toDateString() !==
new Date(previous.when * 1000).toDateString())
? html`
<h4 class="date">
${formatDate(new Date(item.when * 1000), this.hass.locale)}
</h4>
`
: html``}
<div class="entry ${classMap({ "no-entity": !item.entity_id })}">
<div class="icon-message">
${!this.noIcon
? // We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering,
// as they would present a false state in the log (played media right now vs actual historic data).
html`
<state-badge
.hass=${this.hass}
.overrideIcon=${item.icon ||
(item.domain && !stateObj
? domainIcon(item.domain!)
: undefined)}
.overrideImage=${DOMAINS_WITH_DYNAMIC_PICTURE.has(domain)
? ""
: stateObj?.attributes.entity_picture_local ||
stateObj?.attributes.entity_picture}
.stateObj=${stateObj}
.stateColor=${false}
></state-badge>
`
: ""}
<div class="message-relative_time">
<div class="message">
${!this.noName // Used for more-info panel (single entity case)
? this._renderEntity(item.entity_id, item.name)
: ""}
${item.message
? html`${this._formatMessageWithPossibleEntity(
item.message,
seenEntityIds,
item.entity_id
)}`
: item.source
? html` ${this._formatMessageWithPossibleEntity(
item.source,
seenEntityIds,
undefined,
"ui.components.logbook.by"
)}`
: ""}
${item_username
? ` ${this.hass.localize(
"ui.components.logbook.by_user"
)} ${item_username}`
: ``}
${item.context_event_type
? this._formatEventBy(item, seenEntityIds)
: ""}
${item.context_message
? html` ${this._formatMessageWithPossibleEntity(
item.context_message,
seenEntityIds,
item.context_entity_id,
"ui.components.logbook.for"
)}`
: ""}
${item.context_entity_id &&
!seenEntityIds.includes(item.context_entity_id)
? // Another entity such as an automation or script
html` ${this.hass.localize("ui.components.logbook.for")}
${this._renderEntity(
item.context_entity_id,
item.context_entity_id_name
)}`
: ""}
</div>
<div class="secondary">
<span
>${formatTimeWithSeconds(
new Date(item.when * 1000),
this.hass.locale
)}</span
>
-
<ha-relative-time
.hass=${this.hass}
.datetime=${item.when * 1000}
capitalize
></ha-relative-time>
${["script", "automation"].includes(item.domain!) &&
item.context_id! in this.traceContexts
? html`
-
<a
href=${`/config/${
this.traceContexts[item.context_id!].domain
}/trace/${
this.traceContexts[item.context_id!].domain ===
"script"
? `script.${
this.traceContexts[item.context_id!].item_id
}`
: this.traceContexts[item.context_id!].item_id
}?run_id=${
this.traceContexts[item.context_id!].run_id
}`}
@click=${this._close}
>${this.hass.localize(
"ui.components.logbook.show_trace"
)}</a
>
`
: ""}
</div>
</div>
</div>
</div>
</div>
`;
};
@eventOptions({ passive: true })
private _saveScrollPos(e: Event) {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _formatEventBy(item: LogbookEntry, seenEntities: string[]) {
if (item.context_event_type === "call_service") {
return `${this.hass.localize("ui.components.logbook.from_service")} ${
item.context_domain
}.${item.context_service}`;
}
if (item.context_event_type === "automation_triggered") {
if (seenEntities.includes(item.context_entity_id!)) {
return "";
}
seenEntities.push(item.context_entity_id!);
return html`${this.hass.localize("ui.components.logbook.from_automation")}
${this._renderEntity(item.context_entity_id, item.context_name)}`;
}
if (item.context_name) {
return `${this.hass.localize("ui.components.logbook.from")} ${
item.context_name
}`;
}
if (item.context_event_type === "state_changed") {
return "";
}
if (item.context_event_type! in EVENT_LOCALIZE_MAP) {
return `${this.hass.localize(
`ui.components.logbook.${EVENT_LOCALIZE_MAP[item.context_event_type!]}`
)}`;
}
return `${this.hass.localize(
"ui.components.logbook.from"
)} ${this.hass.localize("ui.components.logbook.event")} ${
item.context_event_type
}`;
}
private _renderEntity(
entityId: string | undefined,
entityName: string | undefined
) {
const hasState = entityId && entityId in this.hass.states;
const displayName =
entityName ||
(hasState
? this.hass.states[entityId].attributes.friendly_name || entityId
: entityId);
if (!hasState) {
return displayName;
}
return html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
}
private _formatMessageWithPossibleEntity(
message: string,
seenEntities: string[],
possibleEntity?: string,
localizePrefix?: string
) {
//
// As we are looking at a log(book), we are doing entity_id
// "highlighting"/"colorizing". The goal is to make it easy for
// the user to access the entity that caused the event.
//
// If there is an entity_id in the message that is also in the
// state machine, we search the message for the entity_id and
// replace it with _renderEntity
//
if (message.indexOf(".") !== -1) {
const messageParts = message.split(" ");
for (let i = 0, size = messageParts.length; i < size; i++) {
if (messageParts[i] in this.hass.states) {
const entityId = messageParts[i];
if (seenEntities.includes(entityId)) {
return "";
}
seenEntities.push(entityId);
const messageEnd = messageParts.splice(i);
messageEnd.shift(); // remove the entity
return html` ${messageParts.join(" ")}
${this._renderEntity(
entityId,
this.hass.states[entityId].attributes.friendly_name
)}
${messageEnd.join(" ")}`;
}
}
}
//
// When we have a message has a specific entity_id attached to
// it, and the entity_id is not in the message, we look
// for the friendly name of the entity and replace that with
// _renderEntity if its there so the user can quickly get to
// that entity.
//
if (possibleEntity && possibleEntity in this.hass.states) {
const possibleEntityName =
this.hass.states[possibleEntity].attributes.friendly_name;
if (possibleEntityName && message.endsWith(possibleEntityName)) {
if (seenEntities.includes(possibleEntity)) {
return "";
}
seenEntities.push(possibleEntity);
message = message.substring(
0,
message.length - possibleEntityName.length
);
return html` ${localizePrefix ? this.hass.localize(localizePrefix) : ""}
${message} ${this._renderEntity(possibleEntity, possibleEntityName)}`;
}
}
return message;
}
private _entityClicked(ev: Event) {
const entityId = (ev.currentTarget as any).entityId;
if (!entityId) {
return;
}
ev.preventDefault();
ev.stopPropagation();
fireEvent(this, "hass-more-info", {
entityId: entityId,
});
}
private _close(): void {
setTimeout(() => fireEvent(this, "closed"), 500);
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleScrollbar,
buttonLinkStyle,
css`
:host([virtualize]) {
display: block;
height: 100%;
}
.rtl {
direction: ltr;
}
.entry-container {
width: 100%;
}
.entry {
display: flex;
width: 100%;
line-height: 2em;
padding: 8px 16px;
box-sizing: border-box;
border-top: 1px solid var(--divider-color);
}
.entry.no-entity,
.no-name .entry {
cursor: default;
}
.entry:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
.narrow:not(.no-icon) .time {
margin-left: 32px;
}
.message-relative_time {
display: flex;
flex-direction: column;
}
.secondary {
font-size: 12px;
line-height: 1.7;
}
.secondary a {
color: var(--secondary-text-color);
}
.date {
margin: 8px 0;
padding: 0 16px;
}
.narrow .date {
padding: 0 8px;
}
.rtl .date {
direction: rtl;
}
.icon-message {
display: flex;
align-items: center;
}
.no-entries {
text-align: center;
color: var(--secondary-text-color);
}
state-badge {
margin-right: 16px;
flex-shrink: 0;
color: var(--state-icon-color);
}
.message {
color: var(--primary-text-color);
}
.no-name .message:first-letter {
text-transform: capitalize;
}
a {
color: var(--primary-color);
}
.container {
max-height: var(--logbook-max-height);
}
.container,
lit-virtualizer {
height: 100%;
}
lit-virtualizer {
contain: size layout !important;
}
.narrow .entry {
line-height: 1.5;
padding: 8px;
}
.narrow .icon-message state-badge {
margin-left: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-logbook-renderer": HaLogbookRenderer;
}
}

View File

@ -1,55 +1,31 @@
import "@lit-labs/virtualizer"; import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { import { customElement, property, state } from "lit/decorators";
css, import { isComponentLoaded } from "../../common/config/is_component_loaded";
CSSResultGroup, import { ensureArray } from "../../common/ensure-array";
html, import { computeStateDomain } from "../../common/entity/compute_state_domain";
LitElement, import { throttle } from "../../common/util/throttle";
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../../common/const";
import { formatDate } from "../../common/datetime/format_date";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { domainIcon } from "../../common/entity/domain_icon";
import { computeRTL, emitRTLDirection } from "../../common/util/compute_rtl";
import "../../components/entity/state-badge";
import "../../components/ha-circular-progress"; import "../../components/ha-circular-progress";
import "../../components/ha-relative-time";
import { LogbookEntry } from "../../data/logbook";
import { TraceContexts } from "../../data/trace";
import { import {
haStyle, clearLogbookCache,
haStyleScrollbar, getLogbookData,
buttonLinkStyle, LogbookEntry,
} from "../../resources/styles"; } from "../../data/logbook";
import { loadTraceContexts, TraceContexts } from "../../data/trace";
import { fetchUsers } from "../../data/user";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "./ha-logbook-renderer";
const EVENT_LOCALIZE_MAP = {
script_started: "from_script",
};
@customElement("ha-logbook") @customElement("ha-logbook")
class HaLogbook extends LitElement { export class HaLogbook extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public userIdToName = {}; @property() public time!: { range: [Date, Date] } | { recent: number };
@property({ attribute: false }) @property() public entityId?: string | string[];
public traceContexts: TraceContexts = {};
@property({ attribute: false }) public entries: LogbookEntry[] = [];
@property({ type: Boolean, attribute: "narrow" }) @property({ type: Boolean, attribute: "narrow" })
public narrow = false; public narrow = false;
@property({ attribute: "rtl", type: Boolean })
private _rtl = false;
@property({ type: Boolean, attribute: "virtualize", reflect: true }) @property({ type: Boolean, attribute: "virtualize", reflect: true })
public virtualize = false; public virtualize = false;
@ -62,463 +38,227 @@ class HaLogbook extends LitElement {
@property({ type: Boolean, attribute: "relative-time" }) @property({ type: Boolean, attribute: "relative-time" })
public relativeTime = false; public relativeTime = false;
// @ts-ignore @property({ type: Boolean }) public showMoreLink = true;
@restoreScroll(".container") private _savedScrollPos?: number;
protected shouldUpdate(changedProps: PropertyValues<this>) { @state() private _logbookEntries?: LogbookEntry[];
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const languageChanged =
oldHass === undefined || oldHass.locale !== this.hass.locale;
return ( @state() private _traceContexts?: TraceContexts;
changedProps.has("entries") ||
changedProps.has("traceContexts") ||
languageChanged
);
}
protected updated(_changedProps: PropertyValues) { @state() private _userIdToName = {};
const oldHass = _changedProps.get("hass") as HomeAssistant | undefined;
if (oldHass === undefined || oldHass.language !== this.hass.language) { @state() private _error?: string;
this._rtl = computeRTL(this.hass);
} private _lastLogbookDate?: Date;
}
private _renderId = 1;
private _throttleGetLogbookEntries = throttle(
() => this._getLogBookData(),
10000
);
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.entries?.length) { if (!isComponentLoaded(this.hass, "logbook")) {
return html``;
}
if (this._error) {
return html`<div class="no-entries">
${`${this.hass.localize("ui.components.logbook.retrieval_error")}: ${
this._error
}`}
</div>`;
}
if (this._logbookEntries === undefined) {
return html` return html`
<div class="container no-entries" .dir=${emitRTLDirection(this._rtl)}> <div class="progress-wrapper">
${this.hass.localize("ui.components.logbook.entries_not_found")} <ha-circular-progress
active
alt=${this.hass.localize("ui.common.loading")}
></ha-circular-progress>
</div> </div>
`; `;
} }
if (this._logbookEntries.length === 0) {
return html`<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>`;
}
return html` return html`
<div <ha-logbook-renderer
class="container ha-scrollbar ${classMap({ .hass=${this.hass}
narrow: this.narrow, .narrow=${this.narrow}
rtl: this._rtl, .virtualize=${this.virtualize}
"no-name": this.noName, .noIcon=${this.noIcon}
"no-icon": this.noIcon, .noName=${this.noName}
})}" .relativeTime=${this.relativeTime}
@scroll=${this._saveScrollPos} .entries=${this._logbookEntries}
> .traceContexts=${this._traceContexts}
${this.virtualize .userIdToName=${this._userIdToName}
? html`<lit-virtualizer ></ha-logbook-renderer>
scroller
class="ha-scrollbar"
.items=${this.entries}
.renderItem=${this._renderLogbookItem}
>
</lit-virtualizer>`
: this.entries.map((item, index) =>
this._renderLogbookItem(item, index)
)}
</div>
`; `;
} }
private _renderLogbookItem = ( public async refresh(force = false) {
item: LogbookEntry, if (!force && this._logbookEntries === undefined) {
index: number
): TemplateResult => {
if (!item || index === undefined) {
return html``;
}
const seenEntityIds: string[] = [];
const previous = this.entries[index - 1];
const stateObj = item.entity_id
? this.hass.states[item.entity_id]
: undefined;
const item_username =
item.context_user_id && this.userIdToName[item.context_user_id];
const domain = item.entity_id
? computeDomain(item.entity_id)
: // Domain is there if there is no entity ID.
item.domain!;
return html`
<div class="entry-container">
${index === 0 ||
(item?.when &&
previous?.when &&
new Date(item.when * 1000).toDateString() !==
new Date(previous.when * 1000).toDateString())
? html`
<h4 class="date">
${formatDate(new Date(item.when * 1000), this.hass.locale)}
</h4>
`
: html``}
<div class="entry ${classMap({ "no-entity": !item.entity_id })}">
<div class="icon-message">
${!this.noIcon
? // We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering,
// as they would present a false state in the log (played media right now vs actual historic data).
html`
<state-badge
.hass=${this.hass}
.overrideIcon=${item.icon ||
(item.domain && !stateObj
? domainIcon(item.domain!)
: undefined)}
.overrideImage=${DOMAINS_WITH_DYNAMIC_PICTURE.has(domain)
? ""
: stateObj?.attributes.entity_picture_local ||
stateObj?.attributes.entity_picture}
.stateObj=${stateObj}
.stateColor=${false}
></state-badge>
`
: ""}
<div class="message-relative_time">
<div class="message">
${!this.noName // Used for more-info panel (single entity case)
? this._renderEntity(item.entity_id, item.name)
: ""}
${item.message
? html`${this._formatMessageWithPossibleEntity(
item.message,
seenEntityIds,
item.entity_id
)}`
: item.source
? html` ${this._formatMessageWithPossibleEntity(
item.source,
seenEntityIds,
undefined,
"ui.components.logbook.by"
)}`
: ""}
${item_username
? ` ${this.hass.localize(
"ui.components.logbook.by_user"
)} ${item_username}`
: ``}
${item.context_event_type
? this._formatEventBy(item, seenEntityIds)
: ""}
${item.context_message
? html` ${this._formatMessageWithPossibleEntity(
item.context_message,
seenEntityIds,
item.context_entity_id,
"ui.components.logbook.for"
)}`
: ""}
${item.context_entity_id &&
!seenEntityIds.includes(item.context_entity_id)
? // Another entity such as an automation or script
html` ${this.hass.localize("ui.components.logbook.for")}
${this._renderEntity(
item.context_entity_id,
item.context_entity_id_name
)}`
: ""}
</div>
<div class="secondary">
<span
>${formatTimeWithSeconds(
new Date(item.when * 1000),
this.hass.locale
)}</span
>
-
<ha-relative-time
.hass=${this.hass}
.datetime=${item.when * 1000}
capitalize
></ha-relative-time>
${["script", "automation"].includes(item.domain!) &&
item.context_id! in this.traceContexts
? html`
-
<a
href=${`/config/${
this.traceContexts[item.context_id!].domain
}/trace/${
this.traceContexts[item.context_id!].domain ===
"script"
? `script.${
this.traceContexts[item.context_id!].item_id
}`
: this.traceContexts[item.context_id!].item_id
}?run_id=${
this.traceContexts[item.context_id!].run_id
}`}
@click=${this._close}
>${this.hass.localize(
"ui.components.logbook.show_trace"
)}</a
>
`
: ""}
</div>
</div>
</div>
</div>
</div>
`;
};
@eventOptions({ passive: true })
private _saveScrollPos(e: Event) {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _formatEventBy(item: LogbookEntry, seenEntities: string[]) {
if (item.context_event_type === "call_service") {
return `${this.hass.localize("ui.components.logbook.from_service")} ${
item.context_domain
}.${item.context_service}`;
}
if (item.context_event_type === "automation_triggered") {
if (seenEntities.includes(item.context_entity_id!)) {
return "";
}
seenEntities.push(item.context_entity_id!);
return html`${this.hass.localize("ui.components.logbook.from_automation")}
${this._renderEntity(item.context_entity_id, item.context_name)}`;
}
if (item.context_name) {
return `${this.hass.localize("ui.components.logbook.from")} ${
item.context_name
}`;
}
if (item.context_event_type === "state_changed") {
return "";
}
if (item.context_event_type! in EVENT_LOCALIZE_MAP) {
return `${this.hass.localize(
`ui.components.logbook.${EVENT_LOCALIZE_MAP[item.context_event_type!]}`
)}`;
}
return `${this.hass.localize(
"ui.components.logbook.from"
)} ${this.hass.localize("ui.components.logbook.event")} ${
item.context_event_type
}`;
}
private _renderEntity(
entityId: string | undefined,
entityName: string | undefined
) {
const hasState = entityId && entityId in this.hass.states;
const displayName =
entityName ||
(hasState
? this.hass.states[entityId].attributes.friendly_name || entityId
: entityId);
if (!hasState) {
return displayName;
}
return html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
}
private _formatMessageWithPossibleEntity(
message: string,
seenEntities: string[],
possibleEntity?: string,
localizePrefix?: string
) {
//
// As we are looking at a log(book), we are doing entity_id
// "highlighting"/"colorizing". The goal is to make it easy for
// the user to access the entity that caused the event.
//
// If there is an entity_id in the message that is also in the
// state machine, we search the message for the entity_id and
// replace it with _renderEntity
//
if (message.indexOf(".") !== -1) {
const messageParts = message.split(" ");
for (let i = 0, size = messageParts.length; i < size; i++) {
if (messageParts[i] in this.hass.states) {
const entityId = messageParts[i];
if (seenEntities.includes(entityId)) {
return "";
}
seenEntities.push(entityId);
const messageEnd = messageParts.splice(i);
messageEnd.shift(); // remove the entity
return html` ${messageParts.join(" ")}
${this._renderEntity(
entityId,
this.hass.states[entityId].attributes.friendly_name
)}
${messageEnd.join(" ")}`;
}
}
}
//
// When we have a message has a specific entity_id attached to
// it, and the entity_id is not in the message, we look
// for the friendly name of the entity and replace that with
// _renderEntity if its there so the user can quickly get to
// that entity.
//
if (possibleEntity && possibleEntity in this.hass.states) {
const possibleEntityName =
this.hass.states[possibleEntity].attributes.friendly_name;
if (possibleEntityName && message.endsWith(possibleEntityName)) {
if (seenEntities.includes(possibleEntity)) {
return "";
}
seenEntities.push(possibleEntity);
message = message.substring(
0,
message.length - possibleEntityName.length
);
return html` ${localizePrefix ? this.hass.localize(localizePrefix) : ""}
${message} ${this._renderEntity(possibleEntity, possibleEntityName)}`;
}
}
return message;
}
private _entityClicked(ev: Event) {
const entityId = (ev.currentTarget as any).entityId;
if (!entityId) {
return; return;
} }
ev.preventDefault(); this._throttleGetLogbookEntries.cancel();
ev.stopPropagation(); this._updateTraceContexts.cancel();
fireEvent(this, "hass-more-info", { this._updateUsers.cancel();
entityId: entityId,
}); if ("range" in this.time) {
clearLogbookCache(
this.time.range[0].toISOString(),
this.time.range[1].toISOString()
);
}
this._lastLogbookDate = undefined;
this._logbookEntries = undefined;
this._error = undefined;
this._throttleGetLogbookEntries();
} }
private _close(): void { protected updated(changedProps: PropertyValues): void {
setTimeout(() => fireEvent(this, "closed"), 500); super.updated(changedProps);
if (changedProps.has("time") || changedProps.has("entityId")) {
this.refresh(true);
return;
}
// We only need to fetch again if we track recent entries for an entity
if (
!("recent" in this.time) ||
!changedProps.has("hass") ||
!this.entityId
) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
// Refresh data if we know the entity has changed.
if (
!oldHass ||
ensureArray(this.entityId).some(
(entityId) => this.hass.states[entityId] !== oldHass?.states[entityId]
)
) {
// wait for commit of data (we only account for the default setting of 1 sec)
setTimeout(this._throttleGetLogbookEntries, 1000);
}
} }
static get styles(): CSSResultGroup { private async _getLogBookData() {
this._renderId += 1;
const renderId = this._renderId;
let startTime: Date;
let endTime: Date;
let appendData = false;
if ("range" in this.time) {
[startTime, endTime] = this.time.range;
} else {
// Recent data
appendData = true;
startTime =
this._lastLogbookDate ||
new Date(new Date().getTime() - 24 * 60 * 60 * 1000);
endTime = new Date();
}
this._updateUsers();
if (this.hass.user?.is_admin) {
this._updateTraceContexts();
}
let newEntries: LogbookEntry[];
try {
newEntries = await getLogbookData(
this.hass,
startTime.toISOString(),
endTime.toISOString(),
this.entityId ? ensureArray(this.entityId).toString() : undefined
);
} catch (err: any) {
if (renderId === this._renderId) {
this._error = err.message;
}
return;
}
// New render happening.
if (renderId !== this._renderId) {
return;
}
this._logbookEntries =
appendData && this._logbookEntries
? newEntries.concat(...this._logbookEntries)
: newEntries;
this._lastLogbookDate = endTime;
}
private _updateTraceContexts = throttle(async () => {
this._traceContexts = await loadTraceContexts(this.hass);
}, 60000);
private _updateUsers = throttle(async () => {
const userIdToName = {};
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
// Process persons
for (const entity of Object.values(this.hass.states)) {
if (
entity.attributes.user_id &&
computeStateDomain(entity) === "person"
) {
userIdToName[entity.attributes.user_id] =
entity.attributes.friendly_name;
}
}
// Process users
if (userProm) {
const users = await userProm;
for (const user of users) {
if (!(user.id in userIdToName)) {
userIdToName[user.id] = user.name;
}
}
}
this._userIdToName = userIdToName;
}, 60000);
static get styles() {
return [ return [
haStyle,
haStyleScrollbar,
buttonLinkStyle,
css` css`
:host([virtualize]) { :host([virtualize]) {
display: block; display: block;
height: 100%; height: 100%;
} }
.rtl {
direction: ltr;
}
.entry-container {
width: 100%;
}
.entry {
display: flex;
width: 100%;
line-height: 2em;
padding: 8px 16px;
box-sizing: border-box;
border-top: 1px solid var(--divider-color);
}
.entry.no-entity,
.no-name .entry {
cursor: default;
}
.entry:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
.narrow:not(.no-icon) .time {
margin-left: 32px;
}
.message-relative_time {
display: flex;
flex-direction: column;
}
.secondary {
font-size: 12px;
line-height: 1.7;
}
.secondary a {
color: var(--secondary-text-color);
}
.date {
margin: 8px 0;
padding: 0 16px;
}
.narrow .date {
padding: 0 8px;
}
.rtl .date {
direction: rtl;
}
.icon-message {
display: flex;
align-items: center;
}
.no-entries { .no-entries {
text-align: center; text-align: center;
padding: 16px;
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
state-badge { .progress-wrapper {
margin-right: 16px; display: flex;
flex-shrink: 0; justify-content: center;
color: var(--state-icon-color);
}
.message {
color: var(--primary-text-color);
}
.no-name .message:first-letter {
text-transform: capitalize;
}
a {
color: var(--primary-color);
}
.container {
max-height: var(--logbook-max-height);
}
.container,
lit-virtualizer {
height: 100%; height: 100%;
} align-items: center;
lit-virtualizer {
contain: size layout !important;
}
.narrow .entry {
line-height: 1.5;
padding: 8px;
}
.narrow .icon-message state-badge {
margin-left: 0;
} }
`, `,
]; ];

View File

@ -12,28 +12,19 @@ import {
} from "date-fns"; } from "date-fns";
import { css, html, LitElement, PropertyValues } from "lit"; import { css, html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { navigate } from "../../common/navigate"; import { navigate } from "../../common/navigate";
import { import {
createSearchParam, createSearchParam,
extractSearchParam, extractSearchParamsObject,
} from "../../common/url/search-params"; } from "../../common/url/search-params";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/entity/ha-entity-picker"; import "../../components/entity/ha-entity-picker";
import "../../components/ha-circular-progress"; import type { HaEntityPickerEntityFilterFunc } from "../../components/entity/ha-entity-picker";
import "../../components/ha-date-range-picker"; import "../../components/ha-date-range-picker";
import type { DateRangePickerRanges } from "../../components/ha-date-range-picker"; import type { DateRangePickerRanges } from "../../components/ha-date-range-picker";
import "../../components/ha-icon-button"; import "../../components/ha-icon-button";
import "../../components/ha-menu-button"; import "../../components/ha-menu-button";
import {
clearLogbookCache,
getLogbookData,
LogbookEntry,
} from "../../data/logbook";
import { loadTraceContexts, TraceContexts } from "../../data/trace";
import { fetchUsers } from "../../data/user";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import "../../layouts/ha-app-layout"; import "../../layouts/ha-app-layout";
import { haStyle } from "../../resources/styles"; import { haStyle } from "../../resources/styles";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
@ -45,36 +36,24 @@ export class HaPanelLogbook extends LitElement {
@property({ reflect: true, type: Boolean }) narrow!: boolean; @property({ reflect: true, type: Boolean }) narrow!: boolean;
@property() _startDate: Date; @state() _time: { range: [Date, Date] };
@property() _endDate: Date; @state() _entityId = "";
@property() _entityId = "";
@property() _isLoading = false;
@property() _entries: LogbookEntry[] = [];
@property({ reflect: true, type: Boolean }) rtl = false; @property({ reflect: true, type: Boolean }) rtl = false;
@state() private _ranges?: DateRangePickerRanges; @state() private _ranges?: DateRangePickerRanges;
private _fetchUserPromise?: Promise<void>;
@state() private _userIdToName = {};
@state() private _traceContexts: TraceContexts = {};
public constructor() { public constructor() {
super(); super();
const start = new Date(); const start = new Date();
start.setHours(start.getHours() - 2, 0, 0, 0); start.setHours(start.getHours() - 2, 0, 0, 0);
this._startDate = start;
const end = new Date(); const end = new Date();
end.setHours(end.getHours() + 1, 0, 0, 0); end.setHours(end.getHours() + 1, 0, 0, 0);
this._endDate = end;
this._time = { range: [start, end] };
} }
protected render() { protected render() {
@ -91,19 +70,15 @@ export class HaPanelLogbook extends LitElement {
@click=${this._refreshLogbook} @click=${this._refreshLogbook}
.path=${mdiRefresh} .path=${mdiRefresh}
.label=${this.hass!.localize("ui.common.refresh")} .label=${this.hass!.localize("ui.common.refresh")}
.disabled=${this._isLoading}
></ha-icon-button> ></ha-icon-button>
</app-toolbar> </app-toolbar>
</app-header> </app-header>
${this._isLoading ? html`` : ""}
<div class="filters"> <div class="filters">
<ha-date-range-picker <ha-date-range-picker
.hass=${this.hass} .hass=${this.hass}
?disabled=${this._isLoading} .startDate=${this._time.range[0]}
.startDate=${this._startDate} .endDate=${this._time.range[1]}
.endDate=${this._endDate}
.ranges=${this._ranges} .ranges=${this._ranges}
@change=${this._dateRangeChanged} @change=${this._dateRangeChanged}
></ha-date-range-picker> ></ha-date-range-picker>
@ -114,38 +89,27 @@ export class HaPanelLogbook extends LitElement {
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.components.entity.entity-picker.entity" "ui.components.entity.entity-picker.entity"
)} )}
.disabled=${this._isLoading} .entityFilter=${this._entityFilter}
@change=${this._entityPicked} @change=${this._entityPicked}
></ha-entity-picker> ></ha-entity-picker>
</div> </div>
${this._isLoading <ha-logbook
? html` .hass=${this.hass}
<div class="progress-wrapper"> .time=${this._time}
<ha-circular-progress .entityId=${this._entityId}
active virtualize
alt=${this.hass.localize("ui.common.loading")} ></ha-logbook>
></ha-circular-progress>
</div>
`
: html`
<ha-logbook
.hass=${this.hass}
.entries=${this._entries}
.userIdToName=${this._userIdToName}
.traceContexts=${this._traceContexts}
virtualize
></ha-logbook>
`}
</ha-app-layout> </ha-app-layout>
`; `;
} }
protected firstUpdated(changedProps: PropertyValues) { protected willUpdate(changedProps: PropertyValues) {
super.firstUpdated(changedProps); super.willUpdate(changedProps);
this.hass.loadBackendTranslation("title");
this._fetchUserPromise = this._fetchUserNames(); if (this.hasUpdated) {
return;
}
const today = new Date(); const today = new Date();
const weekStart = startOfWeek(today); const weekStart = startOfWeek(today);
@ -164,151 +128,125 @@ export class HaPanelLogbook extends LitElement {
[addDays(weekStart, -7), addDays(weekEnd, -7)], [addDays(weekStart, -7), addDays(weekEnd, -7)],
}; };
this._entityId = extractSearchParam("entity_id") ?? ""; this._applyURLParams();
const startDate = extractSearchParam("start_date");
if (startDate) {
this._startDate = new Date(startDate);
}
const endDate = extractSearchParam("end_date");
if (endDate) {
this._endDate = new Date(endDate);
}
} }
protected updated(changedProps: PropertyValues<this>) { protected firstUpdated(changedProps: PropertyValues) {
if ( super.firstUpdated(changedProps);
changedProps.has("_startDate") || this.hass.loadBackendTranslation("title");
changedProps.has("_endDate") || }
changedProps.has("_entityId")
) {
this._getData();
}
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
}
private _locationChanged = () => {
this._applyURLParams();
};
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("hass")) { if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.language !== this.hass.language) { if (!oldHass || oldHass.language !== this.hass.language) {
this.rtl = computeRTL(this.hass); this.rtl = computeRTL(this.hass);
} }
} }
this._applyURLParams();
} }
private async _fetchUserNames() { private _applyURLParams() {
const userIdToName = {}; const searchParams = new URLSearchParams(location.search);
// Start loading users if (searchParams.has("entity_id")) {
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass); this._entityId = searchParams.get("entity_id") ?? "";
// Process persons
Object.values(this.hass.states).forEach((entity) => {
if (
entity.attributes.user_id &&
computeStateDomain(entity) === "person"
) {
this._userIdToName[entity.attributes.user_id] =
entity.attributes.friendly_name;
}
});
// Process users
if (userProm) {
const users = await userProm;
for (const user of users) {
if (!(user.id in userIdToName)) {
userIdToName[user.id] = user.name;
}
}
} }
this._userIdToName = userIdToName; const startDateStr = searchParams.get("start_date");
const endDateStr = searchParams.get("end_date");
if (startDateStr || endDateStr) {
const startDate = startDateStr
? new Date(startDateStr)
: this._time.range[0];
const endDate = endDateStr ? new Date(endDateStr) : this._time.range[1];
// Only set if date has changed.
if (
startDate.getTime() !== this._time.range[0].getTime() ||
endDate.getTime() !== this._time.range[1].getTime()
) {
this._time = {
range: [
startDateStr ? new Date(startDateStr) : this._time.range[0],
endDateStr ? new Date(endDateStr) : this._time.range[1],
],
};
}
}
} }
private _dateRangeChanged(ev) { private _dateRangeChanged(ev) {
this._startDate = ev.detail.startDate; const startDate = ev.detail.startDate;
const endDate = ev.detail.endDate; const endDate = ev.detail.endDate;
if (endDate.getHours() === 0 && endDate.getMinutes() === 0) { if (endDate.getHours() === 0 && endDate.getMinutes() === 0) {
endDate.setDate(endDate.getDate() + 1); endDate.setDate(endDate.getDate() + 1);
endDate.setMilliseconds(endDate.getMilliseconds() - 1); endDate.setMilliseconds(endDate.getMilliseconds() - 1);
} }
this._endDate = endDate; this._time = { range: [startDate, endDate] };
this._updatePath({
this._updatePath(); start_date: this._time.range[0].toISOString(),
end_date: this._time.range[1].toISOString(),
});
} }
private _entityPicked(ev) { private _entityPicked(ev) {
this._entityId = ev.target.value; this._entityId = ev.target.value;
this._updatePath({ entity_id: this._entityId });
this._updatePath();
} }
private _updatePath() { private _updatePath(update: Record<string, string>) {
const params: Record<string, string> = {}; const params = extractSearchParamsObject();
for (const [key, value] of Object.entries(update)) {
if (this._entityId) { if (value === undefined) {
params.entity_id = this._entityId; delete params[key];
} else {
params[key] = value;
}
} }
if (this._startDate) {
params.start_date = this._startDate.toISOString();
}
if (this._endDate) {
params.end_date = this._endDate.toISOString();
}
navigate(`/logbook?${createSearchParam(params)}`, { replace: true }); navigate(`/logbook?${createSearchParam(params)}`, { replace: true });
} }
private _refreshLogbook() { private _refreshLogbook() {
this._entries = []; this.shadowRoot!.querySelector("ha-logbook")?.refresh();
clearLogbookCache(
this._startDate.toISOString(),
this._endDate.toISOString()
);
this._getData();
} }
private async _getData() { private _entityFilter: HaEntityPickerEntityFilterFunc = (entity) => {
this._isLoading = true; if (computeStateDomain(entity) !== "sensor") {
let entries; return true;
let traceContexts;
try {
[entries, traceContexts] = await Promise.all([
getLogbookData(
this.hass,
this._startDate.toISOString(),
this._endDate.toISOString(),
this._entityId
),
isComponentLoaded(this.hass, "trace") && this.hass.user?.is_admin
? loadTraceContexts(this.hass)
: {},
this._fetchUserPromise,
]);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.components.logbook.retrieval_error"),
text: err.message,
});
} }
this._entries = entries; return (
this._traceContexts = traceContexts; entity.attributes.unit_of_measurement === undefined &&
this._isLoading = false; entity.attributes.state_class === undefined
} );
};
static get styles() { static get styles() {
return [ return [
haStyle, haStyle,
css` css`
ha-logbook, ha-logbook {
.progress-wrapper {
height: calc(100vh - 136px); height: calc(100vh - 136px);
} }
:host([narrow]) ha-logbook, :host([narrow]) ha-logbook {
:host([narrow]) .progress-wrapper {
height: calc(100vh - 198px); height: calc(100vh - 198px);
} }
@ -321,17 +259,6 @@ export class HaPanelLogbook extends LitElement {
margin-right: 0; margin-right: 0;
} }
.progress-wrapper {
position: relative;
}
ha-circular-progress {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.filters { .filters {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;

View File

@ -9,15 +9,11 @@ import {
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { throttle } from "../../../common/util/throttle";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-circular-progress";
import { fetchUsers } from "../../../data/user";
import { getLogbookData, LogbookEntry } from "../../../data/logbook";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "../../logbook/ha-logbook"; import "../../logbook/ha-logbook";
import type { HaLogbook } from "../../logbook/ha-logbook";
import { findEntities } from "../common/find-entities"; import { findEntities } from "../common/find-entities";
import { processConfigEntities } from "../common/process-config-entities"; import { processConfigEntities } from "../common/process-config-entities";
import "../components/hui-warning"; import "../components/hui-warning";
@ -56,21 +52,9 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
@state() private _config?: LogbookCardConfig; @state() private _config?: LogbookCardConfig;
@state() private _logbookEntries?: LogbookEntry[]; @state() private _time?: HaLogbook["time"];
@state() private _configEntities?: EntityConfig[]; @state() private _entityId?: string[];
@state() private _userIdToName = {};
private _lastLogbookDate?: Date;
private _fetchUserPromise?: Promise<void>;
private _error?: string;
private _throttleGetLogbookEntries = throttle(() => {
this._getLogBookData();
}, 10000);
public getCardSize(): number { public getCardSize(): number {
return 9 + (this._config?.title ? 1 : 0); return 9 + (this._config?.title ? 1 : 0);
@ -81,45 +65,16 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
throw new Error("Entities must be specified"); throw new Error("Entities must be specified");
} }
this._configEntities = processConfigEntities<EntityConfig>(config.entities);
this._config = { this._config = {
hours_to_show: 24, hours_to_show: 24,
...config, ...config,
}; };
} this._time = {
recent: this._config!.hours_to_show! * 60 * 60 * 1000,
protected shouldUpdate(changedProps: PropertyValues): boolean { };
if ( this._entityId = processConfigEntities<EntityConfig>(config.entities).map(
changedProps.has("_config") || (entity) => entity.entity
changedProps.has("_persons") || );
changedProps.has("_logbookEntries")
) {
return true;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
!this._configEntities ||
!oldHass ||
oldHass.themes !== this.hass!.themes ||
oldHass.locale !== this.hass!.locale
) {
return true;
}
for (const entity of this._configEntities) {
if (oldHass.states[entity.entity] !== this.hass!.states[entity.entity]) {
return true;
}
}
return false;
}
protected firstUpdated(): void {
this._fetchUserPromise = this._fetchUserNames();
} }
protected updated(changedProperties: PropertyValues) { protected updated(changedProperties: PropertyValues) {
@ -139,33 +94,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
) { ) {
applyThemesOnElement(this, this.hass.themes, this._config.theme); applyThemesOnElement(this, this.hass.themes, this._config.theme);
} }
if (
configChanged &&
(oldConfig?.entities !== this._config.entities ||
oldConfig?.hours_to_show !== this._config!.hours_to_show)
) {
this._logbookEntries = undefined;
this._lastLogbookDate = undefined;
if (!this._configEntities) {
return;
}
this._throttleGetLogbookEntries();
return;
}
if (
oldHass &&
this._configEntities!.some(
(entity) =>
oldHass.states[entity.entity] !== this.hass!.states[entity.entity]
)
) {
// wait for commit of data (we only account for the default setting of 1 sec)
setTimeout(this._throttleGetLogbookEntries, 1000);
}
} }
protected render(): TemplateResult { protected render(): TemplateResult {
@ -189,116 +117,19 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
class=${classMap({ "no-header": !this._config!.title })} class=${classMap({ "no-header": !this._config!.title })}
> >
<div class="content"> <div class="content">
${this._error <ha-logbook
? html` .hass=${this.hass}
<div class="no-entries"> .time=${this._time}
${`${this.hass.localize( .entityId=${this._entityId}
"ui.components.logbook.retrieval_error" narrow
)}: ${this._error}`} relative-time
</div> virtualize
` ></ha-logbook>
: !this._logbookEntries
? html`
<ha-circular-progress
active
alt=${this.hass.localize("ui.common.loading")}
></ha-circular-progress>
`
: this._logbookEntries.length
? html`
<ha-logbook
narrow
relative-time
virtualize
.hass=${this.hass}
.entries=${this._logbookEntries}
.userIdToName=${this._userIdToName}
></ha-logbook>
`
: html`
<div class="no-entries">
${this.hass.localize(
"ui.components.logbook.entries_not_found"
)}
</div>
`}
</div> </div>
</ha-card> </ha-card>
`; `;
} }
private async _getLogBookData() {
if (
!this.hass ||
!this._config ||
!isComponentLoaded(this.hass, "logbook")
) {
return;
}
const hoursToShowDate = new Date(
new Date().getTime() - this._config!.hours_to_show! * 60 * 60 * 1000
);
const lastDate = this._lastLogbookDate || hoursToShowDate;
const now = new Date();
let newEntries: LogbookEntry[];
try {
[newEntries] = await Promise.all([
getLogbookData(
this.hass,
lastDate.toISOString(),
now.toISOString(),
this._configEntities!.map((entity) => entity.entity).toString()
),
this._fetchUserPromise,
]);
} catch (err: any) {
this._error = err.message;
return;
}
const logbookEntries = this._logbookEntries
? [...newEntries, ...this._logbookEntries]
: newEntries;
this._logbookEntries = logbookEntries.filter(
(logEntry) => new Date(logEntry.when * 1000) > hoursToShowDate
);
this._lastLogbookDate = now;
}
private async _fetchUserNames() {
const userIdToName = {};
// Start loading users
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
// Process persons
Object.values(this.hass!.states).forEach((entity) => {
if (
entity.attributes.user_id &&
computeStateDomain(entity) === "person"
) {
this._userIdToName[entity.attributes.user_id] =
entity.attributes.friendly_name;
}
});
// Process users
if (userProm) {
const users = await userProm;
for (const user of users) {
if (!(user.id in userIdToName)) {
userIdToName[user.id] = user.name;
}
}
}
this._userIdToName = userIdToName;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
css` css`
@ -317,21 +148,10 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
padding-top: 16px; padding-top: 16px;
} }
.no-entries {
text-align: center;
padding: 16px;
color: var(--secondary-text-color);
}
ha-logbook { ha-logbook {
height: 385px; height: 385px;
display: block; display: block;
} }
ha-circular-progress {
display: flex;
justify-content: center;
}
`, `,
]; ];
} }