Compare commits

...

7 Commits

Author SHA1 Message Date
Paul Bottein 665e4a7240 Refine logbook timeline rendering 2026-06-11 00:16:37 +02:00
Paul Bottein b9dba28198 Fix somes issues 2026-06-10 14:38:45 +02:00
Paul Bottein f381233e26 Adjust cause icon sizes: 18px standalone, 16px inline with text
Co-Authored-By: Paul Bottein <paul.bottein@gmail.com>
2026-06-09 19:18:50 +02:00
Paul Bottein 52fa33e8db Show cause icon in inline logbook entries
- Show cause icon (user avatar, trigger type, integration brand) next to
  the time in single-entity inline mode
- Use ha-trigger-icon for trigger-platform causes
- Use ha-domain-icon with brand-fallback for integration causes when
  context_domain is available, falling back to mdiPuzzle
- Tooltip with cause name on hover
- Icon size 18px, user avatar 18x18px

Co-Authored-By: Paul Bottein <paul.bottein@gmail.com>
2026-06-09 19:16:48 +02:00
Paul Bottein c7483a36dc Refine logbook timeline layout and entry rendering
- Three layout modes in ha-logbook-entry: wide (entity → state inline),
  compact (entity/state + context/time), inline (state + cause icon + time)
- Entity name bold in wide and compact modes, consistent with tile card
- Cause icon shown inline next to the time in inline (single-entity) mode
- Unavailable state rendered as an empty circle dot
- Flash icon for entity-triggered causes
- "Show more" chevron link in logbook card, device page, and area page
- Extract _renderWide / _renderCompact / _renderInline from render()
- Scope entity-name flex layout to .line1 > .entity-name (compact only)

