mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-29 01:17:36 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ca1b529ef |
@@ -29,6 +29,7 @@ export class HaTraceLogbook extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entries=${this.logbookEntries}
|
||||
.narrow=${this.narrow}
|
||||
no-row-click
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
|
||||
@@ -437,6 +437,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entries=${entries}
|
||||
.narrow=${this.narrow}
|
||||
no-row-click
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
|
||||
@@ -76,6 +76,15 @@ export const getLogbookDataForContext = async (
|
||||
): Promise<LogbookEntry[]> =>
|
||||
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
|
||||
|
||||
export const getLogbookEvents = (
|
||||
hass: HomeAssistant,
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
entityIds?: string[],
|
||||
contextId?: string
|
||||
): Promise<LogbookEntry[]> =>
|
||||
getLogbookDataFromServer(hass, startDate, endDate, entityIds, contextId);
|
||||
|
||||
const getLogbookDataFromServer = (
|
||||
hass: HomeAssistant,
|
||||
startDate: string,
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/ha-adaptive-dialog";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-relative-time";
|
||||
import "../../components/ha-spinner";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { HassDialog } from "../../dialogs/make-dialog-manager";
|
||||
import {
|
||||
buttonLinkStyle,
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
} from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook-chain";
|
||||
import type { LogbookChain } from "./logbook-chain-resolver";
|
||||
import { resolveLogbookChain } from "./logbook-chain-resolver";
|
||||
import type { LogbookItem } from "./logbook-entry-model";
|
||||
import { computeLogbookItem } from "./logbook-entry-model";
|
||||
import type { LogbookDetailDialogParams } from "./show-dialog-logbook-detail";
|
||||
|
||||
@customElement("dialog-logbook-detail")
|
||||
class DialogLogbookDetail
|
||||
extends LitElement
|
||||
implements HassDialog<LogbookDetailDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: LogbookDetailDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _chain?: LogbookChain;
|
||||
|
||||
@state() private _error = false;
|
||||
|
||||
public showDialog(params: LogbookDetailDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._chain = undefined;
|
||||
this._error = false;
|
||||
if (
|
||||
params.entry.context_event_type === "call_service" &&
|
||||
params.entry.context_domain
|
||||
) {
|
||||
this.hass.loadBackendTranslation("services", params.entry.context_domain);
|
||||
}
|
||||
this._loadChain();
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._chain = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private async _loadChain() {
|
||||
const { entry, userIdToName, systemUserIds } = this._params!;
|
||||
const options = { userIdToName, systemUserIds };
|
||||
const resolveWithoutFetch = () =>
|
||||
resolveLogbookChain(this.hass, entry, options, async () => []);
|
||||
try {
|
||||
const chain = isComponentLoaded(this.hass.config, "logbook")
|
||||
? await resolveLogbookChain(this.hass, entry, options)
|
||||
: await resolveWithoutFetch();
|
||||
if (this._params?.entry !== entry) {
|
||||
return;
|
||||
}
|
||||
this._chain = chain;
|
||||
} catch {
|
||||
if (this._params?.entry !== entry) {
|
||||
return;
|
||||
}
|
||||
this._error = true;
|
||||
this._chain = await resolveWithoutFetch();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const { entry } = this._params;
|
||||
const item = computeLogbookItem(this.hass, entry);
|
||||
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize("ui.dialogs.logbook_detail.title")}
|
||||
@closed=${this._dialogClosed}
|
||||
@hass-more-info=${this._moreInfoOpened}
|
||||
>
|
||||
<div class="content">
|
||||
${this._renderFacts(item, entry)} ${this._renderWhatHappened(entry)}
|
||||
</div>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderFacts(item: LogbookItem, entry: LogbookEntry) {
|
||||
const stateObj = entry.entity_id
|
||||
? this.hass.states[entry.entity_id]
|
||||
: undefined;
|
||||
const transition = this._transitionValues(item, entry, stateObj);
|
||||
const when = new Date(item.when);
|
||||
const subjectKey =
|
||||
item.category === "entity"
|
||||
? "entity"
|
||||
: item.category === "automation"
|
||||
? entry.domain === "script"
|
||||
? "script"
|
||||
: "automation"
|
||||
: "integration";
|
||||
|
||||
return html`
|
||||
<div class="box">
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
${this.hass.localize(
|
||||
`ui.dialogs.logbook_detail.${subjectKey}` as LocalizeKeys
|
||||
)}
|
||||
</span>
|
||||
<span class="value">
|
||||
${this._renderName(item.name, entry.entity_id)}
|
||||
${
|
||||
item.context
|
||||
? html`<span class="sub">${item.context}</span>`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
${
|
||||
transition
|
||||
? html`
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.state")}
|
||||
</span>
|
||||
<span class="value">
|
||||
${
|
||||
transition.oldState
|
||||
? html`<span class="old-state"
|
||||
>${transition.oldState}</span
|
||||
><span class="arrow">${this._arrow()}</span>`
|
||||
: nothing
|
||||
}<span class="new-state">${transition.newState}</span>
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: item.value
|
||||
? html`
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.event")}
|
||||
</span>
|
||||
<span class="value">${item.value.text}</span>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.time")}
|
||||
</span>
|
||||
<span class="value time-value">
|
||||
${formatDateTimeWithSeconds(
|
||||
when,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}
|
||||
<span class="sub">
|
||||
<ha-relative-time .datetime=${when} capitalize></ha-relative-time>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderWhatHappened(entry: LogbookEntry) {
|
||||
return html`
|
||||
<div class="section">
|
||||
<h3 class="section-title">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.what_happened")}
|
||||
</h3>
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this.hass.localize("ui.components.logbook.retrieval_error")}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._chain === undefined
|
||||
? html`<div class="loading"><ha-spinner></ha-spinner></div>`
|
||||
: html`<ha-logbook-chain
|
||||
.hass=${this.hass}
|
||||
.chain=${this._chain}
|
||||
.subject=${entry}
|
||||
.traceContexts=${this._params?.traceContexts ?? {}}
|
||||
></ha-logbook-chain>`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderName(name: string | undefined, entityId?: string) {
|
||||
if (entityId && entityId in this.hass.states) {
|
||||
return html`<button
|
||||
class="link name"
|
||||
.entityId=${entityId}
|
||||
@click=${this._entityClicked}
|
||||
>
|
||||
${name}
|
||||
</button>`;
|
||||
}
|
||||
return html`<span class="name">${name}</span>`;
|
||||
}
|
||||
|
||||
private _arrow() {
|
||||
return computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "←"
|
||||
: "→";
|
||||
}
|
||||
|
||||
private _transitionValues(
|
||||
item: LogbookItem,
|
||||
entry: LogbookEntry,
|
||||
stateObj?: HassEntity
|
||||
): { oldState?: string; newState: string } | undefined {
|
||||
if (item.category !== "entity" || entry.state === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const newState = stateObj
|
||||
? this.hass.formatEntityState(stateObj, entry.state)
|
||||
: entry.state;
|
||||
const previousState = this._params?.previousState;
|
||||
const oldState =
|
||||
previousState !== undefined && previousState !== entry.state
|
||||
? stateObj
|
||||
? this.hass.formatEntityState(stateObj, previousState)
|
||||
: previousState
|
||||
: undefined;
|
||||
return { oldState, newState };
|
||||
}
|
||||
|
||||
private _entityClicked(ev: Event) {
|
||||
const entityId = (ev.currentTarget as HTMLElement & { entityId?: string })
|
||||
.entityId;
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
this.closeDialog();
|
||||
fireEvent(this, "hass-more-info", { entityId });
|
||||
}
|
||||
|
||||
private _moreInfoOpened() {
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.box {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.box > .row + .row {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
min-height: 48px;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.row .label {
|
||||
color: var(--secondary-text-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row .value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: end;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.value .name {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
button.link.name {
|
||||
color: var(--primary-text-color);
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.link.name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: block;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
.old-state {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--disabled-color);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.new-state {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
ha-relative-time {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-logbook-detail": DialogLogbookDetail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { mdiClockOutline, mdiFlash, mdiHomeAssistant } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import "../../components/ha-state-icon";
|
||||
import "../../components/ha-svg-icon";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { createHistoricState, localizeTriggerSource } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
import { buttonLinkStyle, haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { LogbookChain } from "./logbook-chain-resolver";
|
||||
import type { LogbookCause } from "./logbook-entry-model";
|
||||
import {
|
||||
classifyLogbookEntry,
|
||||
computeLogbookItem,
|
||||
computeTraceLink,
|
||||
entityDisplay,
|
||||
isSameLogbookEntry,
|
||||
nodeColor,
|
||||
} from "./logbook-entry-model";
|
||||
import {
|
||||
renderLogbookCauseIcon,
|
||||
renderLogbookGlyph,
|
||||
} from "./logbook-entry-templates";
|
||||
|
||||
@customElement("ha-logbook-chain")
|
||||
class HaLogbookChain extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public chain!: LogbookChain;
|
||||
|
||||
@property({ attribute: false }) public subject!: LogbookEntry;
|
||||
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
|
||||
protected render() {
|
||||
const { rows, runRow, origins, syntheticRun, triggerRow } = this.chain;
|
||||
|
||||
if (!origins.length && !runRow && !syntheticRun && rows.length <= 1) {
|
||||
return html`
|
||||
<div class="box">
|
||||
<p class="no-cause">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.no_known_cause")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// An entity chain shows only its subject's rows; a run chain shows
|
||||
// everything the run did.
|
||||
const subjectIsRun = classifyLogbookEntry(this.subject) === "automation";
|
||||
const visibleRows = subjectIsRun
|
||||
? rows
|
||||
: rows.filter(
|
||||
(row) =>
|
||||
classifyLogbookEntry(row) === "automation" ||
|
||||
(this.subject.entity_id
|
||||
? row.entity_id === this.subject.entity_id
|
||||
: isSameLogbookEntry(row, this.subject))
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="box">
|
||||
<div class="chain">
|
||||
${origins.map((origin) =>
|
||||
this._renderOriginNode(
|
||||
origin,
|
||||
runRow ?? this.subject,
|
||||
origin.type === "state" ? triggerRow : undefined
|
||||
)
|
||||
)}
|
||||
${syntheticRun ? this._renderSyntheticRunNode(syntheticRun) : nothing}
|
||||
${visibleRows.map((row) =>
|
||||
classifyLogbookEntry(row) === "automation"
|
||||
? this._renderRunNode(row)
|
||||
: this._renderEffectNode(row)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSyntheticRunNode(cause: LogbookCause) {
|
||||
const sub = this.subject.context_source
|
||||
? localizeTriggerSource(this.hass.localize, this.subject.context_source)
|
||||
: this.hass.localize(
|
||||
cause.type === "script"
|
||||
? "ui.components.logbook.script_ran"
|
||||
: "ui.components.logbook.automation_triggered"
|
||||
);
|
||||
return html`
|
||||
<div class="chain-row">
|
||||
<span class="chain-node run">${renderLogbookCauseIcon(cause)}</span>
|
||||
<span class="chain-content">
|
||||
${this._renderName(cause.name, cause.entityId)}
|
||||
${sub ? html`<span class="chain-secondary">${sub}</span>` : nothing}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOriginNode(
|
||||
origin: LogbookCause,
|
||||
originRow: LogbookEntry,
|
||||
triggerRow?: LogbookEntry
|
||||
) {
|
||||
const name =
|
||||
origin.name ||
|
||||
this.hass.localize("ui.components.logbook.cause.scheduled");
|
||||
const isState = origin.type === "state";
|
||||
const triggerState = isState
|
||||
? (triggerRow?.state ?? originRow.context_state)
|
||||
: undefined;
|
||||
const stateObj = origin.entityId
|
||||
? this.hass.states[origin.entityId]
|
||||
: undefined;
|
||||
const triggerValue = triggerState
|
||||
? stateObj
|
||||
? this.hass.formatEntityState(stateObj, triggerState)
|
||||
: triggerState
|
||||
: undefined;
|
||||
const secondary = isState
|
||||
? origin.entityId
|
||||
? entityDisplay(this.hass, origin.entityId).secondary
|
||||
: undefined
|
||||
: this._actionUsedText(originRow);
|
||||
const isAvatar = origin.type === "user" && !origin.systemUser;
|
||||
const historicStateObj =
|
||||
stateObj && triggerState
|
||||
? createHistoricState(stateObj, triggerState)
|
||||
: stateObj;
|
||||
const color =
|
||||
isState && historicStateObj
|
||||
? nodeColor("entity", historicStateObj)
|
||||
: undefined;
|
||||
return html`
|
||||
<div class="chain-row">
|
||||
<span
|
||||
class="chain-node ${isAvatar ? "avatar" : ""}"
|
||||
style=${color ? styleMap({ "--node-color": color }) : nothing}
|
||||
>
|
||||
${this._renderOriginIcon(origin, historicStateObj)}
|
||||
</span>
|
||||
<span class="chain-content">
|
||||
${this._renderName(name, origin.entityId)}
|
||||
${
|
||||
secondary
|
||||
? html`<span class="chain-secondary">${secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
${
|
||||
triggerValue
|
||||
? html`<span class="chain-trailing">
|
||||
<span class="trailing-state">${triggerValue}</span>
|
||||
${
|
||||
triggerRow
|
||||
? html`<span class="trailing-time"
|
||||
>${this._formatTimeWithMs(triggerRow.when * 1000)}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _actionUsedText(originRow: LogbookEntry) {
|
||||
if (
|
||||
originRow.context_event_type === "call_service" &&
|
||||
originRow.context_domain &&
|
||||
originRow.context_service
|
||||
) {
|
||||
return this.hass.localize("ui.dialogs.logbook_detail.action_used", {
|
||||
name: this._actionName(
|
||||
originRow.context_domain,
|
||||
originRow.context_service
|
||||
),
|
||||
});
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private _renderOriginIcon(origin: LogbookCause, stateObj?: HassEntity) {
|
||||
if (origin.type === "state") {
|
||||
return stateObj
|
||||
? html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
|
||||
: html`<ha-svg-icon .path=${mdiFlash}></ha-svg-icon>`;
|
||||
}
|
||||
if (origin.type === "scheduled") {
|
||||
return html`<ha-svg-icon .path=${mdiClockOutline}></ha-svg-icon>`;
|
||||
}
|
||||
if (origin.type === "homeassistant") {
|
||||
return html`<ha-svg-icon .path=${mdiHomeAssistant}></ha-svg-icon>`;
|
||||
}
|
||||
return renderLogbookCauseIcon(origin);
|
||||
}
|
||||
|
||||
private _formatTimeWithMs(when: number) {
|
||||
const time = formatTimeWithSeconds(
|
||||
new Date(when),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
const ms = String(Math.floor(when % 1000)).padStart(3, "0");
|
||||
return `${time}.${ms}`;
|
||||
}
|
||||
|
||||
private _renderRunNode(row: LogbookEntry) {
|
||||
const item = computeLogbookItem(this.hass, row);
|
||||
const time = this._formatTimeWithMs(item.when);
|
||||
const traceLink = computeTraceLink(this.traceContexts, row.context_id);
|
||||
return html`
|
||||
<div class="chain-row">
|
||||
<span class="chain-node run">
|
||||
${renderLogbookGlyph(this.hass, row, item.glyph)}
|
||||
</span>
|
||||
<span class="chain-content">
|
||||
${this._renderName(item.name, row.entity_id)}
|
||||
${
|
||||
traceLink
|
||||
? html`<a
|
||||
class="trace-link"
|
||||
href=${traceLink}
|
||||
@click=${this._traceClicked}
|
||||
>${this.hass.localize("ui.components.logbook.view_trace")}</a
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
<span class="chain-trailing">
|
||||
${
|
||||
item.value
|
||||
? html`<span class="trailing-state">${item.value.text}</span>`
|
||||
: nothing
|
||||
}
|
||||
<span class="trailing-time">${time}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderEffectNode(row: LogbookEntry) {
|
||||
const item = computeLogbookItem(this.hass, row);
|
||||
const stateObj = row.entity_id
|
||||
? this.hass.states[row.entity_id]
|
||||
: undefined;
|
||||
const historicStateObj = stateObj
|
||||
? createHistoricState(stateObj, row.state)
|
||||
: undefined;
|
||||
const color = nodeColor(item.category, historicStateObj);
|
||||
const time = this._formatTimeWithMs(item.when);
|
||||
return html`
|
||||
<div class="chain-row">
|
||||
<span
|
||||
class="chain-node"
|
||||
style=${color ? styleMap({ "--node-color": color }) : nothing}
|
||||
>
|
||||
${renderLogbookGlyph(this.hass, row, item.glyph)}
|
||||
</span>
|
||||
<span class="chain-content">
|
||||
${this._renderName(item.name, row.entity_id)}
|
||||
${
|
||||
item.context
|
||||
? html`<span class="chain-secondary">${item.context}</span>`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
<span class="chain-trailing">
|
||||
${
|
||||
item.value
|
||||
? html`<span class="trailing-state">${item.value.text}</span>`
|
||||
: nothing
|
||||
}
|
||||
<span class="trailing-time">${time}</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderName(name: string | undefined, entityId?: string) {
|
||||
if (entityId && entityId in this.hass.states) {
|
||||
return html`<button
|
||||
class="link name"
|
||||
.entityId=${entityId}
|
||||
@click=${this._entityClicked}
|
||||
>
|
||||
${name}
|
||||
</button>`;
|
||||
}
|
||||
return html`<span class="name">${name}</span>`;
|
||||
}
|
||||
|
||||
private _actionName(domain: string, service: string) {
|
||||
return (
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${service}.name` as LocalizeKeys
|
||||
) ||
|
||||
this.hass.services[domain]?.[service]?.name ||
|
||||
`${domain}.${service}`
|
||||
);
|
||||
}
|
||||
|
||||
private _entityClicked(ev: Event) {
|
||||
const entityId = (ev.currentTarget as HTMLElement & { entityId?: string })
|
||||
.entityId;
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "hass-more-info", { entityId });
|
||||
}
|
||||
|
||||
private _traceClicked(ev: MouseEvent) {
|
||||
if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
// navigate() closes the dialogs above this chain.
|
||||
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.box {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chain-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
min-height: 56px;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Caret between rows: the chain reads top-down, cause to effects. */
|
||||
.chain-row + .chain-row::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
inset-inline-start: 27px;
|
||||
border-inline-start: 5px solid transparent;
|
||||
border-inline-end: 5px solid transparent;
|
||||
border-top: 6px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.chain-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background-color: var(--card-background-color);
|
||||
color: var(--node-color, var(--secondary-text-color));
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
|
||||
.chain-node state-badge {
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chain-node::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-color: currentColor;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.chain-node > * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chain-node.run {
|
||||
color: var(
|
||||
--logbook-category-automation-color,
|
||||
var(--light-blue-color)
|
||||
);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
.chain-node.avatar::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chain-node.avatar ha-user-badge {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chain-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chain-content .name {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
button.link.name {
|
||||
color: var(--primary-text-color);
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.link.name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chain-secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
.chain-trailing {
|
||||
text-align: end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trailing-state {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trailing-time {
|
||||
display: block;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.trace-link {
|
||||
flex-shrink: 0;
|
||||
color: var(--primary-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trace-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.no-cause {
|
||||
margin: 0;
|
||||
padding: var(--ha-space-3) var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-logbook-chain": HaLogbookChain;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,52 @@
|
||||
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { computeTimelineColor } from "../../components/chart/timeline-color";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
|
||||
import { useAmPm } from "../../common/datetime/use_am_pm";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/entity/state-badge";
|
||||
import "../../components/ha-relative-time";
|
||||
import "../../components/ha-domain-icon";
|
||||
import "../../components/ha-state-icon";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-tooltip";
|
||||
import "../../components/user/ha-user-badge";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
import type { User } from "../../data/user";
|
||||
import { buttonLinkStyle, haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type {
|
||||
LogbookCause,
|
||||
LogbookCauseType,
|
||||
LogbookGlyph,
|
||||
LogbookItem,
|
||||
LogbookNameDetail,
|
||||
LogbookValue,
|
||||
} from "./logbook-entry-model";
|
||||
import { computeLogbookItem, nodeColor } from "./logbook-entry-model";
|
||||
import {
|
||||
computeLogbookItem,
|
||||
nodeColor,
|
||||
TRIGGER_DOMAINS,
|
||||
} from "./logbook-entry-model";
|
||||
renderLogbookCauseIcon,
|
||||
renderLogbookGlyph,
|
||||
} from "./logbook-entry-templates";
|
||||
|
||||
type EntryLayout = "timeline" | "list" | "inline";
|
||||
|
||||
interface LogbookRenderItem extends LogbookItem {
|
||||
traceLink: string | undefined;
|
||||
renderedTime: TemplateResult | string;
|
||||
renderedValue: TemplateResult | string;
|
||||
}
|
||||
|
||||
// Names are the fixed system user names set by core (cloud/cast integrations).
|
||||
const SYSTEM_USER_ICONS: Record<string, string> = {
|
||||
"Home Assistant Cloud": mdiCloud,
|
||||
"Home Assistant Cast": mdiCast,
|
||||
};
|
||||
export interface LogbookEntrySelectedDetail {
|
||||
index: number;
|
||||
item: LogbookEntry;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"logbook-entry-selected": LogbookEntrySelectedDetail;
|
||||
"logbook-toggle-time": undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ha-logbook-entry")
|
||||
class HaLogbookEntry extends LitElement {
|
||||
@@ -65,7 +59,10 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public systemUserIds = new Set<string>();
|
||||
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
@property({ attribute: false }) public index = -1;
|
||||
|
||||
@property({ type: Boolean, attribute: "no-row-click" }) public noRowClick =
|
||||
false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@@ -99,17 +96,6 @@ class HaLogbookEntry extends LitElement {
|
||||
systemUserIds: this.systemUserIds,
|
||||
});
|
||||
|
||||
const traceContext =
|
||||
entry.domain &&
|
||||
TRIGGER_DOMAINS.includes(entry.domain) &&
|
||||
entry.context_id &&
|
||||
entry.context_id in this.traceContexts
|
||||
? this.traceContexts[entry.context_id]
|
||||
: undefined;
|
||||
const traceLink = traceContext
|
||||
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
|
||||
: undefined;
|
||||
|
||||
const hideName = this.nameDetail === "none";
|
||||
const layout: EntryLayout =
|
||||
!this.narrow && !this.noIcon ? "timeline" : hideName ? "inline" : "list";
|
||||
@@ -125,11 +111,12 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
const ctx: LogbookRenderItem = {
|
||||
...item,
|
||||
traceLink,
|
||||
renderedTime,
|
||||
renderedValue: this._renderValue(item.value, seenEntityIds, !!traceLink),
|
||||
renderedValue: this._renderValue(item.value, seenEntityIds),
|
||||
};
|
||||
|
||||
const clickable = !this.noRowClick;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="entry ${classMap({
|
||||
@@ -138,7 +125,12 @@ class HaLogbookEntry extends LitElement {
|
||||
"last-of-day": this.lastOfDay,
|
||||
[`category-${ctx.category}`]: true,
|
||||
"time-am-pm": useAmPm(this.hass.locale),
|
||||
clickable,
|
||||
})}"
|
||||
role=${ifDefined(clickable ? "button" : undefined)}
|
||||
tabindex=${ifDefined(clickable ? "0" : undefined)}
|
||||
@click=${this._rowClicked}
|
||||
@keydown=${this._rowKeydown}
|
||||
>
|
||||
${
|
||||
layout === "timeline"
|
||||
@@ -176,24 +168,36 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
private _toggleTime(e: Event) {
|
||||
e.stopPropagation();
|
||||
fireEvent(this, "logbook-toggle-time" as any);
|
||||
fireEvent(this, "logbook-toggle-time");
|
||||
}
|
||||
|
||||
private _timeKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fireEvent(this, "logbook-toggle-time" as any);
|
||||
e.stopPropagation();
|
||||
fireEvent(this, "logbook-toggle-time");
|
||||
}
|
||||
}
|
||||
|
||||
private _handleTraceClick(ev: MouseEvent) {
|
||||
// Let modified clicks open in a new tab; otherwise route in-app.
|
||||
if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
|
||||
private _rowClicked() {
|
||||
if (this.noRowClick) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "logbook-entry-selected", {
|
||||
index: this.index,
|
||||
item: this.item,
|
||||
});
|
||||
}
|
||||
|
||||
private _rowKeydown(ev: KeyboardEvent) {
|
||||
if (this.noRowClick || (ev.key !== "Enter" && ev.key !== " ")) {
|
||||
return;
|
||||
}
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
|
||||
fireEvent(this, "closed");
|
||||
this._rowClicked();
|
||||
}
|
||||
|
||||
private _entityClicked(ev: Event) {
|
||||
@@ -217,7 +221,6 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
private _renderTrailing(
|
||||
cause: LogbookCause | undefined,
|
||||
traceLink: string | undefined,
|
||||
renderedTime: TemplateResult | string
|
||||
) {
|
||||
return html`<span class="trailing">
|
||||
@@ -225,24 +228,14 @@ class HaLogbookEntry extends LitElement {
|
||||
cause
|
||||
? html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
|
||||
<span class="cause-badge" id="cause-badge"
|
||||
>${this._renderCauseIcon(cause)}</span
|
||||
>${renderLogbookCauseIcon(cause)}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
${this._renderTimeChip(renderedTime)}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
private _renderTraceLink(traceLink: string) {
|
||||
return html`<a
|
||||
class="trace-link"
|
||||
href=${traceLink}
|
||||
@click=${this._handleTraceClick}
|
||||
>${this.hass.localize("ui.components.logbook.view_trace")}</a
|
||||
>`;
|
||||
}
|
||||
|
||||
private _renderTimeline(ctx: LogbookRenderItem) {
|
||||
const hideName = this.nameDetail === "none";
|
||||
const rtl = computeRTL(
|
||||
@@ -279,15 +272,9 @@ class HaLogbookEntry extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
causePhrase || ctx.traceLink
|
||||
causePhrase
|
||||
? html`<div class="secondary">
|
||||
${
|
||||
causePhrase
|
||||
? html`<span class="cause-phrase">${causePhrase}</span>`
|
||||
: nothing
|
||||
}
|
||||
${causePhrase && ctx.traceLink ? html`·` : nothing}
|
||||
${ctx.traceLink ? this._renderTraceLink(ctx.traceLink) : nothing}
|
||||
<span class="cause-phrase">${causePhrase}</span>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
@@ -297,9 +284,7 @@ class HaLogbookEntry extends LitElement {
|
||||
private _renderList(ctx: LogbookRenderItem) {
|
||||
const cause =
|
||||
this.showCause || ctx.category === "entity" ? ctx.cause : undefined;
|
||||
const trailingTrace = this.showCause ? undefined : ctx.traceLink;
|
||||
const thirdLineTrace = this.showCause ? ctx.traceLink : undefined;
|
||||
const showThirdLine = this.showCause && (cause || thirdLineTrace);
|
||||
const showThirdLine = this.showCause && cause;
|
||||
return html`
|
||||
<div class="primary">
|
||||
<span class="subject"
|
||||
@@ -313,26 +298,22 @@ class HaLogbookEntry extends LitElement {
|
||||
<span class="secondary-text">${ctx.context ?? nothing}</span>
|
||||
${this._renderTrailing(
|
||||
showThirdLine ? undefined : cause,
|
||||
trailingTrace,
|
||||
ctx.renderedTime
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
showThirdLine
|
||||
? html`<div class="secondary">
|
||||
${this._renderListCauseLine(cause, thirdLineTrace)}
|
||||
${this._renderListCauseLine(cause)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderListCauseLine(
|
||||
cause: LogbookCause | undefined,
|
||||
traceLink: string | undefined
|
||||
) {
|
||||
private _renderListCauseLine(cause: LogbookCause | undefined) {
|
||||
if (!cause) {
|
||||
return traceLink ? this._renderTraceLink(traceLink) : nothing;
|
||||
return nothing;
|
||||
}
|
||||
const { localize } = this.hass;
|
||||
if (cause.entityId) {
|
||||
@@ -357,12 +338,10 @@ class HaLogbookEntry extends LitElement {
|
||||
>
|
||||
${cause.name}
|
||||
</button>
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<span class="secondary-text">${this._renderCausePhrase(cause)}</span>
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -372,7 +351,6 @@ class HaLogbookEntry extends LitElement {
|
||||
<span class="primary-text">${ctx.renderedValue}</span>
|
||||
${this._renderTrailing(
|
||||
ctx.category === "entity" ? ctx.cause : undefined,
|
||||
ctx.traceLink,
|
||||
ctx.renderedTime
|
||||
)}
|
||||
</div>
|
||||
@@ -381,21 +359,19 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
private _renderValue(
|
||||
value: LogbookValue | undefined,
|
||||
seenEntityIds: string[],
|
||||
noLink: boolean
|
||||
seenEntityIds: string[]
|
||||
): TemplateResult | string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return value.type === "message"
|
||||
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds, noLink)
|
||||
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds)
|
||||
: value.text;
|
||||
}
|
||||
|
||||
private _renderEntity(
|
||||
entityId: string | undefined,
|
||||
entityName: string | undefined,
|
||||
noLink?: boolean
|
||||
entityName: string | undefined
|
||||
) {
|
||||
const hasState = entityId && entityId in this.hass.states;
|
||||
const displayName =
|
||||
@@ -406,15 +382,13 @@ class HaLogbookEntry extends LitElement {
|
||||
if (!hasState) {
|
||||
return displayName;
|
||||
}
|
||||
return noLink
|
||||
? displayName
|
||||
: html`<button
|
||||
class="link"
|
||||
@click=${this._entityClicked}
|
||||
.entityId=${entityId}
|
||||
>
|
||||
${displayName}
|
||||
</button>`;
|
||||
return html`<button
|
||||
class="link"
|
||||
@click=${this._entityClicked}
|
||||
.entityId=${entityId}
|
||||
>
|
||||
${displayName}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private _renderCausePhrase(cause: LogbookCause): TemplateResult | string {
|
||||
@@ -470,54 +444,9 @@ class HaLogbookEntry extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _renderCauseIcon(cause: LogbookCause) {
|
||||
if (cause.type === "user") {
|
||||
const systemIcon = cause.systemUser
|
||||
? SYSTEM_USER_ICONS[cause.name]
|
||||
: undefined;
|
||||
if (systemIcon) {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${systemIcon}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
return html`<ha-user-badge
|
||||
class="cause-icon cause-avatar"
|
||||
.user=${{ id: cause.userId!, name: cause.name } as User}
|
||||
></ha-user-badge>`;
|
||||
}
|
||||
if (cause.type === "automation") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiRobot}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "script") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiScriptText}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "state") {
|
||||
return nothing;
|
||||
}
|
||||
if (cause.brandDomain) {
|
||||
return html`<ha-domain-icon
|
||||
class="cause-icon"
|
||||
.domain=${cause.brandDomain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>`;
|
||||
}
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiPuzzle}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
|
||||
private _formatMessageWithPossibleEntity(
|
||||
message: string,
|
||||
seenEntities: string[],
|
||||
noLink?: boolean
|
||||
seenEntities: string[]
|
||||
) {
|
||||
if (message.indexOf(".") !== -1) {
|
||||
const messageParts = message.split(" ");
|
||||
@@ -533,8 +462,7 @@ class HaLogbookEntry extends LitElement {
|
||||
return html`${messageParts.join(" ")}
|
||||
${this._renderEntity(
|
||||
entityId,
|
||||
this.hass.states[entityId].attributes.friendly_name,
|
||||
noLink
|
||||
this.hass.states[entityId].attributes.friendly_name
|
||||
)}
|
||||
${messageEnd.join(" ")}`;
|
||||
}
|
||||
@@ -571,49 +499,11 @@ class HaLogbookEntry extends LitElement {
|
||||
const unavailable =
|
||||
item.glyph.type === "state" && item.glyph.stateObj.state === UNAVAILABLE;
|
||||
return html`<div class="node-glyph" style=${style}>
|
||||
${this._renderGlyph(item.glyph)}
|
||||
${renderLogbookGlyph(this.hass, this.item, item.glyph)}
|
||||
${unavailable ? html`<span class="node-badge"></span>` : nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderGlyph(glyph: LogbookGlyph) {
|
||||
if (glyph.type === "automation") {
|
||||
return html`<ha-svg-icon
|
||||
.path=${glyph.script ? mdiScriptText : mdiRobot}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (glyph.type === "state") {
|
||||
return html`<ha-state-icon
|
||||
.stateObj=${glyph.stateObj}
|
||||
.icon=${glyph.icon}
|
||||
></ha-state-icon>`;
|
||||
}
|
||||
return html`<state-badge
|
||||
.overrideIcon=${glyph.icon}
|
||||
.overrideImage=${this._brandImage(glyph.domain)}
|
||||
.stateColor=${false}
|
||||
></state-badge>`;
|
||||
}
|
||||
|
||||
private _brandImage(domain?: string): string | undefined {
|
||||
if (
|
||||
!domain ||
|
||||
this.item.icon ||
|
||||
this.item.state ||
|
||||
!isComponentLoaded(this.hass.config, domain)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return brandsUrl(
|
||||
{
|
||||
domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
@@ -672,6 +562,19 @@ class HaLogbookEntry extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
.entry.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.entry.clickable:hover {
|
||||
background-color: rgba(var(--rgb-primary-text-color), 0.04);
|
||||
}
|
||||
|
||||
.entry.clickable:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -957,6 +860,7 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
.time-chip {
|
||||
flex-shrink: 0;
|
||||
min-width: 4.5em;
|
||||
text-align: end;
|
||||
line-height: 1;
|
||||
font-size: var(--ha-font-size-s);
|
||||
@@ -966,10 +870,6 @@ class HaLogbookEntry extends LitElement {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time-chip {
|
||||
min-width: 4.5em;
|
||||
}
|
||||
|
||||
.entry.time-am-pm .time-chip {
|
||||
min-width: 6em;
|
||||
}
|
||||
@@ -1006,18 +906,6 @@ class HaLogbookEntry extends LitElement {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
/* The trace link sits after the cause; it never shrinks, so a long
|
||||
cause truncates instead. */
|
||||
.trace-link {
|
||||
flex-shrink: 0;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trace-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Entity names read as the subject, not a wall of blue links — the
|
||||
colored node is the scan anchor. */
|
||||
button.link {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { customElement, eventOptions, property, state } from "lit/decorators";
|
||||
import { formatDate } from "../../common/datetime/format_date";
|
||||
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
@@ -13,13 +14,14 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook-entry";
|
||||
import type { LogbookEntrySelectedDetail } from "./ha-logbook-entry";
|
||||
import type { LogbookNameDetail } from "./logbook-entry-model";
|
||||
import { sameDay } from "./logbook-entry-model";
|
||||
import { findPreviousState, sameDay } from "./logbook-entry-model";
|
||||
import { showLogbookDetailDialog } from "./show-dialog-logbook-detail";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"hass-logbook-live": { enable: boolean };
|
||||
"logbook-toggle-time": undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +34,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public systemUserIds = new Set<string>();
|
||||
|
||||
// Not rendered by rows; read at click time and handed to the detail dialog.
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
|
||||
@property({ attribute: false }) public entries: LogbookEntry[] = [];
|
||||
@@ -49,6 +52,9 @@ class HaLogbookRenderer extends LitElement {
|
||||
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
|
||||
false;
|
||||
|
||||
@property({ type: Boolean, attribute: "no-row-click" }) public noRowClick =
|
||||
false;
|
||||
|
||||
@property({ type: String, attribute: "name-detail" })
|
||||
public nameDetail?: LogbookNameDetail;
|
||||
|
||||
@@ -77,7 +83,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
return (
|
||||
changedProps.has("entries") ||
|
||||
changedProps.has("traceContexts") ||
|
||||
changedProps.has("noRowClick") ||
|
||||
changedProps.has("_showRelative" as never) ||
|
||||
languageChanged
|
||||
);
|
||||
@@ -97,6 +103,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
class="container ha-scrollbar"
|
||||
@scroll=${this._saveScrollPos}
|
||||
@logbook-toggle-time=${this._handleToggleTime}
|
||||
@logbook-entry-selected=${this._handleEntrySelected}
|
||||
>
|
||||
${
|
||||
this.virtualize
|
||||
@@ -139,9 +146,9 @@ class HaLogbookRenderer extends LitElement {
|
||||
<ha-logbook-entry
|
||||
.hass=${this.hass}
|
||||
.item=${item}
|
||||
.index=${index}
|
||||
.userIdToName=${this.userIdToName}
|
||||
.systemUserIds=${this.systemUserIds}
|
||||
.traceContexts=${this.traceContexts}
|
||||
.narrow=${this.narrow}
|
||||
.noIcon=${this.noIcon}
|
||||
.graphColor=${this.graphColor}
|
||||
@@ -150,6 +157,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
.lastOfDay=${lastOfDay}
|
||||
.showRelative=${this._showRelative}
|
||||
.showCause=${this.showCause}
|
||||
.noRowClick=${this.noRowClick}
|
||||
></ha-logbook-entry>
|
||||
</div>
|
||||
`;
|
||||
@@ -159,6 +167,24 @@ class HaLogbookRenderer extends LitElement {
|
||||
this._showRelative = !this._showRelative;
|
||||
}
|
||||
|
||||
private _handleEntrySelected(ev: HASSDomEvent<LogbookEntrySelectedDetail>) {
|
||||
ev.stopPropagation();
|
||||
const { item } = ev.detail;
|
||||
let index = ev.detail.index;
|
||||
if (this.entries[index] !== item) {
|
||||
// A recycled virtualizer row can deliver a stale index.
|
||||
index = this.entries.indexOf(item);
|
||||
}
|
||||
showLogbookDetailDialog(this, {
|
||||
entry: item,
|
||||
previousState:
|
||||
index >= 0 ? findPreviousState(this.entries, index) : undefined,
|
||||
traceContexts: this.traceContexts,
|
||||
userIdToName: this.userIdToName,
|
||||
systemUserIds: this.systemUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
private _formatDateHeader(date: Date): string {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { getLogbookEvents } from "../../data/logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { LogbookCause } from "./logbook-entry-model";
|
||||
import {
|
||||
classifyLogbookEntry,
|
||||
computeContextCause,
|
||||
computeLogbookCause,
|
||||
computeUserCause,
|
||||
isRunCause,
|
||||
isSameLogbookEntry,
|
||||
} from "./logbook-entry-model";
|
||||
|
||||
// No run start is available and a delayed script can start long before the
|
||||
// clicked entry.
|
||||
const LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const WHEN_EPSILON = 0.001;
|
||||
|
||||
const MAX_RUN_CANDIDATES = 3;
|
||||
|
||||
export interface LogbookChain {
|
||||
rows: LogbookEntry[];
|
||||
runRow?: LogbookEntry;
|
||||
// Causes shown above the run, topmost first.
|
||||
origins: LogbookCause[];
|
||||
// Stands in for the run row when it could not be fetched.
|
||||
syntheticRun?: LogbookCause;
|
||||
// The state change that fired a state trigger in `origins`.
|
||||
triggerRow?: LogbookEntry;
|
||||
}
|
||||
|
||||
export type LogbookFetcher = (
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
entityIds?: string[],
|
||||
contextId?: string
|
||||
) => Promise<LogbookEntry[]>;
|
||||
|
||||
interface ResolveOptions {
|
||||
userIdToName?: Record<string, string>;
|
||||
systemUserIds?: Set<string>;
|
||||
}
|
||||
|
||||
const lookbackIso = (when: number) =>
|
||||
new Date(when * 1000 - LOOKBACK_MS).toISOString();
|
||||
|
||||
const justAfterIso = (when: number) =>
|
||||
new Date(when * 1000 + 1000).toISOString();
|
||||
|
||||
// Effect rows never carry a context_id, run rows do.
|
||||
const resolveRowsByContextEntity = async (
|
||||
entry: LogbookEntry,
|
||||
fetchEvents: LogbookFetcher
|
||||
): Promise<LogbookEntry[]> => {
|
||||
const contextEntityId = entry.context_entity_id!;
|
||||
const runs = (
|
||||
await fetchEvents(lookbackIso(entry.when), justAfterIso(entry.when), [
|
||||
contextEntityId,
|
||||
])
|
||||
)
|
||||
.filter((row) => row.context_id && row.when <= entry.when + WHEN_EPSILON)
|
||||
.sort((a, b) => b.when - a.when)
|
||||
.slice(0, MAX_RUN_CANDIDATES);
|
||||
|
||||
for (const run of runs) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const rows = await fetchEvents(
|
||||
lookbackIso(entry.when),
|
||||
undefined,
|
||||
undefined,
|
||||
run.context_id
|
||||
);
|
||||
if (rows.some((row) => isSameLogbookEntry(row, entry))) {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const resolveTriggerRow = async (
|
||||
entityId: string,
|
||||
beforeWhen: number,
|
||||
fetchEvents: LogbookFetcher
|
||||
): Promise<LogbookEntry | undefined> => {
|
||||
const rows = await fetchEvents(
|
||||
lookbackIso(beforeWhen),
|
||||
justAfterIso(beforeWhen),
|
||||
[entityId]
|
||||
);
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const row = rows[i];
|
||||
if (row.state !== undefined && row.when <= beforeWhen + WHEN_EPSILON) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const resolveLogbookChain = async (
|
||||
hass: HomeAssistant,
|
||||
entry: LogbookEntry,
|
||||
options: ResolveOptions = {},
|
||||
fetchEvents: LogbookFetcher = (startDate, endDate, entityIds, contextId) =>
|
||||
getLogbookEvents(hass, startDate, endDate, entityIds, contextId)
|
||||
): Promise<LogbookChain> => {
|
||||
const userIdToName = options.userIdToName ?? {};
|
||||
const { systemUserIds } = options;
|
||||
|
||||
let rows: LogbookEntry[] = [];
|
||||
if (entry.context_id) {
|
||||
rows = await fetchEvents(
|
||||
lookbackIso(entry.when),
|
||||
undefined,
|
||||
undefined,
|
||||
entry.context_id
|
||||
);
|
||||
} else if (entry.context_entity_id) {
|
||||
rows = await resolveRowsByContextEntity(entry, fetchEvents);
|
||||
}
|
||||
if (!rows.length) {
|
||||
rows = [entry];
|
||||
}
|
||||
|
||||
let runRow = rows.find((row) => classifyLogbookEntry(row) === "automation");
|
||||
if (runRow && runRow !== entry && isSameLogbookEntry(entry, runRow)) {
|
||||
// The clicked feed copy carries the call_service description that the
|
||||
// fetched copy of the run row never has.
|
||||
rows = rows.map((row) => (row === runRow ? entry : row));
|
||||
runRow = entry;
|
||||
}
|
||||
const origins: LogbookCause[] = [];
|
||||
let syntheticRun: LogbookCause | undefined;
|
||||
if (runRow) {
|
||||
const runCause = computeLogbookCause(
|
||||
hass,
|
||||
runRow,
|
||||
userIdToName,
|
||||
systemUserIds
|
||||
);
|
||||
if (runCause?.type !== "user" && !isSameLogbookEntry(entry, runRow)) {
|
||||
// The run row is its own context origin and comes back without the
|
||||
// user its effects carry: read the user from the clicked entry.
|
||||
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
|
||||
if (userCause) {
|
||||
origins.push(userCause);
|
||||
}
|
||||
}
|
||||
if (runCause) {
|
||||
origins.push(runCause);
|
||||
}
|
||||
} else {
|
||||
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
|
||||
const contextCause = computeContextCause(hass, entry);
|
||||
if (isRunCause(contextCause)) {
|
||||
syntheticRun = contextCause;
|
||||
if (userCause) {
|
||||
origins.push(userCause);
|
||||
}
|
||||
} else {
|
||||
const cause = userCause ?? contextCause;
|
||||
if (cause) {
|
||||
origins.push(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stateOrigin = origins.find(
|
||||
(cause) => cause.type === "state" && cause.entityId
|
||||
);
|
||||
const triggerRow = stateOrigin
|
||||
? await resolveTriggerRow(
|
||||
stateOrigin.entityId!,
|
||||
(runRow ?? entry).when,
|
||||
fetchEvents
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return { rows, runRow, origins, syntheticRun, triggerRow };
|
||||
};
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
localizeStateMessage,
|
||||
parseTriggerSource,
|
||||
} from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export type LogbookEntryCategory = "entity" | "automation" | "integration";
|
||||
|
||||
export const TRIGGER_DOMAINS = ["automation", "script"];
|
||||
const TRIGGER_DOMAINS = ["automation", "script"];
|
||||
|
||||
export const stripEntityId = (message: string, entityId?: string) =>
|
||||
const stripEntityId = (message: string, entityId?: string) =>
|
||||
entityId ? message.replace(entityId, " ") : message;
|
||||
|
||||
export const classifyLogbookEntry = (
|
||||
@@ -96,12 +97,49 @@ export const entityDisplay = (
|
||||
return { primary, secondary };
|
||||
};
|
||||
|
||||
export const hasContext = (item: LogbookEntry) =>
|
||||
const hasContext = (item: LogbookEntry) =>
|
||||
item.context_event_type || item.context_state || item.context_message;
|
||||
|
||||
export const sameDay = (a?: LogbookEntry, b?: LogbookEntry) =>
|
||||
!!a?.when && !!b?.when && isSameDay(a.when * 1000, b.when * 1000);
|
||||
|
||||
// Entries are sorted newest first.
|
||||
export const findPreviousState = (
|
||||
entries: LogbookEntry[],
|
||||
index: number
|
||||
): string | undefined => {
|
||||
const entityId = entries[index]?.entity_id;
|
||||
if (!entityId) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = index + 1; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry.entity_id === entityId && entry.state !== undefined) {
|
||||
return entry.state;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const isSameLogbookEntry = (a: LogbookEntry, b: LogbookEntry) =>
|
||||
a.when === b.when &&
|
||||
a.entity_id === b.entity_id &&
|
||||
a.state === b.state &&
|
||||
a.message === b.message &&
|
||||
a.name === b.name;
|
||||
|
||||
// Every entry of a run shares the run's context id, so effect rows resolve
|
||||
// to their cause's trace too.
|
||||
export const computeTraceLink = (
|
||||
traceContexts: TraceContexts,
|
||||
contextId?: string
|
||||
): string | undefined => {
|
||||
const traceContext = contextId ? traceContexts[contextId] : undefined;
|
||||
return traceContext
|
||||
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// Unavailable is flagged with an orange badge by the row, not a color change.
|
||||
export const nodeColor = (
|
||||
category: LogbookEntryCategory,
|
||||
@@ -131,8 +169,7 @@ export interface LogbookCause {
|
||||
brandDomain?: string;
|
||||
}
|
||||
|
||||
export const computeLogbookCause = (
|
||||
hass: HomeAssistant,
|
||||
export const computeUserCause = (
|
||||
item: LogbookEntry,
|
||||
userIdToName: Record<string, string>,
|
||||
systemUserIds?: Set<string>
|
||||
@@ -140,15 +177,21 @@ export const computeLogbookCause = (
|
||||
const userName = item.context_user_id
|
||||
? userIdToName[item.context_user_id]
|
||||
: undefined;
|
||||
if (userName) {
|
||||
return {
|
||||
type: "user",
|
||||
name: userName,
|
||||
userId: item.context_user_id,
|
||||
systemUser: systemUserIds?.has(item.context_user_id!),
|
||||
};
|
||||
if (!userName) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: "user",
|
||||
name: userName,
|
||||
userId: item.context_user_id,
|
||||
systemUser: systemUserIds?.has(item.context_user_id!),
|
||||
};
|
||||
};
|
||||
|
||||
export const computeContextCause = (
|
||||
hass: HomeAssistant,
|
||||
item: LogbookEntry
|
||||
): LogbookCause | undefined => {
|
||||
if (
|
||||
item.context_event_type === "automation_triggered" ||
|
||||
item.context_event_type === "script_started"
|
||||
@@ -239,6 +282,18 @@ export const computeLogbookCause = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const computeLogbookCause = (
|
||||
hass: HomeAssistant,
|
||||
item: LogbookEntry,
|
||||
userIdToName: Record<string, string>,
|
||||
systemUserIds?: Set<string>
|
||||
): LogbookCause | undefined =>
|
||||
computeUserCause(item, userIdToName, systemUserIds) ??
|
||||
computeContextCause(hass, item);
|
||||
|
||||
export const isRunCause = (cause?: LogbookCause): boolean =>
|
||||
cause?.type === "automation" || cause?.type === "script";
|
||||
|
||||
export type LogbookGlyph =
|
||||
| { type: "state"; stateObj: HassEntity; icon?: string }
|
||||
| { type: "automation"; script: boolean }
|
||||
@@ -284,10 +339,8 @@ const computeLogbookValue = (
|
||||
type: "state",
|
||||
};
|
||||
}
|
||||
const isAutomationRun =
|
||||
domain &&
|
||||
TRIGGER_DOMAINS.includes(domain) &&
|
||||
(item.source || hasContext(item) || !!item.context_user_id);
|
||||
// Core sends run rows with a raw English message; use our own label.
|
||||
const isAutomationRun = domain && TRIGGER_DOMAINS.includes(domain);
|
||||
if (isAutomationRun) {
|
||||
return {
|
||||
text: hass.localize(
|
||||
@@ -348,6 +401,13 @@ export const computeLogbookItem = (
|
||||
? entityDisplay(hass, entry.entity_id, opts.nameDetail)
|
||||
: undefined;
|
||||
|
||||
const userCause = computeUserCause(
|
||||
entry,
|
||||
opts.userIdToName ?? {},
|
||||
opts.systemUserIds
|
||||
);
|
||||
const contextCause = computeContextCause(hass, entry);
|
||||
|
||||
return {
|
||||
category,
|
||||
glyph: computeLogbookGlyph(entry, category, historicStateObj, domain),
|
||||
@@ -355,12 +415,10 @@ export const computeLogbookItem = (
|
||||
name: display?.primary ?? entry.name,
|
||||
context: display?.secondary,
|
||||
value: computeLogbookValue(hass, entry, domain, historicStateObj),
|
||||
cause: computeLogbookCause(
|
||||
hass,
|
||||
entry,
|
||||
opts.userIdToName ?? {},
|
||||
opts.systemUserIds
|
||||
),
|
||||
// A row shows the run over the user who started it; the dialog shows both.
|
||||
cause: isRunCause(contextCause)
|
||||
? contextCause
|
||||
: (userCause ?? contextCause),
|
||||
when: entry.when * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
|
||||
import { html, nothing } from "lit";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import "../../components/entity/state-badge";
|
||||
import "../../components/ha-domain-icon";
|
||||
import "../../components/ha-state-icon";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/user/ha-user-badge";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { User } from "../../data/user";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type { LogbookCause, LogbookGlyph } from "./logbook-entry-model";
|
||||
|
||||
// Names are the fixed system user names set by core (cloud/cast integrations).
|
||||
const SYSTEM_USER_ICONS: Record<string, string> = {
|
||||
"Home Assistant Cloud": mdiCloud,
|
||||
"Home Assistant Cast": mdiCast,
|
||||
};
|
||||
|
||||
export const renderLogbookCauseIcon = (cause: LogbookCause) => {
|
||||
if (cause.type === "user") {
|
||||
const systemIcon = cause.systemUser
|
||||
? SYSTEM_USER_ICONS[cause.name]
|
||||
: undefined;
|
||||
if (systemIcon) {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${systemIcon}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
return html`<ha-user-badge
|
||||
class="cause-icon cause-avatar"
|
||||
.user=${{ id: cause.userId!, name: cause.name } as User}
|
||||
></ha-user-badge>`;
|
||||
}
|
||||
if (cause.type === "automation") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiRobot}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "script") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiScriptText}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "state") {
|
||||
return nothing;
|
||||
}
|
||||
if (cause.brandDomain) {
|
||||
return html`<ha-domain-icon
|
||||
class="cause-icon"
|
||||
.domain=${cause.brandDomain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>`;
|
||||
}
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiPuzzle}
|
||||
></ha-svg-icon>`;
|
||||
};
|
||||
|
||||
const brandImage = (
|
||||
hass: HomeAssistant,
|
||||
entry: LogbookEntry,
|
||||
domain?: string
|
||||
): string | undefined => {
|
||||
if (
|
||||
!domain ||
|
||||
entry.icon ||
|
||||
entry.state ||
|
||||
!isComponentLoaded(hass.config, domain)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return brandsUrl(
|
||||
{
|
||||
domain,
|
||||
type: "icon",
|
||||
darkOptimized: hass.themes?.darkMode,
|
||||
},
|
||||
hass.auth.data.hassUrl
|
||||
);
|
||||
};
|
||||
|
||||
export const renderLogbookGlyph = (
|
||||
hass: HomeAssistant,
|
||||
entry: LogbookEntry,
|
||||
glyph: LogbookGlyph
|
||||
) => {
|
||||
if (glyph.type === "automation") {
|
||||
return html`<ha-svg-icon
|
||||
.path=${glyph.script ? mdiScriptText : mdiRobot}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (glyph.type === "state") {
|
||||
return html`<ha-state-icon
|
||||
.stateObj=${glyph.stateObj}
|
||||
.icon=${glyph.icon}
|
||||
></ha-state-icon>`;
|
||||
}
|
||||
return html`<state-badge
|
||||
.overrideIcon=${glyph.icon}
|
||||
.overrideImage=${brandImage(hass, entry, glyph.domain)}
|
||||
.stateColor=${false}
|
||||
></state-badge>`;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
|
||||
export interface LogbookDetailDialogParams {
|
||||
entry: LogbookEntry;
|
||||
previousState?: string;
|
||||
traceContexts?: TraceContexts;
|
||||
userIdToName?: Record<string, string>;
|
||||
systemUserIds?: Set<string>;
|
||||
}
|
||||
|
||||
export const loadLogbookDetailDialog = () => import("./dialog-logbook-detail");
|
||||
|
||||
export const showLogbookDetailDialog = (
|
||||
element: HTMLElement,
|
||||
params: LogbookDetailDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-logbook-detail",
|
||||
dialogImport: loadLogbookDetailDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
@@ -2046,6 +2046,19 @@
|
||||
"description": "Description",
|
||||
"required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]"
|
||||
},
|
||||
"logbook_detail": {
|
||||
"title": "Activity details",
|
||||
"entity": "Entity",
|
||||
"automation": "Automation",
|
||||
"script": "Script",
|
||||
"integration": "Integration",
|
||||
"state": "State",
|
||||
"event": "Event",
|
||||
"time": "Time",
|
||||
"what_happened": "What happened",
|
||||
"no_known_cause": "No cause was recorded for this activity.",
|
||||
"action_used": "Action used: {name}"
|
||||
},
|
||||
"voice-settings": {
|
||||
"expose_header": "Expose",
|
||||
"aliases_header": "Aliases",
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LogbookEntry } from "../../../src/data/logbook";
|
||||
import type { LogbookFetcher } from "../../../src/panels/logbook/logbook-chain-resolver";
|
||||
import { resolveLogbookChain } from "../../../src/panels/logbook/logbook-chain-resolver";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
const hass = {
|
||||
language: "en",
|
||||
translationMetadata: { translations: {} },
|
||||
states: {},
|
||||
entities: {},
|
||||
devices: {},
|
||||
areas: {},
|
||||
floors: {},
|
||||
localize: () => "",
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const USERS = { user_1: "Alice" };
|
||||
|
||||
const entry = (partial: Partial<LogbookEntry>): LogbookEntry => ({
|
||||
when: 0,
|
||||
name: "",
|
||||
...partial,
|
||||
});
|
||||
|
||||
const runRow = (when: number, contextId: string): LogbookEntry =>
|
||||
entry({
|
||||
when,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
source: "state of binary_sensor.motion",
|
||||
context_id: contextId,
|
||||
});
|
||||
|
||||
const effectRow = (when: number): LogbookEntry =>
|
||||
entry({
|
||||
when,
|
||||
name: "Ceiling light",
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_event_type: "automation_triggered",
|
||||
context_name: "Wake up",
|
||||
context_entity_id: "automation.wake_up",
|
||||
context_source: "state of binary_sensor.motion",
|
||||
});
|
||||
|
||||
interface FetchCall {
|
||||
entityIds?: string[];
|
||||
contextId?: string;
|
||||
}
|
||||
|
||||
const makeFetcher = (
|
||||
handler: (entityIds?: string[], contextId?: string) => LogbookEntry[]
|
||||
): { fetch: LogbookFetcher; calls: FetchCall[] } => {
|
||||
const calls: FetchCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
fetch: async (_start, _end, entityIds, contextId) => {
|
||||
calls.push({ entityIds, contextId });
|
||||
return handler(entityIds, contextId);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("resolveLogbookChain", () => {
|
||||
it("fetches by context id when the entry has one", async () => {
|
||||
const run = runRow(10, "ctx_run");
|
||||
const effect = { ...effectRow(11), context_id: "ctx_run" };
|
||||
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
|
||||
if (contextId === "ctx_run") {
|
||||
return [run, effect];
|
||||
}
|
||||
if (entityIds?.includes("binary_sensor.motion")) {
|
||||
return [
|
||||
entry({ when: 9.8, entity_id: "binary_sensor.motion", state: "on" }),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(calls[0].contextId).toBe("ctx_run");
|
||||
expect(chain.runRow).toBe(run);
|
||||
expect(chain.rows).toEqual([run, effect]);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("state");
|
||||
expect(chain.origins[0].entityId).toBe("binary_sensor.motion");
|
||||
expect(chain.syntheticRun).toBeUndefined();
|
||||
expect(chain.triggerRow?.when).toBe(9.8);
|
||||
});
|
||||
|
||||
it("resolves the run through the context entity and verifies candidates", async () => {
|
||||
const effect = effectRow(11);
|
||||
const otherRun = runRow(10.9, "ctx_other");
|
||||
const goodRun = runRow(10, "ctx_good");
|
||||
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
|
||||
if (entityIds?.includes("automation.wake_up")) {
|
||||
return [goodRun, otherRun];
|
||||
}
|
||||
if (contextId === "ctx_other") {
|
||||
// The closest run does not contain the clicked entry.
|
||||
return [
|
||||
otherRun,
|
||||
entry({ when: 11, entity_id: "light.desk", state: "on" }),
|
||||
];
|
||||
}
|
||||
if (contextId === "ctx_good") {
|
||||
return [goodRun, effect];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(calls[0].entityIds).toEqual(["automation.wake_up"]);
|
||||
expect(calls[1].contextId).toBe("ctx_other");
|
||||
expect(calls[2].contextId).toBe("ctx_good");
|
||||
expect(chain.runRow?.context_id).toBe("ctx_good");
|
||||
expect(chain.rows).toEqual([goodRun, effect]);
|
||||
});
|
||||
|
||||
it("falls back to a synthetic run when no candidate matches", async () => {
|
||||
const effect = effectRow(11);
|
||||
const { fetch } = makeFetcher((entityIds) =>
|
||||
entityIds?.includes("automation.wake_up")
|
||||
? [runRow(10, "ctx_unrelated")]
|
||||
: []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(chain.runRow).toBeUndefined();
|
||||
expect(chain.rows).toEqual([effect]);
|
||||
expect(chain.syntheticRun?.type).toBe("automation");
|
||||
expect(chain.origins).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a direct user action as the only origin", async () => {
|
||||
const direct = entry({
|
||||
when: 5,
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "light",
|
||||
context_service: "turn_on",
|
||||
});
|
||||
const { fetch, calls } = makeFetcher(() => []);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
direct,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(chain.rows).toEqual([direct]);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
expect(chain.origins[0].name).toBe("Alice");
|
||||
expect(chain.syntheticRun).toBeUndefined();
|
||||
});
|
||||
|
||||
it("picks the last trigger state before the run, even minutes earlier", async () => {
|
||||
const run = runRow(600, "ctx_run");
|
||||
const effect = { ...effectRow(601), context_id: "ctx_run" };
|
||||
const { fetch } = makeFetcher((entityIds, contextId) => {
|
||||
if (contextId === "ctx_run") {
|
||||
return [run, effect];
|
||||
}
|
||||
if (entityIds?.includes("binary_sensor.motion")) {
|
||||
return [
|
||||
entry({ when: 100, entity_id: "binary_sensor.motion", state: "on" }),
|
||||
entry({ when: 480, entity_id: "binary_sensor.motion", state: "off" }),
|
||||
entry({ when: 640, entity_id: "binary_sensor.motion", state: "on" }),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(chain.triggerRow?.when).toBe(480);
|
||||
expect(chain.triggerRow?.state).toBe("off");
|
||||
});
|
||||
|
||||
it("prefers the clicked copy of the run row over the fetched one", async () => {
|
||||
const clicked = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "automation",
|
||||
context_service: "trigger",
|
||||
});
|
||||
const fetched = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
});
|
||||
const effect = { ...effectRow(11), context_id: "ctx_run" };
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
clicked,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(chain.runRow).toBe(clicked);
|
||||
expect(chain.rows).toEqual([clicked, effect]);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
expect(chain.origins[0].name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("surfaces the integration that triggered the run", async () => {
|
||||
const clicked = entry({
|
||||
when: 20,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "homekit",
|
||||
context_service: "turn_on",
|
||||
});
|
||||
const fetched = entry({
|
||||
when: 20,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
});
|
||||
const effect = { ...effectRow(21), context_id: "ctx_run" };
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(hass, clicked, {}, fetch);
|
||||
|
||||
expect(chain.runRow).toBe(clicked);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("integration");
|
||||
expect(chain.origins[0].brandDomain).toBe("homekit");
|
||||
});
|
||||
|
||||
it("keeps the user above a run row that does not carry one", async () => {
|
||||
const fetched = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
});
|
||||
const effect = {
|
||||
...effectRow(11),
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
};
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
effect,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(chain.runRow).toBe(fetched);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
expect(chain.origins[0].name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("does not stack the user twice when the run row resolves it", async () => {
|
||||
const fetched = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
});
|
||||
const effect = {
|
||||
...effectRow(11),
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
};
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
effect,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeLogbookItem,
|
||||
computeTraceLink,
|
||||
classifyLogbookEntry,
|
||||
entityDisplay,
|
||||
computeLogbookCause,
|
||||
computeLogbookGlyph,
|
||||
findPreviousState,
|
||||
isSameLogbookEntry,
|
||||
} from "../../../src/panels/logbook/logbook-entry-model";
|
||||
import type { LogbookEntry } from "../../../src/data/logbook";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
@@ -241,7 +244,7 @@ describe("computeLogbookCause", () => {
|
||||
const cause = computeLogbookCause(
|
||||
hass,
|
||||
entry({ context_user_id: "person_1" }),
|
||||
{ person_1: "Paul" },
|
||||
{ person_1: "Alice" },
|
||||
new Set(["cloud_user"])
|
||||
);
|
||||
expect(cause?.type).toBe("user");
|
||||
@@ -403,3 +406,170 @@ describe("computeLogbookItem", () => {
|
||||
expect(model.value).toEqual({ text: "Ran", type: "state" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findPreviousState", () => {
|
||||
const entries: LogbookEntry[] = [
|
||||
entry({ when: 5, entity_id: "light.x", state: "on" }),
|
||||
entry({ when: 4, entity_id: "sensor.y", state: "42" }),
|
||||
entry({ when: 3, entity_id: "automation.z", domain: "automation" }),
|
||||
entry({ when: 2, entity_id: "light.x", state: "off" }),
|
||||
entry({ when: 1, entity_id: "light.x", state: "on" }),
|
||||
];
|
||||
|
||||
it("returns the nearest older state of the same entity", () => {
|
||||
expect(findPreviousState(entries, 0)).toBe("off");
|
||||
expect(findPreviousState(entries, 3)).toBe("on");
|
||||
});
|
||||
|
||||
it("skips entries of other entities", () => {
|
||||
expect(findPreviousState(entries, 1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips state-less entries of the same entity", () => {
|
||||
const list = [
|
||||
entry({ when: 3, entity_id: "automation.z", state: "on" }),
|
||||
entry({ when: 2, entity_id: "automation.z", domain: "automation" }),
|
||||
entry({ when: 1, entity_id: "automation.z", state: "off" }),
|
||||
];
|
||||
expect(findPreviousState(list, 0)).toBe("off");
|
||||
});
|
||||
|
||||
it("returns undefined at the end of the list", () => {
|
||||
expect(findPreviousState(entries, 4)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined without an entity or for an out-of-range index", () => {
|
||||
expect(findPreviousState(entries, 2)).toBeUndefined();
|
||||
expect(findPreviousState(entries, 99)).toBeUndefined();
|
||||
expect(findPreviousState([], 0)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameLogbookEntry", () => {
|
||||
const a = entry({
|
||||
when: 1.234567,
|
||||
entity_id: "light.x",
|
||||
state: "on",
|
||||
name: "Light",
|
||||
});
|
||||
|
||||
it("matches an identical entry", () => {
|
||||
expect(isSameLogbookEntry(a, { ...a })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an entry differing by any field", () => {
|
||||
expect(isSameLogbookEntry(a, { ...a, when: 1.234568 })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, entity_id: "light.y" })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, state: "off" })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, name: "Other" })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, message: "turned on" })).toBe(false);
|
||||
});
|
||||
|
||||
it("matches entity-less entries on name/message/when", () => {
|
||||
const b = entry({ when: 2, name: "HACS", message: "2 updates available" });
|
||||
expect(isSameLogbookEntry(b, { ...b })).toBe(true);
|
||||
expect(isSameLogbookEntry(b, { ...b, message: "other" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTraceLink", () => {
|
||||
const traceContexts = {
|
||||
ctx_1: { run_id: "run_9", domain: "automation", item_id: "auto_1" },
|
||||
};
|
||||
|
||||
it("builds the trace URL for a known context", () => {
|
||||
expect(computeTraceLink(traceContexts, "ctx_1")).toBe(
|
||||
"/config/automation/trace/auto_1?run_id=run_9"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown or missing context", () => {
|
||||
expect(computeTraceLink(traceContexts, "ctx_2")).toBeUndefined();
|
||||
expect(computeTraceLink(traceContexts, undefined)).toBeUndefined();
|
||||
expect(computeTraceLink({}, "ctx_1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLogbookItem cause", () => {
|
||||
const hass = baseHass({ localize: (() => "") as HomeAssistant["localize"] });
|
||||
const users = { user_1: "Alice" };
|
||||
|
||||
it("prefers the automation over the user who ran it", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "automation_triggered",
|
||||
context_entity_id: "automation.wake_up",
|
||||
context_name: "Wake up",
|
||||
}),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause?.type).toBe("automation");
|
||||
expect(model.cause?.name).toBe("Wake up");
|
||||
});
|
||||
|
||||
it("keeps the user for a direct action call", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "light",
|
||||
context_service: "turn_on",
|
||||
}),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause?.type).toBe("user");
|
||||
expect(model.cause?.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("falls back to the context cause without a user", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_event_type: "automation_triggered",
|
||||
context_name: "Wake up",
|
||||
}),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause?.type).toBe("automation");
|
||||
});
|
||||
|
||||
it("leaves the cause empty without any context", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({ entity_id: "light.ceiling", state: "on" }),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLogbookItem run rows", () => {
|
||||
it("uses the run label instead of the raw backend message", () => {
|
||||
const hass = baseHass({
|
||||
localize: ((key: string) => key) as HomeAssistant["localize"],
|
||||
});
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
name: "Wake up",
|
||||
message: "triggered",
|
||||
}),
|
||||
{}
|
||||
);
|
||||
expect(model.value).toEqual({
|
||||
text: "ui.components.logbook.automation_triggered",
|
||||
type: "state",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user