mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-15 10:58:38 +00:00
Compare commits
5
Commits
local-disk-mounts
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e0c572f69 | ||
|
|
e4438a20cb | ||
|
|
ec418498a7 | ||
|
|
8122baca00 | ||
|
|
79738dd257 |
+1
-1
@@ -152,7 +152,7 @@
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.8",
|
||||
"@rspack/core": "2.1.9",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
|
||||
@@ -39,6 +39,25 @@ const formatTimeWithSecondsMem = memoizeOne(
|
||||
})
|
||||
);
|
||||
|
||||
// 9:15:24.123 PM || 21:15:24,123
|
||||
export const formatTimeWithMilliseconds = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
|
||||
|
||||
const formatTimeWithMillisecondsMem = memoizeOne(
|
||||
(locale: FrontendLocaleData, serverTimeZone: string) =>
|
||||
new Intl.DateTimeFormat(locale.language, {
|
||||
hour: useAmPm(locale) ? "numeric" : "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: useAmPm(locale) ? "h12" : "h23",
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
// Tuesday 7:00 PM || Tuesday 19:00
|
||||
export const formatTimeWeekday = (
|
||||
dateObj: Date,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
isMoreInfoView,
|
||||
type MoreInfoView,
|
||||
} from "../../dialogs/more-info/more-info-view";
|
||||
import type { SearchParamsSource } from "./query-params";
|
||||
|
||||
const ENTITY_ID_PARAM = "more-info-entity-id";
|
||||
const VIEW_PARAM = "more-info-view";
|
||||
|
||||
export interface MoreInfoUrlData {
|
||||
entityId?: string;
|
||||
view?: MoreInfoView;
|
||||
hash: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface CreateMoreInfoUrlData {
|
||||
entityId: string;
|
||||
view: MoreInfoView;
|
||||
hash?: URLSearchParams;
|
||||
}
|
||||
|
||||
export const decodeMoreInfoUrl = (
|
||||
search: SearchParamsSource,
|
||||
hash = ""
|
||||
): MoreInfoUrlData => {
|
||||
const params =
|
||||
typeof search === "string"
|
||||
? new URLSearchParams(search)
|
||||
: search instanceof URLSearchParams
|
||||
? search
|
||||
: new URLSearchParams(search);
|
||||
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
|
||||
const view = params.get(VIEW_PARAM) || undefined;
|
||||
|
||||
return {
|
||||
entityId,
|
||||
view: isMoreInfoView(view) ? view : undefined,
|
||||
hash: new URLSearchParams(
|
||||
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const createMoreInfoUrl = (
|
||||
base: string,
|
||||
data: CreateMoreInfoUrlData
|
||||
): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
|
||||
url.searchParams.set(VIEW_PARAM, data.view);
|
||||
if (!__DEMO__ && data.hash !== undefined) {
|
||||
url.hash = data.hash.toString();
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
|
||||
export const removeMoreInfoUrl = (base: string): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.delete(ENTITY_ID_PARAM);
|
||||
url.searchParams.delete(VIEW_PARAM);
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ type: Object }) public columns: DataTableColumnContainer = {};
|
||||
|
||||
@@ -1158,6 +1158,11 @@ export class HaDataTable extends LitElement {
|
||||
/* default mdc styles, colors changed, without checkbox styles */
|
||||
:host {
|
||||
height: 100%;
|
||||
--_cell-padding-inline: 16px;
|
||||
}
|
||||
|
||||
:host([narrow]) {
|
||||
--_cell-padding-inline: 8px;
|
||||
}
|
||||
.mdc-data-table__content {
|
||||
font-family: var(--ha-font-family-body);
|
||||
@@ -1238,8 +1243,7 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__cell,
|
||||
.mdc-data-table__header-cell {
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
padding-inline: var(--_cell-padding-inline);
|
||||
min-width: 150px;
|
||||
align-self: center;
|
||||
overflow: hidden;
|
||||
@@ -1259,14 +1263,8 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__header-cell--checkbox,
|
||||
.mdc-data-table__cell--checkbox {
|
||||
/* @noflip */
|
||||
padding-left: 16px;
|
||||
/* @noflip */
|
||||
padding-right: 0;
|
||||
/* @noflip */
|
||||
padding-inline-start: 16px;
|
||||
/* @noflip */
|
||||
padding-inline-end: initial;
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-inline-end: 0;
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
@@ -1379,8 +1377,7 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--overflow-menu:first-child,
|
||||
.mdc-data-table__header-cell--icon-button:first-child,
|
||||
.mdc-data-table__cell--icon-button:first-child {
|
||||
padding-left: 16px;
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-inline-end: initial;
|
||||
}
|
||||
|
||||
@@ -1388,8 +1385,7 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--overflow-menu:last-child,
|
||||
.mdc-data-table__header-cell--icon-button:last-child,
|
||||
.mdc-data-table__cell--icon-button:last-child {
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
padding-inline-end: var(--_cell-padding-inline);
|
||||
padding-inline-start: initial;
|
||||
}
|
||||
.mdc-data-table__cell--overflow-menu,
|
||||
@@ -1516,11 +1512,17 @@ export class HaDataTable extends LitElement {
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.primary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.scroller {
|
||||
height: calc(100% - 57px);
|
||||
|
||||
@@ -8,6 +8,7 @@ export const datePickerStyles = css`
|
||||
}
|
||||
calendar-date::part(button),
|
||||
calendar-range::part(button) {
|
||||
color: var(--primary-text-color);
|
||||
border: none;
|
||||
background-color: unset;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
|
||||
@@ -29,6 +29,7 @@ export class HaTraceLogbook extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entries=${this.logbookEntries}
|
||||
.narrow=${this.narrow}
|
||||
no-detail
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
|
||||
@@ -437,6 +437,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entries=${entries}
|
||||
.narrow=${this.narrow}
|
||||
no-detail
|
||||
></ha-logbook-renderer>
|
||||
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
|
||||
`
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@ export const getLogbookDataForContext = async (
|
||||
): Promise<LogbookEntry[]> =>
|
||||
getLogbookDataFromServer(hass, startDate, undefined, undefined, contextId);
|
||||
|
||||
const getLogbookDataFromServer = (
|
||||
export const getLogbookDataFromServer = (
|
||||
hass: HomeAssistant,
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
|
||||
@@ -7,21 +7,11 @@ import { isNumericEntity } from "../../data/history";
|
||||
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export const MORE_INFO_VIEWS = [
|
||||
"info",
|
||||
"history",
|
||||
"settings",
|
||||
"related",
|
||||
"add_to",
|
||||
"details",
|
||||
] as const;
|
||||
|
||||
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
|
||||
|
||||
export const isMoreInfoView = (
|
||||
value: string | undefined
|
||||
): value is MoreInfoView =>
|
||||
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
|
||||
export {
|
||||
isMoreInfoView,
|
||||
MORE_INFO_VIEWS,
|
||||
type MoreInfoView,
|
||||
} from "./more-info-view";
|
||||
|
||||
export const DOMAINS_NO_INFO = ["camera", "configurator"];
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "@lit/context";
|
||||
|
||||
export interface MoreInfoContext {
|
||||
hash: URLSearchParams;
|
||||
setHashParam: (key: string, value?: string) => void;
|
||||
}
|
||||
|
||||
export const moreInfoContext =
|
||||
createContext<MoreInfoContext>("more-info-context");
|
||||
@@ -11,6 +11,7 @@ import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
|
||||
import { formatTime } from "../../../common/datetime/format_time";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { formatNumber } from "../../../common/number/format_number";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-relative-time";
|
||||
import "../../../components/ha-spinner";
|
||||
@@ -48,11 +49,16 @@ import type {
|
||||
HomeAssistantFormatters,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../../types";
|
||||
import { moreInfoContext, type MoreInfoContext } from "../context";
|
||||
|
||||
@customElement("more-info-weather")
|
||||
class MoreInfoWeather extends LitElement {
|
||||
@property({ attribute: false }) public stateObj?: WeatherEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: moreInfoContext, subscribe: true })
|
||||
private _moreInfoContext?: MoreInfoContext;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: HomeAssistantInternationalization;
|
||||
@@ -127,15 +133,33 @@ class MoreInfoWeather extends LitElement {
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
|
||||
if (
|
||||
(changedProps.has("stateObj") ||
|
||||
changedProps.has("_moreInfoContext") ||
|
||||
!this._subscribed) &&
|
||||
this.stateObj
|
||||
) {
|
||||
const oldState = changedProps.get("stateObj") as
|
||||
WeatherEntity | undefined;
|
||||
if (
|
||||
oldState?.entity_id !== this.stateObj?.entity_id ||
|
||||
changedProps.has("_moreInfoContext") ||
|
||||
!this._subscribed
|
||||
) {
|
||||
this._forecastType = getDefaultForecastType(this.stateObj);
|
||||
this._subscribeForecastEvents();
|
||||
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
|
||||
const requestedForecastType =
|
||||
this._moreInfoContext?.hash.get("forecast");
|
||||
const selectedForecastType =
|
||||
supportedForecastTypes.find(
|
||||
(forecastType) => forecastType === requestedForecastType
|
||||
) ?? getDefaultForecastType(this.stateObj);
|
||||
if (selectedForecastType !== requestedForecastType) {
|
||||
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
|
||||
}
|
||||
if (this._forecastType !== selectedForecastType || !this._subscribed) {
|
||||
this._forecastType = selectedForecastType;
|
||||
this._subscribeForecastEvents();
|
||||
}
|
||||
}
|
||||
} else if (changedProps.has("_forecastType")) {
|
||||
this._subscribeForecastEvents();
|
||||
@@ -509,8 +533,17 @@ class MoreInfoWeather extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleForecastTypeChanged(ev: CustomEvent): void {
|
||||
private _handleForecastTypeChanged(
|
||||
ev: HASSDomEvent<{ name: ModernForecastType }>
|
||||
): void {
|
||||
if (
|
||||
!this.stateObj ||
|
||||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._forecastType = ev.detail.name;
|
||||
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
mdiTransitConnectionVariant,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { provide } from "@lit/context";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -43,8 +44,10 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
replaceCurrentUrl,
|
||||
updateHistoryState,
|
||||
} from "../../common/navigate";
|
||||
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { withViewTransition } from "../../common/util/view-transition";
|
||||
@@ -85,6 +88,7 @@ import {
|
||||
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
|
||||
type MoreInfoView,
|
||||
} from "./const";
|
||||
import { moreInfoContext, type MoreInfoContext } from "./context";
|
||||
import "./controls/more-info-default";
|
||||
import type { FavoritesDialogContext } from "./favorites";
|
||||
import { getFavoritesDialogHandler } from "./favorites";
|
||||
@@ -102,6 +106,9 @@ export interface MoreInfoDialogParams {
|
||||
tab?: MoreInfoView;
|
||||
large?: boolean;
|
||||
data?: Record<string, any>;
|
||||
hash?: URLSearchParams;
|
||||
fromUrl?: boolean;
|
||||
returnUrl?: string;
|
||||
parentElement?: LitElement;
|
||||
}
|
||||
|
||||
@@ -147,6 +154,12 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _data?: Record<string, any>;
|
||||
|
||||
@provide({ context: moreInfoContext })
|
||||
@state()
|
||||
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
|
||||
|
||||
private _returnUrl?: string;
|
||||
|
||||
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
|
||||
|
||||
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
|
||||
@@ -181,6 +194,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const view = params.view || params.tab || DEFAULT_VIEW;
|
||||
|
||||
this._data = params.data;
|
||||
this._moreInfoContext = this._createMoreInfoContext(params.hash);
|
||||
this._returnUrl = params.returnUrl;
|
||||
this._currView = view;
|
||||
this._initialView = view;
|
||||
this._childViewStack = [];
|
||||
@@ -216,6 +231,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
if (this._returnUrl) {
|
||||
replaceCurrentUrl(this._returnUrl);
|
||||
}
|
||||
this._entityId = undefined;
|
||||
this._parentEntityIds = [];
|
||||
this._entry = undefined;
|
||||
@@ -224,6 +242,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
this._initialView = DEFAULT_VIEW;
|
||||
this._currView = DEFAULT_VIEW;
|
||||
this._childViewStack = [];
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
this._returnUrl = undefined;
|
||||
this._isEscapeEnabled = true;
|
||||
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
|
||||
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
|
||||
@@ -271,7 +291,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
return entity?.device_id ?? null;
|
||||
}
|
||||
|
||||
private _setView(view: MoreInfoView) {
|
||||
private _setView(view: MoreInfoView, preserveHash = false) {
|
||||
if (view !== this._currView && !preserveHash) {
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
}
|
||||
updateHistoryState({
|
||||
dialogParams: {
|
||||
...getHistoryState()?.dialogParams,
|
||||
@@ -279,6 +302,38 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
},
|
||||
});
|
||||
this._currView = view;
|
||||
this._syncUrl();
|
||||
}
|
||||
|
||||
private _syncUrl() {
|
||||
if (!this._returnUrl || !this._entityId) {
|
||||
return;
|
||||
}
|
||||
replaceCurrentUrl(
|
||||
createMoreInfoUrl(this._returnUrl, {
|
||||
entityId: this._entityId,
|
||||
view: this._currView,
|
||||
hash: this._moreInfoContext.hash,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
|
||||
return {
|
||||
hash: new URLSearchParams(hash),
|
||||
setHashParam: (key, value) => this._setHashParam(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
private _setHashParam(key: string, value?: string) {
|
||||
const hash = new URLSearchParams(this._moreInfoContext.hash);
|
||||
if (value) {
|
||||
hash.set(key, value);
|
||||
} else {
|
||||
hash.delete(key);
|
||||
}
|
||||
this._moreInfoContext = this._createMoreInfoContext(hash);
|
||||
this._syncUrl();
|
||||
}
|
||||
|
||||
private _goBack() {
|
||||
@@ -306,7 +361,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
if (this._parentEntityIds.length > 0) {
|
||||
this._entityId = this._parentEntityIds.pop();
|
||||
this._currView = DEFAULT_VIEW;
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
this._loadEntityRegistryEntry();
|
||||
this._syncUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,19 +1064,22 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
|
||||
if (entityId === this._entityId) {
|
||||
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
|
||||
this._infoEditMode = false;
|
||||
this._detailsYamlMode = false;
|
||||
this._setView(view);
|
||||
this._setView(view, true);
|
||||
return;
|
||||
}
|
||||
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
|
||||
this._entityId = entityId;
|
||||
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
|
||||
this._currView = view === "details" ? view : DEFAULT_VIEW;
|
||||
this._initialView = view;
|
||||
this._infoEditMode = false;
|
||||
this._detailsYamlMode = false;
|
||||
this._childViewStack = [];
|
||||
this._loadEntityRegistryEntry();
|
||||
this._syncUrl();
|
||||
}
|
||||
|
||||
private _enableEscapeKeyClose = () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const MORE_INFO_VIEWS = [
|
||||
"info",
|
||||
"history",
|
||||
"settings",
|
||||
"related",
|
||||
"add_to",
|
||||
"details",
|
||||
] as const;
|
||||
|
||||
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
|
||||
|
||||
export const isMoreInfoView = (
|
||||
value: string | undefined
|
||||
): value is MoreInfoView =>
|
||||
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
|
||||
@@ -6,12 +6,16 @@ import { storage } from "../common/decorators/storage";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
|
||||
import { extractSearchParamsObject } from "../common/url/search-params";
|
||||
import { afterNextRender } from "../common/util/render-status";
|
||||
import { fetchHttpConfig } from "../data/http";
|
||||
import type { HttpConfigState } from "../data/http";
|
||||
import type { WindowWithPreloads } from "../data/preloads";
|
||||
import type { RecorderInfo } from "../data/recorder";
|
||||
import { getRecorderInfo } from "../data/recorder";
|
||||
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
|
||||
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import "../resources/custom-card-support";
|
||||
import { HassElement } from "../state/hass-element";
|
||||
import QuickBarMixin from "../state/quick-bar-mixin";
|
||||
@@ -144,6 +148,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// the shadow root. Appending the dialog before that render would let it
|
||||
// tear the freshly-added dialog straight back out of the DOM.
|
||||
this.checkHttpPendingConfig();
|
||||
this._restoreMoreInfoFromUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +183,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// Handle history changes
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
|
||||
// Restore a more-info dialog deep-linked in the current URL, if any.
|
||||
window.addEventListener("location-changed", () =>
|
||||
this._restoreMoreInfoFromUrl()
|
||||
);
|
||||
window.addEventListener("popstate", () => this._restoreMoreInfoFromUrl());
|
||||
|
||||
// Handle clicking on links
|
||||
window.addEventListener("click", (ev) => {
|
||||
const href = isNavigationClick(ev);
|
||||
@@ -297,6 +308,35 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _restoreMoreInfoFromUrl() {
|
||||
// Only restore once the main UI is rendered so the dialog has access to
|
||||
// the loaded entities.
|
||||
if (this.render !== this.renderHass) {
|
||||
return;
|
||||
}
|
||||
const searchParams = extractSearchParamsObject();
|
||||
if (!searchParams["more-info-entity-id"]) {
|
||||
return;
|
||||
}
|
||||
const { entityId, view, hash } = decodeMoreInfoUrl(
|
||||
window.location.search,
|
||||
window.location.hash
|
||||
);
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
// Wait for the next render to ensure the view is fully loaded
|
||||
// because the more info dialog is closed when the url changes
|
||||
afterNextRender(() => {
|
||||
showMoreInfoDialog(this, {
|
||||
entityId,
|
||||
view,
|
||||
hash,
|
||||
fromUrl: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected async checkDataBaseMigration() {
|
||||
if (__DEMO__) {
|
||||
this._databaseMigration = false;
|
||||
|
||||
@@ -2,10 +2,12 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiCloseThick,
|
||||
mdiCog,
|
||||
mdiContentDuplicate,
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
mdiExclamationThick,
|
||||
mdiHelpCircleOutline,
|
||||
mdiInformationOutline,
|
||||
mdiMenuDown,
|
||||
@@ -129,6 +131,28 @@ import {
|
||||
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
|
||||
import { showNewAutomationDialog } from "./show-dialog-new-automation";
|
||||
|
||||
const renderIconBadge = (path: string, color: string) => html`
|
||||
<div
|
||||
style=${styleMap({
|
||||
position: "absolute",
|
||||
top: "-5px",
|
||||
insetInlineEnd: "-7px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: color,
|
||||
boxShadow: "0 0 0 2px var(--data-table-background-color)",
|
||||
color: "var(--data-table-background-color)",
|
||||
"--mdc-icon-size": "12px",
|
||||
})}
|
||||
>
|
||||
<ha-svg-icon style="margin: 0;" .path=${path}></ha-svg-icon>
|
||||
</div>
|
||||
`;
|
||||
|
||||
type AutomationItem = AutomationEntity & {
|
||||
name: string;
|
||||
area: string | undefined;
|
||||
@@ -303,6 +327,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
localize: LocalizeFunc,
|
||||
entitiesToCheck?: any[]
|
||||
): DataTableColumnContainer<AutomationItem> => {
|
||||
const triggeredAtColumn = getTriggeredAtTableColumn<AutomationItem>(
|
||||
localize,
|
||||
this.hass
|
||||
);
|
||||
|
||||
const columns: DataTableColumnContainer<AutomationItem> = {
|
||||
icon: {
|
||||
title: "",
|
||||
@@ -310,18 +339,34 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
type: "icon",
|
||||
moveable: false,
|
||||
showNarrow: true,
|
||||
template: (automation) =>
|
||||
html`<ha-state-icon
|
||||
.stateObj=${automation}
|
||||
style=${styleMap({
|
||||
color:
|
||||
automation.state === UNAVAILABLE
|
||||
template: (automation) => {
|
||||
const unavailable = automation.state === UNAVAILABLE;
|
||||
const disabled = automation.state === "off";
|
||||
return html`<div
|
||||
style="position: relative; display: inline-flex; width: 24px; height: 24px;"
|
||||
>
|
||||
<ha-state-icon
|
||||
.stateObj=${automation}
|
||||
.stateValue=${unavailable || disabled ? "on" : undefined}
|
||||
style=${styleMap({
|
||||
display: "flex",
|
||||
margin: "0",
|
||||
color: unavailable
|
||||
? "var(--error-color)"
|
||||
: automation.state === "on"
|
||||
? "var(--state-active-color)"
|
||||
: disabled
|
||||
? "var(--disabled-color)"
|
||||
: "unset",
|
||||
})}
|
||||
></ha-state-icon>`,
|
||||
})}
|
||||
></ha-state-icon>
|
||||
${
|
||||
unavailable
|
||||
? renderIconBadge(mdiExclamationThick, "var(--error-color)")
|
||||
: disabled
|
||||
? renderIconBadge(mdiCloseThick, "var(--disabled-color)")
|
||||
: nothing
|
||||
}
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
entity_id: getEntityIdHiddenTableColumn(),
|
||||
name: {
|
||||
@@ -342,23 +387,33 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
area: getAreaTableColumn(localize),
|
||||
category: getCategoryTableColumn(localize),
|
||||
labels: getLabelsTableColumn(),
|
||||
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
|
||||
last_triggered: {
|
||||
...triggeredAtColumn,
|
||||
template: (automation) =>
|
||||
narrow && automation.state === "off"
|
||||
? nothing
|
||||
: triggeredAtColumn.template!(automation),
|
||||
},
|
||||
formatted_state: {
|
||||
minWidth: "82px",
|
||||
maxWidth: "82px",
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
hidden: narrow,
|
||||
type: "overflow",
|
||||
title: this.hass.localize("ui.panel.config.automation.picker.state"),
|
||||
template: (automation) => html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
template: (automation) =>
|
||||
narrow
|
||||
? automation.state === "off"
|
||||
? localize("ui.panel.config.automation.picker.disabled")
|
||||
: nothing
|
||||
: html`
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
lastFixed: true,
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { mdiPuzzle } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-adaptive-dialog";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-icon";
|
||||
import "../../components/ha-icon-next";
|
||||
import "../../components/ha-relative-time";
|
||||
import "../../components/ha-spinner";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/item/ha-list-item-base";
|
||||
import "../../components/item/ha-list-item-button";
|
||||
import "../../components/item/ha-list-item-value";
|
||||
import "../../components/list/ha-grouped-list";
|
||||
import { fetchDateWS } from "../../data/history";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { HassDialog } from "../../dialogs/make-dialog-manager";
|
||||
import { haStyle, haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook-chain";
|
||||
import type { LogbookChain } from "./logbook-chain-resolver";
|
||||
import { resolveLogbookChain } from "./logbook-chain-resolver";
|
||||
import type { LogbookItem } from "./logbook-entry-model";
|
||||
import { computeLogbookItem } from "./logbook-entry-model";
|
||||
import { renderLogbookGlyph, transitionArrow } from "./logbook-entry-templates";
|
||||
import type { LogbookDetailDialogParams } from "./show-dialog-logbook-detail";
|
||||
|
||||
@customElement("dialog-logbook-detail")
|
||||
class DialogLogbookDetail
|
||||
extends LitElement
|
||||
implements HassDialog<LogbookDetailDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: LogbookDetailDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _chain?: LogbookChain;
|
||||
|
||||
@state() private _previousState?: string;
|
||||
|
||||
@state() private _error = false;
|
||||
|
||||
public showDialog(params: LogbookDetailDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._chain = undefined;
|
||||
this._previousState = undefined;
|
||||
this._error = false;
|
||||
if (
|
||||
params.entry.context_event_type === "call_service" &&
|
||||
params.entry.context_domain
|
||||
) {
|
||||
this.hass.loadBackendTranslation("services", params.entry.context_domain);
|
||||
}
|
||||
this._loadDetails();
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._chain = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
// Both fetches resolve into a single render so the dialog reflows once.
|
||||
private async _loadDetails() {
|
||||
const { entry } = this._params!;
|
||||
const [{ chain, errored }, previousState] = await Promise.all([
|
||||
this._fetchChain(entry),
|
||||
this._fetchPreviousState(entry),
|
||||
]);
|
||||
if (this._params?.entry !== entry) {
|
||||
return;
|
||||
}
|
||||
this._error = errored;
|
||||
this._chain = chain;
|
||||
this._previousState = previousState;
|
||||
}
|
||||
|
||||
private async _fetchChain(
|
||||
entry: LogbookEntry
|
||||
): Promise<{ chain: LogbookChain; errored: boolean }> {
|
||||
const { userIdToName, systemUserIds } = this._params!;
|
||||
const options = { userIdToName, systemUserIds };
|
||||
const resolveWithoutFetch = () =>
|
||||
resolveLogbookChain(this.hass, entry, options, async () => []);
|
||||
try {
|
||||
const chain = isComponentLoaded(this.hass.config, "logbook")
|
||||
? await resolveLogbookChain(this.hass, entry, options)
|
||||
: await resolveWithoutFetch();
|
||||
return { chain, errored: false };
|
||||
} catch {
|
||||
return { chain: await resolveWithoutFetch(), errored: true };
|
||||
}
|
||||
}
|
||||
|
||||
// The feed the row was clicked in can be filtered or partially loaded, so
|
||||
// the state active just before the entry is resolved from history instead.
|
||||
private async _fetchPreviousState(
|
||||
entry: LogbookEntry
|
||||
): Promise<string | undefined> {
|
||||
if (
|
||||
!entry.entity_id ||
|
||||
entry.state === undefined ||
|
||||
!isComponentLoaded(this.hass.config, "history")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const end = new Date(entry.when * 1000);
|
||||
const start = new Date(end.getTime() - 1);
|
||||
try {
|
||||
const states = await fetchDateWS(this.hass, start, end, [
|
||||
entry.entity_id,
|
||||
]);
|
||||
return states[entry.entity_id]?.[0]?.s;
|
||||
} catch {
|
||||
// The row is still useful without an old state.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const { entry } = this._params;
|
||||
const item = computeLogbookItem(this.hass, entry);
|
||||
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize("ui.dialogs.logbook_detail.title")}
|
||||
@closed=${this._dialogClosed}
|
||||
@hass-more-info=${this._moreInfoOpened}
|
||||
>
|
||||
<div class="content">
|
||||
${this._renderFacts(item, entry)} ${this._renderWhatHappened(entry)}
|
||||
</div>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderFacts(item: LogbookItem, entry: LogbookEntry) {
|
||||
const stateObj = entry.entity_id
|
||||
? this.hass.states[entry.entity_id]
|
||||
: undefined;
|
||||
const transition = this._transitionValues(item, entry, stateObj);
|
||||
const when = this._entryDate(item.when);
|
||||
|
||||
return html`
|
||||
<ha-grouped-list>
|
||||
${this._renderSubjectRow(item, entry)}
|
||||
${
|
||||
transition
|
||||
? html`
|
||||
<ha-list-item-value
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.logbook_detail.state"
|
||||
)}
|
||||
>
|
||||
${
|
||||
transition.oldState
|
||||
? html`<span class="old-state"
|
||||
>${transition.oldState}</span
|
||||
><span class="arrow"
|
||||
>${transitionArrow(this.hass)}</span
|
||||
>`
|
||||
: nothing
|
||||
}<span class="new-state">${transition.newState}</span>
|
||||
</ha-list-item-value>
|
||||
`
|
||||
: item.value
|
||||
? html`
|
||||
<ha-list-item-value
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.logbook_detail.event"
|
||||
)}
|
||||
>
|
||||
${item.value.text}
|
||||
</ha-list-item-value>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-list-item-value
|
||||
class="time-value"
|
||||
.label=${this.hass.localize("ui.dialogs.logbook_detail.time")}
|
||||
>
|
||||
${formatDateTimeWithSeconds(when, this.hass.locale, this.hass.config)}
|
||||
<span class="sub">
|
||||
<ha-relative-time .datetime=${when} capitalize></ha-relative-time>
|
||||
</span>
|
||||
</ha-list-item-value>
|
||||
</ha-grouped-list>
|
||||
`;
|
||||
}
|
||||
|
||||
// Mirrors the target picker: icon, name, area ▸ device.
|
||||
private _renderSubjectRow(item: LogbookItem, entry: LogbookEntry) {
|
||||
const icon = html`<span class="subject-icon" slot="start">
|
||||
${this._renderSubjectIcon(item, entry)}
|
||||
</span>`;
|
||||
|
||||
if (!entry.entity_id || !(entry.entity_id in this.hass.states)) {
|
||||
return html`
|
||||
<ha-list-item-base
|
||||
.headline=${item.name}
|
||||
.supportingText=${item.context}
|
||||
>
|
||||
${icon}
|
||||
</ha-list-item-base>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
.headline=${item.name}
|
||||
.supportingText=${item.context}
|
||||
.entityId=${entry.entity_id}
|
||||
@click=${this._entityClicked}
|
||||
>
|
||||
${icon}
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
}
|
||||
|
||||
// The feed draws brand rows as a brands image, which stays blank for the
|
||||
// integrations that have none. An integration domain resolves to no domain
|
||||
// icon either, so fall back to the chain's own integration glyph.
|
||||
private _renderSubjectIcon(item: LogbookItem, entry: LogbookEntry) {
|
||||
if (item.glyph.type !== "brand") {
|
||||
return renderLogbookGlyph(this.hass, entry, item.glyph);
|
||||
}
|
||||
return item.glyph.icon
|
||||
? html`<ha-icon .icon=${item.glyph.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon .path=${mdiPuzzle}></ha-svg-icon>`;
|
||||
}
|
||||
|
||||
private _entityClicked(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement & { entityId: string };
|
||||
fireEvent(target, "hass-more-info", { entityId: target.entityId });
|
||||
}
|
||||
|
||||
private _renderWhatHappened(entry: LogbookEntry) {
|
||||
return html`
|
||||
${
|
||||
this._error
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this.hass.localize("ui.components.logbook.retrieval_error")}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<ha-grouped-list
|
||||
.header=${this.hass.localize("ui.dialogs.logbook_detail.what_happened")}
|
||||
>
|
||||
<div class="chain-area">
|
||||
${
|
||||
this._chain === undefined
|
||||
? html`<div class="loading"><ha-spinner></ha-spinner></div>`
|
||||
: html`<ha-logbook-chain
|
||||
.hass=${this.hass}
|
||||
.chain=${this._chain}
|
||||
.subject=${entry}
|
||||
.traceContexts=${this._params?.traceContexts ?? {}}
|
||||
></ha-logbook-chain>`
|
||||
}
|
||||
</div>
|
||||
</ha-grouped-list>
|
||||
`;
|
||||
}
|
||||
|
||||
private _entryDate = memoizeOne((when: number) => new Date(when));
|
||||
|
||||
private _transitionValues(
|
||||
item: LogbookItem,
|
||||
entry: LogbookEntry,
|
||||
stateObj?: HassEntity
|
||||
): { oldState?: string; newState: string } | undefined {
|
||||
if (item.category !== "entity" || entry.state === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const newState = stateObj
|
||||
? this.hass.formatEntityState(stateObj, entry.state)
|
||||
: entry.state;
|
||||
const previousState = this._previousState;
|
||||
const oldState =
|
||||
previousState !== undefined && previousState !== entry.state
|
||||
? stateObj
|
||||
? this.hass.formatEntityState(stateObj, previousState)
|
||||
: previousState
|
||||
: undefined;
|
||||
return { oldState, newState };
|
||||
}
|
||||
|
||||
private _moreInfoOpened() {
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-list-item-button,
|
||||
ha-list-item-base {
|
||||
--ha-row-item-gap: var(--ha-space-3);
|
||||
--ha-row-item-min-height: 56px;
|
||||
}
|
||||
|
||||
/* Match the weight the chain gives its own row names. */
|
||||
ha-list-item-button::part(headline),
|
||||
ha-list-item-base::part(headline) {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.subject-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
color: var(--secondary-text-color);
|
||||
--state-icon-color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.subject-icon state-badge {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ha-icon-next {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.sub {
|
||||
display: block;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
.old-state {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--disabled-color);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.new-state {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
ha-relative-time {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* minmax(0, 1fr) lets the chain shrink: a grid item defaults to its
|
||||
min-content width, which a long automation name blows past. */
|
||||
.chain-area {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Held at two chain rows, the typical chain height, so the swap from
|
||||
spinner to content barely moves the dialog. The resolved content
|
||||
sizes itself — a lone "no cause" line must not sit in a tall box. */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 114px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-logbook-detail": DialogLogbookDetail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { mdiStateMachine } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { formatTimeWithMilliseconds } from "../../common/datetime/format_time";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import "../../components/ha-icon-next";
|
||||
import "../../components/ha-state-icon";
|
||||
import "../../components/ha-svg-icon";
|
||||
import { computeServiceLabel } from "../../data/compute-service-info";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { createHistoricState, localizeTriggerSource } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { LogbookChain } from "./logbook-chain-resolver";
|
||||
import type { LogbookCause } from "./logbook-entry-model";
|
||||
import {
|
||||
computeLogbookItem,
|
||||
computeTraceLink,
|
||||
entityDisplay,
|
||||
isRunRow,
|
||||
isSameLogbookEntry,
|
||||
nodeColor,
|
||||
} from "./logbook-entry-model";
|
||||
import {
|
||||
renderLogbookCauseIcon,
|
||||
renderLogbookGlyph,
|
||||
} from "./logbook-entry-templates";
|
||||
|
||||
@customElement("ha-logbook-chain")
|
||||
class HaLogbookChain extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public chain!: LogbookChain;
|
||||
|
||||
@property({ attribute: false }) public subject!: LogbookEntry;
|
||||
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
|
||||
protected render() {
|
||||
const { rows, runRow, origins, syntheticRun, triggerRow } = this.chain;
|
||||
|
||||
if (!origins.length && !runRow && !syntheticRun && rows.length <= 1) {
|
||||
return html`
|
||||
<p class="no-cause">
|
||||
${this.hass.localize("ui.dialogs.logbook_detail.no_known_cause")}
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
// The chain is the subject's cause path: the runs recorded before its
|
||||
// rows (user → automation → script → entity). Sibling effects and runs
|
||||
// recorded after the subject are not causes and stay out.
|
||||
const isSubjectRow = (row: LogbookEntry) =>
|
||||
this.subject.entity_id
|
||||
? row.entity_id === this.subject.entity_id
|
||||
: isSameLogbookEntry(row, this.subject);
|
||||
const firstSubjectIndex = rows.findIndex(isSubjectRow);
|
||||
const visibleRows = rows.filter(
|
||||
(row, index) =>
|
||||
isSubjectRow(row) ||
|
||||
(isRunRow(row) &&
|
||||
(firstSubjectIndex === -1 || index < firstSubjectIndex))
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="chain">
|
||||
${origins.map((origin) =>
|
||||
this._renderOriginNode(
|
||||
origin,
|
||||
runRow ?? this.subject,
|
||||
origin.type === "state" ? triggerRow : undefined
|
||||
)
|
||||
)}
|
||||
${syntheticRun ? this._renderSyntheticRunNode(syntheticRun) : nothing}
|
||||
${visibleRows.map((row) =>
|
||||
isRunRow(row) ? this._renderRunNode(row) : this._renderEffectNode(row)
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// A div rather than a button: a run row nests the trace link inside it.
|
||||
private _renderRow(entityId: string | undefined, content: TemplateResult) {
|
||||
if (!entityId || !(entityId in this.hass.states)) {
|
||||
return html`<div class="chain-row">${content}</div>`;
|
||||
}
|
||||
return html`
|
||||
<div
|
||||
class="chain-row clickable"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
.entityId=${entityId}
|
||||
@click=${this._entityClicked}
|
||||
@keydown=${this._rowKeydown}
|
||||
>
|
||||
${content}
|
||||
<ha-icon-next class="chevron"></ha-icon-next>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTraceLink(traceLink?: string) {
|
||||
if (!traceLink) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<a
|
||||
class="trace-link"
|
||||
href=${traceLink}
|
||||
@click=${this._traceClicked}
|
||||
>${this.hass.localize("ui.components.logbook.view_trace")}</a
|
||||
>`;
|
||||
}
|
||||
|
||||
private _renderSyntheticRunNode(cause: LogbookCause) {
|
||||
const mechanism = this.subject.context_source
|
||||
? localizeTriggerSource(this.hass.localize, this.subject.context_source)
|
||||
: this.hass.localize(
|
||||
cause.type === "script"
|
||||
? "ui.components.logbook.script_ran"
|
||||
: "ui.components.logbook.automation_triggered"
|
||||
);
|
||||
const traceLink = computeTraceLink(
|
||||
this.traceContexts,
|
||||
this.subject.context_id
|
||||
);
|
||||
return this._renderRow(
|
||||
cause.entityId,
|
||||
html`
|
||||
<span class="chain-node run">${renderLogbookCauseIcon(cause)}</span>
|
||||
<span class="chain-content">
|
||||
<span class="name">${cause.name}</span>
|
||||
${
|
||||
mechanism
|
||||
? html`<span class="chain-secondary">${mechanism}</span>`
|
||||
: nothing
|
||||
}
|
||||
${this._renderTraceLink(traceLink)}
|
||||
</span>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
private _renderOriginNode(
|
||||
origin: LogbookCause,
|
||||
originRow: LogbookEntry,
|
||||
triggerRow?: LogbookEntry
|
||||
) {
|
||||
const name =
|
||||
origin.name ||
|
||||
this.hass.localize("ui.components.logbook.cause.scheduled");
|
||||
const isState = origin.type === "state";
|
||||
const triggerState = isState
|
||||
? (triggerRow?.state ?? originRow.context_state)
|
||||
: undefined;
|
||||
const stateObj = origin.entityId
|
||||
? this.hass.states[origin.entityId]
|
||||
: undefined;
|
||||
const triggerValue = triggerState
|
||||
? stateObj
|
||||
? this.hass.formatEntityState(stateObj, triggerState)
|
||||
: triggerState
|
||||
: undefined;
|
||||
const secondary = isState
|
||||
? origin.entityId
|
||||
? entityDisplay(this.hass, origin.entityId).secondary
|
||||
: undefined
|
||||
: this._actionUsedText(originRow);
|
||||
const isAvatar = origin.type === "user" && !origin.systemUser;
|
||||
const historicStateObj =
|
||||
stateObj && triggerState
|
||||
? createHistoricState(stateObj, triggerState)
|
||||
: stateObj;
|
||||
const color =
|
||||
isState && historicStateObj
|
||||
? nodeColor("entity", historicStateObj)
|
||||
: undefined;
|
||||
return this._renderRow(
|
||||
origin.entityId,
|
||||
html`
|
||||
<span
|
||||
class="chain-node ${isAvatar ? "avatar" : ""}"
|
||||
style=${color ? styleMap({ "--node-color": color }) : nothing}
|
||||
>
|
||||
${this._renderOriginIcon(origin, historicStateObj)}
|
||||
</span>
|
||||
<span class="chain-content">
|
||||
<span class="name">${name}</span>
|
||||
${
|
||||
secondary
|
||||
? html`<span class="chain-secondary">${secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
${
|
||||
triggerValue
|
||||
? html`<span class="chain-trailing">
|
||||
<span class="trailing-state">${triggerValue}</span>
|
||||
${
|
||||
triggerRow
|
||||
? html`<span class="trailing-time"
|
||||
>${this._formatTimeWithMs(triggerRow.when * 1000)}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
private _actionUsedText(originRow: LogbookEntry) {
|
||||
if (
|
||||
originRow.context_event_type === "call_service" &&
|
||||
originRow.context_domain &&
|
||||
originRow.context_service
|
||||
) {
|
||||
return this.hass.localize("ui.dialogs.logbook_detail.action_used", {
|
||||
name: computeServiceLabel(
|
||||
this.hass.localize,
|
||||
this.hass.services,
|
||||
`${originRow.context_domain}.${originRow.context_service}`
|
||||
),
|
||||
});
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private _renderOriginIcon(origin: LogbookCause, stateObj?: HassEntity) {
|
||||
if (origin.type === "state") {
|
||||
return stateObj
|
||||
? html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
|
||||
: html`<ha-svg-icon .path=${mdiStateMachine}></ha-svg-icon>`;
|
||||
}
|
||||
return renderLogbookCauseIcon(origin);
|
||||
}
|
||||
|
||||
private _formatTimeWithMs(when: number) {
|
||||
return formatTimeWithMilliseconds(
|
||||
new Date(when),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
}
|
||||
|
||||
private _renderRunNode(row: LogbookEntry) {
|
||||
const item = computeLogbookItem(this.hass, row);
|
||||
const time = this._formatTimeWithMs(item.when);
|
||||
const traceLink = computeTraceLink(this.traceContexts, row.context_id);
|
||||
return this._renderRow(
|
||||
row.entity_id,
|
||||
html`
|
||||
<span class="chain-node run">
|
||||
${renderLogbookGlyph(this.hass, row, item.glyph)}
|
||||
</span>
|
||||
<span class="chain-content">
|
||||
<span class="name">${item.name}</span>
|
||||
${this._renderTraceLink(traceLink)}
|
||||
</span>
|
||||
<span class="chain-trailing">
|
||||
${
|
||||
item.value
|
||||
? html`<span class="trailing-state">${item.value.text}</span>`
|
||||
: nothing
|
||||
}
|
||||
<span class="trailing-time">${time}</span>
|
||||
</span>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
private _renderEffectNode(row: LogbookEntry) {
|
||||
const item = computeLogbookItem(this.hass, row);
|
||||
const stateObj = row.entity_id
|
||||
? this.hass.states[row.entity_id]
|
||||
: undefined;
|
||||
const historicStateObj = stateObj
|
||||
? createHistoricState(stateObj, row.state)
|
||||
: undefined;
|
||||
const color = nodeColor(item.category, historicStateObj);
|
||||
const time = this._formatTimeWithMs(item.when);
|
||||
return this._renderRow(
|
||||
row.entity_id,
|
||||
html`
|
||||
<span
|
||||
class="chain-node"
|
||||
style=${color ? styleMap({ "--node-color": color }) : nothing}
|
||||
>
|
||||
${renderLogbookGlyph(this.hass, row, item.glyph)}
|
||||
</span>
|
||||
<span class="chain-content">
|
||||
<span class="name">${item.name}</span>
|
||||
${
|
||||
item.context
|
||||
? html`<span class="chain-secondary">${item.context}</span>`
|
||||
: nothing
|
||||
}
|
||||
</span>
|
||||
<span class="chain-trailing">
|
||||
${
|
||||
item.value
|
||||
? html`<span class="trailing-state">${item.value.text}</span>`
|
||||
: nothing
|
||||
}
|
||||
<span class="trailing-time">${time}</span>
|
||||
</span>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
private _entityClicked(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement & { entityId: string };
|
||||
fireEvent(target, "hass-more-info", { entityId: target.entityId });
|
||||
}
|
||||
|
||||
private _rowKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key !== "Enter" && ev.key !== " ") {
|
||||
return;
|
||||
}
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this._entityClicked(ev);
|
||||
}
|
||||
|
||||
private _traceClicked(ev: MouseEvent) {
|
||||
// The row underneath opens the more info; the link owns its own click.
|
||||
ev.stopPropagation();
|
||||
const href = isNavigationClick(ev);
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
// navigate() closes the dialogs above this chain.
|
||||
navigate(href);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The enclosing ha-grouped-list owns the frame; the rows only have to
|
||||
line up with its own items. */
|
||||
.chain-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: var(--ha-space-2)
|
||||
var(--ha-row-item-padding-inline, var(--ha-space-3));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.chain-row.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chain-row.clickable:hover {
|
||||
background-color: rgba(var(--rgb-primary-text-color), 0.04);
|
||||
}
|
||||
|
||||
.chain-row.clickable:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Caret between rows: the chain reads top-down, cause to effects.
|
||||
Centered on the node: row padding + half the 32px node - half the
|
||||
caret. */
|
||||
.chain-row + .chain-row::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
inset-inline-start: calc(
|
||||
var(--ha-row-item-padding-inline, var(--ha-space-3)) + 11px
|
||||
);
|
||||
border-inline-start: 5px solid transparent;
|
||||
border-inline-end: 5px solid transparent;
|
||||
border-top: 6px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.chain-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background-color: var(--card-background-color);
|
||||
color: var(--node-color, var(--secondary-text-color));
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
|
||||
.chain-node state-badge {
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chain-node::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background-color: currentColor;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.chain-node > * {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chain-node.run {
|
||||
color: var(
|
||||
--logbook-category-automation-color,
|
||||
var(--light-blue-color)
|
||||
);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
.chain-node.avatar::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chain-node.avatar ha-user-badge {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chain-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chain-content .name {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chain-secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
.trace-link {
|
||||
align-self: flex-start;
|
||||
color: var(--primary-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trace-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chain-trailing {
|
||||
text-align: end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.trailing-state {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trailing-time {
|
||||
display: block;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.no-cause {
|
||||
margin: 0;
|
||||
padding: var(--ha-space-3)
|
||||
var(--ha-row-item-padding-inline, var(--ha-space-3));
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-logbook-chain": HaLogbookChain;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,50 @@
|
||||
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { computeTimelineColor } from "../../components/chart/timeline-color";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
|
||||
import { useAmPm } from "../../common/datetime/use_am_pm";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import "../../components/entity/state-badge";
|
||||
import "../../components/ha-relative-time";
|
||||
import "../../components/ha-domain-icon";
|
||||
import "../../components/ha-state-icon";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-icon-next";
|
||||
import "../../components/ha-tooltip";
|
||||
import "../../components/user/ha-user-badge";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
import type { User } from "../../data/user";
|
||||
import { buttonLinkStyle, haStyle } from "../../resources/styles";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type {
|
||||
LogbookCause,
|
||||
LogbookCauseType,
|
||||
LogbookGlyph,
|
||||
LogbookItem,
|
||||
LogbookNameDetail,
|
||||
LogbookValue,
|
||||
} from "./logbook-entry-model";
|
||||
import { computeLogbookItem, nodeColor } from "./logbook-entry-model";
|
||||
import {
|
||||
computeLogbookItem,
|
||||
nodeColor,
|
||||
TRIGGER_DOMAINS,
|
||||
} from "./logbook-entry-model";
|
||||
renderLogbookCauseIcon,
|
||||
renderLogbookGlyph,
|
||||
transitionArrow,
|
||||
} from "./logbook-entry-templates";
|
||||
|
||||
type EntryLayout = "timeline" | "list" | "inline";
|
||||
|
||||
interface LogbookRenderItem extends LogbookItem {
|
||||
traceLink: string | undefined;
|
||||
renderedTime: TemplateResult | string;
|
||||
renderedTime: string;
|
||||
renderedValue: TemplateResult | string;
|
||||
}
|
||||
|
||||
// Names are the fixed system user names set by core (cloud/cast integrations).
|
||||
const SYSTEM_USER_ICONS: Record<string, string> = {
|
||||
"Home Assistant Cloud": mdiCloud,
|
||||
"Home Assistant Cast": mdiCast,
|
||||
};
|
||||
export interface LogbookEntrySelectedDetail {
|
||||
item: LogbookEntry;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"logbook-entry-selected": LogbookEntrySelectedDetail;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ha-logbook-entry")
|
||||
class HaLogbookEntry extends LitElement {
|
||||
@@ -65,7 +57,7 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public systemUserIds = new Set<string>();
|
||||
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@@ -80,8 +72,6 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public lastOfDay = false;
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public showRelative = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
|
||||
false;
|
||||
|
||||
@@ -99,37 +89,28 @@ class HaLogbookEntry extends LitElement {
|
||||
systemUserIds: this.systemUserIds,
|
||||
});
|
||||
|
||||
const traceContext =
|
||||
entry.domain &&
|
||||
TRIGGER_DOMAINS.includes(entry.domain) &&
|
||||
entry.context_id &&
|
||||
entry.context_id in this.traceContexts
|
||||
? this.traceContexts[entry.context_id]
|
||||
: undefined;
|
||||
const traceLink = traceContext
|
||||
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
|
||||
: undefined;
|
||||
|
||||
const hideName = this.nameDetail === "none";
|
||||
const layout: EntryLayout =
|
||||
!this.narrow && !this.noIcon ? "timeline" : hideName ? "inline" : "list";
|
||||
const node = layout === "timeline" ? "icon" : "dot";
|
||||
|
||||
const when = new Date(item.when);
|
||||
const renderedTime = this.showRelative
|
||||
? html`<ha-relative-time
|
||||
.datetime=${when}
|
||||
format="short"
|
||||
></ha-relative-time>`
|
||||
: formatTimeWithSeconds(when, this.hass.locale, this.hass.config);
|
||||
const renderedTime = formatTimeWithSeconds(
|
||||
new Date(item.when),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
|
||||
const ctx: LogbookRenderItem = {
|
||||
...item,
|
||||
traceLink,
|
||||
renderedTime,
|
||||
renderedValue: this._renderValue(item.value, seenEntityIds, !!traceLink),
|
||||
renderedValue: this._renderValue(item.value, seenEntityIds),
|
||||
};
|
||||
|
||||
const clickable = !this.noDetail;
|
||||
// On wide screens the hover state carries the affordance; a chevron would
|
||||
// sit far away from the text it belongs to.
|
||||
const chevron = clickable && layout !== "timeline";
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="entry ${classMap({
|
||||
@@ -138,17 +119,16 @@ class HaLogbookEntry extends LitElement {
|
||||
"last-of-day": this.lastOfDay,
|
||||
[`category-${ctx.category}`]: true,
|
||||
"time-am-pm": useAmPm(this.hass.locale),
|
||||
clickable,
|
||||
})}"
|
||||
role=${ifDefined(clickable ? "button" : undefined)}
|
||||
tabindex=${ifDefined(clickable ? "0" : undefined)}
|
||||
@click=${this._rowClicked}
|
||||
@keydown=${this._rowKeydown}
|
||||
>
|
||||
${
|
||||
layout === "timeline"
|
||||
? html`<div
|
||||
class="time"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${this._toggleTime}
|
||||
@keydown=${this._timeKeydown}
|
||||
>
|
||||
? html`<div class="time">
|
||||
<span class="time-content">${renderedTime}</span>
|
||||
</div>`
|
||||
: nothing
|
||||
@@ -161,109 +141,73 @@ class HaLogbookEntry extends LitElement {
|
||||
>
|
||||
${this._renderNode(ctx, layout)}
|
||||
</div>
|
||||
<div class="content">
|
||||
${
|
||||
layout === "timeline"
|
||||
? this._renderTimeline(ctx)
|
||||
: layout === "list"
|
||||
? this._renderList(ctx)
|
||||
: this._renderInline(ctx)
|
||||
}
|
||||
<div class="body">
|
||||
<div class="content">
|
||||
${
|
||||
layout === "timeline"
|
||||
? this._renderTimeline(ctx)
|
||||
: layout === "list"
|
||||
? this._renderList(ctx)
|
||||
: this._renderInline(ctx)
|
||||
}
|
||||
</div>
|
||||
${chevron ? html`<ha-icon-next class="chevron"></ha-icon-next>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleTime(e: Event) {
|
||||
e.stopPropagation();
|
||||
fireEvent(this, "logbook-toggle-time" as any);
|
||||
}
|
||||
|
||||
private _timeKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
fireEvent(this, "logbook-toggle-time" as any);
|
||||
private _rowClicked() {
|
||||
if (this.noDetail) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "logbook-entry-selected", { item: this.item });
|
||||
}
|
||||
|
||||
private _handleTraceClick(ev: MouseEvent) {
|
||||
// Let modified clicks open in a new tab; otherwise route in-app.
|
||||
if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey) {
|
||||
private _rowKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key !== "Enter" && ev.key !== " ") {
|
||||
return;
|
||||
}
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
navigate((ev.currentTarget as HTMLAnchorElement).getAttribute("href")!);
|
||||
fireEvent(this, "closed");
|
||||
}
|
||||
|
||||
private _entityClicked(ev: Event) {
|
||||
const entityId = (ev.currentTarget as any).entityId;
|
||||
if (!entityId) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "hass-more-info", { entityId });
|
||||
}
|
||||
|
||||
private _renderTimeChip(renderedTime: TemplateResult | string) {
|
||||
return html`<span
|
||||
class="time-chip"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${this._toggleTime}
|
||||
@keydown=${this._timeKeydown}
|
||||
>${renderedTime}</span
|
||||
>`;
|
||||
this._rowClicked();
|
||||
}
|
||||
|
||||
private _renderTrailing(
|
||||
cause: LogbookCause | undefined,
|
||||
traceLink: string | undefined,
|
||||
renderedTime: TemplateResult | string
|
||||
renderedTime: string
|
||||
) {
|
||||
return html`<span class="trailing">
|
||||
${
|
||||
cause
|
||||
? html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
|
||||
<span class="cause-badge" id="cause-badge"
|
||||
>${this._renderCauseIcon(cause)}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
${this._renderTimeChip(renderedTime)}
|
||||
${cause ? this._renderCauseBadge(cause) : nothing}
|
||||
<span class="time-chip">${renderedTime}</span>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
private _renderTraceLink(traceLink: string) {
|
||||
return html`<a
|
||||
class="trace-link"
|
||||
href=${traceLink}
|
||||
@click=${this._handleTraceClick}
|
||||
>${this.hass.localize("ui.components.logbook.view_trace")}</a
|
||||
>`;
|
||||
private _renderCauseBadge(cause: LogbookCause) {
|
||||
return html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
|
||||
<span class="cause-badge" id="cause-badge"
|
||||
>${renderLogbookCauseIcon(cause)}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
private _renderTimeline(ctx: LogbookRenderItem) {
|
||||
const hideName = this.nameDetail === "none";
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
const valueIsState = ctx.value?.type === "state";
|
||||
const causePhrase = ctx.cause
|
||||
? this._renderCausePhrase(ctx.cause)
|
||||
: undefined;
|
||||
return html`
|
||||
<div class="primary">
|
||||
<span class="primary-text"
|
||||
>${
|
||||
!hideName
|
||||
? html`<span class="subject"
|
||||
>${this._renderEntity(ctx.entityId, ctx.name)}</span
|
||||
>${this._entityName(ctx.entityId, ctx.name)}</span
|
||||
>${
|
||||
ctx.renderedValue
|
||||
? valueIsState
|
||||
? html`<span class="arrow">${rtl ? "←" : "→"}</span>`
|
||||
? html`<span class="arrow"
|
||||
>${transitionArrow(this.hass)}</span
|
||||
>`
|
||||
: " "
|
||||
: nothing
|
||||
}`
|
||||
@@ -279,15 +223,9 @@ class HaLogbookEntry extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
causePhrase || ctx.traceLink
|
||||
ctx.cause
|
||||
? html`<div class="secondary">
|
||||
${
|
||||
causePhrase
|
||||
? html`<span class="cause-phrase">${causePhrase}</span>`
|
||||
: nothing
|
||||
}
|
||||
${causePhrase && ctx.traceLink ? html`·` : nothing}
|
||||
${ctx.traceLink ? this._renderTraceLink(ctx.traceLink) : nothing}
|
||||
${this._renderCauseLine(ctx.cause)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
@@ -295,16 +233,11 @@ class HaLogbookEntry extends LitElement {
|
||||
}
|
||||
|
||||
private _renderList(ctx: LogbookRenderItem) {
|
||||
const cause =
|
||||
this.showCause || ctx.category === "entity" ? ctx.cause : undefined;
|
||||
const trailingTrace = this.showCause ? undefined : ctx.traceLink;
|
||||
const thirdLineTrace = this.showCause ? ctx.traceLink : undefined;
|
||||
const showThirdLine = this.showCause && (cause || thirdLineTrace);
|
||||
const cause = ctx.cause;
|
||||
const showThirdLine = this.showCause && cause;
|
||||
return html`
|
||||
<div class="primary">
|
||||
<span class="subject"
|
||||
>${this._renderEntity(ctx.entityId, ctx.name)}</span
|
||||
>
|
||||
<span class="subject">${this._entityName(ctx.entityId, ctx.name)}</span>
|
||||
<span class="value" title=${ctx.value?.text ?? ""}
|
||||
>${ctx.renderedValue}</span
|
||||
>
|
||||
@@ -313,27 +246,18 @@ class HaLogbookEntry extends LitElement {
|
||||
<span class="secondary-text">${ctx.context ?? nothing}</span>
|
||||
${this._renderTrailing(
|
||||
showThirdLine ? undefined : cause,
|
||||
trailingTrace,
|
||||
ctx.renderedTime
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
showThirdLine
|
||||
? html`<div class="secondary">
|
||||
${this._renderListCauseLine(cause, thirdLineTrace)}
|
||||
</div>`
|
||||
? html`<div class="secondary">${this._renderCauseLine(cause)}</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderListCauseLine(
|
||||
cause: LogbookCause | undefined,
|
||||
traceLink: string | undefined
|
||||
) {
|
||||
if (!cause) {
|
||||
return traceLink ? this._renderTraceLink(traceLink) : nothing;
|
||||
}
|
||||
private _renderCauseLine(cause: LogbookCause) {
|
||||
const { localize } = this.hass;
|
||||
if (cause.entityId) {
|
||||
const prefixMap: Partial<Record<LogbookCauseType, string>> = {
|
||||
@@ -348,115 +272,67 @@ class HaLogbookEntry extends LitElement {
|
||||
}),
|
||||
};
|
||||
const prefix = prefixMap[cause.type];
|
||||
return html`
|
||||
${prefix ? html`<span class="cause-prefix">${prefix}</span>` : nothing}
|
||||
<button
|
||||
class="link cause-entity"
|
||||
@click=${this._entityClicked}
|
||||
.entityId=${cause.entityId}
|
||||
>
|
||||
${cause.name}
|
||||
</button>
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
`;
|
||||
return html`<span class="cause-line">
|
||||
${prefix ?? nothing}<span class="cause-entity">${cause.name}</span>
|
||||
</span>`;
|
||||
}
|
||||
return html`
|
||||
<span class="secondary-text">${this._renderCausePhrase(cause)}</span>
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
`;
|
||||
return html`<span class="cause-line"
|
||||
>${this._renderCausePhrase(cause)}</span
|
||||
>`;
|
||||
}
|
||||
|
||||
private _renderInline(ctx: LogbookRenderItem) {
|
||||
return html`
|
||||
<div class="primary">
|
||||
<span class="primary-text">${ctx.renderedValue}</span>
|
||||
${this._renderTrailing(
|
||||
ctx.category === "entity" ? ctx.cause : undefined,
|
||||
ctx.traceLink,
|
||||
ctx.renderedTime
|
||||
)}
|
||||
${this._renderTrailing(ctx.cause, ctx.renderedTime)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderValue(
|
||||
value: LogbookValue | undefined,
|
||||
seenEntityIds: string[],
|
||||
noLink: boolean
|
||||
seenEntityIds: string[]
|
||||
): TemplateResult | string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
return value.type === "message"
|
||||
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds, noLink)
|
||||
? this._formatMessageWithPossibleEntity(value.text, seenEntityIds)
|
||||
: value.text;
|
||||
}
|
||||
|
||||
private _renderEntity(
|
||||
private _entityName(
|
||||
entityId: string | undefined,
|
||||
entityName: string | undefined,
|
||||
noLink?: boolean
|
||||
entityName: string | undefined
|
||||
) {
|
||||
const hasState = entityId && entityId in this.hass.states;
|
||||
const displayName =
|
||||
return (
|
||||
entityName ||
|
||||
(hasState
|
||||
(entityId && entityId in this.hass.states
|
||||
? 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>`;
|
||||
: entityId)
|
||||
);
|
||||
}
|
||||
|
||||
private _renderCausePhrase(cause: LogbookCause): TemplateResult | string {
|
||||
private _renderCausePhrase(cause: LogbookCause): string {
|
||||
const { localize } = this.hass;
|
||||
const nameEl = cause.entityId
|
||||
? html`<button
|
||||
class="link"
|
||||
@click=${this._entityClicked}
|
||||
.entityId=${cause.entityId}
|
||||
>
|
||||
${cause.name}
|
||||
</button>`
|
||||
: cause.name;
|
||||
switch (cause.type) {
|
||||
case "user":
|
||||
return localize("ui.components.logbook.cause.by", {
|
||||
name: cause.name,
|
||||
});
|
||||
case "automation":
|
||||
return cause.entityId
|
||||
? html`${localize("ui.components.logbook.cause.by_automation", {
|
||||
name: "",
|
||||
})}${nameEl}`
|
||||
: localize("ui.components.logbook.cause.by_automation", {
|
||||
name: cause.name,
|
||||
});
|
||||
return localize("ui.components.logbook.cause.by_automation", {
|
||||
name: cause.name,
|
||||
});
|
||||
case "script":
|
||||
return cause.entityId
|
||||
? html`${localize("ui.components.logbook.cause.by_script", {
|
||||
name: "",
|
||||
})}${nameEl}`
|
||||
: localize("ui.components.logbook.cause.by_script", {
|
||||
name: cause.name,
|
||||
});
|
||||
return localize("ui.components.logbook.cause.by_script", {
|
||||
name: cause.name,
|
||||
});
|
||||
case "state":
|
||||
return cause.entityId
|
||||
? html`${localize("ui.components.logbook.cause.by_state_change", {
|
||||
name: "",
|
||||
})}${nameEl}`
|
||||
: localize("ui.components.logbook.cause.by_state_change", {
|
||||
name: cause.name,
|
||||
});
|
||||
return localize("ui.components.logbook.cause.by_state_change", {
|
||||
name: cause.name,
|
||||
});
|
||||
case "scheduled":
|
||||
return localize("ui.components.logbook.cause.scheduled");
|
||||
case "homeassistant":
|
||||
@@ -470,54 +346,9 @@ class HaLogbookEntry extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _renderCauseIcon(cause: LogbookCause) {
|
||||
if (cause.type === "user") {
|
||||
const systemIcon = cause.systemUser
|
||||
? SYSTEM_USER_ICONS[cause.name]
|
||||
: undefined;
|
||||
if (systemIcon) {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${systemIcon}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
return html`<ha-user-badge
|
||||
class="cause-icon cause-avatar"
|
||||
.user=${{ id: cause.userId!, name: cause.name } as User}
|
||||
></ha-user-badge>`;
|
||||
}
|
||||
if (cause.type === "automation") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiRobot}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "script") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiScriptText}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "state") {
|
||||
return nothing;
|
||||
}
|
||||
if (cause.brandDomain) {
|
||||
return html`<ha-domain-icon
|
||||
class="cause-icon"
|
||||
.domain=${cause.brandDomain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>`;
|
||||
}
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiPuzzle}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
|
||||
private _formatMessageWithPossibleEntity(
|
||||
message: string,
|
||||
seenEntities: string[],
|
||||
noLink?: boolean
|
||||
seenEntities: string[]
|
||||
) {
|
||||
if (message.indexOf(".") !== -1) {
|
||||
const messageParts = message.split(" ");
|
||||
@@ -531,10 +362,9 @@ class HaLogbookEntry extends LitElement {
|
||||
const messageEnd = messageParts.splice(i);
|
||||
messageEnd.shift();
|
||||
return html`${messageParts.join(" ")}
|
||||
${this._renderEntity(
|
||||
${this._entityName(
|
||||
entityId,
|
||||
this.hass.states[entityId].attributes.friendly_name,
|
||||
noLink
|
||||
this.hass.states[entityId].attributes.friendly_name
|
||||
)}
|
||||
${messageEnd.join(" ")}`;
|
||||
}
|
||||
@@ -571,53 +401,14 @@ class HaLogbookEntry extends LitElement {
|
||||
const unavailable =
|
||||
item.glyph.type === "state" && item.glyph.stateObj.state === UNAVAILABLE;
|
||||
return html`<div class="node-glyph" style=${style}>
|
||||
${this._renderGlyph(item.glyph)}
|
||||
${renderLogbookGlyph(this.hass, this.item, item.glyph)}
|
||||
${unavailable ? html`<span class="node-badge"></span>` : nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderGlyph(glyph: LogbookGlyph) {
|
||||
if (glyph.type === "automation") {
|
||||
return html`<ha-svg-icon
|
||||
.path=${glyph.script ? mdiScriptText : mdiRobot}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (glyph.type === "state") {
|
||||
return html`<ha-state-icon
|
||||
.stateObj=${glyph.stateObj}
|
||||
.icon=${glyph.icon}
|
||||
></ha-state-icon>`;
|
||||
}
|
||||
return html`<state-badge
|
||||
.overrideIcon=${glyph.icon}
|
||||
.overrideImage=${this._brandImage(glyph.domain)}
|
||||
.stateColor=${false}
|
||||
></state-badge>`;
|
||||
}
|
||||
|
||||
private _brandImage(domain?: string): string | undefined {
|
||||
if (
|
||||
!domain ||
|
||||
this.item.icon ||
|
||||
this.item.state ||
|
||||
!isComponentLoaded(this.hass.config, domain)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return brandsUrl(
|
||||
{
|
||||
domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
@@ -672,6 +463,20 @@ class HaLogbookEntry extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
.entry.clickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.entry.clickable:hover {
|
||||
background-color: rgba(var(--rgb-primary-text-color), 0.04);
|
||||
}
|
||||
|
||||
.entry.clickable:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -681,12 +486,6 @@ class HaLogbookEntry extends LitElement {
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.time-content {
|
||||
@@ -831,19 +630,33 @@ class HaLogbookEntry extends LitElement {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Discreet divider between rows, aligned to the content so it does
|
||||
not cross the rail. Suppressed on the last row of each day. */
|
||||
.body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.entry.last-of-day .body {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
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;
|
||||
}
|
||||
|
||||
/* List/inline: fixed padding instead of justify-content:center so
|
||||
@@ -874,6 +687,13 @@ class HaLogbookEntry extends LitElement {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
/* Inline is the only layout carrying the trailing block in .primary,
|
||||
and an icon badge has no baseline of its own — it would ride 5px
|
||||
above the text and miss the chevron's centerline. */
|
||||
.entry.layout-inline .primary {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.entry.layout-inline .primary-text:first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@@ -895,14 +715,6 @@ class HaLogbookEntry extends LitElement {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.primary > .subject button.link {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.subject {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
@@ -951,33 +763,20 @@ class HaLogbookEntry extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
ha-relative-time {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.time-chip {
|
||||
flex-shrink: 0;
|
||||
min-width: 4.5em;
|
||||
text-align: end;
|
||||
line-height: 1;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time-chip {
|
||||
min-width: 4.5em;
|
||||
}
|
||||
|
||||
.entry.time-am-pm .time-chip {
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
.time-chip:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.cause-icon {
|
||||
flex-shrink: 0;
|
||||
--mdc-icon-size: var(--cause-icon-size);
|
||||
@@ -991,44 +790,19 @@ class HaLogbookEntry extends LitElement {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.cause-prefix {
|
||||
flex-shrink: 0;
|
||||
/* The cause reads as one sentence on one line, truncating as a whole. */
|
||||
.cause-line {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.cause-entity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
/* The trace link sits after the cause; it never shrinks, so a long
|
||||
cause truncates instead. */
|
||||
.trace-link {
|
||||
flex-shrink: 0;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.trace-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Entity names read as the subject, not a wall of blue links — the
|
||||
colored node is the scan anchor. */
|
||||
button.link {
|
||||
color: var(--primary-text-color);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { VisibilityChangedEvent } from "@lit-labs/virtualizer";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, eventOptions, property, state } from "lit/decorators";
|
||||
import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { formatDate } from "../../common/datetime/format_date";
|
||||
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
@@ -13,13 +14,14 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook-entry";
|
||||
import type { LogbookEntrySelectedDetail } from "./ha-logbook-entry";
|
||||
import type { LogbookNameDetail } from "./logbook-entry-model";
|
||||
import { sameDay } from "./logbook-entry-model";
|
||||
import { showLogbookDetailDialog } from "./show-dialog-logbook-detail";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"hass-logbook-live": { enable: boolean };
|
||||
"logbook-toggle-time": undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +34,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public systemUserIds = new Set<string>();
|
||||
|
||||
// Not rendered by rows; read at click time and handed to the detail dialog.
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
|
||||
@property({ attribute: false }) public entries: LogbookEntry[] = [];
|
||||
@@ -49,14 +52,14 @@ class HaLogbookRenderer extends LitElement {
|
||||
@property({ type: Boolean, attribute: "show-cause" }) public showCause =
|
||||
false;
|
||||
|
||||
@property({ type: Boolean, attribute: "no-detail" }) public noDetail = false;
|
||||
|
||||
@property({ type: String, attribute: "name-detail" })
|
||||
public nameDetail?: LogbookNameDetail;
|
||||
|
||||
// @ts-ignore
|
||||
@restoreScroll(".container") private _savedScrollPos?: number;
|
||||
|
||||
@state() private _showRelative = false;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (
|
||||
(!this.hasUpdated && this.virtualize) ||
|
||||
@@ -77,8 +80,9 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
return (
|
||||
changedProps.has("entries") ||
|
||||
changedProps.has("traceContexts") ||
|
||||
changedProps.has("_showRelative" as never) ||
|
||||
changedProps.has("noDetail") ||
|
||||
changedProps.has("userIdToName") ||
|
||||
changedProps.has("systemUserIds") ||
|
||||
languageChanged
|
||||
);
|
||||
}
|
||||
@@ -96,7 +100,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
<div
|
||||
class="container ha-scrollbar"
|
||||
@scroll=${this._saveScrollPos}
|
||||
@logbook-toggle-time=${this._handleToggleTime}
|
||||
@logbook-entry-selected=${this._handleEntrySelected}
|
||||
>
|
||||
${
|
||||
this.virtualize
|
||||
@@ -105,7 +109,12 @@ class HaLogbookRenderer extends LitElement {
|
||||
scroller
|
||||
class="ha-scrollbar"
|
||||
.items=${this.entries}
|
||||
.renderItem=${this._getRenderRow(this._showRelative) as any}
|
||||
.renderItem=${
|
||||
this._getRenderRow(
|
||||
this.userIdToName,
|
||||
this.systemUserIds
|
||||
) as any
|
||||
}
|
||||
>
|
||||
</lit-virtualizer>`
|
||||
: this.entries.map((item, index) => this._renderItem(item, index))
|
||||
@@ -114,9 +123,12 @@ class HaLogbookRenderer extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Memoized on every input the rows render from, so the virtualizer sees a
|
||||
// new renderItem and refreshes already-rendered rows when one changes.
|
||||
private _getRenderRow = memoizeOne(
|
||||
(_showRelative: boolean) => (item: LogbookEntry, index: number) =>
|
||||
this._renderItem(item, index)
|
||||
(_userIdToName: Record<string, string>, _systemUserIds: Set<string>) =>
|
||||
(item: LogbookEntry, index: number) =>
|
||||
this._renderItem(item, index)
|
||||
);
|
||||
|
||||
private _renderItem = (item: LogbookEntry, index: number) => {
|
||||
@@ -141,22 +153,27 @@ class HaLogbookRenderer extends LitElement {
|
||||
.item=${item}
|
||||
.userIdToName=${this.userIdToName}
|
||||
.systemUserIds=${this.systemUserIds}
|
||||
.traceContexts=${this.traceContexts}
|
||||
.narrow=${this.narrow}
|
||||
.noIcon=${this.noIcon}
|
||||
.graphColor=${this.graphColor}
|
||||
.nameDetail=${this.nameDetail}
|
||||
.firstOfDay=${firstOfDay}
|
||||
.lastOfDay=${lastOfDay}
|
||||
.showRelative=${this._showRelative}
|
||||
.showCause=${this.showCause}
|
||||
.noDetail=${this.noDetail}
|
||||
></ha-logbook-entry>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
private _handleToggleTime() {
|
||||
this._showRelative = !this._showRelative;
|
||||
private _handleEntrySelected(ev: HASSDomEvent<LogbookEntrySelectedDetail>) {
|
||||
ev.stopPropagation();
|
||||
showLogbookDetailDialog(this, {
|
||||
entry: ev.detail.item,
|
||||
traceContexts: this.traceContexts,
|
||||
userIdToName: this.userIdToName,
|
||||
systemUserIds: this.systemUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
private _formatDateHeader(date: Date): string {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { getLogbookDataFromServer } from "../../data/logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { LogbookCause } from "./logbook-entry-model";
|
||||
import {
|
||||
computeContextCause,
|
||||
computeLogbookCause,
|
||||
computeUserCause,
|
||||
isRunCause,
|
||||
isRunRow,
|
||||
isSameLogbookEntry,
|
||||
} from "./logbook-entry-model";
|
||||
|
||||
// No run start is available and a delayed script can start long before the
|
||||
// clicked entry.
|
||||
const LOOKBACK_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const WHEN_EPSILON = 0.001;
|
||||
|
||||
const MAX_RUN_CANDIDATES = 3;
|
||||
|
||||
export interface LogbookChain {
|
||||
rows: LogbookEntry[];
|
||||
runRow?: LogbookEntry;
|
||||
// Causes shown above the run, topmost first.
|
||||
origins: LogbookCause[];
|
||||
// Stands in for the run row when it could not be fetched.
|
||||
syntheticRun?: LogbookCause;
|
||||
// The state change that fired a state trigger in `origins`.
|
||||
triggerRow?: LogbookEntry;
|
||||
}
|
||||
|
||||
export type LogbookFetcher = (
|
||||
startDate: string,
|
||||
endDate?: string,
|
||||
entityIds?: string[],
|
||||
contextId?: string
|
||||
) => Promise<LogbookEntry[]>;
|
||||
|
||||
interface ResolveOptions {
|
||||
userIdToName?: Record<string, string>;
|
||||
systemUserIds?: Set<string>;
|
||||
}
|
||||
|
||||
const lookbackIso = (when: number) =>
|
||||
new Date(when * 1000 - LOOKBACK_MS).toISOString();
|
||||
|
||||
const justAfterIso = (when: number) =>
|
||||
new Date(when * 1000 + 1000).toISOString();
|
||||
|
||||
// Effect rows never carry a context_id, run rows do: the logbook processor
|
||||
// only emits it on rows described by the automation/script platforms. This
|
||||
// candidate discovery + verification is a workaround for that gap; once core
|
||||
// emits context_id on described rows too, the fast path by entry.context_id
|
||||
// covers every click and this can be deleted.
|
||||
const resolveRowsByContextEntity = async (
|
||||
entry: LogbookEntry,
|
||||
fetchEvents: LogbookFetcher
|
||||
): Promise<LogbookEntry[]> => {
|
||||
const contextEntityId = entry.context_entity_id!;
|
||||
const runs = (
|
||||
await fetchEvents(lookbackIso(entry.when), justAfterIso(entry.when), [
|
||||
contextEntityId,
|
||||
])
|
||||
)
|
||||
.filter((row) => row.context_id && row.when <= entry.when + WHEN_EPSILON)
|
||||
.sort((a, b) => b.when - a.when)
|
||||
.slice(0, MAX_RUN_CANDIDATES);
|
||||
|
||||
for (const run of runs) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const rows = await fetchEvents(
|
||||
lookbackIso(entry.when),
|
||||
undefined,
|
||||
undefined,
|
||||
run.context_id
|
||||
);
|
||||
if (rows.some((row) => isSameLogbookEntry(row, entry))) {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const resolveTriggerRow = async (
|
||||
entityId: string,
|
||||
beforeWhen: number,
|
||||
fetchEvents: LogbookFetcher
|
||||
): Promise<LogbookEntry | undefined> => {
|
||||
const rows = await fetchEvents(
|
||||
lookbackIso(beforeWhen),
|
||||
justAfterIso(beforeWhen),
|
||||
[entityId]
|
||||
);
|
||||
for (let i = rows.length - 1; i >= 0; i--) {
|
||||
const row = rows[i];
|
||||
if (row.state !== undefined && row.when <= beforeWhen + WHEN_EPSILON) {
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const resolveLogbookChain = async (
|
||||
hass: HomeAssistant,
|
||||
entry: LogbookEntry,
|
||||
options: ResolveOptions = {},
|
||||
fetchEvents: LogbookFetcher = (startDate, endDate, entityIds, contextId) =>
|
||||
getLogbookDataFromServer(hass, startDate, endDate, entityIds, contextId)
|
||||
): Promise<LogbookChain> => {
|
||||
const userIdToName = options.userIdToName ?? {};
|
||||
const { systemUserIds } = options;
|
||||
|
||||
let rows: LogbookEntry[] = [];
|
||||
if (entry.context_id) {
|
||||
rows = await fetchEvents(
|
||||
lookbackIso(entry.when),
|
||||
undefined,
|
||||
undefined,
|
||||
entry.context_id
|
||||
);
|
||||
} else if (entry.context_entity_id) {
|
||||
rows = await resolveRowsByContextEntity(entry, fetchEvents);
|
||||
}
|
||||
if (!rows.length) {
|
||||
rows = [entry];
|
||||
}
|
||||
|
||||
let runRow = rows.find(isRunRow);
|
||||
if (runRow && runRow !== entry && isSameLogbookEntry(entry, runRow)) {
|
||||
// The clicked feed copy carries the call_service description that the
|
||||
// fetched copy of the run row never has.
|
||||
rows = rows.map((row) => (row === runRow ? entry : row));
|
||||
runRow = entry;
|
||||
}
|
||||
const origins: LogbookCause[] = [];
|
||||
let syntheticRun: LogbookCause | undefined;
|
||||
if (runRow) {
|
||||
const runCause = computeLogbookCause(
|
||||
hass,
|
||||
runRow,
|
||||
userIdToName,
|
||||
systemUserIds
|
||||
);
|
||||
if (runCause?.type !== "user" && !isSameLogbookEntry(entry, runRow)) {
|
||||
// The run row is its own context origin and comes back without the
|
||||
// user its effects carry: read the user from the clicked entry.
|
||||
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
|
||||
if (userCause) {
|
||||
origins.push(userCause);
|
||||
}
|
||||
}
|
||||
if (runCause) {
|
||||
origins.push(runCause);
|
||||
}
|
||||
} else {
|
||||
const userCause = computeUserCause(entry, userIdToName, systemUserIds);
|
||||
const contextCause = computeContextCause(hass, entry);
|
||||
if (isRunCause(contextCause)) {
|
||||
syntheticRun = contextCause;
|
||||
if (userCause) {
|
||||
origins.push(userCause);
|
||||
}
|
||||
} else {
|
||||
const cause = userCause ?? contextCause;
|
||||
if (cause) {
|
||||
origins.push(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stateOrigin = origins.find(
|
||||
(cause) => cause.type === "state" && cause.entityId
|
||||
);
|
||||
const triggerRow = stateOrigin
|
||||
? await resolveTriggerRow(
|
||||
stateOrigin.entityId!,
|
||||
(runRow ?? entry).when,
|
||||
fetchEvents
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return { rows, runRow, origins, syntheticRun, triggerRow };
|
||||
};
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
localizeStateMessage,
|
||||
parseTriggerSource,
|
||||
} from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export type LogbookEntryCategory = "entity" | "automation" | "integration";
|
||||
|
||||
export const TRIGGER_DOMAINS = ["automation", "script"];
|
||||
const TRIGGER_DOMAINS = ["automation", "script"];
|
||||
|
||||
export const stripEntityId = (message: string, entityId?: string) =>
|
||||
const stripEntityId = (message: string, entityId?: string) =>
|
||||
entityId ? message.replace(entityId, " ") : message;
|
||||
|
||||
export const classifyLogbookEntry = (
|
||||
@@ -35,6 +36,10 @@ export const classifyLogbookEntry = (
|
||||
return "integration";
|
||||
};
|
||||
|
||||
// A run row is the acting automation or script; every other row is an effect.
|
||||
export const isRunRow = (item: LogbookEntry): boolean =>
|
||||
classifyLogbookEntry(item) === "automation";
|
||||
|
||||
// How much naming detail an entity row shows, from least to most. The value is
|
||||
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
|
||||
// entity), `area` (area ▸ device ▸ entity).
|
||||
@@ -96,12 +101,31 @@ export const entityDisplay = (
|
||||
return { primary, secondary };
|
||||
};
|
||||
|
||||
export const hasContext = (item: LogbookEntry) =>
|
||||
const hasContext = (item: LogbookEntry) =>
|
||||
item.context_event_type || item.context_state || item.context_message;
|
||||
|
||||
export const sameDay = (a?: LogbookEntry, b?: LogbookEntry) =>
|
||||
!!a?.when && !!b?.when && isSameDay(a.when * 1000, b.when * 1000);
|
||||
|
||||
export const isSameLogbookEntry = (a: LogbookEntry, b: LogbookEntry) =>
|
||||
a.when === b.when &&
|
||||
a.entity_id === b.entity_id &&
|
||||
a.state === b.state &&
|
||||
a.message === b.message &&
|
||||
a.name === b.name;
|
||||
|
||||
// Every entry of a run shares the run's context id, so effect rows resolve
|
||||
// to their cause's trace too.
|
||||
export const computeTraceLink = (
|
||||
traceContexts: TraceContexts,
|
||||
contextId?: string
|
||||
): string | undefined => {
|
||||
const traceContext = contextId ? traceContexts[contextId] : undefined;
|
||||
return traceContext
|
||||
? `/config/${traceContext.domain}/trace/${traceContext.item_id}?run_id=${traceContext.run_id}`
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// Unavailable is flagged with an orange badge by the row, not a color change.
|
||||
export const nodeColor = (
|
||||
category: LogbookEntryCategory,
|
||||
@@ -131,8 +155,7 @@ export interface LogbookCause {
|
||||
brandDomain?: string;
|
||||
}
|
||||
|
||||
export const computeLogbookCause = (
|
||||
hass: HomeAssistant,
|
||||
export const computeUserCause = (
|
||||
item: LogbookEntry,
|
||||
userIdToName: Record<string, string>,
|
||||
systemUserIds?: Set<string>
|
||||
@@ -140,15 +163,21 @@ export const computeLogbookCause = (
|
||||
const userName = item.context_user_id
|
||||
? userIdToName[item.context_user_id]
|
||||
: undefined;
|
||||
if (userName) {
|
||||
return {
|
||||
type: "user",
|
||||
name: userName,
|
||||
userId: item.context_user_id,
|
||||
systemUser: systemUserIds?.has(item.context_user_id!),
|
||||
};
|
||||
if (!userName) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: "user",
|
||||
name: userName,
|
||||
userId: item.context_user_id,
|
||||
systemUser: systemUserIds?.has(item.context_user_id!),
|
||||
};
|
||||
};
|
||||
|
||||
export const computeContextCause = (
|
||||
hass: HomeAssistant,
|
||||
item: LogbookEntry
|
||||
): LogbookCause | undefined => {
|
||||
if (
|
||||
item.context_event_type === "automation_triggered" ||
|
||||
item.context_event_type === "script_started"
|
||||
@@ -239,6 +268,18 @@ export const computeLogbookCause = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const computeLogbookCause = (
|
||||
hass: HomeAssistant,
|
||||
item: LogbookEntry,
|
||||
userIdToName: Record<string, string>,
|
||||
systemUserIds?: Set<string>
|
||||
): LogbookCause | undefined =>
|
||||
computeUserCause(item, userIdToName, systemUserIds) ??
|
||||
computeContextCause(hass, item);
|
||||
|
||||
export const isRunCause = (cause?: LogbookCause): boolean =>
|
||||
cause?.type === "automation" || cause?.type === "script";
|
||||
|
||||
export type LogbookGlyph =
|
||||
| { type: "state"; stateObj: HassEntity; icon?: string }
|
||||
| { type: "automation"; script: boolean }
|
||||
@@ -284,10 +325,11 @@ const computeLogbookValue = (
|
||||
type: "state",
|
||||
};
|
||||
}
|
||||
// Core sends run rows (carrying the automation/script entity) with a raw
|
||||
// English message; use our own label. Domain-only entries (e.g. logbook.log)
|
||||
// keep their custom message.
|
||||
const isAutomationRun =
|
||||
domain &&
|
||||
TRIGGER_DOMAINS.includes(domain) &&
|
||||
(item.source || hasContext(item) || !!item.context_user_id);
|
||||
item.entity_id && domain && TRIGGER_DOMAINS.includes(domain);
|
||||
if (isAutomationRun) {
|
||||
return {
|
||||
text: hass.localize(
|
||||
@@ -348,6 +390,13 @@ export const computeLogbookItem = (
|
||||
? entityDisplay(hass, entry.entity_id, opts.nameDetail)
|
||||
: undefined;
|
||||
|
||||
const userCause = computeUserCause(
|
||||
entry,
|
||||
opts.userIdToName ?? {},
|
||||
opts.systemUserIds
|
||||
);
|
||||
const contextCause = computeContextCause(hass, entry);
|
||||
|
||||
return {
|
||||
category,
|
||||
glyph: computeLogbookGlyph(entry, category, historicStateObj, domain),
|
||||
@@ -355,12 +404,10 @@ export const computeLogbookItem = (
|
||||
name: display?.primary ?? entry.name,
|
||||
context: display?.secondary,
|
||||
value: computeLogbookValue(hass, entry, domain, historicStateObj),
|
||||
cause: computeLogbookCause(
|
||||
hass,
|
||||
entry,
|
||||
opts.userIdToName ?? {},
|
||||
opts.systemUserIds
|
||||
),
|
||||
// A row shows the run over the user who started it; the dialog shows both.
|
||||
cause: isRunCause(contextCause)
|
||||
? contextCause
|
||||
: (userCause ?? contextCause),
|
||||
when: entry.when * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import {
|
||||
mdiCast,
|
||||
mdiClockOutline,
|
||||
mdiCloud,
|
||||
mdiPuzzle,
|
||||
mdiRobot,
|
||||
mdiScriptText,
|
||||
mdiStateMachine,
|
||||
} from "@mdi/js";
|
||||
import { html } from "lit";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
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/user/ha-user-badge";
|
||||
import { mdiHomeAssistant } from "../../resources/home-assistant-logo-svg";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { User } from "../../data/user";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type { LogbookCause, LogbookGlyph } from "./logbook-entry-model";
|
||||
|
||||
// Names are the fixed system user names set by core (cloud/cast integrations).
|
||||
const SYSTEM_USER_ICONS: Record<string, string> = {
|
||||
"Home Assistant Cloud": mdiCloud,
|
||||
"Home Assistant Cast": mdiCast,
|
||||
};
|
||||
|
||||
export const renderLogbookCauseIcon = (cause: LogbookCause) => {
|
||||
if (cause.type === "user") {
|
||||
const systemIcon = cause.systemUser
|
||||
? SYSTEM_USER_ICONS[cause.name]
|
||||
: undefined;
|
||||
if (systemIcon) {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${systemIcon}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
return html`<ha-user-badge
|
||||
class="cause-icon cause-avatar"
|
||||
.user=${{ id: cause.userId!, name: cause.name } as User}
|
||||
></ha-user-badge>`;
|
||||
}
|
||||
if (cause.type === "automation") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiRobot}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "script") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiScriptText}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "state") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiStateMachine}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "scheduled") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiClockOutline}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.type === "homeassistant") {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiHomeAssistant}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (cause.brandDomain) {
|
||||
return html`<ha-domain-icon
|
||||
class="cause-icon"
|
||||
.domain=${cause.brandDomain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>`;
|
||||
}
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${mdiPuzzle}
|
||||
></ha-svg-icon>`;
|
||||
};
|
||||
|
||||
const brandImage = (
|
||||
hass: HomeAssistant,
|
||||
entry: LogbookEntry,
|
||||
domain?: string
|
||||
): string | undefined => {
|
||||
if (
|
||||
!domain ||
|
||||
entry.icon ||
|
||||
entry.state ||
|
||||
!isComponentLoaded(hass.config, domain)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return brandsUrl(
|
||||
{
|
||||
domain,
|
||||
type: "icon",
|
||||
darkOptimized: hass.themes?.darkMode,
|
||||
},
|
||||
hass.auth.data.hassUrl
|
||||
);
|
||||
};
|
||||
|
||||
export const transitionArrow = (hass: HomeAssistant) =>
|
||||
computeRTL(hass.language, hass.translationMetadata.translations) ? "←" : "→";
|
||||
|
||||
export const renderLogbookGlyph = (
|
||||
hass: HomeAssistant,
|
||||
entry: LogbookEntry,
|
||||
glyph: LogbookGlyph
|
||||
) => {
|
||||
if (glyph.type === "automation") {
|
||||
return html`<ha-svg-icon
|
||||
.path=${glyph.script ? mdiScriptText : mdiRobot}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
if (glyph.type === "state") {
|
||||
return html`<ha-state-icon
|
||||
.stateObj=${glyph.stateObj}
|
||||
.icon=${glyph.icon}
|
||||
></ha-state-icon>`;
|
||||
}
|
||||
return html`<state-badge
|
||||
.overrideIcon=${glyph.icon}
|
||||
.overrideImage=${brandImage(hass, entry, glyph.domain)}
|
||||
.stateColor=${false}
|
||||
></state-badge>`;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
|
||||
export interface LogbookDetailDialogParams {
|
||||
entry: LogbookEntry;
|
||||
traceContexts?: TraceContexts;
|
||||
userIdToName?: Record<string, string>;
|
||||
systemUserIds?: Set<string>;
|
||||
}
|
||||
|
||||
export const loadLogbookDetailDialog = () => import("./dialog-logbook-detail");
|
||||
|
||||
export const showLogbookDetailDialog = (
|
||||
element: HTMLElement,
|
||||
params: LogbookDetailDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-logbook-detail",
|
||||
dialogImport: loadLogbookDetailDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
@@ -70,8 +70,6 @@ import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../dialogs/generic/show-dialog-box";
|
||||
import { isMoreInfoView } from "../../dialogs/more-info/const";
|
||||
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
@@ -723,21 +721,6 @@ class HUIRoot extends LitElement {
|
||||
} else if (searchParams.conversation === "1") {
|
||||
this._clearParam("conversation");
|
||||
this._showVoiceCommandDialog();
|
||||
} else if (searchParams["more-info-entity-id"]) {
|
||||
const entityId = searchParams["more-info-entity-id"];
|
||||
const view = searchParams["more-info-view"];
|
||||
this._clearParam("more-info-entity-id");
|
||||
if (view) {
|
||||
this._clearParam("more-info-view");
|
||||
}
|
||||
// Wait for the next render to ensure the view is fully loaded
|
||||
// because the more info dialog is closed when the url changes
|
||||
afterNextRender(() => {
|
||||
showMoreInfoDialog(this, {
|
||||
entityId,
|
||||
view: isMoreInfoView(view) ? view : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { replaceCurrentUrl } from "../common/navigate";
|
||||
import {
|
||||
createMoreInfoUrl,
|
||||
removeMoreInfoUrl,
|
||||
} from "../common/url/more-info-query-params";
|
||||
import { showDialog } from "../dialogs/make-dialog-manager";
|
||||
import type { MoreInfoDialogParams } from "../dialogs/more-info/ha-more-info-dialog";
|
||||
import type { Constructor } from "../types";
|
||||
@@ -28,12 +34,19 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
||||
private async _handleMoreInfo(
|
||||
ev: HASSDomEvent<HASSDomEvents["hass-more-info"]>
|
||||
) {
|
||||
showDialog(
|
||||
const view = ev.detail.view || ev.detail.tab || "info";
|
||||
const currentUrl = `${mainWindow.location.pathname}${mainWindow.location.search}${mainWindow.location.hash}`;
|
||||
const returnUrl = ev.detail.fromUrl
|
||||
? removeMoreInfoUrl(currentUrl)
|
||||
: currentUrl;
|
||||
|
||||
replaceCurrentUrl(returnUrl);
|
||||
const shown = await showDialog(
|
||||
this,
|
||||
"ha-more-info-dialog",
|
||||
{
|
||||
entityId: ev.detail.entityId,
|
||||
view: ev.detail.view || ev.detail.tab,
|
||||
view,
|
||||
large:
|
||||
ev.detail.large ??
|
||||
(ev.detail.entityId
|
||||
@@ -42,9 +55,20 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
|
||||
)
|
||||
: false),
|
||||
data: ev.detail.data,
|
||||
hash: ev.detail.hash,
|
||||
returnUrl,
|
||||
},
|
||||
() => import("../dialogs/more-info/ha-more-info-dialog"),
|
||||
ev.detail.parentElement
|
||||
);
|
||||
if (shown && ev.detail.entityId) {
|
||||
replaceCurrentUrl(
|
||||
createMoreInfoUrl(returnUrl, {
|
||||
entityId: ev.detail.entityId,
|
||||
view,
|
||||
hash: ev.detail.hash,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2054,6 +2054,15 @@
|
||||
"description": "Description",
|
||||
"required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]"
|
||||
},
|
||||
"logbook_detail": {
|
||||
"title": "Activity details",
|
||||
"state": "State",
|
||||
"event": "Event",
|
||||
"time": "Time",
|
||||
"what_happened": "What happened",
|
||||
"no_known_cause": "No cause was recorded for this activity.",
|
||||
"action_used": "Action used: {name}"
|
||||
},
|
||||
"voice-settings": {
|
||||
"expose_header": "Expose",
|
||||
"aliases_header": "Aliases",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, describe, it } from "vitest";
|
||||
import {
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
formatTimeWithMilliseconds,
|
||||
formatTimeWeekday,
|
||||
formatTime24h,
|
||||
} from "../../../src/common/datetime/format_time";
|
||||
@@ -85,6 +86,58 @@ describe("formatTimeWithSeconds", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimeWithMilliseconds", () => {
|
||||
const dateObj = new Date(2017, 10, 18, 23, 12, 13, 400);
|
||||
|
||||
it("Formats English times with milliseconds", () => {
|
||||
expect(
|
||||
formatTimeWithMilliseconds(
|
||||
dateObj,
|
||||
{
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.am_pm,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
demoConfig
|
||||
)
|
||||
).toBe("11:12:13.400 PM");
|
||||
expect(
|
||||
formatTimeWithMilliseconds(
|
||||
dateObj,
|
||||
{
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.twenty_four,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
demoConfig
|
||||
)
|
||||
).toBe("23:12:13.400");
|
||||
});
|
||||
|
||||
it("Uses the locale decimal separator", () => {
|
||||
expect(
|
||||
formatTimeWithMilliseconds(
|
||||
dateObj,
|
||||
{
|
||||
language: "fr",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.twenty_four,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
demoConfig
|
||||
)
|
||||
).toBe("23:12:13,400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimeWeekday", () => {
|
||||
const dateObj = new Date(2017, 10, 18, 23, 12, 13, 1400);
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
serviceTargetFromQueryParams,
|
||||
type QueryParamConfig,
|
||||
} from "../../../src/common/url/query-params";
|
||||
import {
|
||||
createMoreInfoUrl,
|
||||
decodeMoreInfoUrl,
|
||||
removeMoreInfoUrl,
|
||||
} from "../../../src/common/url/more-info-query-params";
|
||||
import {
|
||||
createTodoQueryString,
|
||||
decodeTodoQueryParams,
|
||||
@@ -240,3 +245,67 @@ describe("todo query params", () => {
|
||||
expect(encoded).toBe("add_item=true&entity_id=todo.tasks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("more-info query params", () => {
|
||||
it("decodes the entity, view, and named hash params", () => {
|
||||
const params = decodeMoreInfoUrl(
|
||||
"?more-info-entity-id=weather.home&more-info-view=info",
|
||||
"#forecast=hourly"
|
||||
);
|
||||
|
||||
expect(params.entityId).toBe("weather.home");
|
||||
expect(params.view).toBe("info");
|
||||
expect(params.hash.get("forecast")).toBe("hourly");
|
||||
});
|
||||
|
||||
it("ignores invalid views", () => {
|
||||
expect(
|
||||
decodeMoreInfoUrl(
|
||||
"?more-info-entity-id=weather.home&more-info-view=forecast"
|
||||
).view
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates a link without dropping unrelated query params", () => {
|
||||
expect(
|
||||
createMoreInfoUrl("/lovelace/home?theme=dark", {
|
||||
entityId: "weather.home",
|
||||
view: "info",
|
||||
hash: new URLSearchParams({ forecast: "hourly" }),
|
||||
})
|
||||
).toBe(
|
||||
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
|
||||
);
|
||||
});
|
||||
|
||||
it("removes more-info query params but preserves other hash state", () => {
|
||||
expect(
|
||||
removeMoreInfoUrl(
|
||||
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
|
||||
)
|
||||
).toBe("/lovelace/home?theme=dark#forecast=hourly");
|
||||
});
|
||||
|
||||
it("preserves an unrelated hash when creating a more-info url without a dialog hash", () => {
|
||||
expect(
|
||||
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
|
||||
entityId: "light.kitchen",
|
||||
view: "info",
|
||||
})
|
||||
).toBe(
|
||||
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info#some-anchor"
|
||||
);
|
||||
});
|
||||
|
||||
it("clears an existing hash when an empty dialog hash is explicitly supplied", () => {
|
||||
expect(
|
||||
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
|
||||
entityId: "light.kitchen",
|
||||
view: "info",
|
||||
hash: new URLSearchParams(),
|
||||
})
|
||||
).toBe(
|
||||
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+59
-1
@@ -211,7 +211,7 @@ test("keeps the launch screen until initial panel content renders", async ({
|
||||
name: "generated dashboard",
|
||||
path: "/?scenario=delayed-generated-dashboard#/climate",
|
||||
loadingSelector: "#ha-launch-screen",
|
||||
readySelector: "hui-view",
|
||||
readySelector: "hui-card",
|
||||
resolvers: ["resolveGeneratedDashboard"],
|
||||
},
|
||||
{
|
||||
@@ -327,6 +327,64 @@ test.describe("Light more-info dialog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Weather more-info deep link", () => {
|
||||
test("opens and synchronizes the selected forecast", async ({ page }) => {
|
||||
await goToPanel(
|
||||
page,
|
||||
"/?scenario=weather-more-info&more-info-entity-id=weather.test_weather&more-info-view=info#/lovelace"
|
||||
);
|
||||
|
||||
const dialog = page.locator("ha-more-info-dialog");
|
||||
const weather = dialog.locator("more-info-weather");
|
||||
await expect(weather).toBeAttached({ timeout: SHELL_TIMEOUT });
|
||||
await expect(page).toHaveURL(
|
||||
/more-info-entity-id=weather\.test_weather&more-info-view=info/
|
||||
);
|
||||
await expect(
|
||||
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
|
||||
).toBeAttached();
|
||||
|
||||
await page.locator("ha-test").evaluate((el) => {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("hass-more-info", {
|
||||
detail: {
|
||||
entityId: "weather.test_weather",
|
||||
hash: new URLSearchParams({ forecast: "hourly" }),
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Hourly" })
|
||||
).toBeAttached();
|
||||
|
||||
await dialog.getByRole("button", { name: "History" }).click();
|
||||
await expect(page).toHaveURL(/more-info-view=history/);
|
||||
|
||||
await dialog.getByRole("button", { name: "Back" }).click();
|
||||
|
||||
await expect(
|
||||
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
|
||||
).toBeAttached();
|
||||
|
||||
await weather
|
||||
.locator("ha-tab-group-tab")
|
||||
.filter({ hasText: "Daily" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
|
||||
).toBeAttached();
|
||||
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page).not.toHaveURL(/more-info-entity-id/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,6 +5,10 @@ import type {
|
||||
} from "../../../../../src/data/entity/entity_registry";
|
||||
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
|
||||
import type { MediaPlayerItem } from "../../../../../src/data/media-player";
|
||||
import {
|
||||
WeatherEntityFeature,
|
||||
type ForecastEvent,
|
||||
} from "../../../../../src/data/weather";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
|
||||
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
|
||||
@@ -95,6 +99,51 @@ const lightMoreInfoScenario: Scenario = async (hass) => {
|
||||
hass.mockWS("config/entity_registry/get", () => registryEntry);
|
||||
};
|
||||
|
||||
const weatherMoreInfoScenario: Scenario = (hass) => {
|
||||
hass.addEntities([
|
||||
{
|
||||
entity_id: "weather.test_weather",
|
||||
state: "sunny",
|
||||
attributes: {
|
||||
friendly_name: "Test Weather",
|
||||
supported_features:
|
||||
WeatherEntityFeature.FORECAST_DAILY +
|
||||
WeatherEntityFeature.FORECAST_HOURLY,
|
||||
precipitation_unit: "mm",
|
||||
pressure_unit: "hPa",
|
||||
temperature: 20,
|
||||
temperature_unit: "°C",
|
||||
visibility_unit: "km",
|
||||
wind_speed_unit: "km/h",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
hass.mockWS("weather/subscribe_forecast", (message, _hass, onChange) => {
|
||||
onChange?.({
|
||||
type: message.forecast_type,
|
||||
forecast: [
|
||||
{
|
||||
datetime: "2026-08-13T10:00:00Z",
|
||||
temperature: 20,
|
||||
condition: "sunny",
|
||||
},
|
||||
{
|
||||
datetime: "2026-08-13T11:00:00Z",
|
||||
temperature: 21,
|
||||
condition: "sunny",
|
||||
},
|
||||
{
|
||||
datetime: "2026-08-13T12:00:00Z",
|
||||
temperature: 22,
|
||||
condition: "sunny",
|
||||
},
|
||||
],
|
||||
} satisfies ForecastEvent);
|
||||
return () => undefined;
|
||||
});
|
||||
};
|
||||
|
||||
const quickSearchAssistScenario: Scenario = async (hass) => {
|
||||
const pipeline: AssistPipeline = {
|
||||
id: "test-pipeline",
|
||||
@@ -253,6 +302,7 @@ export const scenarios: Record<string, Scenario> = {
|
||||
"delayed-media-browse": delayedMediaBrowseScenario,
|
||||
"delayed-media-browse-error": delayedMediaBrowseErrorScenario,
|
||||
"light-more-info": lightMoreInfoScenario,
|
||||
"weather-more-info": weatherMoreInfoScenario,
|
||||
"quick-search-assist": quickSearchAssistScenario,
|
||||
"delayed-lovelace": delayedLovelaceScenario,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { LogbookEntry } from "../../../src/data/logbook";
|
||||
import type { LogbookFetcher } from "../../../src/panels/logbook/logbook-chain-resolver";
|
||||
import { resolveLogbookChain } from "../../../src/panels/logbook/logbook-chain-resolver";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
|
||||
const hass = {
|
||||
language: "en",
|
||||
translationMetadata: { translations: {} },
|
||||
states: {},
|
||||
entities: {},
|
||||
devices: {},
|
||||
areas: {},
|
||||
floors: {},
|
||||
localize: () => "",
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const USERS = { user_1: "Alice" };
|
||||
|
||||
const entry = (partial: Partial<LogbookEntry>): LogbookEntry => ({
|
||||
when: 0,
|
||||
name: "",
|
||||
...partial,
|
||||
});
|
||||
|
||||
const runRow = (when: number, contextId: string): LogbookEntry =>
|
||||
entry({
|
||||
when,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
source: "state of binary_sensor.motion",
|
||||
context_id: contextId,
|
||||
});
|
||||
|
||||
const effectRow = (when: number): LogbookEntry =>
|
||||
entry({
|
||||
when,
|
||||
name: "Ceiling light",
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_event_type: "automation_triggered",
|
||||
context_name: "Wake up",
|
||||
context_entity_id: "automation.wake_up",
|
||||
context_source: "state of binary_sensor.motion",
|
||||
});
|
||||
|
||||
interface FetchCall {
|
||||
entityIds?: string[];
|
||||
contextId?: string;
|
||||
}
|
||||
|
||||
const makeFetcher = (
|
||||
handler: (entityIds?: string[], contextId?: string) => LogbookEntry[]
|
||||
): { fetch: LogbookFetcher; calls: FetchCall[] } => {
|
||||
const calls: FetchCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
fetch: async (_start, _end, entityIds, contextId) => {
|
||||
calls.push({ entityIds, contextId });
|
||||
return handler(entityIds, contextId);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("resolveLogbookChain", () => {
|
||||
it("fetches by context id when the entry has one", async () => {
|
||||
const run = runRow(10, "ctx_run");
|
||||
const effect = { ...effectRow(11), context_id: "ctx_run" };
|
||||
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
|
||||
if (contextId === "ctx_run") {
|
||||
return [run, effect];
|
||||
}
|
||||
if (entityIds?.includes("binary_sensor.motion")) {
|
||||
return [
|
||||
entry({ when: 9.8, entity_id: "binary_sensor.motion", state: "on" }),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
// One context fetch + one trigger fetch: no candidate discovery.
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0].contextId).toBe("ctx_run");
|
||||
expect(chain.runRow).toBe(run);
|
||||
expect(chain.rows).toEqual([run, effect]);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("state");
|
||||
expect(chain.origins[0].entityId).toBe("binary_sensor.motion");
|
||||
expect(chain.syntheticRun).toBeUndefined();
|
||||
expect(chain.triggerRow?.when).toBe(9.8);
|
||||
});
|
||||
|
||||
it("resolves the run through the context entity and verifies candidates", async () => {
|
||||
const effect = effectRow(11);
|
||||
const otherRun = runRow(10.9, "ctx_other");
|
||||
const goodRun = runRow(10, "ctx_good");
|
||||
const { fetch, calls } = makeFetcher((entityIds, contextId) => {
|
||||
if (entityIds?.includes("automation.wake_up")) {
|
||||
return [goodRun, otherRun];
|
||||
}
|
||||
if (contextId === "ctx_other") {
|
||||
// The closest run does not contain the clicked entry.
|
||||
return [
|
||||
otherRun,
|
||||
entry({ when: 11, entity_id: "light.desk", state: "on" }),
|
||||
];
|
||||
}
|
||||
if (contextId === "ctx_good") {
|
||||
return [goodRun, effect];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(calls[0].entityIds).toEqual(["automation.wake_up"]);
|
||||
expect(calls[1].contextId).toBe("ctx_other");
|
||||
expect(calls[2].contextId).toBe("ctx_good");
|
||||
expect(chain.runRow?.context_id).toBe("ctx_good");
|
||||
expect(chain.rows).toEqual([goodRun, effect]);
|
||||
});
|
||||
|
||||
it("falls back to a synthetic run when no candidate matches", async () => {
|
||||
const effect = effectRow(11);
|
||||
const { fetch } = makeFetcher((entityIds) =>
|
||||
entityIds?.includes("automation.wake_up")
|
||||
? [runRow(10, "ctx_unrelated")]
|
||||
: []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(chain.runRow).toBeUndefined();
|
||||
expect(chain.rows).toEqual([effect]);
|
||||
expect(chain.syntheticRun?.type).toBe("automation");
|
||||
expect(chain.origins).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a direct user action as the only origin", async () => {
|
||||
const direct = entry({
|
||||
when: 5,
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "light",
|
||||
context_service: "turn_on",
|
||||
});
|
||||
const { fetch, calls } = makeFetcher(() => []);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
direct,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(calls).toHaveLength(0);
|
||||
expect(chain.rows).toEqual([direct]);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
expect(chain.origins[0].name).toBe("Alice");
|
||||
expect(chain.syntheticRun).toBeUndefined();
|
||||
});
|
||||
|
||||
it("picks the last trigger state before the run, even minutes earlier", async () => {
|
||||
const run = runRow(600, "ctx_run");
|
||||
const effect = { ...effectRow(601), context_id: "ctx_run" };
|
||||
const { fetch } = makeFetcher((entityIds, contextId) => {
|
||||
if (contextId === "ctx_run") {
|
||||
return [run, effect];
|
||||
}
|
||||
if (entityIds?.includes("binary_sensor.motion")) {
|
||||
return [
|
||||
entry({ when: 100, entity_id: "binary_sensor.motion", state: "on" }),
|
||||
entry({ when: 480, entity_id: "binary_sensor.motion", state: "off" }),
|
||||
entry({ when: 640, entity_id: "binary_sensor.motion", state: "on" }),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const chain = await resolveLogbookChain(hass, effect, {}, fetch);
|
||||
|
||||
expect(chain.triggerRow?.when).toBe(480);
|
||||
expect(chain.triggerRow?.state).toBe("off");
|
||||
});
|
||||
|
||||
it("prefers the clicked copy of the run row over the fetched one", async () => {
|
||||
const clicked = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "automation",
|
||||
context_service: "trigger",
|
||||
});
|
||||
const fetched = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
});
|
||||
const effect = { ...effectRow(11), context_id: "ctx_run" };
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
clicked,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(chain.runRow).toBe(clicked);
|
||||
expect(chain.rows).toEqual([clicked, effect]);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
expect(chain.origins[0].name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("surfaces the integration that triggered the run", async () => {
|
||||
const clicked = entry({
|
||||
when: 20,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "homekit",
|
||||
context_service: "turn_on",
|
||||
});
|
||||
const fetched = entry({
|
||||
when: 20,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
});
|
||||
const effect = { ...effectRow(21), context_id: "ctx_run" };
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(hass, clicked, {}, fetch);
|
||||
|
||||
expect(chain.runRow).toBe(clicked);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("integration");
|
||||
expect(chain.origins[0].brandDomain).toBe("homekit");
|
||||
});
|
||||
|
||||
it("keeps the user above a run row that does not carry one", async () => {
|
||||
const fetched = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
});
|
||||
const effect = {
|
||||
...effectRow(11),
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
};
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
effect,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(chain.runRow).toBe(fetched);
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
expect(chain.origins[0].name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("does not stack the user twice when the run row resolves it", async () => {
|
||||
const fetched = entry({
|
||||
when: 10,
|
||||
name: "Wake up",
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
});
|
||||
const effect = {
|
||||
...effectRow(11),
|
||||
context_id: "ctx_run",
|
||||
context_user_id: "user_1",
|
||||
};
|
||||
const { fetch } = makeFetcher((_entityIds, contextId) =>
|
||||
contextId === "ctx_run" ? [fetched, effect] : []
|
||||
);
|
||||
|
||||
const chain = await resolveLogbookChain(
|
||||
hass,
|
||||
effect,
|
||||
{ userIdToName: USERS },
|
||||
fetch
|
||||
);
|
||||
|
||||
expect(chain.origins).toHaveLength(1);
|
||||
expect(chain.origins[0].type).toBe("user");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeLogbookItem,
|
||||
computeTraceLink,
|
||||
classifyLogbookEntry,
|
||||
entityDisplay,
|
||||
computeLogbookCause,
|
||||
computeLogbookGlyph,
|
||||
isSameLogbookEntry,
|
||||
} from "../../../src/panels/logbook/logbook-entry-model";
|
||||
import type { LogbookEntry } from "../../../src/data/logbook";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
@@ -241,7 +243,7 @@ describe("computeLogbookCause", () => {
|
||||
const cause = computeLogbookCause(
|
||||
hass,
|
||||
entry({ context_user_id: "person_1" }),
|
||||
{ person_1: "Paul" },
|
||||
{ person_1: "Alice" },
|
||||
new Set(["cloud_user"])
|
||||
);
|
||||
expect(cause?.type).toBe("user");
|
||||
@@ -403,3 +405,144 @@ describe("computeLogbookItem", () => {
|
||||
expect(model.value).toEqual({ text: "Ran", type: "state" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSameLogbookEntry", () => {
|
||||
const a = entry({
|
||||
when: 1.234567,
|
||||
entity_id: "light.x",
|
||||
state: "on",
|
||||
name: "Light",
|
||||
});
|
||||
|
||||
it("matches an identical entry", () => {
|
||||
expect(isSameLogbookEntry(a, { ...a })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an entry differing by any field", () => {
|
||||
expect(isSameLogbookEntry(a, { ...a, when: 1.234568 })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, entity_id: "light.y" })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, state: "off" })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, name: "Other" })).toBe(false);
|
||||
expect(isSameLogbookEntry(a, { ...a, message: "turned on" })).toBe(false);
|
||||
});
|
||||
|
||||
it("matches entity-less entries on name/message/when", () => {
|
||||
const b = entry({ when: 2, name: "HACS", message: "2 updates available" });
|
||||
expect(isSameLogbookEntry(b, { ...b })).toBe(true);
|
||||
expect(isSameLogbookEntry(b, { ...b, message: "other" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeTraceLink", () => {
|
||||
const traceContexts = {
|
||||
ctx_1: { run_id: "run_9", domain: "automation", item_id: "auto_1" },
|
||||
};
|
||||
|
||||
it("builds the trace URL for a known context", () => {
|
||||
expect(computeTraceLink(traceContexts, "ctx_1")).toBe(
|
||||
"/config/automation/trace/auto_1?run_id=run_9"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for an unknown or missing context", () => {
|
||||
expect(computeTraceLink(traceContexts, "ctx_2")).toBeUndefined();
|
||||
expect(computeTraceLink(traceContexts, undefined)).toBeUndefined();
|
||||
expect(computeTraceLink({}, "ctx_1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLogbookItem cause", () => {
|
||||
const hass = baseHass({ localize: (() => "") as HomeAssistant["localize"] });
|
||||
const users = { user_1: "Alice" };
|
||||
|
||||
it("prefers the automation over the user who ran it", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "automation_triggered",
|
||||
context_entity_id: "automation.wake_up",
|
||||
context_name: "Wake up",
|
||||
}),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause?.type).toBe("automation");
|
||||
expect(model.cause?.name).toBe("Wake up");
|
||||
});
|
||||
|
||||
it("keeps the user for a direct action call", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_user_id: "user_1",
|
||||
context_event_type: "call_service",
|
||||
context_domain: "light",
|
||||
context_service: "turn_on",
|
||||
}),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause?.type).toBe("user");
|
||||
expect(model.cause?.name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("falls back to the context cause without a user", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "light.ceiling",
|
||||
state: "on",
|
||||
context_event_type: "automation_triggered",
|
||||
context_name: "Wake up",
|
||||
}),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause?.type).toBe("automation");
|
||||
});
|
||||
|
||||
it("leaves the cause empty without any context", () => {
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({ entity_id: "light.ceiling", state: "on" }),
|
||||
{ userIdToName: users }
|
||||
);
|
||||
expect(model.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeLogbookItem run rows", () => {
|
||||
it("uses the run label instead of the raw backend message", () => {
|
||||
const hass = baseHass({
|
||||
localize: ((key: string) => key) as HomeAssistant["localize"],
|
||||
});
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({
|
||||
entity_id: "automation.wake_up",
|
||||
domain: "automation",
|
||||
name: "Wake up",
|
||||
message: "triggered",
|
||||
}),
|
||||
{}
|
||||
);
|
||||
expect(model.value).toEqual({
|
||||
text: "ui.components.logbook.automation_triggered",
|
||||
type: "state",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the custom message of a domain-only entry (logbook.log)", () => {
|
||||
const hass = baseHass({
|
||||
localize: ((key: string) => key) as HomeAssistant["localize"],
|
||||
});
|
||||
const model = computeLogbookItem(
|
||||
hass,
|
||||
entry({ domain: "script", name: "Backup", message: "Backup finished" }),
|
||||
{}
|
||||
);
|
||||
expect(model.value).toEqual({ text: "Backup finished", type: "message" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4765,65 +4765,79 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-arm64@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.1.8"
|
||||
"@rspack/binding-darwin-arm64@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.1.9"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-darwin-x64@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.1.8"
|
||||
"@rspack/binding-darwin-x64@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.1.9"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.8"
|
||||
"@rspack/binding-linux-arm64-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.8"
|
||||
"@rspack/binding-linux-arm64-musl@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.9"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.8"
|
||||
"@rspack/binding-linux-ppc64-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-ppc64-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=ppc64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=riscv64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.8"
|
||||
"@rspack/binding-linux-riscv64-musl@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.9"
|
||||
conditions: os=linux & cpu=riscv64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.8"
|
||||
"@rspack/binding-linux-s390x-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-s390x-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=s390x & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.9"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-musl@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.8"
|
||||
"@rspack/binding-linux-x64-musl@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.9"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-wasm32-wasi@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.8"
|
||||
"@rspack/binding-wasm32-wasi@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.9"
|
||||
dependencies:
|
||||
"@emnapi/core": "npm:1.11.3"
|
||||
"@emnapi/runtime": "npm:1.11.3"
|
||||
@@ -4832,43 +4846,45 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.8"
|
||||
"@rspack/binding-win32-arm64-msvc@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.9"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.8"
|
||||
"@rspack/binding-win32-ia32-msvc@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.9"
|
||||
conditions: os=win32 & cpu=ia32
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.8"
|
||||
"@rspack/binding-win32-x64-msvc@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.9"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/binding@npm:2.1.8"
|
||||
"@rspack/binding@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/binding@npm:2.1.9"
|
||||
dependencies:
|
||||
"@rspack/binding-darwin-arm64": "npm:2.1.8"
|
||||
"@rspack/binding-darwin-x64": "npm:2.1.8"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.1.8"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.1.8"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.8"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.1.8"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.1.8"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.1.8"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.1.8"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.1.8"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.1.8"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.1.8"
|
||||
"@rspack/binding-darwin-arm64": "npm:2.1.9"
|
||||
"@rspack/binding-darwin-x64": "npm:2.1.9"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.1.9"
|
||||
"@rspack/binding-linux-ppc64-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.1.9"
|
||||
"@rspack/binding-linux-s390x-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.1.9"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.1.9"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.1.9"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.1.9"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.1.9"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.1.9"
|
||||
dependenciesMeta:
|
||||
"@rspack/binding-darwin-arm64":
|
||||
optional: true
|
||||
@@ -4878,10 +4894,14 @@ __metadata:
|
||||
optional: true
|
||||
"@rspack/binding-linux-arm64-musl":
|
||||
optional: true
|
||||
"@rspack/binding-linux-ppc64-gnu":
|
||||
optional: true
|
||||
"@rspack/binding-linux-riscv64-gnu":
|
||||
optional: true
|
||||
"@rspack/binding-linux-riscv64-musl":
|
||||
optional: true
|
||||
"@rspack/binding-linux-s390x-gnu":
|
||||
optional: true
|
||||
"@rspack/binding-linux-x64-gnu":
|
||||
optional: true
|
||||
"@rspack/binding-linux-x64-musl":
|
||||
@@ -4894,15 +4914,15 @@ __metadata:
|
||||
optional: true
|
||||
"@rspack/binding-win32-x64-msvc":
|
||||
optional: true
|
||||
checksum: 10/74c73ac543059fa62365557ca949ef7a84e1cebc42fe51a343126c875d90f557b35a52b33bacd9498d9231e8fdb8ab13907988e1fd39131ffbd56becb4260785
|
||||
checksum: 10/bf3ef6f75b0fa3dafc8dc73bfb49ae31e706e466699a88e0ef35d7273800e3c241489def836041688dded31b9ec3eca61d12bc0c30f8fa18839ae879ab8d2182
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/core@npm:2.1.8":
|
||||
version: 2.1.8
|
||||
resolution: "@rspack/core@npm:2.1.8"
|
||||
"@rspack/core@npm:2.1.9":
|
||||
version: 2.1.9
|
||||
resolution: "@rspack/core@npm:2.1.9"
|
||||
dependencies:
|
||||
"@rspack/binding": "npm:2.1.8"
|
||||
"@rspack/binding": "npm:2.1.9"
|
||||
peerDependencies:
|
||||
"@module-federation/runtime-tools": ^0.24.1 || ^2.0.0
|
||||
"@swc/helpers": ^0.5.23
|
||||
@@ -4911,7 +4931,7 @@ __metadata:
|
||||
optional: true
|
||||
"@swc/helpers":
|
||||
optional: true
|
||||
checksum: 10/c818257225a24feb22af81eb3ed7d02d4b74d6e6cb70689c8cbcd34e3facfcfe833dfe4078e0a35277e926f431cae9fd7f2101c3944b396a72baa7cf3ce99d19
|
||||
checksum: 10/81a8d5b4855ea8783d18e9e0c422e18337e50b820bf1f867d5287dc5a3643307c03e39011049f485e63e676480ba71f5fb0e7fb7f4ceea72e4bc2eec2651c13d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9936,7 +9956,7 @@ __metadata:
|
||||
"@playwright/test": "npm:1.62.1"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.1"
|
||||
"@rspack/core": "npm:2.1.8"
|
||||
"@rspack/core": "npm:2.1.9"
|
||||
"@rspack/dev-server": "npm:2.2.0"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
|
||||
Reference in New Issue
Block a user