467 lines
13 KiB
TypeScript
467 lines
13 KiB
TypeScript
import { css, html, LitElement, PropertyValues, nothing } from "lit";
|
|
import { customElement, property, state } from "lit/decorators";
|
|
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
|
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
|
import { throttle } from "../../common/util/throttle";
|
|
import "../../components/ha-circular-progress";
|
|
import {
|
|
LogbookEntry,
|
|
LogbookStreamMessage,
|
|
subscribeLogbook,
|
|
} from "../../data/logbook";
|
|
import { loadTraceContexts, TraceContexts } from "../../data/trace";
|
|
import { fetchUsers } from "../../data/user";
|
|
import { HomeAssistant } from "../../types";
|
|
import "./ha-logbook-renderer";
|
|
|
|
interface LogbookTimePeriod {
|
|
now: Date;
|
|
startTime: Date;
|
|
endTime: Date;
|
|
purgeBeforePythonTime: number | undefined;
|
|
}
|
|
|
|
const findStartOfRecentTime = (now: Date, recentTime: number) =>
|
|
new Date(now.getTime() - recentTime * 1000).getTime() / 1000;
|
|
|
|
const idsChanged = (oldIds?: string[], newIds?: string[]) => {
|
|
if (oldIds === undefined && newIds === undefined) {
|
|
return false;
|
|
}
|
|
return (
|
|
!oldIds ||
|
|
!newIds ||
|
|
oldIds.length !== newIds.length ||
|
|
oldIds.some((val) => !newIds.includes(val)) ||
|
|
newIds.some((val) => !oldIds.includes(val))
|
|
);
|
|
};
|
|
|
|
@customElement("ha-logbook")
|
|
export class HaLogbook extends LitElement {
|
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
|
|
|
@property({ attribute: false }) public time!:
|
|
| { range: [Date, Date] }
|
|
| {
|
|
// Seconds
|
|
recent: number;
|
|
};
|
|
|
|
@property({ attribute: false }) public entityIds?: string[];
|
|
|
|
@property({ attribute: false }) public deviceIds?: string[];
|
|
|
|
@property({ type: Boolean }) public narrow = false;
|
|
|
|
@property({ type: Boolean, 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: "show-indicator" })
|
|
public showIndicator = false;
|
|
|
|
@property({ type: Boolean, attribute: "relative-time" })
|
|
public relativeTime = false;
|
|
|
|
@property({ type: Boolean }) public showMoreLink = true;
|
|
|
|
@state() private _logbookEntries?: LogbookEntry[];
|
|
|
|
@state() private _traceContexts: TraceContexts = {};
|
|
|
|
@state() private _userIdToName = {};
|
|
|
|
@state() private _error?: string;
|
|
|
|
private _subscribed?: Promise<(() => Promise<void>) | void>;
|
|
|
|
private _liveUpdatesEnabled = true;
|
|
|
|
private _pendingStreamMessages: LogbookStreamMessage[] = [];
|
|
|
|
private _throttleGetLogbookEntries = throttle(
|
|
() => this._getLogBookData(),
|
|
1000
|
|
);
|
|
|
|
protected render() {
|
|
if (!isComponentLoaded(this.hass, "logbook")) {
|
|
return nothing;
|
|
}
|
|
|
|
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`
|
|
<div class="progress-wrapper">
|
|
<ha-circular-progress indeterminate></ha-circular-progress>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (this._logbookEntries.length === 0) {
|
|
return html`<div class="no-entries">
|
|
${this.hass.localize("ui.components.logbook.entries_not_found")}
|
|
</div>`;
|
|
}
|
|
|
|
return html`
|
|
<ha-logbook-renderer
|
|
.hass=${this.hass}
|
|
.narrow=${this.narrow}
|
|
.virtualize=${this.virtualize}
|
|
.noIcon=${this.noIcon}
|
|
.noName=${this.noName}
|
|
.showIndicator=${this.showIndicator}
|
|
.relativeTime=${this.relativeTime}
|
|
.entries=${this._logbookEntries}
|
|
.traceContexts=${this._traceContexts}
|
|
.userIdToName=${this._userIdToName}
|
|
@hass-logbook-live=${this._handleLogbookLive}
|
|
></ha-logbook-renderer>
|
|
`;
|
|
}
|
|
|
|
public async refresh(force = false) {
|
|
if (!force && (this._subscribed || this._logbookEntries === undefined)) {
|
|
return;
|
|
}
|
|
|
|
this._throttleGetLogbookEntries.cancel();
|
|
this._updateTraceContexts.cancel();
|
|
this._updateUsers.cancel();
|
|
await this._unsubscribeSetLoading();
|
|
|
|
if (force) {
|
|
this._getLogBookData();
|
|
} else {
|
|
this._throttleGetLogbookEntries();
|
|
}
|
|
}
|
|
|
|
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
|
if (changedProps.size !== 1 || !changedProps.has("hass")) {
|
|
return true;
|
|
}
|
|
// We only respond to hass changes if the translations changed
|
|
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
|
return !oldHass || oldHass.localize !== this.hass.localize;
|
|
}
|
|
|
|
protected willUpdate(changedProps: PropertyValues): void {
|
|
let changed = changedProps.has("time");
|
|
|
|
for (const key of ["entityIds", "deviceIds"]) {
|
|
if (!changedProps.has(key)) {
|
|
continue;
|
|
}
|
|
|
|
const oldValue = changedProps.get(key) as string[] | undefined;
|
|
const curValue = this[key] as string[] | undefined;
|
|
|
|
// If they make the filter more specific we want
|
|
// to change the subscription since it will reduce
|
|
// the overhead on the backend as the event stream
|
|
// can be a firehose for all state events.
|
|
if (idsChanged(oldValue, curValue)) {
|
|
changed = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (changed) {
|
|
this.refresh(true);
|
|
}
|
|
}
|
|
|
|
private _handleLogbookLive(ev: CustomEvent) {
|
|
if (ev.detail.enable && !this._liveUpdatesEnabled) {
|
|
// Process everything we queued up while we were scrolled down
|
|
this._pendingStreamMessages.forEach((msg) =>
|
|
this._processStreamMessage(msg)
|
|
);
|
|
this._pendingStreamMessages = [];
|
|
}
|
|
this._liveUpdatesEnabled = ev.detail.enable;
|
|
}
|
|
|
|
private get _filterAlwaysEmptyResults(): boolean {
|
|
const entityIds = this.entityIds;
|
|
const deviceIds = this.deviceIds;
|
|
|
|
// If all specified filters are empty lists, we can return an empty list.
|
|
return (
|
|
Boolean(entityIds || deviceIds) &&
|
|
(!entityIds || entityIds.length === 0) &&
|
|
(!deviceIds || deviceIds.length === 0)
|
|
);
|
|
}
|
|
|
|
private async _unsubscribe(): Promise<void> {
|
|
if (this._subscribed) {
|
|
const unsub = await this._subscribed;
|
|
if (unsub) {
|
|
try {
|
|
await unsub();
|
|
} catch (e) {
|
|
// The backend will cancel the subscription if
|
|
// we subscribe to entities that will all be
|
|
// filtered away
|
|
}
|
|
}
|
|
this._subscribed = undefined;
|
|
}
|
|
}
|
|
|
|
public connectedCallback() {
|
|
super.connectedCallback();
|
|
if (this.hasUpdated) {
|
|
this._subscribeLogbookPeriod(this._calculateLogbookPeriod());
|
|
}
|
|
}
|
|
|
|
public disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
this._unsubscribeSetLoading();
|
|
}
|
|
|
|
/** Unsubscribe because we are unloading
|
|
* or about to resubscribe.
|
|
* Setting this._logbookEntries to undefined
|
|
* will put the page in a loading state.
|
|
*/
|
|
private async _unsubscribeSetLoading() {
|
|
await this._unsubscribe();
|
|
this._logbookEntries = undefined;
|
|
this._pendingStreamMessages = [];
|
|
}
|
|
|
|
/** Unsubscribe because there are no results.
|
|
* Setting this._logbookEntries to an empty
|
|
* list will show a no results message.
|
|
*/
|
|
private async _unsubscribeNoResults() {
|
|
await this._unsubscribe();
|
|
this._logbookEntries = [];
|
|
this._pendingStreamMessages = [];
|
|
}
|
|
|
|
private _calculateLogbookPeriod() {
|
|
const now = new Date();
|
|
if ("range" in this.time) {
|
|
return <LogbookTimePeriod>{
|
|
now: now,
|
|
startTime: this.time.range[0],
|
|
endTime: this.time.range[1],
|
|
purgeBeforePythonTime: undefined,
|
|
};
|
|
}
|
|
if ("recent" in this.time) {
|
|
const purgeBeforePythonTime = findStartOfRecentTime(
|
|
now,
|
|
this.time.recent
|
|
);
|
|
return <LogbookTimePeriod>{
|
|
now: now,
|
|
startTime: new Date(purgeBeforePythonTime * 1000),
|
|
// end streaming one year from now
|
|
endTime: new Date(now.getTime() + 86400 * 365 * 1000),
|
|
purgeBeforePythonTime: findStartOfRecentTime(now, this.time.recent),
|
|
};
|
|
}
|
|
throw new Error("Unexpected time specified");
|
|
}
|
|
|
|
private _subscribeLogbookPeriod(logbookPeriod: LogbookTimePeriod) {
|
|
if (this._subscribed) {
|
|
return true;
|
|
}
|
|
this._subscribed = subscribeLogbook(
|
|
this.hass,
|
|
(streamMessage) => {
|
|
// "recent" means start time is a sliding window
|
|
// so we need to calculate an expireTime to
|
|
// purge old events
|
|
if (!this._subscribed) {
|
|
// Message came in before we had a chance to unload
|
|
return;
|
|
}
|
|
this._processOrQueueStreamMessage(streamMessage);
|
|
},
|
|
logbookPeriod.startTime.toISOString(),
|
|
logbookPeriod.endTime.toISOString(),
|
|
this.entityIds,
|
|
this.deviceIds
|
|
).catch((err) => {
|
|
this._subscribed = undefined;
|
|
this._error = err;
|
|
});
|
|
return true;
|
|
}
|
|
|
|
private async _getLogBookData() {
|
|
this._error = undefined;
|
|
|
|
if (this._filterAlwaysEmptyResults) {
|
|
this._unsubscribeNoResults();
|
|
return;
|
|
}
|
|
|
|
const logbookPeriod = this._calculateLogbookPeriod();
|
|
|
|
if (logbookPeriod.startTime > logbookPeriod.now) {
|
|
// Time Travel not yet invented
|
|
this._unsubscribeNoResults();
|
|
return;
|
|
}
|
|
|
|
this._updateUsers();
|
|
if (this.hass.user?.is_admin) {
|
|
this._updateTraceContexts();
|
|
}
|
|
|
|
this._subscribeLogbookPeriod(logbookPeriod);
|
|
}
|
|
|
|
private _nonExpiredRecords = (purgeBeforePythonTime: number | undefined) =>
|
|
!this._logbookEntries
|
|
? []
|
|
: purgeBeforePythonTime
|
|
? this._logbookEntries.filter(
|
|
(entry) => entry.when > purgeBeforePythonTime!
|
|
)
|
|
: this._logbookEntries;
|
|
|
|
private _processOrQueueStreamMessage = (
|
|
streamMessage: LogbookStreamMessage
|
|
) => {
|
|
if (this._liveUpdatesEnabled) {
|
|
this._processStreamMessage(streamMessage);
|
|
return;
|
|
}
|
|
this._pendingStreamMessages.push(streamMessage);
|
|
};
|
|
|
|
private _processStreamMessage = (streamMessage: LogbookStreamMessage) => {
|
|
const purgeBeforePythonTime =
|
|
"recent" in this.time
|
|
? findStartOfRecentTime(new Date(), this.time.recent)
|
|
: undefined;
|
|
// Put newest ones on top. Reverse works in-place so
|
|
// make a copy first.
|
|
const newEntries = [...streamMessage.events].reverse();
|
|
if (!this._logbookEntries || !this._logbookEntries.length) {
|
|
this._logbookEntries = newEntries;
|
|
return;
|
|
}
|
|
if (!newEntries.length) {
|
|
// Empty messages are still sent to
|
|
// indicate no more historical events
|
|
return;
|
|
}
|
|
const nonExpiredRecords = this._nonExpiredRecords(purgeBeforePythonTime);
|
|
|
|
// Entries are sorted in descending order with newest first.
|
|
if (!nonExpiredRecords.length) {
|
|
// We have no records left, so we can just replace the list
|
|
this._logbookEntries = newEntries;
|
|
} else if (
|
|
newEntries[newEntries.length - 1].when > // oldest new entry
|
|
nonExpiredRecords[0].when // newest old entry
|
|
) {
|
|
// The new records are newer than the old records
|
|
// append the old records to the end of the new records
|
|
this._logbookEntries = newEntries.concat(nonExpiredRecords);
|
|
} else if (
|
|
nonExpiredRecords[nonExpiredRecords.length - 1].when > // oldest old entry
|
|
newEntries[0].when // newest new entry
|
|
) {
|
|
// The new records are older than the old records
|
|
// append the new records to the end of the old records
|
|
this._logbookEntries = nonExpiredRecords.concat(newEntries);
|
|
} else {
|
|
// The new records are in the middle of the old records
|
|
// so we need to re-sort them
|
|
this._logbookEntries = nonExpiredRecords
|
|
.concat(newEntries)
|
|
.sort((a, b) => b.when - a.when);
|
|
}
|
|
};
|
|
|
|
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 [
|
|
css`
|
|
:host {
|
|
display: block;
|
|
}
|
|
|
|
:host([virtualize]) {
|
|
height: 100%;
|
|
}
|
|
|
|
.no-entries {
|
|
text-align: center;
|
|
padding: 16px;
|
|
color: var(--secondary-text-color);
|
|
}
|
|
|
|
.progress-wrapper {
|
|
display: flex;
|
|
justify-content: center;
|
|
height: 100%;
|
|
align-items: center;
|
|
}
|
|
`,
|
|
];
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"ha-logbook": HaLogbook;
|
|
}
|
|
}
|