Co-Authored-By: Paul Bottein <paul.bottein@gmail.com>
2026-06-09 19:09:57 +02:00
Paul Bottein 55a07d7678 Update color 2026-06-09 15:03:37 +02:00
Paul Bottein 8630b24fbc Redesign the Activity (logbook) as a timeline with entity context 2026-06-08 15:43:51 +02:00
17 changed files with 1957 additions and 930 deletions
-2
View File
@@ -17,8 +17,6 @@ export type LocalizeKeys =
| `ui.common.${string}`
| `ui.components.calendar.event.rrule.${string}`
| `ui.components.selectors.file.${string}`
| `ui.components.logbook.messages.detected_device_classes.${string}`
| `ui.components.logbook.messages.cleared_device_classes.${string}`
| `ui.dialogs.entity_registry.editor.${string}`
| `ui.dialogs.more_info_control.lawn_mower.${string}`
| `ui.dialogs.more_info_control.vacuum.${string}`
-1
View File
@@ -26,7 +26,6 @@ export class HaTraceLogbook extends LitElement {
return this.logbookEntries.length
? html`
<ha-logbook-renderer
relative-time
.hass=${this.hass}
.entries=${this.logbookEntries}
.narrow=${this.narrow}
@@ -388,7 +388,6 @@ export class HaTracePathDetails extends LitElement {
return entries.length
? html`
<ha-logbook-renderer
relative-time
.hass=${this.hass}
.entries=${entries}
.narrow=${this.narrow}
+2 -2
View File
@@ -18,7 +18,7 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
import { localizeTriggerDescription } from "../../data/logbook";
import { localizeTriggerSource } from "../../data/logbook";
import type {
ChooseAction,
IfAction,
@@ -333,7 +333,7 @@ class ActionRenderer {
: "other",
alias: triggerStep.changed_variables.trigger?.alias,
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
trigger: localizeTriggerDescription(
trigger: localizeTriggerSource(
this.hass.localize,
this.trace.trigger
),
+79 -195
View File
@@ -1,15 +1,9 @@
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import {
BINARY_STATE_OFF,
BINARY_STATE_ON,
DOMAINS_WITH_DYNAMIC_PICTURE,
} from "../common/const";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { autoCaseNoun } from "../common/translations/auto_case_noun";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE, UNKNOWN } from "./entity/entity";
import { isNumericEntity } from "./history";
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
@@ -29,7 +23,7 @@ export interface LogbookEntry {
message?: string;
entity_id?: string;
icon?: string;
source?: string; // The trigger source
source?: string; // The trigger source (English phrase, parsed for the cause)
domain?: string;
state?: string; // The state of the entity
// Context data
@@ -50,23 +44,27 @@ export interface LogbookEntry {
// Localization mapping for all the triggers in core
// in homeassistant.components.homeassistant.triggers
//
type TriggerPhraseKeys =
| "triggered_by_numeric_state_of"
| "triggered_by_state_of"
| "triggered_by_event"
| "triggered_by_time"
| "triggered_by_time_pattern"
| "triggered_by_homeassistant_stopping"
| "triggered_by_homeassistant_starting";
// Keys are the bare translation keys under `ui.components.logbook`.
//
type TriggerPhraseKey =
| "numeric_state_of"
| "state_of"
| "event"
| "time_pattern"
| "time"
| "homeassistant_stopping"
| "homeassistant_starting";
const triggerPhrases: Record<TriggerPhraseKeys, string> = {
triggered_by_numeric_state_of: "numeric state of", // number state trigger
triggered_by_state_of: "state of", // state trigger
triggered_by_event: "event", // event trigger
triggered_by_time_pattern: "time pattern", // time trigger
triggered_by_time: "time", // time trigger
triggered_by_homeassistant_stopping: "Home Assistant stopping", // stop event
triggered_by_homeassistant_starting: "Home Assistant starting", // start event
// Order matters: "time pattern" must be tested before "time" because the
// source phrase is matched with `startsWith`.
const triggerPhrases: Record<TriggerPhraseKey, string> = {
numeric_state_of: "numeric state of", // number state trigger
state_of: "state of", // state trigger
event: "event", // event trigger
time_pattern: "time pattern", // time trigger
time: "time", // time trigger
homeassistant_stopping: "Home Assistant stopping", // stop event
homeassistant_starting: "Home Assistant starting", // start event
};
export const getLogbookDataForContext = async (
@@ -158,215 +156,101 @@ export const createHistoricState = (
state: state,
attributes: {
// Rebuild the historical state by copying static attributes only
device_class: currentStateObj?.attributes.device_class,
source_type: currentStateObj?.attributes.source_type,
has_date: currentStateObj?.attributes.has_date,
has_time: currentStateObj?.attributes.has_time,
device_class: currentStateObj.attributes.device_class,
// Needed so numeric states keep their unit (e.g. "21 °C") and are
// recognised as numeric by computeStateDisplay.
unit_of_measurement: currentStateObj.attributes.unit_of_measurement,
state_class: currentStateObj.attributes.state_class,
source_type: currentStateObj.attributes.source_type,
has_date: currentStateObj.attributes.has_date,
has_time: currentStateObj.attributes.has_time,
// We do not want to use dynamic entity pictures (e.g., from media player) for the log book rendering,
// as they would present a false state in the log (played media right now vs actual historic data).
entity_picture_local: DOMAINS_WITH_DYNAMIC_PICTURE.has(
computeDomain(currentStateObj.entity_id)
)
? undefined
: currentStateObj?.attributes.entity_picture_local,
: currentStateObj.attributes.entity_picture_local,
entity_picture: DOMAINS_WITH_DYNAMIC_PICTURE.has(
computeDomain(currentStateObj.entity_id)
)
? undefined
: currentStateObj?.attributes.entity_picture,
: currentStateObj.attributes.entity_picture,
},
}) as unknown as HassEntity;
// Localize a backend trigger `source` phrase (e.g. "state of sensor.x") by
// translating the leading phrase while keeping the entity id. The automation
// trace timeline frames it with its own "triggered by" wording, so we only
// translate the bare description here.
export const localizeTriggerSource = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhraseKey of Object.keys(
triggerPhrases
) as TriggerPhraseKeys[]) {
const phrase = triggerPhrases[triggerPhraseKey];
for (const key of Object.keys(triggerPhrases) as TriggerPhraseKey[]) {
const phrase = triggerPhrases[key];
if (source.startsWith(phrase)) {
return source.replace(
phrase,
`${localize(`ui.components.logbook.${triggerPhraseKey}`)}`
);
return source.replace(phrase, localize(`ui.components.logbook.${key}`));
}
}
return source;
};
// Mapping from a phrase key to the bare-phrase translation key (without the
// "triggered by" prefix), used by localizeTriggerDescription below.
const triggerDescriptionKeys: Record<
TriggerPhraseKeys,
| "numeric_state_of"
| "state_of"
| "event"
export type TriggerPlatform =
| "state"
| "numeric_state"
| "time"
| "time_pattern"
| "homeassistant_stopping"
| "homeassistant_starting"
> = {
triggered_by_numeric_state_of: "numeric_state_of",
triggered_by_state_of: "state_of",
triggered_by_event: "event",
triggered_by_time_pattern: "time_pattern",
triggered_by_time: "time",
triggered_by_homeassistant_stopping: "homeassistant_stopping",
triggered_by_homeassistant_starting: "homeassistant_starting",
| "event"
| "homeassistant";
// Maps the English `triggerPhrases` to automation trigger platforms, so the
// feed can reuse the editor's trigger-type labels instead of dedicated strings.
const triggerPlatform: Record<TriggerPhraseKey, TriggerPlatform> = {
numeric_state_of: "numeric_state",
state_of: "state",
event: "event",
time_pattern: "time_pattern",
time: "time",
homeassistant_stopping: "homeassistant",
homeassistant_starting: "homeassistant",
};
// Like localizeTriggerSource, but returns just the bare localized trigger
// description (without the "triggered by" prefix). Used where the surrounding
// template already supplies its own "triggered by" wording.
export const localizeTriggerDescription = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhraseKey of Object.keys(
triggerPhrases
) as TriggerPhraseKeys[]) {
const phrase = triggerPhrases[triggerPhraseKey];
if (source.startsWith(phrase)) {
const bareKey = triggerDescriptionKeys[triggerPhraseKey];
return source.replace(
phrase,
`${localize(`ui.components.logbook.${bareKey}`)}`
);
export interface ParsedTriggerSource {
platform?: TriggerPlatform;
entityId?: string;
}
// Best-effort parse of the backend's English trigger `source` (e.g. "numeric
// state of sensor.x", "time pattern") into a platform + triggering entity.
// Temporary bridge until the backend sends the trigger structurally.
export const parseTriggerSource = (source: string): ParsedTriggerSource => {
for (const key of Object.keys(triggerPhrases) as TriggerPhraseKey[]) {
const phrase = triggerPhrases[key];
if (!source.startsWith(phrase)) {
continue;
}
const rest = source.slice(phrase.length).trim();
const entityId = /^[a-z_]+\.[a-z0-9_]+$/.test(rest) ? rest : undefined;
return { platform: triggerPlatform[key], entityId };
}
return source;
return {};
};
export const localizeStateMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,
state: string,
stateObj: HassEntity,
domain: string
): string => {
switch (domain) {
case "device_tracker":
case "person":
if (state === "not_home") {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_away`);
}
if (state === "home") {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_at_home`);
}
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_at_state`, { state });
case "sun":
return state === "above_horizon"
? localize(`${LOGBOOK_LOCALIZE_PATH}.rose`)
: localize(`${LOGBOOK_LOCALIZE_PATH}.set`);
case "binary_sensor": {
const isOn = state === BINARY_STATE_ON;
const isOff = state === BINARY_STATE_OFF;
const device_class = stateObj.attributes.device_class;
if (device_class && (isOn || isOff)) {
return (
localize(
`${LOGBOOK_LOCALIZE_PATH}.${isOn ? "detected_device_classes" : "cleared_device_classes"}.${device_class}`,
{
device_class: autoCaseNoun(
localize(
`component.binary_sensor.entity_component.${device_class}.name`
) || device_class,
hass.language
),
}
) ||
// If there's no key for a specific device class, fallback to generic string
localize(
`${LOGBOOK_LOCALIZE_PATH}.${isOn ? "detected_device_class" : "cleared_device_class"}`,
{
device_class: autoCaseNoun(
localize(
`component.binary_sensor.entity_component.${device_class}.name`
) || device_class,
hass.language
),
}
)
);
}
break;
}
case "cover":
switch (state) {
case "open":
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_opened`);
case "opening":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_opening`);
case "closing":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_closing`);
case "closed":
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_closed`);
}
break;
case "event": {
return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
// TODO: This is not working yet, as we don't get historic attribute values
const event_type = hass
.formatEntityAttributeValue(stateObj, "event_type")
?.toString();
if (!event_type) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_unknown_event`);
}
return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event`, {
event_type: autoCaseNoun(event_type, hass.language),
});
}
case "lock":
switch (state) {
case "unlocked":
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unlocked`);
case "locking":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_locking`);
case "unlocking":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_unlocking`);
case "opening":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_opening`);
case "open":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_opened`);
case "locked":
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_locked`);
case "jammed":
return localize(`${LOGBOOK_LOCALIZE_PATH}.is_jammed`);
}
break;
// Events expose a timestamp as their state, which has no meaningful display
// value, so keep a dedicated phrase.
if (domain === "event") {
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
}
if (state === BINARY_STATE_ON) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.turned_on`);
}
if (state === BINARY_STATE_OFF) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.turned_off`);
}
if (state === UNKNOWN) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.became_unknown`);
}
if (state === UNAVAILABLE) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.became_unavailable`);
}
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.changed_to_state`, {
state: stateObj ? hass.formatEntityState(stateObj, state) : state,
});
// Every other domain reuses the backend state translation, so the logbook
// speaks the same vocabulary as the rest of the UI.
return hass.formatEntityState(stateObj, state);
};
export const filterLogbookCompatibleEntities = (
@@ -42,11 +42,9 @@ export class MoreInfoLogbook extends LitElement {
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._entityIdAsList(this.entityId)}
.scope=${"entity"}
narrow
no-icon
no-name
show-indicator
relative-time
></ha-logbook>
`;
}
+35 -4
View File
@@ -1,5 +1,7 @@
import { startOfYesterday } from "date-fns";
import { consume } from "@lit/context";
import {
mdiChevronRight,
mdiDelete,
mdiDevices,
mdiDotsVertical,
@@ -30,6 +32,7 @@ import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { goBack, navigate } from "../../../common/navigate";
import { createSearchParam } from "../../../common/url/search-params";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
@@ -645,15 +648,30 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
<div class="column">
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card
outlined
.header=${this.hass.localize("panel.logbook")}
>
<ha-card outlined>
<div class="card-header logbook-header">
<span>${this.hass.localize("panel.logbook")}</span>
<a
href="/logbook?${createSearchParam({
area_id: this.areaId,
start_date: startOfYesterday().toISOString(),
back: "1",
})}"
>
<ha-icon-button
.path=${mdiChevronRight}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
></ha-icon-button>
</a>
</div>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._allEntities(memberships)}
.deviceIds=${this._allDeviceIds(memberships.devices)}
.scope=${"area"}
virtualize
narrow
no-icon
@@ -969,6 +987,19 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
opacity: 0.5;
border-radius: var(--ha-border-radius-circle);
}
.logbook-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.logbook-header a {
color: var(--primary-text-color);
margin-right: calc(var(--ha-space-2) * -1);
margin-inline-end: calc(var(--ha-space-2) * -1);
margin-inline-start: initial;
}
ha-logbook {
height: 400px;
}
@@ -1,7 +1,9 @@
import { startOfYesterday } from "date-fns";
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiCog,
mdiChevronRight,
mdiDelete,
mdiDotsVertical,
mdiDownload,
@@ -97,6 +99,7 @@ import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
@@ -1002,14 +1005,29 @@ export class HaConfigDevicePage extends LitElement {
${isComponentLoaded(this.hass.config, "logbook")
? html`
<ha-card outlined>
<h1 class="card-header">
${this.hass.localize("panel.logbook")}
</h1>
<div class="card-header">
<span>${this.hass.localize("panel.logbook")}</span>
<a
href="/logbook?${createSearchParam({
device_id: this.deviceId,
start_date: startOfYesterday().toISOString(),
back: "1",
})}"
>
<ha-icon-button
.path=${mdiChevronRight}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
></ha-icon-button>
</a>
</div>
<ha-logbook
.hass=${this.hass}
.time=${this._logbookTime}
.entityIds=${this._entityIds(entities)}
.deviceIds=${this._deviceIdInList(this.deviceId)}
.scope=${"device"}
virtualize
narrow
no-icon
@@ -1772,6 +1790,19 @@ export class HaConfigDevicePage extends LitElement {
display: block;
}
ha-card:has(ha-logbook) .card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
ha-card:has(ha-logbook) .card-header a {
color: var(--primary-text-color);
margin-right: calc(var(--ha-space-2) * -1);
margin-inline-end: calc(var(--ha-space-2) * -1);
margin-inline-start: initial;
}
ha-card:has(ha-logbook) {
padding-bottom: var(
--ha-card-border-radius,
+907
View File
@@ -0,0 +1,907 @@
import { 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 { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { relativeTime } from "../../common/datetime/relative_time";
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-domain-icon";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-trigger-icon";
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,
LogbookGlyph,
LogbookItem,
LogbookScope,
LogbookWhat,
} from "./logbook-entry-model";
import {
buildLogbookItem,
nodeColor,
TRIGGER_DOMAINS,
} from "./logbook-entry-model";
// How the row content is arranged (wide = desktop 3-line, compact = narrow
// 2-line, inline = narrow single-line) — orthogonal to the node style.
type EntryLayout = "wide" | "compact" | "inline";
// The timeline node: a tinted icon circle, or a small colored dot.
type EntryNode = "icon" | "dot";
@customElement("ha-logbook-entry")
class HaLogbookEntry extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public item!: LogbookEntry;
@property({ attribute: false }) public userIdToName: Record<string, string> =
{};
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: false }) public noIcon = false;
@property({ attribute: false }) public scope?: LogbookScope;
@property({ type: Boolean, attribute: false }) public firstOfDay = false;
@property({ type: Boolean, attribute: false }) public lastOfDay = false;
// Live computed-style handle, resolved once per element — reading custom
// properties forces a style recalc, costly to repeat per row while scrolling.
private _computedStyle?: CSSStyleDeclaration;
protected render() {
const item = this.item;
const seenEntityIds: string[] = [];
const model = buildLogbookItem(this.hass, item, {
scope: this.scope,
userIdToName: this.userIdToName,
});
const traceContext =
item.domain &&
TRIGGER_DOMAINS.includes(item.domain) &&
item.context_id &&
item.context_id in this.traceContexts
? this.traceContexts[item.context_id]
: undefined;
const traceLink = traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined;
// Two orthogonal style axes derived from the props:
// layout = how the content is arranged (driven by narrow + scope)
// node = icon circle vs colored dot (driven by noIcon)
const hideName = this.scope === "entity";
const layout: EntryLayout = !this.narrow
? "wide"
: hideName
? "inline"
: "compact";
const node: EntryNode = this.noIcon ? "dot" : "icon";
const whatHappened = this._renderWhat(
model.what,
seenEntityIds,
!!traceLink
);
const when = new Date(model.when);
const timeLabel = formatTimeWithSeconds(
when,
this.hass.locale,
this.hass.config
);
const relativeLabel = relativeTime(when, this.hass.locale);
return html`
<div
class="entry ${classMap({
[`layout-${layout}`]: true,
[`node-${node}`]: true,
"last-of-day": this.lastOfDay,
[`category-${model.category}`]: true,
})}"
>
${layout === "wide"
? html`<div class="time" title=${relativeLabel}>${timeLabel}</div>`
: nothing}
<div
class="node ${classMap({
"rail-trim-top": this.firstOfDay,
"rail-trim-bottom": this.lastOfDay,
})}"
>
${this._renderNode(model)}
</div>
<div class="content">
${layout === "wide"
? this._renderWide(
hideName,
model.entityId,
model.name,
traceLink,
whatHappened,
model.what?.kind === "value",
model.cause,
model.context
)
: layout === "compact"
? this._renderCompact(
model.entityId,
model.name,
traceLink,
whatHappened,
model.cause,
model.context,
timeLabel,
relativeLabel
)
: this._renderInline(
whatHappened,
model.cause,
traceLink,
timeLabel,
relativeLabel
)}
</div>
</div>
`;
}
private _renderTraceLink(traceLink: string) {
return html`<a
class="view-trace"
href=${traceLink}
@click=${this._handleTraceClick}
>${this.hass.localize("ui.components.logbook.view_trace")}</a
>`;
}
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) {
return;
}
ev.preventDefault();
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
fireEvent(this, "closed");
}
private _renderWhat(
what: LogbookWhat | undefined,
seenEntityIds: string[],
noLink: boolean
): TemplateResult | string {
if (!what) {
return "";
}
return what.kind === "phrase"
? this._formatMessageWithPossibleEntity(
what.text,
seenEntityIds,
undefined,
noLink
)
: what.text;
}
private _renderInline(
whatHappened: TemplateResult | string,
cause: LogbookCause | undefined,
traceLink: string | undefined,
timeLabel: string,
relativeLabel: string
) {
return html`
<div class="headline">
<span class="headline-main">${whatHappened}</span>
<span class="trailing">
${cause
? html`<span class="cause-icon-only" title=${cause.name}
>${this._causeIcon(cause)}</span
>`
: nothing}
${traceLink ? this._renderTraceLink(traceLink) : nothing}
<span class="time-inline" title=${relativeLabel}>${timeLabel}</span>
</span>
</div>
`;
}
private _renderCompact(
entityId: string | undefined,
name: string | undefined,
traceLink: string | undefined,
whatHappened: TemplateResult | string,
cause: LogbookCause | undefined,
contextText: string | undefined,
timeLabel: string,
relativeLabel: string
) {
return html`
<div class="headline">
<span class="entity-name"
>${this._renderEntity(entityId, name, !!traceLink)}</span
>
<span class="state-value">${whatHappened}</span>
</div>
${this._renderMeta(
cause,
contextText,
traceLink,
timeLabel,
relativeLabel
)}
`;
}
private _renderWide(
hideName: boolean,
entityId: string | undefined,
name: string | undefined,
traceLink: string | undefined,
whatHappened: TemplateResult | string,
whatIsValue: boolean,
cause: LogbookCause | undefined,
contextText: string | undefined
) {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<div class="headline">
<span class="headline-main"
>${!hideName
? html`<span class="entity-name"
>${this._renderEntity(entityId, name, !!traceLink)}</span
>${whatHappened
? whatIsValue
? html`<span class="state-arrow">${rtl ? "←" : "→"}</span>`
: " "
: nothing}`
: nothing}${whatHappened}</span
>
</div>
${contextText
? html`<div class="meta">
<span class="meta-main">${contextText}</span>
</div>`
: nothing}
${cause || traceLink
? html`<div class="meta">
${cause ? this._renderCauseLabel(cause) : nothing}
${traceLink ? this._renderTraceLink(traceLink) : nothing}
</div>`
: nothing}
`;
}
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
.hass=${this.hass}
.overrideIcon=${glyph.icon}
.overrideImage=${this._brandImage(glyph.domain)}
.stateColor=${false}
></state-badge>`;
}
// Integration brand logo for entries with no icon/state of their own.
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
);
}
private _renderNode(model: LogbookItem) {
const stateObj =
model.glyph.type === "state" ? model.glyph.stateObj : undefined;
const isUnavailable = this.item.state === UNAVAILABLE;
const color =
this.noIcon && !isUnavailable
? this.item.state
? computeTimelineColor(
this.item.state,
(this._computedStyle ??= getComputedStyle(this)),
stateObj
)
: undefined
: nodeColor(model.category, stateObj);
const style = color ? styleMap({ "--node-color": color }) : nothing;
if (this.noIcon) {
return html`<span
class="dot ${classMap({ unavailable: isUnavailable })}"
style=${style}
></span>`;
}
const unavailable =
model.glyph.type === "state" &&
model.glyph.stateObj.state === UNAVAILABLE;
return html`<div class="node-glyph" style=${style}>
${this._renderGlyph(model.glyph)}
${unavailable ? html`<span class="node-badge"></span>` : nothing}
</div>`;
}
// Narrow rows: context on the left, then the cause reduced to just its icon
// (name in the tooltip) sitting inline with the time.
private _renderMeta(
cause: LogbookCause | undefined,
contextText: string | undefined,
traceLink: string | undefined,
timeLabel: TemplateResult | string,
relativeLabel: string
) {
return html`<div class="meta">
<span class="meta-main">${contextText ?? nothing}</span>
<span class="trailing">
${cause
? html`<span class="cause-icon-only" title=${cause.name}
>${this._causeIcon(cause)}</span
>`
: nothing}
${traceLink ? this._renderTraceLink(traceLink) : nothing}
<span class="time-inline" title=${relativeLabel}>${timeLabel}</span>
</span>
</div>`;
}
private _causeIcon(cause: LogbookCause) {
if (cause.userId) {
return html`<ha-user-badge
class="cause-icon cause-avatar"
.user=${this._causeUser(cause.userId, cause.name)}
></ha-user-badge>`;
}
if (cause.triggerPlatform) {
return html`<ha-trigger-icon
class="cause-icon"
.trigger=${cause.triggerPlatform}
></ha-trigger-icon>`;
}
if (cause.brandDomain) {
return html`<ha-domain-icon
class="cause-icon"
.domain=${cause.brandDomain}
brand-fallback
></ha-domain-icon>`;
}
if (cause.iconPath) {
return html`<ha-svg-icon
class="cause-icon"
.path=${cause.iconPath}
></ha-svg-icon>`;
}
return nothing;
}
private _renderCauseLabel(cause: LogbookCause) {
return html`<span class="cause">
${this._causeIcon(cause)}
<span class="cause-name">${cause.name}</span>
</span>`;
}
// ha-user-badge only needs id + name; it resolves the picture from the user's
// person entity (or falls back to initials).
private _causeUser(id: string, name: string): User {
return { id, name } as User;
}
private _renderEntity(
entityId: string | undefined,
entityName: string | undefined,
noLink?: boolean
) {
const hasState = entityId && entityId in this.hass.states;
const displayName =
entityName ||
(hasState
? this.hass.states[entityId].attributes.friendly_name || entityId
: entityId);
if (!hasState) {
return displayName;
}
return noLink
? displayName
: html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
}
private _formatMessageWithPossibleEntity(
message: string,
seenEntities: string[],
possibleEntity?: string,
noLink?: boolean
) {
// Replace an entity_id in the message with a clickable entity link.
if (message.indexOf(".") !== -1) {
const messageParts = message.split(" ");
for (let i = 0, size = messageParts.length; i < size; i++) {
if (messageParts[i] in this.hass.states) {
const entityId = messageParts[i];
if (seenEntities.includes(entityId)) {
return "";
}
seenEntities.push(entityId);
const messageEnd = messageParts.splice(i);
messageEnd.shift();
return html`${messageParts.join(" ")}
${this._renderEntity(
entityId,
this.hass.states[entityId].attributes.friendly_name,
noLink
)}
${messageEnd.join(" ")}`;
}
}
}
// Otherwise link the attached entity if the message ends with its name.
if (possibleEntity && possibleEntity in this.hass.states) {
const possibleEntityName =
this.hass.states[possibleEntity].attributes.friendly_name;
if (possibleEntityName && message.endsWith(possibleEntityName)) {
if (seenEntities.includes(possibleEntity)) {
return "";
}
seenEntities.push(possibleEntity);
message = message.substring(
0,
message.length - possibleEntityName.length
);
return html`${message}
${this._renderEntity(possibleEntity, possibleEntityName, noLink)}`;
}
}
return message;
}
private _entityClicked(ev: Event) {
const entityId = (ev.currentTarget as any).entityId;
if (!entityId) {
return;
}
ev.preventDefault();
ev.stopPropagation();
fireEvent(this, "hass-more-info", { entityId });
}
static get styles(): CSSResultGroup {
return [
haStyle,
buttonLinkStyle,
css`
:host {
display: block;
}
.entry {
position: relative;
display: grid;
column-gap: var(--ha-space-3);
width: 100%;
box-sizing: border-box;
/* No vertical padding: the rail must reach the row edges so it stays
continuous between nodes. Air comes from min-height instead. */
padding: 0 var(--ha-space-4);
/* compact is the default; wide and inline override below. */
min-height: 60px;
line-height: var(--ha-line-height-normal);
align-items: stretch;
}
/* Wide: time column + node + content, taller to fit three lines. */
.entry.layout-wide {
grid-template-columns: 72px 36px minmax(0, 1fr);
min-height: 72px;
}
/* Compact & inline drop the time column (time moves inline). */
.entry.layout-compact,
.entry.layout-inline {
grid-template-columns: 36px minmax(0, 1fr);
}
.entry.layout-inline {
min-height: 40px;
}
/* Dot node is 10px, so its column can shrink. */
.entry.node-dot.layout-compact,
.entry.node-dot.layout-inline {
grid-template-columns: 28px minmax(0, 1fr);
column-gap: var(--ha-space-2);
}
.entry.category-automation {
--category-color: var(
--logbook-category-automation-color,
var(--light-blue-color)
);
}
.entry.category-integration {
--category-color: var(
--logbook-category-integration-color,
var(--teal-color)
);
}
.time {
display: flex;
align-items: center;
justify-content: flex-end;
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
white-space: nowrap;
}
.node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-self: stretch;
}
/* Two rail segments (::before = top, ::after = bottom) with a 2px gap
on each side of the node. --rail-gap = node half-size + 2px clearance. */
.node::before,
.node::after {
content: "";
position: absolute;
left: 50%;
width: 2px;
transform: translateX(-50%);
background-color: var(--divider-color);
z-index: 0;
}
.node::before {
top: 0;
bottom: calc(50% + var(--rail-gap, 22px));
}
.node::after {
top: calc(50% + var(--rail-gap, 22px));
bottom: 0;
}
/* Dot is 10px — gap of 7px (5px radius + 2px clearance). */
.entry.node-dot .node {
--rail-gap: 9px;
}
/* Two-line dot rows (compact): align dot to headline instead of
centering. --dot-pos is measured from node top and matches headline's
center (~20px = 8px content offset + 12px half-lineheight in a 60px row). */
.entry.node-dot:not(.layout-inline) .node {
--dot-pos: 20px;
justify-content: flex-start;
padding-top: calc(var(--dot-pos) - 5px);
}
.entry.node-dot:not(.layout-inline) .node::before {
bottom: calc(100% - var(--dot-pos) + 9px);
}
.entry.node-dot:not(.layout-inline) .node::after {
top: calc(var(--dot-pos) + 9px);
}
/* First row of a day: no rail above the icon. */
.node.rail-trim-top::before {
display: none;
}
/* Last row of a day: no rail below the icon. */
.node.rail-trim-bottom::after {
display: none;
}
.node-glyph {
--node-color: var(--category-color, var(--secondary-text-color));
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 36px;
height: 36px;
border-radius: var(--ha-border-radius-circle);
/* Opaque base so the rail reads as passing behind. */
background-color: var(--card-background-color);
color: var(--node-color);
--mdc-icon-size: 24px;
}
/* Tinted fill via an opacity layer (color-mix is not safe for our
browser support). */
.node-glyph::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background-color: var(--node-color);
opacity: 0.18;
}
.node-glyph > * {
position: relative;
}
/* Orange "attention" badge in the icon corner (unavailable). */
.node-badge {
position: absolute;
top: -1px;
right: -1px;
z-index: 2;
width: 9px;
height: 9px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--orange-color);
border: 1.5px solid var(--card-background-color);
}
/* Entity state changes stay round; system/app events use a squircle. */
.entry.category-automation .node-glyph,
.entry.category-integration .node-glyph {
border-radius: var(--ha-border-radius-md);
}
.node-glyph state-badge {
margin: 0;
color: inherit;
}
.dot {
--node-color: var(--category-color, var(--disabled-color));
position: relative;
z-index: 1;
width: 10px;
height: 10px;
border-radius: var(--ha-border-radius-circle);
background-color: var(--node-color);
}
.dot.unavailable {
background-color: transparent;
border: 2px solid var(--disabled-color);
box-sizing: border-box;
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
gap: 2px;
/* Discreet divider between rows, aligned to the content so it does
not cross the rail. Suppressed on the last row of each day. */
border-bottom: 1px solid var(--divider-color);
}
.entry.last-of-day .content {
border-bottom: none;
}
.entry.layout-compact .content,
.entry.layout-inline .content {
gap: 0;
}
.headline {
display: flex;
align-items: center;
gap: var(--ha-space-2);
color: var(--primary-text-color);
}
.headline-main {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entity-name {
font-weight: var(--ha-font-weight-medium);
}
.headline > .entity-name {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.headline > .entity-name button.link {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.state-value {
/* Don't shrink: the name (flex-shrink 1) absorbs all truncation so a
short state stays whole. max-width still caps a long one. */
flex: 0 0 auto;
min-width: 0;
max-width: 60%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
}
.time-inline {
flex-shrink: 0;
line-height: 1;
color: var(--secondary-text-color);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.cause-avatar {
flex-shrink: 0;
width: 16px;
height: 16px;
font-size: 9px;
}
/* Icon + time share one centered box so they align to each other,
independent of headline/meta's text height. */
.trailing {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
flex-shrink: 0;
font-size: var(--ha-font-size-s);
}
.cause-icon-only {
display: inline-flex;
align-items: center;
}
.meta {
display: flex;
align-items: center;
gap: var(--ha-space-2);
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.meta-main {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* .headline-main is a flex item (blockified), so ::first-letter applies;
.headline itself is a flex container, where it would not. */
.entry.layout-inline .headline-main:first-letter {
text-transform: capitalize;
}
.state-arrow {
color: var(--disabled-color);
padding: 0 2px;
}
/* Inline-flex so the icon/avatar is centered against the "by name"
text (custom-element icons have an unreliable baseline). */
.cause {
display: inline-flex;
align-items: center;
gap: var(--ha-space-1);
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.cause-icon {
flex-shrink: 0;
--mdc-icon-size: 16px;
color: var(--secondary-text-color);
}
.cause-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--primary-text-color);
}
/* The trace link sits after the cause; it never shrinks, so a long
cause truncates instead. */
.view-trace {
flex-shrink: 0;
color: var(--primary-color);
text-decoration: none;
}
.view-trace: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 {
color: var(--primary-text-color);
font-weight: var(--ha-font-weight-medium);
text-decoration: none;
}
button.link:hover {
text-decoration: underline;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-logbook-entry": HaLogbookEntry;
}
}
+47 -580
View File
@@ -1,37 +1,18 @@
import type { VisibilityChangedEvent } from "@lit-labs/virtualizer";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { formatDate } from "../../common/datetime/format_date";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { computeTimelineColor } from "../../components/chart/timeline-color";
import "../../components/entity/state-badge";
import "../../components/ha-icon-next";
import "../../components/ha-relative-time";
import { domainToName } from "../../data/integration";
import type { LogbookEntry } from "../../data/logbook";
import {
createHistoricState,
localizeStateMessage,
localizeTriggerSource,
} from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import {
buttonLinkStyle,
haStyle,
haStyleScrollbar,
} from "../../resources/styles";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "./ha-logbook-entry";
import type { LogbookScope } from "./logbook-entry-model";
import { sameDay } from "./logbook-entry-model";
declare global {
interface HASSDomEvents {
@@ -39,41 +20,25 @@ declare global {
}
}
const triggerDomains = ["script", "automation"];
const hasContext = (item: LogbookEntry) =>
item.context_event_type || item.context_state || item.context_message;
const stripEntityId = (message: string, entityId?: string) =>
entityId ? message.replace(entityId, " ") : message;
@customElement("ha-logbook-renderer")
class HaLogbookRenderer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public userIdToName = {};
@property({ attribute: false }) public userIdToName: Record<string, string> =
{};
@property({ attribute: false })
public traceContexts: TraceContexts = {};
@property({ attribute: false }) public traceContexts: TraceContexts = {};
@property({ attribute: false }) public entries: LogbookEntry[] = [];
@property({ type: Boolean, attribute: "narrow" })
public narrow = false;
@property({ type: Boolean, attribute: "narrow" }) public narrow = false;
@property({ type: Boolean, attribute: "virtualize", reflect: true })
public virtualize = false;
@property({ type: Boolean, attribute: "show-indicator" })
public showIndicator = false;
@property({ type: Boolean, attribute: "no-icon" }) public noIcon = false;
@property({ type: Boolean, attribute: "no-icon" })
public noIcon = false;
@property({ type: Boolean, attribute: "no-name" })
public noName = false;
@property({ type: Boolean, attribute: "relative-time" })
public relativeTime = false;
@property({ attribute: false }) public scope?: LogbookScope;
// @ts-ignore
@restoreScroll(".container") private _savedScrollPos?: number;
@@ -92,7 +57,9 @@ class HaLogbookRenderer extends LitElement {
protected shouldUpdate(changedProps: PropertyValues<this>) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const languageChanged =
oldHass === undefined || oldHass.locale !== this.hass.locale;
oldHass === undefined ||
oldHass.locale !== this.hass.locale ||
oldHass.localize !== this.hass.localize;
return (
changedProps.has("entries") ||
@@ -111,147 +78,54 @@ class HaLogbookRenderer extends LitElement {
}
return html`
<div
class="container ha-scrollbar ${classMap({
narrow: this.narrow,
"no-name": this.noName,
"no-icon": this.noIcon,
})}"
@scroll=${this._saveScrollPos}
>
<div class="container ha-scrollbar" @scroll=${this._saveScrollPos}>
${this.virtualize
? html`<lit-virtualizer
@visibilityChanged=${this._visibilityChanged}
scroller
class="ha-scrollbar"
.items=${this.entries}
.renderItem=${this._renderLogbookItem}
.renderItem=${this._renderRow}
>
</lit-virtualizer>`
: this.entries.map((item, index) =>
this._renderLogbookItem(item, index)
)}
: this.entries.map((item, index) => this._renderRow(item, index))}
</div>
`;
}
private _renderLogbookItem = (item: LogbookEntry, index: number) => {
if (!item || index === undefined) {
private _renderRow = (item: LogbookEntry, index: number) => {
if (!item) {
return nothing;
}
const previous = this.entries[index - 1] as LogbookEntry | undefined;
const seenEntityIds: string[] = [];
const currentStateObj = item.entity_id
? this.hass.states[item.entity_id]
: undefined;
const historicStateObj = currentStateObj
? createHistoricState(currentStateObj, item.state!)
: undefined;
const domain = item.entity_id
? computeDomain(item.entity_id)
: // Domain is there if there is no entity ID.
item.domain!;
const overrideImage =
!historicStateObj &&
!item.icon &&
!item.state &&
domain &&
isComponentLoaded(this.hass.config, domain)
? brandsUrl(
{
domain: domain!,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)
: undefined;
const traceContext =
triggerDomains.includes(item.domain!) &&
item.context_id &&
item.context_id in this.traceContexts
? this.traceContexts[item.context_id!]
: undefined;
const hasTrace = traceContext !== undefined;
const next = this.entries[index + 1] as LogbookEntry | undefined;
const firstOfDay = index === 0 || !sameDay(item, previous);
const lastOfDay = index === this.entries.length - 1 || !sameDay(item, next);
// The virtualizer positions one element per item, so the date header and
// the row share a single wrapper.
return html`
<div
class="entry-container ${classMap({ clickable: hasTrace })}"
.traceLink=${traceContext
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
: undefined}
@click=${this._handleClick}
>
${index === 0 ||
(item?.when &&
previous?.when &&
new Date(item.when * 1000).toDateString() !==
new Date(previous.when * 1000).toDateString())
? html`
<h4 class="date">
${formatDate(
new Date(item.when * 1000),
this.hass.locale,
this.hass.config
)}
</h4>
`
<div class="entry-container">
${firstOfDay
? html`<h4 class="date">
${formatDate(
new Date(item.when * 1000),
this.hass.locale,
this.hass.config
)}
</h4>`
: nothing}
<div class="entry ${classMap({ "no-entity": !item.entity_id })}">
<div class="icon-message">
${!this.noIcon
? html`
<state-badge
.hass=${this.hass}
.overrideIcon=${item.icon}
.overrideImage=${overrideImage}
.stateObj=${item.icon ? undefined : historicStateObj}
.stateColor=${false}
></state-badge>
`
: ""}
${this.showIndicator ? this._renderIndicator(item) : ""}
<div class="message-relative_time">
<div class="message">
${!this.noName // Used for more-info panel (single entity case)
? this._renderEntity(item.entity_id, item.name, hasTrace)
: ""}
${this._renderMessage(
item,
seenEntityIds,
domain,
historicStateObj,
hasTrace
)}
${this._renderContextMessage(item, seenEntityIds, hasTrace)}
</div>
<div class="secondary">
<span
>${formatTimeWithSeconds(
new Date(item.when * 1000),
this.hass.locale,
this.hass.config
)}</span
>
-
<ha-relative-time
.datetime=${item.when * 1000}
capitalize
></ha-relative-time>
${item.context_user_id ? html`${this._renderUser(item)}` : ""}
${hasTrace
? `- ${this.hass.localize(
"ui.components.logbook.show_trace"
)}`
: ""}
</div>
</div>
</div>
${hasTrace ? html`<ha-icon-next></ha-icon-next>` : ""}
</div>
<ha-logbook-entry
.hass=${this.hass}
.item=${item}
.userIdToName=${this.userIdToName}
.traceContexts=${this.traceContexts}
.narrow=${this.narrow}
.noIcon=${this.noIcon}
.scope=${this.scope}
.firstOfDay=${firstOfDay}
.lastOfDay=${lastOfDay}
></ha-logbook-entry>
</div>
`;
};
@@ -268,393 +142,25 @@ class HaLogbookRenderer extends LitElement {
});
}
private _renderIndicator(item: LogbookEntry) {
const stateObj = this.hass.states[item.entity_id!] as
| HassEntity
| undefined;
const computedStyles = getComputedStyle(this);
const color =
item.state !== undefined
? computeTimelineColor(item.state, computedStyles, stateObj)
: undefined;
const style = {
backgroundColor: color,
};
return html` <div class="indicator" style=${styleMap(style)}></div> `;
}
private _renderMessage(
item: LogbookEntry,
seenEntityIds: string[],
domain?: string,
historicStateObj?: HassEntity,
noLink?: boolean
) {
if (item.entity_id) {
if (item.state) {
return historicStateObj
? localizeStateMessage(
this.hass,
this.hass.localize,
item.state,
historicStateObj,
domain!
)
: item.state;
}
}
const itemHasContext = hasContext(item);
let message = item.message;
if (triggerDomains.includes(domain!) && item.source) {
if (itemHasContext) {
// These domains include the trigger source in the message
// but if we have the context we want to display that instead
// as otherwise we display duplicate triggers
return "";
}
message = localizeTriggerSource(this.hass.localize, item.source);
}
return message
? this._formatMessageWithPossibleEntity(
itemHasContext
? stripEntityId(message, item.context_entity_id)
: message,
seenEntityIds,
undefined,
noLink
)
: "";
}
private _renderUser(item: LogbookEntry) {
const item_username =
item.context_user_id && this.userIdToName[item.context_user_id];
if (item_username) {
return `- ${item_username}`;
}
return "";
}
private _renderUnseenContextSourceEntity(
item: LogbookEntry,
seenEntityIds: string[],
noLink: boolean
) {
if (
!item.context_entity_id ||
seenEntityIds.includes(item.context_entity_id!)
) {
return "";
}
// We don't know what caused this entity
// to be included since its an integration
// described event.
return html` (${this._renderEntity(
item.context_entity_id,
item.context_entity_id_name,
noLink
)})`;
}
private _renderContextMessage(
item: LogbookEntry,
seenEntityIds: string[],
noLink: boolean
) {
// State change
if (item.context_state) {
const historicStateObj =
item.context_entity_id && item.context_entity_id in this.hass.states
? createHistoricState(
this.hass.states[item.context_entity_id],
item.context_state
)
: undefined;
return html`${this.hass.localize(
"ui.components.logbook.triggered_by_state_of"
)}
${this._renderEntity(
item.context_entity_id,
item.context_entity_id_name,
noLink
)}
${historicStateObj
? localizeStateMessage(
this.hass,
this.hass.localize,
item.context_state,
historicStateObj,
computeDomain(item.context_entity_id!)
)
: item.context_state}`;
}
// Service call
if (item.context_event_type === "call_service") {
return html`${this.hass.localize(
"ui.components.logbook.triggered_by_action"
)}
${item.context_domain && item.context_service
? `${domainToName(this.hass.localize, item.context_domain)}:
${
this.hass.localize(
`component.${item.context_domain}.services.${item.context_service}.name`,
this.hass.services[item.context_domain][item.context_service]
.description_placeholders
) ||
this.hass.services[item.context_domain]?.[item.context_service]?.name ||
item.context_service
}`
: ""}`;
}
if (
!item.context_message ||
seenEntityIds.includes(item.context_entity_id!)
) {
return "";
}
// Automation or script
if (
item.context_event_type === "automation_triggered" ||
item.context_event_type === "script_started"
) {
// context_source is available in 2022.6 and later
const triggerMsg = item.context_source
? item.context_source
: item.context_message.replace("triggered by ", "");
const contextTriggerSource = localizeTriggerSource(
this.hass.localize,
triggerMsg
);
return html`${this.hass.localize(
item.context_event_type === "automation_triggered"
? "ui.components.logbook.triggered_by_automation"
: "ui.components.logbook.triggered_by_script"
)}
${this._renderEntity(
item.context_entity_id,
item.context_entity_id_name,
noLink
)}
${item.context_message
? this._formatMessageWithPossibleEntity(
contextTriggerSource,
seenEntityIds,
undefined,
noLink
)
: ""}`;
}
// Generic externally described logbook platform
// These are not localizable
return html` ${this.hass.localize("ui.components.logbook.triggered_by")}
${item.context_name}
${this._formatMessageWithPossibleEntity(
item.context_message,
seenEntityIds,
item.context_entity_id,
noLink
)}
${this._renderUnseenContextSourceEntity(item, seenEntityIds, noLink)}`;
}
private _renderEntity(
entityId: string | undefined,
entityName: string | undefined,
noLink?: boolean
) {
const hasState = entityId && entityId in this.hass.states;
const displayName =
entityName ||
(hasState
? this.hass.states[entityId].attributes.friendly_name || entityId
: entityId);
if (!hasState) {
return displayName;
}
return noLink
? displayName
: html`<button
class="link"
@click=${this._entityClicked}
.entityId=${entityId}
>
${displayName}
</button>`;
}
private _formatMessageWithPossibleEntity(
message: string,
seenEntities: string[],
possibleEntity?: string,
noLink?: boolean
) {
//
// As we are looking at a log(book), we are doing entity_id
// "highlighting"/"colorizing". The goal is to make it easy for
// the user to access the entity that caused the event.
//
// If there is an entity_id in the message that is also in the
// state machine, we search the message for the entity_id and
// replace it with _renderEntity
//
if (message.indexOf(".") !== -1) {
const messageParts = message.split(" ");
for (let i = 0, size = messageParts.length; i < size; i++) {
if (messageParts[i] in this.hass.states) {
const entityId = messageParts[i];
if (seenEntities.includes(entityId)) {
return "";
}
seenEntities.push(entityId);
const messageEnd = messageParts.splice(i);
messageEnd.shift(); // remove the entity
return html`${messageParts.join(" ")}
${this._renderEntity(
entityId,
this.hass.states[entityId].attributes.friendly_name,
noLink
)}
${messageEnd.join(" ")}`;
}
}
}
//
// When we have a message has a specific entity_id attached to
// it, and the entity_id is not in the message, we look
// for the friendly name of the entity and replace that with
// _renderEntity if its there so the user can quickly get to
// that entity.
//
if (possibleEntity && possibleEntity in this.hass.states) {
const possibleEntityName =
this.hass.states[possibleEntity].attributes.friendly_name;
if (possibleEntityName && message.endsWith(possibleEntityName)) {
if (seenEntities.includes(possibleEntity)) {
return "";
}
seenEntities.push(possibleEntity);
message = message.substring(
0,
message.length - possibleEntityName.length
);
return html`${message}
${this._renderEntity(possibleEntity, possibleEntityName, noLink)}`;
}
}
return message;
}
private _entityClicked(ev: Event) {
const entityId = (ev.currentTarget as any).entityId;
if (!entityId) {
return;
}
ev.preventDefault();
ev.stopPropagation();
fireEvent(this, "hass-more-info", {
entityId: entityId,
});
}
private _handleClick(ev: Event) {
const target = ev.currentTarget as any;
if (!target.traceLink) {
return;
}
navigate(target.traceLink);
fireEvent(this, "closed");
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleScrollbar,
buttonLinkStyle,
css`
:host([virtualize]) {
display: block;
height: 100%;
}
/* The virtualizer positions items shrink-to-fit, so force full width. */
.entry-container {
width: 100%;
}
.entry {
position: relative;
display: flex;
width: 100%;
line-height: var(--ha-line-height-expanded);
padding: 8px 16px;
box-sizing: border-box;
border-top: 1px solid var(--divider-color);
justify-content: space-between;
align-items: center;
}
.indicator {
background-color: var(--disabled-color);
height: 8px;
width: 8px;
border-radius: var(--ha-border-radius-sm);
flex-shrink: 0;
margin-right: 12px;
margin-inline-start: initial;
margin-inline-end: 12px;
direction: var(--direction);
}
ha-icon-next {
color: var(--secondary-text-color);
}
.clickable {
cursor: pointer;
}
:not(.clickable) .entry.no-entity,
:not(.clickable) .no-name .entry {
cursor: default;
}
.entry:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
.narrow:not(.no-icon) .time {
margin-left: 32px;
margin-inline-start: 32px;
margin-inline-end: initial;
direction: var(--direction);
}
.message-relative_time {
display: flex;
flex-direction: column;
}
.secondary {
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-normal);
}
.secondary a {
color: var(--secondary-text-color);
}
.date {
margin: 8px 0;
padding: 0 16px;
}
.icon-message {
display: flex;
align-items: center;
margin: var(--ha-space-2) 0 0;
padding: var(--ha-space-2) var(--ha-space-4) 0;
font-weight: var(--ha-font-weight-medium);
}
.no-entries {
@@ -662,33 +168,6 @@ class HaLogbookRenderer extends LitElement {
color: var(--secondary-text-color);
}
state-badge {
margin-right: 16px;
margin-inline-start: initial;
flex-shrink: 0;
color: var(--state-icon-color);
margin-inline-end: 16px;
direction: var(--direction);
}
.message {
color: var(--primary-text-color);
}
.no-name .message:first-letter {
text-transform: capitalize;
}
a {
color: var(--primary-color);
text-decoration: none;
}
button.link {
color: var(--state-icon-color);
text-decoration: none;
}
.container {
max-height: var(--logbook-max-height);
}
@@ -701,18 +180,6 @@ class HaLogbookRenderer extends LitElement {
lit-virtualizer {
contain: size layout !important;
}
.narrow .entry {
line-height: var(--ha-line-height-normal);
}
.narrow .icon-message state-badge {
margin-left: 0;
margin-inline-start: 0;
margin-right: 8px;
margin-inline-end: 8px;
direction: var(--direction);
}
`,
];
}
+5 -10
View File
@@ -13,6 +13,7 @@ import { loadTraceContexts } from "../../data/trace";
import { fetchUsers } from "../../data/user";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-renderer";
import type { LogbookScope } from "./logbook-entry-model";
interface LogbookTimePeriod {
now: Date;
@@ -60,13 +61,9 @@ export class HaLogbook extends LitElement {
@property({ type: Boolean, attribute: "no-icon" }) public noIcon = false;
@property({ type: Boolean, attribute: "no-name" }) public noName = false;
@property({ type: Boolean, attribute: "show-indicator" })
public showIndicator = false;
@property({ type: Boolean, attribute: "relative-time" })
public relativeTime = false;
// Surface scope: removes the context (and, for "entity", the subject name)
// the surface already implies.
@property({ attribute: false }) public scope?: LogbookScope;
@property({ attribute: "show-more-link", type: Boolean })
public showMoreLink = true;
@@ -125,9 +122,7 @@ export class HaLogbook extends LitElement {
.narrow=${this.narrow}
.virtualize=${this.virtualize}
.noIcon=${this.noIcon}
.noName=${this.noName}
.showIndicator=${this.showIndicator}
.relativeTime=${this.relativeTime}
.scope=${this.scope}
.entries=${this._logbookEntries}
.traceContexts=${this._traceContexts}
.userIdToName=${this._userIdToName}
+1
View File
@@ -114,6 +114,7 @@ export class HaPanelLogbook extends LitElement {
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._getEntityIds()}
.narrow=${this.narrow}
virtualize
></ha-logbook>
</div>
+341
View File
@@ -0,0 +1,341 @@
import { mdiFlash, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
import { isSameDay } from "date-fns";
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { stateColorCss } from "../../common/entity/state_color";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import type { LogbookEntry } from "../../data/logbook";
import {
createHistoricState,
localizeStateMessage,
parseTriggerSource,
} from "../../data/logbook";
import type { HomeAssistant } from "../../types";
export type LogbookEntryCategory = "entity" | "automation" | "integration";
export const TRIGGER_DOMAINS = ["automation", "script"];
export const stripEntityId = (message: string, entityId?: string) =>
entityId ? message.replace(entityId, " ") : message;
export const classifyLogbookEntry = (
item: LogbookEntry
): LogbookEntryCategory => {
// A state change always wins, even for automation/script entities (e.g.
// turning an automation on/off is an entity state change, not a run).
if (item.entity_id && item.state !== undefined) {
return "entity";
}
if (item.domain && TRIGGER_DOMAINS.includes(item.domain)) {
return "automation";
}
return "integration";
};
// A device lives in exactly one area, so `device` (and `entity`) imply it too.
export type LogbookScope = "entity" | "device" | "area";
export interface EntityDisplay {
// Undefined when the entity no longer exists.
primary?: string;
secondary?: string;
}
export const entityDisplay = (
hass: HomeAssistant,
entityId: string,
scope?: LogbookScope
): EntityDisplay => {
const stateObj = hass.states[entityId] as HassEntity | undefined;
if (!stateObj) {
return {};
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || entityId;
// The device is context only when the entity has its own name; otherwise the
// device name *is* the subject and showing it again would duplicate it.
const deviceContext = entityName ? deviceName : undefined;
let parts: (string | undefined)[];
switch (scope) {
case "entity":
case "device":
parts = [];
break;
case "area":
parts = [deviceContext];
break;
default:
parts = [areaName, deviceContext];
}
const filtered = parts.filter(Boolean) as string[];
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const secondary = filtered.length
? filtered.join(isRTL ? " ◂ " : " ▸ ")
: undefined;
return { primary, secondary };
};
export 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);
// Dashboard state color for entity nodes; unavailable is flagged with an orange
// badge by the row, not here.
export const nodeColor = (
category: LogbookEntryCategory,
historicStateObj: HassEntity | undefined
): string | undefined => {
if (category !== "entity" || !historicStateObj) {
return undefined;
}
return stateColorCss(historicStateObj);
};
export interface LogbookCause {
name: string;
userId?: string;
iconPath?: string;
triggerPlatform?: string;
brandDomain?: string;
}
// Localize a built-in trigger platform (state, time, …) via the automation
// editor labels reused in our always-loaded namespace. Falls back to the key.
const localizeTriggerName = (hass: HomeAssistant, platform: string): string =>
hass.localize(
`ui.components.logbook.trigger_type.${platform}` as LocalizeKeys
) || platform;
const localizeServiceName = (
hass: HomeAssistant,
domain: string,
service: string
): string =>
hass.localize(
`component.${domain}.services.${service}.name` as LocalizeKeys
) ||
hass.services[domain]?.[service]?.name ||
service;
// Who/what caused an entry: a user, an automation/script, a triggering entity,
// or an integration. Returns the actor's glyph/avatar + name.
export const resolveLogbookCause = (
hass: HomeAssistant,
item: LogbookEntry,
userIdToName: Record<string, string>
): LogbookCause | undefined => {
const userName = item.context_user_id
? userIdToName[item.context_user_id]
: undefined;
if (userName) {
return { name: userName, userId: item.context_user_id };
}
if (
item.context_event_type === "automation_triggered" ||
item.context_event_type === "script_started"
) {
const name =
(item.context_entity_id
? entityDisplay(hass, item.context_entity_id).primary
: undefined) ?? item.context_name;
if (name) {
return {
iconPath:
item.context_event_type === "script_started"
? mdiScriptText
: mdiRobot,
name,
};
}
}
if (item.context_event_type === "call_service" && item.context_domain) {
const serviceName = item.context_service
? localizeServiceName(hass, item.context_domain, item.context_service)
: undefined;
const domainName = domainToName(hass.localize, item.context_domain);
return {
brandDomain: item.context_domain,
name: serviceName ? `${domainName}: ${serviceName}` : domainName,
};
}
if (item.context_state && item.context_entity_id) {
const name =
entityDisplay(hass, item.context_entity_id).primary ??
item.context_entity_id_name;
if (name) {
return { name, iconPath: mdiFlash };
}
}
if (
item.domain &&
TRIGGER_DOMAINS.includes(item.domain) &&
!hasContext(item) &&
item.source
) {
const { platform } = parseTriggerSource(item.source);
if (platform) {
return {
triggerPlatform: platform,
name: localizeTriggerName(hass, platform),
};
}
}
if (item.context_name) {
return item.context_domain
? { brandDomain: item.context_domain, name: item.context_name }
: { iconPath: mdiPuzzle, name: item.context_name };
}
return undefined;
};
// The node glyph, decided by type. The component switches on `type` to render
// (state icon / robot-script / brand logo); it owns the brand URL and colors.
export type LogbookGlyph =
| { type: "state"; stateObj: HassEntity; icon?: string }
| { type: "automation"; script: boolean }
| { type: "brand"; domain?: string; icon?: string };
export const resolveLogbookGlyph = (
item: LogbookEntry,
category: LogbookEntryCategory,
stateObj: HassEntity | undefined,
domain: string | undefined
): LogbookGlyph => {
if (category === "automation") {
return { type: "automation", script: domain === "script" };
}
if (stateObj) {
return { type: "state", stateObj: stateObj, icon: item.icon };
}
return { type: "brand", domain, icon: item.icon };
};
// What happened. `value` reads as a state/result and gets the "name → value"
// arrow; `phrase` is a full sentence (integration message) rendered inline and
// linkified by the component.
export interface LogbookWhat {
text: string;
kind: "value" | "phrase";
}
const resolveLogbookWhat = (
hass: HomeAssistant,
item: LogbookEntry,
domain: string | undefined,
stateObj: HassEntity | undefined
): LogbookWhat | undefined => {
if (item.entity_id && item.state) {
return {
text: stateObj
? localizeStateMessage(hass, item.state, stateObj, domain!)
: item.state,
kind: "value",
};
}
// An automation/script run: self-triggered (has `source`) or started by
// something else (has a context). Either way show the generic "Triggered"/
// "Ran" headline; a bare logbook.log message (no source, no context) falls
// through to render its own text.
if (
domain &&
TRIGGER_DOMAINS.includes(domain) &&
(item.source || hasContext(item))
) {
return {
text: hass.localize(
domain === "script"
? "ui.components.logbook.script_ran"
: "ui.components.logbook.automation_triggered"
),
kind: "value",
};
}
if (item.message) {
return {
text: hasContext(item)
? stripEntityId(item.message, item.context_entity_id)
: item.message,
kind: "phrase",
};
}
return undefined;
};
// The type-agnostic view-model: name / what / context / cause + the node glyph,
// derived once per entry so the display just arranges these fields by style.
export interface LogbookItem {
category: LogbookEntryCategory;
glyph: LogbookGlyph;
entityId?: string;
name?: string;
context?: string;
what?: LogbookWhat;
cause?: LogbookCause;
when: number; // ms timestamp
}
export interface BuildLogbookItemOptions {
scope?: LogbookScope;
userIdToName?: Record<string, string>;
}
export const buildLogbookItem = (
hass: HomeAssistant,
item: LogbookEntry,
opts: BuildLogbookItemOptions = {}
): LogbookItem => {
const category = classifyLogbookEntry(item);
const domain = item.entity_id ? computeDomain(item.entity_id) : item.domain;
const currentStateObj = item.entity_id
? hass.states[item.entity_id]
: undefined;
const historicStateObj = currentStateObj
? createHistoricState(currentStateObj, item.state)
: undefined;
// Context (and the registry-resolved name) only for entity rows — an
// automation's configured area is noise.
const display =
category === "entity" && item.entity_id
? entityDisplay(hass, item.entity_id, opts.scope)
: undefined;
return {
category,
glyph: resolveLogbookGlyph(item, category, historicStateObj, domain),
entityId: item.entity_id,
name: display?.primary ?? item.name,
context: display?.secondary,
what: resolveLogbookWhat(hass, item, domain, historicStateObj),
cause: resolveLogbookCause(hass, item, opts.userIdToName ?? {}),
when: item.when * 1000,
};
};
+66 -5
View File
@@ -1,3 +1,5 @@
import { startOfYesterday } from "date-fns";
import { mdiChevronRight } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -6,7 +8,9 @@ import memoizeOne from "memoize-one";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { createSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import type { HomeAssistant } from "../../../types";
import "../../logbook/ha-logbook";
import type { HaLogbook } from "../../logbook/ha-logbook";
@@ -135,6 +139,32 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
this._stateFilter = ensureArray(config.state_filter);
}
// Built in render() (not cached) so start_date stays relative to "now", not
// to when the card was configured — long-lived dashboards stay current.
private _showMoreUrl(): string {
const target = this._targetPickerValue;
const params: Record<string, string> = {
start_date: startOfYesterday().toISOString(),
back: "1",
};
if (target.entity_id) {
params.entity_id = ensureArray(target.entity_id).join(",");
}
if (target.device_id) {
params.device_id = ensureArray(target.device_id).join(",");
}
if (target.area_id) {
params.area_id = ensureArray(target.area_id).join(",");
}
if (target.floor_id) {
params.floor_id = ensureArray(target.floor_id).join(",");
}
if (target.label_id) {
params.label_id = ensureArray(target.label_id).join(",");
}
return `/logbook?${createSearchParam(params)}`;
}
private _getEntityIds(): string[] | undefined {
const entities = this._getMemoizedEntityIds(
this._targetPickerValue,
@@ -200,10 +230,20 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
}
return html`
<ha-card
.header=${this._config!.title}
class=${classMap({ "no-header": !this._config!.title })}
>
<ha-card class=${classMap({ "no-header": !this._config!.title })}>
${this._config!.title
? html`<div class="card-header">
<h1 class="name">${this._config!.title}</h1>
<a href=${this._showMoreUrl()}>
<ha-icon-button
.path=${mdiChevronRight}
.label=${this.hass.localize(
"ui.dialogs.more_info_control.show_more"
)}
></ha-icon-button>
</a>
</div>`
: nothing}
<div class="content">
<ha-logbook
class=${classMap({
@@ -215,7 +255,7 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
.entityIds=${this._getEntityIds()}
.stateFilter=${this._stateFilter}
narrow
relative-time
no-icon
virtualize
></ha-logbook>
</div>
@@ -233,6 +273,27 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
justify-content: space-between;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 0;
}
.card-header .name {
margin: 0;
font-size: var(--ha-card-header-font-size, 1.4rem);
font-weight: var(--ha-card-header-font-weight, 500);
color: var(--ha-card-header-color, var(--primary-text-color));
}
.card-header a {
color: var(--primary-text-color);
margin-right: calc(var(--ha-space-2) * -1);
margin-inline-end: calc(var(--ha-space-2) * -1);
margin-inline-start: initial;
}
.content {
height: 100%;
padding: 0 16px 16px;
+23 -107
View File
@@ -669,17 +669,28 @@
},
"logbook": {
"entries_not_found": "No activity found.",
"triggered_by": "triggered by",
"triggered_by_automation": "triggered by automation",
"triggered_by_script": "triggered by script",
"triggered_by_action": "triggered by action",
"triggered_by_numeric_state_of": "triggered by numeric state of",
"triggered_by_state_of": "triggered by state of",
"triggered_by_event": "triggered by event",
"triggered_by_time": "triggered by time",
"triggered_by_time_pattern": "triggered by time pattern",
"triggered_by_homeassistant_stopping": "triggered by Home Assistant stopping",
"triggered_by_homeassistant_starting": "triggered by Home Assistant starting",
"automation_triggered": "Triggered",
"script_ran": "Ran",
"view_trace": "View trace",
"trigger_type": {
"calendar": "[%key:ui::panel::config::automation::editor::triggers::type::calendar::label%]",
"conversation": "[%key:ui::panel::config::automation::editor::triggers::type::conversation::label%]",
"device": "[%key:ui::panel::config::automation::editor::triggers::type::device::label%]",
"event": "[%key:ui::panel::config::automation::editor::triggers::type::event::label%]",
"geo_location": "[%key:ui::panel::config::automation::editor::triggers::type::geo_location::label%]",
"homeassistant": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::label%]",
"mqtt": "[%key:ui::panel::config::automation::editor::triggers::type::mqtt::label%]",
"numeric_state": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::label%]",
"persistent_notification": "[%key:ui::panel::config::automation::editor::triggers::type::persistent_notification::label%]",
"state": "[%key:ui::panel::config::automation::editor::triggers::type::state::label%]",
"sun": "[%key:ui::panel::config::automation::editor::triggers::type::sun::label%]",
"tag": "[%key:ui::panel::config::automation::editor::triggers::type::tag::label%]",
"template": "[%key:ui::panel::config::automation::editor::triggers::type::template::label%]",
"time": "[%key:ui::panel::config::automation::editor::triggers::type::time::label%]",
"time_pattern": "[%key:ui::panel::config::automation::editor::triggers::type::time_pattern::label%]",
"webhook": "[%key:ui::panel::config::automation::editor::triggers::type::webhook::label%]",
"zone": "[%key:ui::panel::config::automation::editor::triggers::type::zone::label%]"
},
"numeric_state_of": "numeric state of",
"state_of": "state of",
"event": "event",
@@ -687,105 +698,10 @@
"time_pattern": "time pattern",
"homeassistant_stopping": "Home Assistant stopping",
"homeassistant_starting": "Home Assistant starting",
"show_trace": "[%key:ui::panel::config::automation::editor::show_trace%]",
"retrieval_error": "Could not load activity",
"not_loaded": "[%key:ui::dialogs::helper_settings::platform_not_loaded%]",
"messages": {
"was_away": "was detected away",
"was_at_state": "was detected at {state}",
"rose": "rose",
"set": "set",
"was_low": "was low",
"was_normal": "was normal",
"was_connected": "was connected",
"was_disconnected": "was disconnected",
"was_opened": "was opened",
"was_closed": "was closed",
"is_opening": "is opening",
"is_opened": "is opened",
"is_closing": "is closing",
"was_unlocked": "was unlocked",
"was_locked": "was locked",
"is_unlocking": "is unlocking",
"is_locking": "is locking",
"is_jammed": "is jammed",
"was_plugged_in": "was plugged in",
"was_unplugged": "was unplugged",
"was_at_home": "was detected at home",
"was_unsafe": "was unsafe",
"was_safe": "was safe",
"detected_device_class": "detected {device_class}",
"cleared_device_class": "cleared (no {device_class} detected)",
"turned_off": "turned off",
"turned_on": "turned on",
"changed_to_state": "changed to {state}",
"became_unavailable": "became unavailable",
"became_unknown": "became unknown",
"detected_tampering": "detected tampering",
"cleared_tampering": "cleared tampering",
"detected_event": "{event_type} event detected",
"detected_event_no_type": "detected an event",
"detected_unknown_event": "detected an unknown event",
"detected_device_classes": {
"battery": "[%key:ui::components::logbook::messages::was_low%]",
"battery_charging": "started charging",
"carbon_monoxide": "[%key:ui::components::logbook::messages::detected_device_class%]",
"cold": "[%key:ui::components::logbook::messages::detected_device_class%]",
"connectivity": "[%key:ui::components::logbook::messages::was_connected%]",
"door": "[%key:ui::components::logbook::messages::was_opened%]",
"garage_door": "[%key:ui::components::logbook::messages::was_opened%]",
"gas": "[%key:ui::components::logbook::messages::detected_device_class%]",
"heat": "[%key:ui::components::logbook::messages::detected_device_class%]",
"light": "[%key:ui::components::logbook::messages::detected_device_class%]",
"lock": "[%key:ui::components::logbook::messages::was_unlocked%]",
"moisture": "[%key:ui::components::logbook::messages::detected_device_class%]",
"motion": "[%key:ui::components::logbook::messages::detected_device_class%]",
"moving": "[%key:ui::components::logbook::messages::detected_device_class%]",
"occupancy": "[%key:ui::components::logbook::messages::detected_device_class%]",
"opening": "[%key:ui::components::logbook::messages::was_opened%]",
"plug": "[%key:ui::components::logbook::messages::was_plugged_in%]",
"power": "[%key:ui::components::logbook::messages::detected_device_class%]",
"presence": "[%key:ui::components::logbook::messages::was_at_home%]",
"problem": "[%key:ui::components::logbook::messages::detected_device_class%]",
"running": "[%key:ui::components::logbook::messages::detected_device_class%]",
"safety": "[%key:ui::components::logbook::messages::was_unsafe%]",
"smoke": "[%key:ui::components::logbook::messages::detected_device_class%]",
"sound": "[%key:ui::components::logbook::messages::detected_device_class%]",
"tamper": "[%key:ui::components::logbook::messages::detected_tampering%]",
"update": "[%key:ui::components::logbook::messages::detected_device_class%]",
"vibration": "[%key:ui::components::logbook::messages::detected_device_class%]",
"window": "[%key:ui::components::logbook::messages::was_opened%]"
},
"cleared_device_classes": {
"battery": "[%key:ui::components::logbook::messages::was_normal%]",
"battery_charging": "stopped charging",
"carbon_monoxide": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"cold": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"connectivity": "[%key:ui::components::logbook::messages::was_disconnected%]",
"door": "[%key:ui::components::logbook::messages::was_closed%]",
"garage_door": "[%key:ui::components::logbook::messages::was_closed%]",
"gas": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"heat": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"light": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"lock": "[%key:ui::components::logbook::messages::was_locked%]",
"moisture": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"motion": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"moving": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"occupancy": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"opening": "[%key:ui::components::logbook::messages::was_closed%]",
"plug": "[%key:ui::components::logbook::messages::was_unplugged%]",
"power": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"presence": "[%key:ui::components::logbook::messages::was_away%]",
"problem": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"running": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"safety": "[%key:ui::components::logbook::messages::was_safe%]",
"smoke": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"sound": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"tamper": "[%key:ui::components::logbook::messages::cleared_tampering%]",
"update": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"vibration": "[%key:ui::components::logbook::messages::cleared_device_class%]",
"window": "[%key:ui::components::logbook::messages::was_closed%]"
}
"detected_event_no_type": "detected an event"
}
},
"entity": {
+38 -17
View File
@@ -1,22 +1,28 @@
import { describe, it, expect } from "vitest";
import {
localizeTriggerDescription,
localizeTriggerSource,
parseTriggerSource,
} from "../../src/data/logbook";
const fakeLocalize = ((key: string) => `<${key}>`) as any;
describe("localizeTriggerSource", () => {
it("replaces a known phrase with the prefixed translation", () => {
it("replaces a known phrase with the bare translation", () => {
expect(localizeTriggerSource(fakeLocalize, "Home Assistant starting")).toBe(
"<ui.components.logbook.triggered_by_homeassistant_starting>"
"<ui.components.logbook.homeassistant_starting>"
);
});
it("preserves trailing context after the matched phrase", () => {
expect(
localizeTriggerSource(fakeLocalize, "state of binary_sensor.foo")
).toBe("<ui.components.logbook.triggered_by_state_of> binary_sensor.foo");
).toBe("<ui.components.logbook.state_of> binary_sensor.foo");
});
it("matches 'time pattern' before the shorter 'time' phrase", () => {
expect(localizeTriggerSource(fakeLocalize, "time pattern")).toBe(
"<ui.components.logbook.time_pattern>"
);
});
it("returns the source unchanged when no phrase matches", () => {
@@ -26,22 +32,37 @@ describe("localizeTriggerSource", () => {
});
});
describe("localizeTriggerDescription", () => {
it("returns just the bare-phrase translation, without 'triggered by'", () => {
expect(
localizeTriggerDescription(fakeLocalize, "Home Assistant starting")
).toBe("<ui.components.logbook.homeassistant_starting>");
describe("parseTriggerSource", () => {
it("extracts the platform and entity id for state triggers", () => {
expect(parseTriggerSource("state of binary_sensor.foo")).toEqual({
platform: "state",
entityId: "binary_sensor.foo",
});
expect(parseTriggerSource("numeric state of sensor.bar")).toEqual({
platform: "numeric_state",
entityId: "sensor.bar",
});
});
it("preserves trailing context after the matched phrase", () => {
expect(
localizeTriggerDescription(fakeLocalize, "state of binary_sensor.foo")
).toBe("<ui.components.logbook.state_of> binary_sensor.foo");
it("returns the platform without an entity for time triggers", () => {
expect(parseTriggerSource("time pattern")).toEqual({
platform: "time_pattern",
entityId: undefined,
});
expect(parseTriggerSource("time")).toEqual({
platform: "time",
entityId: undefined,
});
});
it("returns the source unchanged when no phrase matches", () => {
expect(localizeTriggerDescription(fakeLocalize, "something else")).toBe(
"something else"
);
it("maps Home Assistant start/stop to the homeassistant platform", () => {
expect(parseTriggerSource("Home Assistant starting")).toEqual({
platform: "homeassistant",
entityId: undefined,
});
});
it("returns an empty result when no phrase matches", () => {
expect(parseTriggerSource("something else")).toEqual({});
});
});
@@ -0,0 +1,378 @@
import { describe, expect, it } from "vitest";
import {
buildLogbookItem,
classifyLogbookEntry,
entityDisplay,
resolveLogbookCause,
resolveLogbookGlyph,
} from "../../../src/panels/logbook/logbook-entry-model";
import type { LogbookEntry } from "../../../src/data/logbook";
import type { HomeAssistant } from "../../../src/types";
import {
mockArea,
mockDevice,
mockEntity,
mockStateObj,
} from "../../common/entity/context/context-mock";
const baseHass = (partial: Partial<HomeAssistant>): HomeAssistant =>
({
language: "en",
translationMetadata: { translations: {} },
states: {},
entities: {},
devices: {},
areas: {},
floors: {},
...partial,
}) as unknown as HomeAssistant;
const entry = (partial: Partial<LogbookEntry>): LogbookEntry => ({
when: 0,
name: "",
...partial,
});
describe("classifyLogbookEntry", () => {
it("classifies an entity state change as 'entity'", () => {
expect(
classifyLogbookEntry(entry({ entity_id: "light.x", state: "on" }))
).toBe("entity");
});
it("classifies an automation/script run as 'automation'", () => {
expect(
classifyLogbookEntry(
entry({
entity_id: "automation.x",
domain: "automation",
source: "time",
})
)
).toBe("automation");
expect(
classifyLogbookEntry(entry({ entity_id: "script.x", domain: "script" }))
).toBe("automation");
});
it("treats turning an automation on/off as an entity state change", () => {
expect(
classifyLogbookEntry(
entry({ entity_id: "automation.x", domain: "automation", state: "on" })
)
).toBe("entity");
});
it("classifies an integration/app event (message, no state) as 'integration'", () => {
expect(
classifyLogbookEntry(
entry({ domain: "hacs", message: "2 updates available" })
)
).toBe("integration");
});
});
describe("entityDisplay", () => {
const hass = baseHass({
states: {
"sensor.allee_battery": mockStateObj({
entity_id: "sensor.allee_battery",
attributes: { friendly_name: "Caméra Allée Battery state" },
}),
},
entities: {
"sensor.allee_battery": mockEntity({
entity_id: "sensor.allee_battery",
name: "Battery state",
device_id: "device_1",
}),
},
devices: {
device_1: mockDevice({
id: "device_1",
name: "Caméra Allée",
area_id: "area_1",
}),
},
areas: { area_1: mockArea({ area_id: "area_1", name: "Allée" }) },
});
it("shows 'Area ▸ Device' with no scope", () => {
expect(entityDisplay(hass, "sensor.allee_battery")).toEqual({
primary: "Battery state",
secondary: "Allée ▸ Caméra Allée",
});
});
it("shows device only in an area-scoped logbook", () => {
expect(entityDisplay(hass, "sensor.allee_battery", "area")).toEqual({
primary: "Battery state",
secondary: "Caméra Allée",
});
});
it("shows no context in a device-scoped logbook", () => {
expect(entityDisplay(hass, "sensor.allee_battery", "device")).toEqual({
primary: "Battery state",
secondary: undefined,
});
});
it("shows no context in an entity-scoped logbook", () => {
expect(entityDisplay(hass, "sensor.allee_battery", "entity")).toEqual({
primary: "Battery state",
secondary: undefined,
});
});
it("drops the device from context when the entity uses the device name", () => {
const h = baseHass({
states: { "sensor.desk": mockStateObj({ entity_id: "sensor.desk" }) },
entities: {
"sensor.desk": mockEntity({
entity_id: "sensor.desk",
device_id: "device_1",
}),
},
devices: {
device_1: mockDevice({
id: "device_1",
name: "Desk",
area_id: "area_1",
}),
},
areas: { area_1: mockArea({ area_id: "area_1", name: "Office" }) },
});
// entity has no own name -> primary is the device name, context is area only
expect(entityDisplay(h, "sensor.desk")).toEqual({
primary: "Desk",
secondary: "Office",
});
});
it("returns an empty display for a deleted entity", () => {
expect(entityDisplay(baseHass({}), "light.removed")).toEqual({});
});
});
describe("resolveLogbookCause", () => {
const localizeStub = (table: Record<string, string> = {}) =>
((key: string) => table[key] ?? "") as HomeAssistant["localize"];
it("uses the trigger type (icon + name), not the entity it fired on", () => {
const hass = baseHass({
localize: localizeStub({
"ui.components.logbook.trigger_type.state": "State",
}),
});
const cause = resolveLogbookCause(
hass,
entry({
domain: "automation",
source: "state of binary_sensor.porte",
}),
{}
);
expect(cause?.name).toBe("State");
expect(cause?.triggerPlatform).toBe("state");
});
it("labels a service call with the action name and integration icon", () => {
const hass = baseHass({
localize: localizeStub({
"component.light.title": "Light",
"component.light.services.turn_on.name": "Turn on",
}),
});
const cause = resolveLogbookCause(
hass,
entry({
context_event_type: "call_service",
context_domain: "light",
context_service: "turn_on",
}),
{}
);
expect(cause?.name).toBe("Light: Turn on");
expect(cause?.brandDomain).toBe("light");
});
it("falls back to the raw platform key when untranslated", () => {
const hass = baseHass({ localize: localizeStub() });
const cause = resolveLogbookCause(
hass,
entry({ domain: "automation", source: "numeric state of sensor.temp" }),
{}
);
expect(cause?.name).toBe("numeric_state");
expect(cause?.triggerPlatform).toBe("numeric_state");
});
it("parses the English source for the trigger platform", () => {
const hass = baseHass({
localize: localizeStub({
"ui.components.logbook.trigger_type.time_pattern": "Time pattern",
}),
});
const cause = resolveLogbookCause(
hass,
entry({ domain: "automation", source: "time pattern" }),
{}
);
expect(cause?.name).toBe("Time pattern");
});
});
describe("resolveLogbookGlyph", () => {
it("returns the automation/script glyph for a run", () => {
expect(
resolveLogbookGlyph(
entry({ entity_id: "automation.x", domain: "automation" }),
"automation",
undefined,
"automation"
)
).toEqual({ type: "automation", script: false });
expect(
resolveLogbookGlyph(
entry({ entity_id: "script.x", domain: "script" }),
"automation",
undefined,
"script"
)
).toEqual({ type: "automation", script: true });
});
it("returns the entity state glyph when there is a historic state", () => {
const historic = mockStateObj({ entity_id: "light.x" });
expect(
resolveLogbookGlyph(
entry({ entity_id: "light.x", icon: "mdi:bulb" }),
"entity",
historic,
"light"
)
).toEqual({ type: "state", stateObj: historic, icon: "mdi:bulb" });
});
it("falls back to the integration brand glyph", () => {
expect(
resolveLogbookGlyph(
entry({ domain: "zha", message: "x" }),
"integration",
undefined,
"zha"
)
).toEqual({ type: "brand", domain: "zha", icon: undefined });
});
});
describe("buildLogbookItem", () => {
it("builds an entity item (name, state as 'value', context, glyph)", () => {
const hass = baseHass({
localize: ((key: string) => key) as HomeAssistant["localize"],
formatEntityState: ((_stateObj: any, state: string) =>
state === "on"
? "Allumé"
: state) as HomeAssistant["formatEntityState"],
states: {
"light.salon": mockStateObj({
entity_id: "light.salon",
attributes: { friendly_name: "Spots Salon" },
}),
},
entities: {
"light.salon": mockEntity({
entity_id: "light.salon",
name: "Spots Salon",
device_id: "device_1",
}),
},
devices: {
device_1: mockDevice({
id: "device_1",
name: "Ampli",
area_id: "area_1",
}),
},
areas: { area_1: mockArea({ area_id: "area_1", name: "Salon" }) },
});
const model = buildLogbookItem(
hass,
entry({ entity_id: "light.salon", state: "on", when: 1000 }),
{}
);
expect(model.category).toBe("entity");
expect(model.name).toBe("Spots Salon");
expect(model.context).toBe("Salon ▸ Ampli");
expect(model.what).toEqual({ text: "Allumé", kind: "value" });
expect(model.glyph.type).toBe("state");
expect(model.when).toBe(1_000_000);
});
it("builds an automation item ('Triggered' value, automation glyph, trigger cause)", () => {
const hass = baseHass({
localize: ((key: string) =>
({
"ui.components.logbook.automation_triggered": "Triggered",
"ui.components.logbook.trigger_type.state": "State",
})[key] ?? "") as HomeAssistant["localize"],
});
const model = buildLogbookItem(
hass,
entry({
entity_id: "automation.x",
domain: "automation",
name: "Mode nuit",
source: "state of binary_sensor.porte",
}),
{}
);
expect(model.category).toBe("automation");
expect(model.name).toBe("Mode nuit");
expect(model.context).toBeUndefined();
expect(model.what).toEqual({ text: "Triggered", kind: "value" });
expect(model.glyph).toEqual({ type: "automation", script: false });
expect(model.cause?.name).toBe("State");
});
it("builds an integration item (message as 'phrase', brand glyph)", () => {
const hass = baseHass({
localize: (() => "") as HomeAssistant["localize"],
});
const model = buildLogbookItem(
hass,
entry({ domain: "zha", name: "Remote", message: "button pressed" }),
{}
);
expect(model.category).toBe("integration");
expect(model.name).toBe("Remote");
expect(model.what).toEqual({ text: "button pressed", kind: "phrase" });
expect(model.glyph).toEqual({
type: "brand",
domain: "zha",
icon: undefined,
});
expect(model.cause).toBeUndefined();
});
it("shows 'Ran' for a script started by something else (context, no source)", () => {
const hass = baseHass({
localize: ((key: string) =>
({ "ui.components.logbook.script_ran": "Ran" })[key] ??
"") as HomeAssistant["localize"],
});
const model = buildLogbookItem(
hass,
entry({
entity_id: "script.x",
domain: "script",
message: "started",
context_event_type: "automation_triggered",
context_name: "Some automation",
}),
{}
);
expect(model.what).toEqual({ text: "Ran", kind: "value" });
});
});