Compare commits

...

1 Commits

Author SHA1 Message Date
Paul Bottein 1ca1b529ef Add activity detail dialog 2026-07-28 18:54:27 +02:00
14 changed files with 1891 additions and 220 deletions
+1
View File
@@ -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>
`
+9
View File
@@ -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,
+384
View File
@@ -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;
}
}
+491
View File
@@ -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;
}
}
+81 -193
View File
@@ -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 {
+30 -4
View File
@@ -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 };
};
+80 -22
View File
@@ -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,
});
};
+13
View File
@@ -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");
});
});
+171 -1
View File
@@ -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",
});
});
});