mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-15 04:42:04 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 665e4a7240 | |||
| b9dba28198 | |||
| f381233e26 | |||
| 52fa33e8db | |||
| c7483a36dc | |||
| 55a07d7678 | |||
| 8630b24fbc |
@@ -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}`
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user