mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-18 04:27:45 +00:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb479a1ccb | ||
|
|
8ebe2111b0 | ||
|
|
49a7c1aadd | ||
|
|
53be309e34 | ||
|
|
a3aa5d38dc | ||
|
|
ba9e696e96 | ||
|
|
3fc41198c7 | ||
|
|
cd52f7b193 | ||
|
|
86dea86b6c | ||
|
|
04035016b0 | ||
|
|
52b0b46991 | ||
|
|
d5389b5bef | ||
|
|
f828c636a2 | ||
|
|
e91337b320 | ||
|
|
8ab7c118a9 | ||
|
|
0eafb1f4b4 | ||
|
|
9a7722a02e | ||
|
|
3e184eeb39 | ||
|
|
61be4e2952 | ||
|
|
315cf97e56 | ||
|
|
771a8aa4c9 | ||
|
|
d9aa649854 | ||
|
|
7a087cd6d0 | ||
|
|
32a30468bb | ||
|
|
0ace3cedfb | ||
|
|
aba7478e26 | ||
|
|
c8b2e5160a | ||
|
|
4c46ca0918 | ||
|
|
0483106f61 | ||
|
|
7e0c572f69 | ||
|
|
e4438a20cb | ||
|
|
ec418498a7 | ||
|
|
8122baca00 | ||
|
|
79738dd257 |
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
"app": 585518,
|
||||
"core": 57048,
|
||||
"authorize": 552423,
|
||||
"onboarding": 666818
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
"app": 887384,
|
||||
"core": 244482,
|
||||
"authorize": 844995,
|
||||
"onboarding": 1008816
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +102,11 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
cost_sensors: {},
|
||||
solar_forecast_domains: [],
|
||||
}));
|
||||
hass.mockWS("energy/validate", () => ({
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
({ period }): FossilEnergyConsumption => ({
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSensor = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"sensor/device_class_convertible_units",
|
||||
({ device_class }: { device_class: string }) => ({
|
||||
units: device_class === "energy" ? ["kWh"] : ["W"],
|
||||
})
|
||||
);
|
||||
hass.mockWS("sensor/numeric_device_classes", () => ({
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
@@ -9,7 +8,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import "../../../../src/components/ha-target-picker";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
|
||||
@@ -155,9 +153,6 @@ interface Sample {
|
||||
description: string;
|
||||
selector: Selector;
|
||||
value: unknown;
|
||||
// Render ha-target-picker directly in compact (chip) mode instead of the
|
||||
// ha-selector, which does not expose the compact option.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SAMPLES: Sample[] = [
|
||||
@@ -168,14 +163,6 @@ const SAMPLES: Sample[] = [
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
},
|
||||
{
|
||||
name: "Target (compact)",
|
||||
description:
|
||||
"In compact mode the replaced reference is shown as a warning chip.",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
name: "Device (unfiltered, multiple matches)",
|
||||
description:
|
||||
@@ -274,23 +261,13 @@ class DemoHaSelectorReplacedDevice
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${sample.name}</span>
|
||||
<span slot="description">${sample.description}</span>
|
||||
${
|
||||
sample.compact
|
||||
? html`<ha-target-picker
|
||||
compact
|
||||
.hass=${this.hass}
|
||||
.value=${this._values[idx] as HassServiceTarget}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-target-picker>`
|
||||
: html`<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>`
|
||||
}
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>
|
||||
</ha-settings-row>
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,6 @@ const ALL_FEATURES =
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.FAN_SPEED +
|
||||
VacuumEntityFeature.BATTERY +
|
||||
VacuumEntityFeature.STATUS +
|
||||
VacuumEntityFeature.LOCATE +
|
||||
VacuumEntityFeature.CLEAN_SPOT +
|
||||
@@ -28,8 +27,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Full featured vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 85,
|
||||
battery_icon: "mdi:battery-80",
|
||||
fan_speed: "balanced",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Charged",
|
||||
@@ -41,8 +38,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Cleaning vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 62,
|
||||
battery_icon: "mdi:battery-60",
|
||||
fan_speed: "turbo",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Cleaning bedroom",
|
||||
@@ -58,10 +53,7 @@ const ENTITIES = [
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.BATTERY,
|
||||
battery_level: 23,
|
||||
battery_icon: "mdi:battery-20",
|
||||
VacuumEntityFeature.RETURN_HOME,
|
||||
status: "Returning to dock",
|
||||
},
|
||||
},
|
||||
@@ -96,8 +88,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Paused vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 45,
|
||||
battery_icon: "mdi:battery-40",
|
||||
fan_speed: "standard",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Paused",
|
||||
|
||||
+5
-6
@@ -103,11 +103,11 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.6.17",
|
||||
"hls.js": "1.7.0",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"js-yaml": "5.2.3",
|
||||
"js-yaml": "5.3.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -151,8 +151,8 @@
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.8",
|
||||
"@rsdoctor/rspack-plugin": "1.6.2",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -186,7 +186,7 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.9.0",
|
||||
"globals": "17.11.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -223,7 +223,6 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.9.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type {
|
||||
CustomSeriesOption,
|
||||
CustomSeriesRenderItem,
|
||||
@@ -22,6 +23,10 @@ import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
// Rows with their name drawn above the bar need room for both.
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
export class StateHistoryChartTimeline extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -38,6 +43,14 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/**
|
||||
* Draw each row's name inside the plot, above its bar, instead of in a label
|
||||
* column next to it. Gives long names the full width, at the cost of taller
|
||||
* rows.
|
||||
*/
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -60,6 +73,20 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
// Inside labels are truncated to the plot, so their width follows the chart.
|
||||
@state() private _width = 0;
|
||||
|
||||
// The host is an inline element, which a resize observer skips, so the chart
|
||||
// itself is the one being observed.
|
||||
@query("ha-chart-base") private _chartBase?: HTMLElement;
|
||||
|
||||
private _resizeController = new ResizeController<void>(this, {
|
||||
target: null,
|
||||
callback: (entries) => {
|
||||
this._width = entries[0]?.contentRect.width ?? 0;
|
||||
},
|
||||
});
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
protected render() {
|
||||
@@ -67,7 +94,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * 30 + 30}px`}
|
||||
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + 30}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -163,6 +190,12 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
)}<br />${formattedDuration}`;
|
||||
};
|
||||
|
||||
protected firstUpdated() {
|
||||
if (this._chartBase) {
|
||||
this._resizeController.observe(this._chartBase);
|
||||
}
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
this.isConnected &&
|
||||
@@ -182,8 +215,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
changedProps.has("_yWidth") ||
|
||||
changedProps.has("_width")
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -193,14 +228,24 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
// Inside labels take no width of their own, but the plot still lines up
|
||||
// with the line charts that share the y-axis padding.
|
||||
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
|
||||
// Before the first resize observation the width is unknown, and a zero
|
||||
// width would hide the labels instead of truncating them.
|
||||
const insideLabelWidth = this._width
|
||||
? Math.max(0, this._width - plotPadding - labelMargin)
|
||||
: undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -224,37 +269,54 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
// Sits on the row's baseline, lifted above the bar.
|
||||
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "bottom",
|
||||
width: insideLabelWidth,
|
||||
overflow: "truncate",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
// Room for the first row's name above its bar.
|
||||
top: insideLabels ? 20 : 10,
|
||||
bottom: 30,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
left: rtl ? 1 : plotPadding,
|
||||
right: rtl ? plotPadding : 1,
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
|
||||
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw timeline row names above their bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -227,6 +231,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Names inside the plot sit close to the chart above them, so the groups
|
||||
need more room between them to stay apart. */
|
||||
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mdiCalendar } from "@mdi/js";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button-next";
|
||||
import "../ha-icon-button-prev";
|
||||
import "../ha-svg-icon";
|
||||
import {
|
||||
haDateRangePickerStyles,
|
||||
HaDateRangePicker,
|
||||
} from "./ha-date-range-picker";
|
||||
|
||||
/**
|
||||
* Date range picker as a single pill that also steps through ranges: a
|
||||
* previous button, the selected range and a next button. Meant for a toolbar,
|
||||
* next to other chips.
|
||||
*/
|
||||
@customElement("ha-date-range-nav")
|
||||
export class HaDateRangeNav extends HaDateRangePicker {
|
||||
protected override _renderField() {
|
||||
return html`
|
||||
<ha-icon-button-prev
|
||||
class="step"
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handlePrev}
|
||||
></ha-icon-button-prev>
|
||||
<ha-assist-chip
|
||||
id="field"
|
||||
class="range"
|
||||
.label=${this._formatRange(" – ")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiCalendar}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
<ha-icon-button-next
|
||||
class="step"
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handleNext}
|
||||
></ha-icon-button-next>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
haDateRangePickerStyles,
|
||||
css`
|
||||
/* The three controls read as one pill, with the range chip's borders as
|
||||
the dividers between them. */
|
||||
.date-range-inputs {
|
||||
gap: 0;
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: var(--ha-assist-chip-container-shape, 10px);
|
||||
background: var(--ha-assist-chip-container-color, transparent);
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.step {
|
||||
--ha-icon-button-size: 32px;
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
.range {
|
||||
--md-assist-chip-outline-color: transparent;
|
||||
--ha-assist-chip-container-shape: 0;
|
||||
--ha-assist-chip-container-color: transparent;
|
||||
border-inline: 1px solid var(--divider-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-date-range-nav": HaDateRangeNav;
|
||||
}
|
||||
}
|
||||
@@ -42,18 +42,67 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
|
||||
"now-30d",
|
||||
];
|
||||
|
||||
export const haDateRangePickerStyles = css`
|
||||
ha-icon-button {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.date-range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-textarea {
|
||||
display: inline-block;
|
||||
width: 340px;
|
||||
}
|
||||
@media only screen and (max-width: 460px) {
|
||||
ha-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease-out;
|
||||
}
|
||||
|
||||
wa-popover.open::part(dialog)::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
min-width: max(var(--body-width), 250px);
|
||||
max-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right) - var(
|
||||
--ha-space-8
|
||||
)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
@customElement("ha-date-range-picker")
|
||||
export class HaDateRangePicker extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
protected _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _hassConfig!: HassConfig;
|
||||
protected _hassConfig!: HassConfig;
|
||||
|
||||
@property({ attribute: false }) public startDate!: Date;
|
||||
|
||||
@@ -143,73 +192,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="date-range-inputs">
|
||||
${
|
||||
!this.minimal
|
||||
? html`<ha-textarea
|
||||
id="field"
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${
|
||||
(isThisYear(this.startDate)
|
||||
? formatShortDateTime(
|
||||
this.startDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.startDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)) +
|
||||
(window.innerWidth >= 459 ? " - " : " - \n") +
|
||||
(isThisYear(this.endDate)
|
||||
? formatShortDateTime(
|
||||
this.endDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.endDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
))
|
||||
}
|
||||
.label=${
|
||||
this._i18n.localize(
|
||||
"ui.components.date-range-picker.start_date"
|
||||
) +
|
||||
" - " +
|
||||
this._i18n.localize(
|
||||
"ui.components.date-range-picker.end_date"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
readonly
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`
|
||||
: html`<ha-icon-button
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
id="field"
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select_date_range"
|
||||
)}
|
||||
.path=${mdiCalendar}
|
||||
></ha-icon-button>`
|
||||
}
|
||||
</div>
|
||||
<div class="date-range-inputs">${this._renderField()}</div>
|
||||
${
|
||||
this._pickerWrapperOpen || this._opened
|
||||
? this._openedNarrow
|
||||
@@ -248,6 +231,63 @@ export class HaDateRangePicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control that opens the picker. It has to carry `id="field"`, which the
|
||||
* popover anchors to.
|
||||
*/
|
||||
protected _renderField() {
|
||||
if (this.minimal) {
|
||||
return html`<ha-icon-button
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
id="field"
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select_date_range"
|
||||
)}
|
||||
.path=${mdiCalendar}
|
||||
></ha-icon-button>`;
|
||||
}
|
||||
|
||||
return html`<ha-textarea
|
||||
id="field"
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
|
||||
.label=${
|
||||
this._i18n.localize("ui.components.date-range-picker.start_date") +
|
||||
" - " +
|
||||
this._i18n.localize("ui.components.date-range-picker.end_date")
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
readonly
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`;
|
||||
}
|
||||
|
||||
/** The selected range as text, with the year only when it is not this year. */
|
||||
protected _formatRange(separator: string): string {
|
||||
const format = (date: Date) =>
|
||||
isThisYear(date)
|
||||
? formatShortDateTime(date, this._i18n.locale, this._hassConfig)
|
||||
: formatShortDateTimeWithYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
return format(this.startDate) + separator + format(this.endDate);
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
if (!this._opened) {
|
||||
return nothing;
|
||||
@@ -303,12 +343,12 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._opened = false;
|
||||
};
|
||||
|
||||
private _handleNext(ev: MouseEvent): void {
|
||||
protected _handleNext(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(true);
|
||||
}
|
||||
|
||||
private _handlePrev(ev: MouseEvent): void {
|
||||
protected _handlePrev(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(false);
|
||||
}
|
||||
@@ -336,7 +376,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._pickerWrapperOpen = false;
|
||||
}
|
||||
|
||||
private _openPicker(ev?: Event) {
|
||||
protected _openPicker(ev?: Event) {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -352,7 +392,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleKeydown(ev: KeyboardEvent) {
|
||||
protected _handleKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
this._openPicker(ev);
|
||||
@@ -369,56 +409,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
ha-icon-button {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.date-range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-textarea {
|
||||
display: inline-block;
|
||||
width: 340px;
|
||||
}
|
||||
@media only screen and (max-width: 460px) {
|
||||
ha-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease-out;
|
||||
}
|
||||
|
||||
wa-popover.open::part(dialog)::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
min-width: max(var(--body-width), 250px);
|
||||
max-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(
|
||||
--safe-area-inset-right
|
||||
) - var(--ha-space-8)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
static styles = [haDateRangePickerStyles];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -34,6 +34,8 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
@property({ type: Boolean, reflect: true, attribute: "full-width" })
|
||||
public fullWidth = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public variant:
|
||||
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
|
||||
|
||||
@@ -57,6 +59,7 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
.value=${button.value}
|
||||
@click=${this._handleClick}
|
||||
.title=${button.label}
|
||||
.disabled=${this.disabled}
|
||||
.appearance=${this.active === button.value ? "accent" : "filled"}
|
||||
>
|
||||
${
|
||||
|
||||
@@ -68,6 +68,7 @@ export class HaDurationInput extends LitElement {
|
||||
{ label: "-", iconPath: mdiMinusThick, value: "-" },
|
||||
]}
|
||||
.active=${this._negative ? "-" : "+"}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._negativeChanged}
|
||||
></ha-button-toggle-group>
|
||||
`
|
||||
@@ -235,8 +236,8 @@ export class HaDurationInput extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const negative = (ev.detail?.value || ev.target.value) === "-";
|
||||
this._toggleNegative = negative;
|
||||
const value = this.data;
|
||||
if (value) {
|
||||
if (this.data) {
|
||||
const value = { ...this.data };
|
||||
FIELDS.forEach((t) => {
|
||||
if (value[t]) {
|
||||
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Centered placeholder for a surface that has nothing to show, with an icon, a
|
||||
* heading, an optional description and optional actions.
|
||||
*
|
||||
* @slot - Actions that help the user fill the surface, e.g. a button.
|
||||
*/
|
||||
@customElement("ha-empty-state")
|
||||
export class HaEmptyState extends LitElement {
|
||||
/** SVG path of the icon shown above the heading. */
|
||||
@property() public icon?: string;
|
||||
|
||||
@property() public heading?: string;
|
||||
|
||||
@property() public description?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="content">
|
||||
${
|
||||
this.icon
|
||||
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
|
||||
${this.description ? html`<p>${this.description}</p>` : nothing}
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
max-width: 500px;
|
||||
padding: var(--ha-space-8) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ha-svg-icon {
|
||||
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-empty-state": HaEmptyState;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext, statesContext } from "../data/context";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-check-list-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-list";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
interface DeviceClassItem {
|
||||
deviceClass: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@customElement("ha-filter-device-classes")
|
||||
export class HaFilterDeviceClasses extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@state()
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@state()
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this._localize("ui.components.filter-device-classes.caption")}
|
||||
${
|
||||
this.value?.length
|
||||
? html`<div class="badge">${this.value?.length}</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
),
|
||||
(item) => item.deviceClass,
|
||||
(item) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${item.deviceClass}
|
||||
.selected=${(this.value || []).includes(item.deviceClass)}
|
||||
>
|
||||
${item.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceClasses = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
localize: LocalizeFunc,
|
||||
language: string | undefined,
|
||||
filter: string | undefined
|
||||
): DeviceClassItem[] => {
|
||||
// The same device class can be used by multiple domains; label it with
|
||||
// the first domain that has a translation for it.
|
||||
const names = new Map<string, string | undefined>();
|
||||
Object.values(states).forEach((stateObj) => {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
if (!deviceClass || names.get(deviceClass)) {
|
||||
return;
|
||||
}
|
||||
names.set(
|
||||
deviceClass,
|
||||
localize(
|
||||
`component.${computeStateDomain(stateObj)}.entity_component.${deviceClass}.name`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return Array.from(names.entries())
|
||||
.map(([deviceClass, name]) => ({
|
||||
deviceClass,
|
||||
name: name || deviceClass,
|
||||
}))
|
||||
.filter(
|
||||
(item) =>
|
||||
!filter ||
|
||||
item.deviceClass.toLowerCase().includes(filter) ||
|
||||
item.name.toLowerCase().includes(filter)
|
||||
)
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
}
|
||||
);
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
|
||||
const deviceClasses = this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
);
|
||||
|
||||
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
|
||||
const preserved = (this.value || []).filter((d) => !visible.has(d));
|
||||
const selected = [...ev.detail.index]
|
||||
.map((i) => deviceClasses[i]?.deviceClass)
|
||||
.filter((d): d is string => !!d);
|
||||
|
||||
this.value = [...preserved, ...selected];
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearFilter(ev) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-device-classes": HaFilterDeviceClasses;
|
||||
}
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export class HaFilterDomains extends LitElement {
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: initial;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
ha-check-list-item {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Chip that opens a filter pane, with a badge showing how many filters are
|
||||
* active.
|
||||
*/
|
||||
@customElement("ha-filter-pane-chip")
|
||||
export class HaFilterPaneChip extends LitElement {
|
||||
@property() public label = "";
|
||||
|
||||
/** SVG path of the leading icon. */
|
||||
@property() public path?: string;
|
||||
|
||||
/** Number of active filters, shown as a badge when there is at least one. */
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
@property({ type: Boolean }) public active = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-assist-chip
|
||||
.label=${this.label}
|
||||
.active=${this.active}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
${
|
||||
this.path
|
||||
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-assist-chip>
|
||||
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0 2px;
|
||||
color: var(--text-primary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-pane-chip": HaFilterPaneChip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-adaptive-dialog";
|
||||
import "./ha-button";
|
||||
import "./ha-dialog-footer";
|
||||
import "./ha-filter-pane-chip";
|
||||
import "./ha-icon-button";
|
||||
|
||||
/**
|
||||
* Filter pane for a filtered page: a column next to the content on wide
|
||||
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
|
||||
* `hass-tabs-subpage-data-table` for pages that are not a data table.
|
||||
*
|
||||
* The page keeps ownership of whether the pane is shown, so that it can also
|
||||
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
|
||||
* while it is open.
|
||||
*
|
||||
* @slot - Filter panels, e.g. `ha-filter-domains`.
|
||||
*/
|
||||
@customElement("ha-filter-pane")
|
||||
export class HaFilterPane extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
/** Header label, defaults to "Filters". */
|
||||
@property() public label?: string;
|
||||
|
||||
/** SVG path of the header chip icon. */
|
||||
@property() public path = mdiFilterVariant;
|
||||
|
||||
/** Number of active filters, shown as a badge. */
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
/**
|
||||
* Number of results the current filters resolve to, shown on the narrow
|
||||
* confirm button. Leave undefined when the page shows everything.
|
||||
*/
|
||||
@property({ attribute: false }) public resultCount?: number;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
const label =
|
||||
this.label ?? this._localize("ui.components.subpage-data-table.filters");
|
||||
|
||||
if (this.narrow) {
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
open
|
||||
flexcontent
|
||||
.headerTitle=${label}
|
||||
@closed=${this._close}
|
||||
>
|
||||
${this._renderClearButton("headerActionItems")}
|
||||
<div class="sheet-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" @click=${this._close}>
|
||||
${
|
||||
this.resultCount === undefined
|
||||
? this._localize("ui.common.close")
|
||||
: this._localize(
|
||||
"ui.components.subpage-data-table.show_results",
|
||||
{ number: this.resultCount }
|
||||
)
|
||||
}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<ha-filter-pane-chip
|
||||
active
|
||||
.label=${label}
|
||||
.path=${this.path}
|
||||
.count=${this.count}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._close}
|
||||
></ha-filter-pane-chip>
|
||||
${this._renderClearButton()}
|
||||
</div>
|
||||
<div class="content ha-scrollbar">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderClearButton(slot?: string) {
|
||||
if (!this.count) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-icon-button
|
||||
slot=${ifDefined(slot)}
|
||||
.path=${mdiFilterVariantRemove}
|
||||
.disabled=${this.disabled}
|
||||
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
|
||||
@click=${this._clear}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _close() {
|
||||
fireEvent(this, "close-filter-pane");
|
||||
}
|
||||
|
||||
private _clear() {
|
||||
fireEvent(this, "clear-filter");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 var(--ha-filter-pane-width, 320px);
|
||||
width: var(--ha-filter-pane-width, 320px);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-inline-end: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* The bottom sheet positions itself, so the pane takes no space. */
|
||||
:host([narrow]) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.content,
|
||||
.sheet-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
/* Fixed height so the sheet does not resize while filtering. */
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-pane": HaFilterPane;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"close-filter-pane": undefined;
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ export class HaChooseSelector extends LitElement {
|
||||
.required=${this.required}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
.helper=${this.helper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-selector>`;
|
||||
}
|
||||
|
||||
@@ -107,7 +108,12 @@ export class HaChooseSelector extends LitElement {
|
||||
: {
|
||||
[this._activeChoice!]: this.value,
|
||||
};
|
||||
this._activeChoice = ev.detail?.value || ev.target.value;
|
||||
const choice = ev.detail?.value || ev.target.value;
|
||||
this._activeChoice = choice;
|
||||
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
|
||||
value[choice] =
|
||||
this.selector.choose.choices[choice].selector.constant?.value;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...value,
|
||||
|
||||
@@ -664,6 +664,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { DataTableFiltersValue } from "../data/data_table_filters";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import type { EntitySources } from "../data/entity/entity_sources";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-filter-device-classes";
|
||||
import "./ha-filter-domains";
|
||||
import "./ha-filter-integrations";
|
||||
import "./ha-target-picker";
|
||||
|
||||
/**
|
||||
* Ways to narrow down the entities a target selection resolves to. Not to be
|
||||
* confused with `EntitySources`, which maps an entity to its integration.
|
||||
*/
|
||||
export interface SourceFilters {
|
||||
domains?: string[];
|
||||
deviceClasses?: string[];
|
||||
integrations?: string[];
|
||||
}
|
||||
|
||||
const TARGET_KEYS = [
|
||||
"floor_id",
|
||||
"area_id",
|
||||
"device_id",
|
||||
"entity_id",
|
||||
"label_id",
|
||||
] as const;
|
||||
|
||||
/** Number of picked targets, no matter which type they are. */
|
||||
export const countTargets = (target: HassServiceTarget): number =>
|
||||
TARGET_KEYS.reduce(
|
||||
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
|
||||
0
|
||||
);
|
||||
|
||||
/** Number of filters that have at least one option selected. */
|
||||
export const countSourceFilters = (filters: SourceFilters): number =>
|
||||
Object.values(filters).filter((value) => value?.length).length;
|
||||
|
||||
/**
|
||||
* Narrows entity IDs down by the selected filters: an entity is kept when it
|
||||
* matches every filter that has a selection. The integration filter is skipped
|
||||
* while the entity sources are still loading.
|
||||
*/
|
||||
export const applySourceFilters = (
|
||||
entityIds: string[],
|
||||
filters: SourceFilters,
|
||||
states: HomeAssistant["states"],
|
||||
entitySources?: EntitySources
|
||||
): string[] => {
|
||||
const domains = filters.domains?.length ? filters.domains : undefined;
|
||||
const deviceClasses = filters.deviceClasses?.length
|
||||
? filters.deviceClasses
|
||||
: undefined;
|
||||
const integrations =
|
||||
filters.integrations?.length && entitySources
|
||||
? filters.integrations
|
||||
: undefined;
|
||||
|
||||
if (!domains && !deviceClasses && !integrations) {
|
||||
return entityIds;
|
||||
}
|
||||
|
||||
return entityIds.filter((entityId) => {
|
||||
if (domains && !domains.includes(computeDomain(entityId))) {
|
||||
return false;
|
||||
}
|
||||
if (deviceClasses) {
|
||||
const deviceClass = states[entityId]?.attributes.device_class;
|
||||
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (integrations) {
|
||||
const integration = entitySources![entityId]?.domain;
|
||||
if (!integration || !integrations.includes(integration)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Picker for what a page shows: the targets to include, narrowed down by
|
||||
* domain, device class and integration. Meant to be placed in an
|
||||
* `ha-filter-pane`.
|
||||
*/
|
||||
@customElement("ha-sources-picker")
|
||||
export class HaSourcesPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public value: HassServiceTarget = {};
|
||||
|
||||
@property({ attribute: false }) public filters: SourceFilters = {};
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
/** Explains what the page shows while no target is picked. */
|
||||
@property() public description?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
// Only one filter panel is expanded at a time, so that the expanded one can
|
||||
// use the height that is left in the pane.
|
||||
@state() private _expandedFilter?: keyof SourceFilters;
|
||||
|
||||
protected render() {
|
||||
const noTargets = countTargets(this.value) === 0;
|
||||
|
||||
return html`
|
||||
${
|
||||
this.description && noTargets
|
||||
? html`<div class="description">${this.description}</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-target-picker
|
||||
class=${classMap({ "no-padding-top": noTargets })}
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetsChanged}
|
||||
></ha-target-picker>
|
||||
<div
|
||||
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
|
||||
>
|
||||
<ha-filter-domains
|
||||
.value=${this.filters.domains}
|
||||
.narrow=${this.narrow}
|
||||
.expanded=${this._expandedFilter === "domains"}
|
||||
@data-table-filter-changed=${this._domainsChanged}
|
||||
@expanded-changed=${this._domainsExpanded}
|
||||
></ha-filter-domains>
|
||||
<ha-filter-device-classes
|
||||
.value=${this.filters.deviceClasses}
|
||||
.narrow=${this.narrow}
|
||||
.expanded=${this._expandedFilter === "deviceClasses"}
|
||||
@data-table-filter-changed=${this._deviceClassesChanged}
|
||||
@expanded-changed=${this._deviceClassesExpanded}
|
||||
></ha-filter-device-classes>
|
||||
<ha-filter-integrations
|
||||
.value=${this.filters.integrations}
|
||||
.narrow=${this.narrow}
|
||||
.expanded=${this._expandedFilter === "integrations"}
|
||||
@data-table-filter-changed=${this._integrationsChanged}
|
||||
@expanded-changed=${this._integrationsExpanded}
|
||||
></ha-filter-integrations>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
// The filter panels label themselves with keys from the config panel.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
private _targetsChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
|
||||
}
|
||||
|
||||
private _domainsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesChanged(ev: CustomEvent) {
|
||||
this._filterChanged("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value as DataTableFiltersValue;
|
||||
fireEvent(this, "source-filters-changed", {
|
||||
value: {
|
||||
...this.filters,
|
||||
[key]: Array.isArray(value) && value.length ? value : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _domainsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
if (ev.detail.expanded) {
|
||||
this._expandedFilter = key;
|
||||
} else if (this._expandedFilter === key) {
|
||||
this._expandedFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
/* The sections are laid out by the pane, so that an expanded filter
|
||||
panel can use the height that is left. */
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.description {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 92px;
|
||||
margin: var(--ha-space-4) var(--ha-space-4) 0;
|
||||
padding: 0 var(--ha-space-6);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-target-picker {
|
||||
display: block;
|
||||
flex: none;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* The description already spaces the picker from the pane header. */
|
||||
ha-target-picker.no-padding-top {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 auto;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* An expanded panel sizes itself to the space that is left over. */
|
||||
.filters.expanded {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-sources-picker": HaSourcesPicker;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"source-filters-changed": { value: SourceFilters };
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type DevicePickerItem,
|
||||
} from "../data/device/device_picker";
|
||||
import {
|
||||
devicesInEffectiveArea,
|
||||
fetchDeviceCompositeSplits,
|
||||
type DeviceCompositeSplits,
|
||||
} from "../data/device/device_registry";
|
||||
@@ -42,9 +41,6 @@ import { domainToName } from "../data/integration";
|
||||
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import {
|
||||
areaMeetsFilter,
|
||||
deviceMeetsFilter,
|
||||
entityRegMeetsFilter,
|
||||
getTargetComboBoxItemType,
|
||||
type TargetItem,
|
||||
type TargetType,
|
||||
@@ -67,7 +63,6 @@ import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-value-chip";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
@@ -86,8 +81,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public compact = false;
|
||||
|
||||
@property({ attribute: false }) public createDomains?: string[];
|
||||
|
||||
@property({ type: Boolean, attribute: "primary-entities-only" })
|
||||
@@ -117,8 +110,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
|
||||
|
||||
@state() private _selectedSection?: TargetTypeFloorless;
|
||||
|
||||
@state() private _replaceTarget?: TargetItem;
|
||||
@@ -254,119 +245,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
Fuse.createIndex(keys, states);
|
||||
|
||||
protected render() {
|
||||
if (this.addOnTop) {
|
||||
return html` ${this._renderPicker()} ${this._renderItems()} `;
|
||||
}
|
||||
return html` ${this._renderItems()} ${this._renderPicker()} `;
|
||||
}
|
||||
|
||||
private _renderValueChips() {
|
||||
const entityIds = this.value?.entity_id
|
||||
? ensureArray(this.value.entity_id)
|
||||
: [];
|
||||
const deviceIds = this.value?.device_id
|
||||
? ensureArray(this.value.device_id)
|
||||
: [];
|
||||
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
|
||||
const floorIds = this.value?.floor_id
|
||||
? ensureArray(this.value.floor_id)
|
||||
: [];
|
||||
const labelIds = this.value?.label_id
|
||||
? ensureArray(this.value.label_id)
|
||||
: [];
|
||||
|
||||
if (
|
||||
!entityIds.length &&
|
||||
!deviceIds.length &&
|
||||
!areaIds.length &&
|
||||
!floorIds.length &&
|
||||
!labelIds.length
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="items">
|
||||
${
|
||||
floorIds.length
|
||||
? floorIds.map(
|
||||
(floor_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="floor"
|
||||
.itemId=${floor_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
areaIds.length
|
||||
? areaIds.map(
|
||||
(area_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="area"
|
||||
.itemId=${area_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
deviceIds.length
|
||||
? deviceIds.map(
|
||||
(device_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="device"
|
||||
.itemId=${device_id}
|
||||
.compositeSplits=${this._compositeSplits}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
entityIds.length
|
||||
? entityIds.map(
|
||||
(entity_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="entity"
|
||||
.itemId=${entity_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
labelIds.length
|
||||
? labelIds.map(
|
||||
(label_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="label"
|
||||
.itemId=${label_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderValueGroups() {
|
||||
const entityIds = this.value?.entity_id
|
||||
? ensureArray(this.value.entity_id)
|
||||
@@ -480,9 +361,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderItems() {
|
||||
return html`
|
||||
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
|
||||
`;
|
||||
return html` ${this._renderValueGroups()} `;
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
@@ -657,162 +536,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleExpand(ev: HASSDomEvent<HASSDomEvents["expand-target-item"]>) {
|
||||
const type = ev.detail.type;
|
||||
const itemId = ev.detail.id;
|
||||
const newAreas: string[] = [];
|
||||
const newDevices: string[] = [];
|
||||
const newEntities: string[] = [];
|
||||
|
||||
if (type === "floor") {
|
||||
Object.values(this.hass.areas).forEach((area) => {
|
||||
if (
|
||||
area.floor_id === itemId &&
|
||||
!this.value!.area_id?.includes(area.area_id) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newAreas.push(area.area_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "area") {
|
||||
// Splitting an area yields its effective-area devices, so a child device
|
||||
// that belongs to a different area is not pulled into this area.
|
||||
devicesInEffectiveArea(this.hass.devices, itemId).forEach((device) => {
|
||||
if (
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newDevices.push(device.id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.area_id === itemId &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "device") {
|
||||
// Splitting a device into entities includes its child devices' entities,
|
||||
// since targeting the device would target its children too.
|
||||
const deviceIds = new Set([
|
||||
itemId,
|
||||
...Object.values(this.hass.devices)
|
||||
.filter((device) => device.parent_device_id === itemId)
|
||||
.map((device) => device.id),
|
||||
]);
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.device_id &&
|
||||
deviceIds.has(entity.device_id) &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "label") {
|
||||
Object.values(this.hass.areas).forEach((area) => {
|
||||
if (
|
||||
area.labels.includes(itemId) &&
|
||||
!this.value!.area_id?.includes(area.area_id) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newAreas.push(area.area_id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.devices).forEach((device) => {
|
||||
if (
|
||||
device.labels.includes(itemId) &&
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newDevices.push(device.id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.labels.includes(itemId) &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
true,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let value = this.value;
|
||||
if (newEntities.length) {
|
||||
value = this._addItems(value, "entity_id", newEntities);
|
||||
}
|
||||
if (newDevices.length) {
|
||||
value = this._addItems(value, "device_id", newDevices);
|
||||
}
|
||||
if (newAreas.length) {
|
||||
value = this._addItems(value, "area_id", newAreas);
|
||||
}
|
||||
value = this._removeItem(value, type, itemId);
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _handleReplace(
|
||||
ev: HASSDomEvent<HASSDomEvents["replace-target-item"]>
|
||||
) {
|
||||
@@ -852,17 +575,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._replaceTargetAnchor = undefined;
|
||||
}
|
||||
|
||||
private _addItems(
|
||||
value: this["value"],
|
||||
type: string,
|
||||
ids: string[]
|
||||
): this["value"] {
|
||||
return {
|
||||
...value,
|
||||
[type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids,
|
||||
};
|
||||
}
|
||||
|
||||
private _removeItem(
|
||||
value: this["value"],
|
||||
type: TargetType,
|
||||
@@ -1438,13 +1150,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.items {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--ha-space-2) 0;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.item-groups {
|
||||
overflow: hidden;
|
||||
border: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
@@ -1460,7 +1165,6 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
"remove-target-item": TargetItem;
|
||||
"expand-target-item": TargetItem;
|
||||
"replace-target-item": TargetItem;
|
||||
"migrate-target-item": { id: string; replacements: string[] };
|
||||
"remove-target-group": string;
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import "@home-assistant/webawesome/dist/components/tag/tag";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlertOutline,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
mdiTextureBox,
|
||||
mdiUnfoldMoreVertical,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { TargetType } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-domain-icon";
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-icon";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-state-icon";
|
||||
import "../ha-tooltip";
|
||||
|
||||
@customElement("ha-target-picker-value-chip")
|
||||
export class HaTargetPickerValueChip extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public type!: TargetType;
|
||||
|
||||
@property({ attribute: "item-id" }) public itemId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
protected render() {
|
||||
const { name, iconPath, fallbackIconPath, stateObject, color } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
const split =
|
||||
this.type === "device" && !this.hass.devices?.[this.itemId]
|
||||
? this.compositeSplits?.[this.itemId]
|
||||
: undefined;
|
||||
// Show the replaced reference using the primary replacement device's name,
|
||||
// falling back to the first still-existing split device if the primary
|
||||
// device itself was deleted. If no replacement device exists at all, fall
|
||||
// back to the normal "not found" display.
|
||||
const replacementDevice = split
|
||||
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
|
||||
split.split_ids
|
||||
.map((id) => this.hass.devices?.[id])
|
||||
.find((device) => device)
|
||||
: undefined;
|
||||
const replaced = !!replacementDevice;
|
||||
const replacedName = replacementDevice
|
||||
? computeDeviceNameDisplay(
|
||||
replacementDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<wa-tag
|
||||
pill
|
||||
with-remove
|
||||
class=${classMap({ [this.type]: true, replaced })}
|
||||
style=${color ? `--color: rgb(${color});` : ""}
|
||||
@wa-remove=${this._removeItem}
|
||||
>
|
||||
<div class="icon">
|
||||
${
|
||||
replaced
|
||||
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
|
||||
: iconPath
|
||||
? html`<ha-icon .icon=${iconPath}></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
alt=${this._domainName || ""}
|
||||
width="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon
|
||||
.path=${fallbackIconPath}
|
||||
></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`<ha-state-icon
|
||||
.stateObj=${stateObject}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<span class="name">
|
||||
${
|
||||
replaced
|
||||
? replacedName ||
|
||||
this.hass.localize(
|
||||
"ui.components.target-picker.replaced_device"
|
||||
)
|
||||
: name
|
||||
}
|
||||
</span>
|
||||
${
|
||||
this.type === "entity" || replaced
|
||||
? nothing
|
||||
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
|
||||
>${this.hass.localize(
|
||||
`ui.components.target-picker.expand_${this.type}_id`
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<ha-icon-button
|
||||
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.target-picker.expand"
|
||||
)}
|
||||
.path=${mdiUnfoldMoreVertical}
|
||||
hide-title
|
||||
.id="expand-${slugify(this.itemId)}"
|
||||
.type=${this.type}
|
||||
@click=${this._handleExpand}
|
||||
></ha-icon-button>`
|
||||
}
|
||||
</wa-tag>
|
||||
`;
|
||||
}
|
||||
|
||||
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
|
||||
if (type === "floor") {
|
||||
const floor = this.hass.floors?.[itemId];
|
||||
return {
|
||||
name: floor?.name || itemId,
|
||||
iconPath: floor?.icon,
|
||||
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
|
||||
};
|
||||
}
|
||||
if (type === "area") {
|
||||
const area = this.hass.areas?.[itemId];
|
||||
return {
|
||||
name: area?.name || itemId,
|
||||
iconPath: area?.icon,
|
||||
fallbackIconPath: mdiTextureBox,
|
||||
};
|
||||
}
|
||||
if (type === "device") {
|
||||
const device = this.hass.devices?.[itemId];
|
||||
|
||||
if (device?.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
|
||||
return {
|
||||
name: device
|
||||
? computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: itemId,
|
||||
fallbackIconPath: mdiDevices,
|
||||
};
|
||||
}
|
||||
if (type === "entity") {
|
||||
this._setDomainName(computeDomain(itemId));
|
||||
|
||||
const stateObj = this.hass.states[itemId];
|
||||
return {
|
||||
name: computeStateName(stateObj) || itemId,
|
||||
stateObject: stateObj,
|
||||
};
|
||||
}
|
||||
|
||||
// type label
|
||||
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
|
||||
let color = label?.color ? computeCssColor(label.color) : undefined;
|
||||
if (color?.startsWith("var(")) {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
color = computedStyles.getPropertyValue(
|
||||
color.substring(4, color.length - 1)
|
||||
);
|
||||
}
|
||||
if (color?.startsWith("#")) {
|
||||
color = hex2rgb(color).join(",");
|
||||
}
|
||||
return {
|
||||
name: label?.name || itemId,
|
||||
iconPath: label?.icon,
|
||||
fallbackIconPath: mdiLabel,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
private _setDomainName(domain: string) {
|
||||
this._domainName = domainToName(this.hass.localize, domain);
|
||||
}
|
||||
|
||||
private async _getDeviceDomain(configEntryId: string) {
|
||||
try {
|
||||
const data = await getConfigEntry(this.hass, configEntryId);
|
||||
const domain = data.config_entry.domain;
|
||||
this._iconImg = brandsUrl(
|
||||
{
|
||||
domain: domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
);
|
||||
|
||||
this._setDomainName(domain);
|
||||
} catch {
|
||||
// failed to load config entry -> ignore
|
||||
}
|
||||
}
|
||||
|
||||
private _removeItem(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "remove-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleExpand(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "expand-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
wa-tag {
|
||||
background-color: var(--card-background-color);
|
||||
border-width: var(--ha-border-width-md);
|
||||
padding-inline-start: 0;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
wa-tag.entity {
|
||||
border-color: var(--ha-color-green-80);
|
||||
--background-color: var(--ha-color-green-80);
|
||||
}
|
||||
wa-tag.device {
|
||||
border-color: var(--ha-color-primary-80);
|
||||
--background-color: var(--ha-color-primary-80);
|
||||
}
|
||||
wa-tag.area {
|
||||
border-color: var(--ha-color-orange-80);
|
||||
--background-color: var(--ha-color-orange-80);
|
||||
}
|
||||
wa-tag.label {
|
||||
border-color: var(--color);
|
||||
--background-color: var(--color);
|
||||
--icon-primary-color: var(--primary-text-color);
|
||||
}
|
||||
wa-tag.replaced {
|
||||
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
|
||||
--background-color: var(--warning-color);
|
||||
color: var(--ha-color-on-warning-normal, var(--warning-color));
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
padding: var(--ha-space-2) var(--ha-space-1);
|
||||
display: flex;
|
||||
}
|
||||
.expand-btn {
|
||||
--ha-icon-button-size: 16px;
|
||||
--mdc-icon-size: 14px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-target-picker-value-chip": HaTargetPickerValueChip;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -22,7 +22,6 @@ export enum VacuumEntityFeature {
|
||||
STOP = 8,
|
||||
RETURN_HOME = 16,
|
||||
FAN_SPEED = 32,
|
||||
BATTERY = 64,
|
||||
STATUS = 128,
|
||||
SEND_COMMAND = 256,
|
||||
LOCATE = 512,
|
||||
@@ -34,7 +33,6 @@ export enum VacuumEntityFeature {
|
||||
}
|
||||
|
||||
interface VacuumEntityAttributes extends HassEntityAttributeBase {
|
||||
battery_level?: number;
|
||||
fan_speed?: string;
|
||||
fan_speed_list?: string[];
|
||||
[key: string]: any;
|
||||
|
||||
+3
-2
@@ -566,9 +566,10 @@ export const getWeatherStateIcon = (
|
||||
if (userDefinedIcon) {
|
||||
return html`
|
||||
<div
|
||||
style="background-size: cover;${styleMap({
|
||||
style=${styleMap({
|
||||
"background-size": "cover",
|
||||
"background-image": userDefinedIcon,
|
||||
})}"
|
||||
})}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
@@ -159,23 +159,6 @@ class MoreInfoVacuum extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Use deprecated battery_level and battery_icon attributes
|
||||
if (
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
|
||||
this.stateObj.attributes.battery_level
|
||||
) {
|
||||
return html`
|
||||
<span class="battery" slot="after-time">
|
||||
<span
|
||||
>${Math.round(
|
||||
this.stateObj.attributes.battery_level
|
||||
)}${blankBeforePercent(this._i18n.locale)}%</span
|
||||
>
|
||||
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -74,11 +74,6 @@ export class MockVacuumEntity extends MockBaseEntity {
|
||||
stateAttrs.fan_speed = attrs.fan_speed ?? null;
|
||||
}
|
||||
|
||||
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.BATTERY)) {
|
||||
stateAttrs.battery_level = attrs.battery_level ?? null;
|
||||
stateAttrs.battery_icon = attrs.battery_icon ?? null;
|
||||
}
|
||||
|
||||
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.STATUS)) {
|
||||
stateAttrs.status = attrs.status ?? null;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import "../components/ha-dialog-footer";
|
||||
import "../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
|
||||
import "../components/ha-dropdown-item";
|
||||
import "../components/ha-filter-pane-chip";
|
||||
import "../components/ha-icon-button";
|
||||
import "../components/ha-svg-icon";
|
||||
import "../components/input/ha-input-search";
|
||||
@@ -237,20 +238,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
const localize = this.localizeFunc || this.hass.localize;
|
||||
const showPane = this._showPaneController.value ?? !this.narrow;
|
||||
const filterButton = this.hasFilters
|
||||
? html`<div class="relative">
|
||||
<ha-assist-chip
|
||||
.label=${localize("ui.components.subpage-data-table.filters")}
|
||||
.active=${this.filters}
|
||||
@click=${this._toggleFilters}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
${
|
||||
this.filters
|
||||
? html`<div class="badge">${this.filters}</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
? html`<ha-filter-pane-chip
|
||||
.label=${localize("ui.components.subpage-data-table.filters")}
|
||||
.path=${mdiFilterVariant}
|
||||
.count=${this.filters}
|
||||
.active=${!!this.filters}
|
||||
@click=${this._toggleFilters}
|
||||
></ha-filter-pane-chip>`
|
||||
: nothing;
|
||||
|
||||
const selectModeBtn =
|
||||
@@ -471,18 +465,14 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
? nothing
|
||||
: html`<div class="pane" slot="pane">
|
||||
<div class="table-header">
|
||||
<ha-assist-chip
|
||||
<ha-filter-pane-chip
|
||||
.label=${localize(
|
||||
"ui.components.subpage-data-table.filters"
|
||||
)}
|
||||
.path=${mdiFilterVariant}
|
||||
active
|
||||
@click=${this._toggleFilters}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFilterVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
></ha-filter-pane-chip>
|
||||
${
|
||||
this.filters
|
||||
? html`<ha-icon-button
|
||||
@@ -885,24 +875,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.narrow-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -951,11 +923,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ha-assist-chip {
|
||||
ha-assist-chip,
|
||||
ha-filter-pane-chip {
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
--ha-assist-chip-container-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,6 +49,7 @@ const SUPPORTED_UI_TYPES = [
|
||||
|
||||
const secretTag = defineScalarTag("!secret", {
|
||||
resolve: (data) => `!secret ${data}`,
|
||||
identify: () => false,
|
||||
});
|
||||
|
||||
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiPencil } from "@mdi/js";
|
||||
import { mdiAccount, mdiAccountPlus, mdiPencil } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -19,6 +19,7 @@ import type { PersonMutableParams } from "../../../data/person";
|
||||
import type { User } from "../../../data/user";
|
||||
import {
|
||||
deleteUser,
|
||||
fetchUsers,
|
||||
SYSTEM_GROUP_ID_ADMIN,
|
||||
SYSTEM_GROUP_ID_USER,
|
||||
updateUser,
|
||||
@@ -36,6 +37,8 @@ import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { showAddUserDialog } from "../users/show-dialog-add-user";
|
||||
import { showAdminChangePasswordDialog } from "../users/show-dialog-admin-change-password";
|
||||
import type { PersonDetailDialogParams } from "./show-dialog-person-detail";
|
||||
import { showListItemsDialog } from "../../../dialogs/dialog-list-items/show-list-items-dialog";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
|
||||
const includeDomains = ["device_tracker"];
|
||||
|
||||
@@ -85,6 +88,8 @@ class DialogPersonDetail
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
private _linkedExistingUser = false;
|
||||
|
||||
private _deviceTrackersAvailable = memoizeOne((hass) =>
|
||||
Object.keys(hass.states).some(
|
||||
(entityId) =>
|
||||
@@ -140,7 +145,7 @@ class DialogPersonDetail
|
||||
private _dialogClosed() {
|
||||
// If we do not have a person ID yet (= person creation dialog was just cancelled), but
|
||||
// we already created a user ID for it, delete it now to not have it "free floating".
|
||||
if (!this._personExists && this._userId) {
|
||||
if (!this._personExists && this._userId && !this._linkedExistingUser) {
|
||||
const callback = this._params?.refreshUsers;
|
||||
deleteUser(this.hass, this._userId).then(() => {
|
||||
callback?.();
|
||||
@@ -426,26 +431,71 @@ class DialogPersonDetail
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
private async _linkUser(user: User, newUser: boolean) {
|
||||
this._linkedExistingUser = !newUser;
|
||||
if (this._params!.entry && this._params!.updateEntry) {
|
||||
await this._params!.updateEntry({ user_id: user.id });
|
||||
}
|
||||
if (newUser) {
|
||||
this._params?.refreshUsers?.();
|
||||
}
|
||||
this._user = user;
|
||||
this._userId = user.id;
|
||||
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
|
||||
this._localOnly = user.local_only;
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
private async _allowLoginChanged(ev): Promise<void> {
|
||||
const target = ev.target;
|
||||
if (target.checked) {
|
||||
target.checked = false;
|
||||
showAddUserDialog(this, {
|
||||
userAddedCallback: async (user?: User) => {
|
||||
if (user) {
|
||||
target.checked = true;
|
||||
if (this._params!.entry && this._params!.updateEntry) {
|
||||
await this._params!.updateEntry({ user_id: user.id });
|
||||
|
||||
const users = await fetchUsers(this.hass);
|
||||
const currentLinkedUsers = new Set(
|
||||
Object.values(this.hass.states)
|
||||
.filter(
|
||||
(s) =>
|
||||
computeDomain(s.entity_id) === "person" && s.attributes.user_id
|
||||
)
|
||||
.map((s) => s.attributes.user_id)
|
||||
);
|
||||
const eligibleUsers = users.filter(
|
||||
(u) =>
|
||||
!currentLinkedUsers.has(u.id) && !u.system_generated && u.username
|
||||
);
|
||||
const addUserDialog = () =>
|
||||
showAddUserDialog(this, {
|
||||
userAddedCallback: async (user?: User) => {
|
||||
if (user) {
|
||||
target.checked = true;
|
||||
this._linkUser(user, true);
|
||||
}
|
||||
this._params?.refreshUsers?.();
|
||||
this._user = user;
|
||||
this._userId = user.id;
|
||||
this._isAdmin = user.group_ids.includes(SYSTEM_GROUP_ID_ADMIN);
|
||||
this._localOnly = user.local_only;
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
},
|
||||
name: this._name,
|
||||
},
|
||||
name: this._name,
|
||||
});
|
||||
|
||||
if (eligibleUsers.length === 0) {
|
||||
addUserDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
showListItemsDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.person.detail.select_user"),
|
||||
items: [
|
||||
{
|
||||
iconPath: mdiAccountPlus,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.person.detail.create_new_user"
|
||||
),
|
||||
action: addUserDialog,
|
||||
},
|
||||
...eligibleUsers.map((user) => ({
|
||||
iconPath: mdiAccount,
|
||||
label: `${user.name} (${user.username})`,
|
||||
action: () => this._linkUser(user, false),
|
||||
})),
|
||||
],
|
||||
});
|
||||
} else if (this._userId) {
|
||||
if (
|
||||
|
||||
@@ -291,11 +291,17 @@ export const showRepairsFlowDialog = (
|
||||
},
|
||||
|
||||
renderMenuOption(hass, step, option) {
|
||||
return hass.localize(
|
||||
`component.${issue.domain}.issues.${
|
||||
issue.translation_key || issue.issue_id
|
||||
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
|
||||
mergePlaceholders(issue, step)
|
||||
return (
|
||||
hass.localize(
|
||||
`component.${issue.domain}.issues.${
|
||||
issue.translation_key || issue.issue_id
|
||||
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
|
||||
mergePlaceholders(issue, step)
|
||||
) ||
|
||||
// Newer backends can offer options this frontend has no
|
||||
// translation for yet — show the raw option key instead of
|
||||
// an empty menu entry
|
||||
option
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -20,9 +20,10 @@ type ReloadableDomain = Exclude<
|
||||
"heading" | "introduction" | "reload"
|
||||
>;
|
||||
|
||||
interface TranslatedReloadableDomain {
|
||||
domain: ReloadableDomain;
|
||||
interface TranslatedReloadableItem {
|
||||
domain: ReloadableDomain | "frontend";
|
||||
name: string;
|
||||
service?: string;
|
||||
}
|
||||
|
||||
@customElement("tools-yaml-config")
|
||||
@@ -37,7 +38,7 @@ export class ToolsYamlConfig extends LitElement {
|
||||
|
||||
@state() private _validating = false;
|
||||
|
||||
@state() private _reloadableDomains: TranslatedReloadableDomain[] = [];
|
||||
@state() private _reloadableItems: TranslatedReloadableItem[] = [];
|
||||
|
||||
@state() private _validateResult?: CheckConfigResult;
|
||||
|
||||
@@ -54,10 +55,10 @@ export class ToolsYamlConfig extends LitElement {
|
||||
oldHass.config.components !== this.hass.config.components ||
|
||||
oldHass.localize !== this.hass.localize)
|
||||
) {
|
||||
this._reloadableDomains = (
|
||||
this._reloadableItems = (
|
||||
componentsWithService(this.hass, "reload") as ReloadableDomain[]
|
||||
)
|
||||
.map((domain) => ({
|
||||
.map<TranslatedReloadableItem>((domain) => ({
|
||||
domain,
|
||||
name:
|
||||
this.hass.localize(
|
||||
@@ -68,6 +69,15 @@ export class ToolsYamlConfig extends LitElement {
|
||||
{ domain: domainToName(this.hass.localize, domain) }
|
||||
),
|
||||
}))
|
||||
.concat([
|
||||
{
|
||||
domain: "frontend",
|
||||
service: "reload_themes",
|
||||
name: this.hass.localize(
|
||||
`ui.panel.config.tools.tabs.yaml.section.reloading.themes`
|
||||
),
|
||||
},
|
||||
])
|
||||
.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
@@ -190,12 +200,12 @@ export class ToolsYamlConfig extends LitElement {
|
||||
)}
|
||||
</ha-call-service-button>
|
||||
</div>
|
||||
${this._reloadableDomains.map(
|
||||
${this._reloadableItems.map(
|
||||
(reloadable) => html`
|
||||
<div class="card-actions">
|
||||
<ha-call-service-button
|
||||
.domain=${reloadable.domain}
|
||||
service="reload"
|
||||
.service=${reloadable.service || "reload"}
|
||||
>${reloadable.name}
|
||||
</ha-call-service-button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {
|
||||
mdiChartBoxOutline,
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
mdiFilterRemove,
|
||||
mdiImagePlus,
|
||||
mdiTuneVariant,
|
||||
} from "@mdi/js";
|
||||
import { differenceInHours } from "date-fns";
|
||||
import type {
|
||||
@@ -10,10 +11,11 @@ import type {
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket/dist/types";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { storage } from "../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
@@ -29,14 +31,25 @@ import {
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
|
||||
import "../../components/chart/state-history-charts";
|
||||
import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
|
||||
import "../../components/date-picker/ha-date-range-picker";
|
||||
import "../../components/date-picker/ha-date-range-nav";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
|
||||
import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-empty-state";
|
||||
import "../../components/ha-filter-pane-chip";
|
||||
import "../../components/ha-filter-pane";
|
||||
import "../../components/ha-icon-button";
|
||||
import {
|
||||
applySourceFilters,
|
||||
countSourceFilters,
|
||||
countTargets,
|
||||
} from "../../components/ha-sources-picker";
|
||||
import type { SourceFilters } from "../../components/ha-sources-picker";
|
||||
import "../../components/ha-spinner";
|
||||
import "../../components/ha-target-picker";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import type { HistoryResult } from "../../data/history";
|
||||
import {
|
||||
computeHistory,
|
||||
@@ -52,6 +65,8 @@ import type { HomeAssistant } from "../../types";
|
||||
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
|
||||
import { csvSafeString, csvDownload } from "../../util/csv";
|
||||
|
||||
const EMPTY_STATES: HomeAssistant["states"] = {};
|
||||
|
||||
@customElement("ha-panel-history")
|
||||
class HaPanelHistory extends LitElement {
|
||||
@property({ attribute: false }) hass!: HomeAssistant;
|
||||
@@ -78,6 +93,21 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
@state() private _isLoading = false;
|
||||
|
||||
@state() private _filters: SourceFilters = {};
|
||||
|
||||
// Restored on the next visit, like the target selection the filters narrow
|
||||
// down.
|
||||
@storage({
|
||||
key: "historySourceFilters",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _storedFilters?: SourceFilters;
|
||||
|
||||
@state() private _showSources = false;
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _stateHistory?: HistoryResult;
|
||||
|
||||
private _mungedStateHistory?: HistoryResult;
|
||||
@@ -119,7 +149,22 @@ class HaPanelHistory extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const entitiesSelected = this._getEntityIds().length > 0;
|
||||
const targetCount = countTargets(this._targetPickerValue);
|
||||
const sourceCount = targetCount + countSourceFilters(this._filters);
|
||||
// History only shows something once a target is picked, so narrowing it
|
||||
// down further does not make it any less empty.
|
||||
const hasTargets = targetCount > 0;
|
||||
// Keyed on the entities the selection resolves to, not on the targets
|
||||
// themselves: a target whose entities are all filtered out fetches nothing
|
||||
// and would otherwise load forever.
|
||||
const loading =
|
||||
this._isLoading ||
|
||||
(this._getEntityIds().length > 0 && !this._mungedStateHistory);
|
||||
const hasResults =
|
||||
!!this._mungedStateHistory &&
|
||||
(this._mungedStateHistory.line.length > 0 ||
|
||||
this._mungedStateHistory.timeline.length > 0);
|
||||
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
@@ -128,13 +173,6 @@ class HaPanelHistory extends LitElement {
|
||||
<h1 class="page-title" slot="title">
|
||||
${this.hass.localize("panel.history")}
|
||||
</h1>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._removeAll}
|
||||
.disabled=${this._isLoading || !entitiesSelected}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.panel.history.remove_all")}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
@@ -153,51 +191,109 @@ class HaPanelHistory extends LitElement {
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
|
||||
<div class="flex content ha-scrollbar">
|
||||
<div class="filters">
|
||||
<ha-date-range-picker
|
||||
?disabled=${this._isLoading}
|
||||
.startDate=${this._startDate}
|
||||
.endDate=${this._endDate}
|
||||
extended-presets
|
||||
time-picker
|
||||
@value-changed=${this._dateRangeChanged}
|
||||
></ha-date-range-picker>
|
||||
<ha-target-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this._targetPickerValue}
|
||||
.disabled=${this._isLoading}
|
||||
add-on-top
|
||||
@value-changed=${this._targetsChanged}
|
||||
compact
|
||||
></ha-target-picker>
|
||||
</div>
|
||||
${
|
||||
this._isLoading
|
||||
? html`<div class="progress-wrapper">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>`
|
||||
: !entitiesSelected
|
||||
? html`<div class="start-search">
|
||||
${this.hass.localize("ui.panel.history.start_search")}
|
||||
</div>`
|
||||
: html`
|
||||
<state-history-charts
|
||||
<div class="content">
|
||||
<div class="main">
|
||||
${
|
||||
this._showSources
|
||||
? html`<ha-filter-pane
|
||||
.narrow=${this.narrow}
|
||||
.label=${this.hass.localize("ui.panel.history.sources")}
|
||||
.path=${mdiTuneVariant}
|
||||
.count=${sourceCount}
|
||||
.resultCount=${this._getEntityIds().length}
|
||||
.disabled=${this._isLoading}
|
||||
@close-filter-pane=${this._closeSources}
|
||||
@clear-filter=${this._clearSources}
|
||||
>
|
||||
<ha-sources-picker
|
||||
.hass=${this.hass}
|
||||
.historyData=${this._mungedStateHistory}
|
||||
.startTime=${this._startDate}
|
||||
.endTime=${this._endDate}
|
||||
.value=${this._targetPickerValue}
|
||||
.filters=${this._filters}
|
||||
.narrow=${this.narrow}
|
||||
sync-charts
|
||||
>
|
||||
</state-history-charts>
|
||||
`
|
||||
}
|
||||
.disabled=${this._isLoading}
|
||||
.description=${this.hass.localize(
|
||||
"ui.panel.history.no_targets"
|
||||
)}
|
||||
@value-changed=${this._targetsChanged}
|
||||
@source-filters-changed=${this._filtersChanged}
|
||||
></ha-sources-picker>
|
||||
</ha-filter-pane>`
|
||||
: nothing
|
||||
}
|
||||
<div class="content-column">
|
||||
<div class="toolbar">
|
||||
${
|
||||
this._showSources && !this.narrow
|
||||
? nothing
|
||||
: html`<ha-filter-pane-chip
|
||||
.label=${this.hass.localize("ui.panel.history.sources")}
|
||||
.path=${mdiTuneVariant}
|
||||
.count=${sourceCount}
|
||||
.active=${sourceCount > 0}
|
||||
.disabled=${this._isLoading}
|
||||
@click=${this._toggleSources}
|
||||
></ha-filter-pane-chip>`
|
||||
}
|
||||
<ha-date-range-nav
|
||||
.disabled=${this._isLoading}
|
||||
.startDate=${this._startDate}
|
||||
.endDate=${this._endDate}
|
||||
extended-presets
|
||||
time-picker
|
||||
@value-changed=${this._dateRangeChanged}
|
||||
></ha-date-range-nav>
|
||||
</div>
|
||||
<div class="results ha-scrollbar">
|
||||
${
|
||||
loading
|
||||
? html`<div class="progress-wrapper">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>`
|
||||
: !hasTargets || !hasResults
|
||||
? this._renderEmptyState(hasTargets)
|
||||
: html`
|
||||
<state-history-charts
|
||||
.hass=${this.hass}
|
||||
.historyData=${this._mungedStateHistory}
|
||||
.startTime=${this._startDate}
|
||||
.endTime=${this._endDate}
|
||||
.narrow=${this.narrow}
|
||||
inside-labels
|
||||
sync-charts
|
||||
>
|
||||
</state-history-charts>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ha-top-app-bar-fixed>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderEmptyState(hasTargets: boolean) {
|
||||
return html`
|
||||
<ha-empty-state
|
||||
.icon=${mdiChartBoxOutline}
|
||||
.heading=${this.hass.localize(
|
||||
hasTargets
|
||||
? "ui.panel.history.no_results_title"
|
||||
: "ui.panel.history.start_search_title"
|
||||
)}
|
||||
.description=${this.hass.localize(
|
||||
hasTargets
|
||||
? "ui.panel.history.no_results"
|
||||
: "ui.panel.history.start_search"
|
||||
)}
|
||||
>
|
||||
<ha-button appearance="plain" @click=${this._openSources}>
|
||||
${this.hass.localize("ui.panel.history.add_targets")}
|
||||
</ha-button>
|
||||
</ha-empty-state>
|
||||
`;
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
@@ -226,12 +322,16 @@ class HaPanelHistory extends LitElement {
|
||||
const queryParams = decodeHistoryLogbookQueryParams(
|
||||
extractSearchParamsObject()
|
||||
);
|
||||
const initialValue =
|
||||
historyLogbookTargetFromQueryParams(queryParams) ??
|
||||
this._storedTargetPickerValue;
|
||||
const urlTarget = historyLogbookTargetFromQueryParams(queryParams);
|
||||
const initialValue = urlTarget ?? this._storedTargetPickerValue;
|
||||
if (initialValue) {
|
||||
this._targetPickerValue = initialValue;
|
||||
}
|
||||
// A target in the URL describes the whole selection. Restoring the stored
|
||||
// filters on top of it could narrow it down to nothing.
|
||||
if (!urlTarget && this._storedFilters) {
|
||||
this._filters = this._storedFilters;
|
||||
}
|
||||
if (queryParams.start_date) {
|
||||
this._startDate = queryParams.start_date;
|
||||
}
|
||||
@@ -242,6 +342,10 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
// Needed to map entities to their integration for the integration filter.
|
||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||
this._entitySources = sources;
|
||||
});
|
||||
const searchParams = extractSearchParamsObject();
|
||||
if (searchParams.back === "1" && history.length > 1) {
|
||||
this._showBack = true;
|
||||
@@ -256,6 +360,8 @@ class HaPanelHistory extends LitElement {
|
||||
changedProps.has("_startDate") ||
|
||||
changedProps.has("_endDate") ||
|
||||
changedProps.has("_targetPickerValue") ||
|
||||
changedProps.has("_filters") ||
|
||||
changedProps.has("_entitySources") ||
|
||||
(!this._stateHistory &&
|
||||
(changedProps.has("_deviceEntityLookup") ||
|
||||
changedProps.has("_areaEntityLookup") ||
|
||||
@@ -266,7 +372,28 @@ class HaPanelHistory extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _removeAll() {
|
||||
private _toggleSources() {
|
||||
this._showSources = !this._showSources;
|
||||
}
|
||||
|
||||
private _openSources() {
|
||||
this._showSources = true;
|
||||
}
|
||||
|
||||
private _closeSources() {
|
||||
this._showSources = false;
|
||||
}
|
||||
|
||||
private _filtersChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
|
||||
) {
|
||||
this._filters = ev.detail.value;
|
||||
this._storedFilters = this._filters;
|
||||
}
|
||||
|
||||
private _clearSources() {
|
||||
this._filters = {};
|
||||
this._storedFilters = this._filters;
|
||||
this._targetPickerValue = {};
|
||||
this._storedTargetPickerValue = this._targetPickerValue;
|
||||
this._updatePath();
|
||||
@@ -312,7 +439,11 @@ class HaPanelHistory extends LitElement {
|
||||
const entityIds = this._getEntityIds();
|
||||
|
||||
if (entityIds.length === 0) {
|
||||
// The running subscription still holds the previous entities, so it would
|
||||
// keep pushing the ones the selection no longer covers.
|
||||
this._unsubscribeHistory();
|
||||
this._stateHistory = undefined;
|
||||
this._isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,20 +510,39 @@ class HaPanelHistory extends LitElement {
|
||||
private _getEntityIds(): string[] {
|
||||
return this.__getEntityIds(
|
||||
this._targetPickerValue,
|
||||
this._filters,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas
|
||||
this.hass.areas,
|
||||
// Only the device class filter reads the states, so they stay out of the
|
||||
// memoization key while it is not used.
|
||||
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
|
||||
this._entitySources
|
||||
);
|
||||
}
|
||||
|
||||
private __getEntityIds = memoizeOne(
|
||||
(
|
||||
targetPickerValue: HassServiceTarget,
|
||||
filters: SourceFilters,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"]
|
||||
areas: HomeAssistant["areas"],
|
||||
states: HomeAssistant["states"],
|
||||
entitySources: EntitySources | undefined
|
||||
): string[] =>
|
||||
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
|
||||
applySourceFilters(
|
||||
resolveEntityIDs(
|
||||
this.hass,
|
||||
targetPickerValue,
|
||||
entities,
|
||||
devices,
|
||||
areas
|
||||
),
|
||||
filters,
|
||||
states,
|
||||
entitySources
|
||||
)
|
||||
);
|
||||
|
||||
private _dateRangeChanged(ev) {
|
||||
@@ -573,7 +723,15 @@ class HaPanelHistory extends LitElement {
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
:host {
|
||||
/* The picker of the target picker is wider than the pane. */
|
||||
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
|
||||
--ha-generic-picker-max-width: 400px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(
|
||||
100vh - var(--header-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
@@ -581,13 +739,57 @@ class HaPanelHistory extends LitElement {
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
padding: 0 16px 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host([virtualize]) {
|
||||
height: 100%;
|
||||
--ha-generic-picker-max-width: 400px;
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Sits in the content column, so it shifts along with the pane. */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
direction: var(--direction);
|
||||
/* Keep the controls at their size and scroll them if they do not fit. */
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.results {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden auto;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
/* Line the charts up with the toolbar when there are no axis labels. */
|
||||
:host([narrow]) .results {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
@@ -597,42 +799,6 @@ class HaPanelHistory extends LitElement {
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
ha-date-range-picker {
|
||||
margin-right: 16px;
|
||||
margin-inline-end: 16px;
|
||||
margin-inline-start: initial;
|
||||
max-width: 100%;
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
ha-target-picker {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media all and (max-width: 1025px) {
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
ha-date-range-picker {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.start-search {
|
||||
padding-top: 16px;
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { customElement, eventOptions, property, state } from "lit/decorators";
|
||||
import { formatDate } from "../../common/datetime/format_date";
|
||||
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import type { TraceContexts } from "../../data/trace";
|
||||
@@ -13,13 +14,14 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-logbook-entry";
|
||||
import type { LogbookEntrySelectedDetail } from "./ha-logbook-entry";
|
||||
import type { LogbookNameDetail } from "./logbook-entry-model";
|
||||
import { sameDay } from "./logbook-entry-model";
|
||||
import { 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,13 +52,17 @@ 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;
|
||||
// Index of the row at the top of the list, which the floating date header
|
||||
// takes its day from.
|
||||
@state() private _firstVisibleIndex = 0;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (
|
||||
@@ -78,7 +85,10 @@ 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") ||
|
||||
changedProps.has("_firstVisibleIndex" as never) ||
|
||||
languageChanged
|
||||
);
|
||||
}
|
||||
@@ -92,12 +102,26 @@ class HaLogbookRenderer extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// The virtualizer positions its rows, so an inline date header cannot
|
||||
// stick. Instead one header floats above the list and follows the day of
|
||||
// the row that is at the top.
|
||||
const floatingEntry = this.virtualize
|
||||
? this.entries[this._firstVisibleIndex]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="container ha-scrollbar"
|
||||
@scroll=${this._saveScrollPos}
|
||||
@logbook-toggle-time=${this._handleToggleTime}
|
||||
@logbook-entry-selected=${this._handleEntrySelected}
|
||||
>
|
||||
${
|
||||
floatingEntry
|
||||
? html`<h4 class="date floating-date">
|
||||
${this._formatDateHeader(new Date(floatingEntry.when * 1000))}
|
||||
</h4>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.virtualize
|
||||
? html`<lit-virtualizer
|
||||
@@ -105,7 +129,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 +143,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 +173,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 {
|
||||
@@ -183,6 +220,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _visibilityChanged(e: VisibilityChangedEvent) {
|
||||
this._firstVisibleIndex = Math.max(0, e.first);
|
||||
fireEvent(this, "hass-logbook-live", {
|
||||
enable: e.first === 0,
|
||||
});
|
||||
@@ -209,12 +247,25 @@ class HaLogbookRenderer extends LitElement {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
/* Floats above the virtualized list and lines up with the inline date
|
||||
headers, so they scroll underneath it. */
|
||||
.floating-date {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
inset-inline: 0;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding-bottom: var(--ha-space-2);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.no-entries {
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
max-height: var(--logbook-max-height);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ const idsChanged = (oldIds?: string[], newIds?: string[]) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @slot empty - Shown instead of the default text when there is no activity,
|
||||
* for surfaces that can offer a way out (e.g. changing the filters).
|
||||
*/
|
||||
@customElement("ha-logbook")
|
||||
export class HaLogbook extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -126,9 +130,11 @@ export class HaLogbook extends LitElement {
|
||||
}
|
||||
|
||||
if (this._logbookEntries.length === 0) {
|
||||
return html`<div class="no-entries">
|
||||
${this.hass.localize("ui.components.logbook.entries_not_found")}
|
||||
</div>`;
|
||||
return html`<slot name="empty">
|
||||
<div class="no-entries">
|
||||
${this.hass.localize("ui.components.logbook.entries_not_found")}
|
||||
</div>
|
||||
</slot>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
@@ -243,9 +249,9 @@ export class HaLogbook extends LitElement {
|
||||
if (this._unsubLogbook) {
|
||||
this._unsubLogbook.then((unsub) => unsub());
|
||||
this._unsubLogbook = undefined;
|
||||
this._logbookEntries = loading ? undefined : [];
|
||||
this._pendingStreamMessages = [];
|
||||
}
|
||||
this._logbookEntries = loading ? undefined : [];
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
|
||||
@@ -3,14 +3,17 @@ import {
|
||||
mdiDownload,
|
||||
mdiFilterRemove,
|
||||
mdiRefresh,
|
||||
mdiTextBoxOutline,
|
||||
mdiTuneVariant,
|
||||
} from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fromUnixTime } from "date-fns";
|
||||
import { storage } from "../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
import {
|
||||
@@ -23,14 +26,25 @@ import {
|
||||
removeSearchParam,
|
||||
} from "../../common/url/search-params";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import "../../components/date-picker/ha-date-range-picker";
|
||||
import "../../components/date-picker/ha-date-range-nav";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
|
||||
import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-empty-state";
|
||||
import "../../components/ha-filter-pane-chip";
|
||||
import "../../components/ha-filter-pane";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-target-picker";
|
||||
import {
|
||||
applySourceFilters,
|
||||
countSourceFilters,
|
||||
countTargets,
|
||||
} from "../../components/ha-sources-picker";
|
||||
import type { SourceFilters } from "../../components/ha-sources-picker";
|
||||
import "../../components/ha-top-app-bar-fixed";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import { filterLogbookCompatibleEntities } from "../../data/logbook";
|
||||
import { resolveEntityIDs } from "../../data/selector";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
@@ -39,6 +53,8 @@ import "./ha-logbook";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { csvDownload, csvSafeString } from "../../util/csv";
|
||||
|
||||
const EMPTY_STATES: HomeAssistant["states"] = {};
|
||||
|
||||
interface LogbookState {
|
||||
time: { range: [Date, Date] };
|
||||
targetPickerValue: HassServiceTarget;
|
||||
@@ -57,6 +73,12 @@ export class HaPanelLogbook extends LitElement {
|
||||
@state()
|
||||
private _showBack?: boolean;
|
||||
|
||||
@state() private _filters: SourceFilters = {};
|
||||
|
||||
@state() private _showSources = false;
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _targetPickerValue: HassServiceTarget = {};
|
||||
|
||||
// Remembers the last user-picked selection as a fallback for visits without
|
||||
@@ -69,25 +91,31 @@ export class HaPanelLogbook extends LitElement {
|
||||
})
|
||||
private _storedTargetPickerValue?: HassServiceTarget;
|
||||
|
||||
// Restored on the next visit, like the target selection the filters narrow
|
||||
// down.
|
||||
@storage({
|
||||
key: "logbookSourceFilters",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _storedFilters?: SourceFilters;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
this._time = this._defaultState.time;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const entityIds = this._getEntityIds();
|
||||
const sourceCount =
|
||||
countTargets(this._targetPickerValue) + countSourceFilters(this._filters);
|
||||
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
.backButton=${!!this._showBack}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.logbook")}</div>
|
||||
<ha-icon-button
|
||||
slot="actionItems"
|
||||
@click=${this._resetLogbook}
|
||||
.disabled=${this._isDefaultState()}
|
||||
.path=${mdiFilterRemove}
|
||||
.label=${this.hass.localize("ui.common.reset")}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
|
||||
<ha-icon-button
|
||||
@@ -105,40 +133,128 @@ export class HaPanelLogbook extends LitElement {
|
||||
${this.hass.localize("ui.panel.logbook.download_data")}
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item value="reset" .disabled=${this._isDefaultState()}>
|
||||
${this.hass.localize("ui.common.reset")}
|
||||
<ha-svg-icon slot="icon" .path=${mdiFilterRemove}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
|
||||
<div class="content">
|
||||
<div class="filters">
|
||||
<ha-date-range-picker
|
||||
.startDate=${this._time.range[0]}
|
||||
.endDate=${this._time.range[1]}
|
||||
@value-changed=${this._dateRangeChanged}
|
||||
time-picker
|
||||
></ha-date-range-picker>
|
||||
<div class="main">
|
||||
${
|
||||
this._showSources
|
||||
? html`<ha-filter-pane
|
||||
.narrow=${this.narrow}
|
||||
.label=${this.hass.localize("ui.panel.logbook.sources")}
|
||||
.path=${mdiTuneVariant}
|
||||
.count=${sourceCount}
|
||||
.resultCount=${entityIds?.length}
|
||||
@close-filter-pane=${this._closeSources}
|
||||
@clear-filter=${this._clearSources}
|
||||
>
|
||||
<ha-sources-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this._targetPickerValue}
|
||||
.filters=${this._filters}
|
||||
.entityFilter=${this._filterFunc}
|
||||
.narrow=${this.narrow}
|
||||
.description=${this.hass.localize(
|
||||
"ui.panel.logbook.no_targets"
|
||||
)}
|
||||
@value-changed=${this._targetsChanged}
|
||||
@source-filters-changed=${this._filtersChanged}
|
||||
></ha-sources-picker>
|
||||
</ha-filter-pane>`
|
||||
: nothing
|
||||
}
|
||||
<div class="content-column">
|
||||
<div class="toolbar">
|
||||
${
|
||||
this._showSources && !this.narrow
|
||||
? nothing
|
||||
: html`<ha-filter-pane-chip
|
||||
.label=${this.hass.localize("ui.panel.logbook.sources")}
|
||||
.path=${mdiTuneVariant}
|
||||
.count=${sourceCount}
|
||||
.active=${sourceCount > 0}
|
||||
@click=${this._toggleSources}
|
||||
></ha-filter-pane-chip>`
|
||||
}
|
||||
<ha-date-range-nav
|
||||
.startDate=${this._time.range[0]}
|
||||
.endDate=${this._time.range[1]}
|
||||
@value-changed=${this._dateRangeChanged}
|
||||
time-picker
|
||||
></ha-date-range-nav>
|
||||
</div>
|
||||
|
||||
<ha-target-picker
|
||||
.hass=${this.hass}
|
||||
.entityFilter=${this._filterFunc}
|
||||
.value=${this._targetPickerValue}
|
||||
add-on-top
|
||||
@value-changed=${this._targetsChanged}
|
||||
compact
|
||||
></ha-target-picker>
|
||||
<ha-logbook
|
||||
.hass=${this.hass}
|
||||
.time=${this._time}
|
||||
.entityIds=${entityIds}
|
||||
.narrow=${this.narrow}
|
||||
show-cause
|
||||
virtualize
|
||||
>
|
||||
${
|
||||
sourceCount > 0
|
||||
? html`<ha-empty-state
|
||||
slot="empty"
|
||||
.icon=${mdiTextBoxOutline}
|
||||
.heading=${this.hass.localize(
|
||||
"ui.panel.logbook.no_results_title"
|
||||
)}
|
||||
.description=${this.hass.localize(
|
||||
"ui.panel.logbook.no_results"
|
||||
)}
|
||||
>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this._openSources}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.logbook.change_sources"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-empty-state>`
|
||||
: nothing
|
||||
}
|
||||
</ha-logbook>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ha-logbook
|
||||
.hass=${this.hass}
|
||||
.time=${this._time}
|
||||
.entityIds=${this._getEntityIds()}
|
||||
.narrow=${this.narrow}
|
||||
show-cause
|
||||
virtualize
|
||||
></ha-logbook>
|
||||
</div>
|
||||
</ha-top-app-bar-fixed>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleSources() {
|
||||
this._showSources = !this._showSources;
|
||||
}
|
||||
|
||||
private _openSources() {
|
||||
this._showSources = true;
|
||||
}
|
||||
|
||||
private _closeSources() {
|
||||
this._showSources = false;
|
||||
}
|
||||
|
||||
private _filtersChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
|
||||
) {
|
||||
this._filters = ev.detail.value;
|
||||
this._storedFilters = this._filters;
|
||||
}
|
||||
|
||||
private _clearSources() {
|
||||
this._filters = {};
|
||||
this._storedFilters = this._filters;
|
||||
this._targetPickerValue = {};
|
||||
this._storedTargetPickerValue = this._targetPickerValue;
|
||||
this._updatePath();
|
||||
}
|
||||
|
||||
private _filterFunc: HaEntityPickerEntityFilterFunc = (entity) =>
|
||||
filterLogbookCompatibleEntities(entity);
|
||||
|
||||
@@ -155,6 +271,10 @@ export class HaPanelLogbook extends LitElement {
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.hass.loadBackendTranslation("title");
|
||||
// Needed to map entities to their integration for the integration filter.
|
||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||
this._entitySources = sources;
|
||||
});
|
||||
|
||||
const searchParams = extractSearchParamsObject();
|
||||
if (searchParams.back === "1" && history.length > 1) {
|
||||
@@ -179,17 +299,32 @@ export class HaPanelLogbook extends LitElement {
|
||||
this._applyURLParams();
|
||||
};
|
||||
|
||||
/**
|
||||
* The entities to show activity for, or undefined for all of them. Filters
|
||||
* without a target narrow down every entity the logbook can show.
|
||||
*/
|
||||
private _getEntityIds(): string[] | undefined {
|
||||
const entities = this.__getEntityIds(
|
||||
const targetEntities = this.__getEntityIds(
|
||||
this._targetPickerValue,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas
|
||||
);
|
||||
if (entities.length === 0) {
|
||||
return undefined;
|
||||
|
||||
if (!countSourceFilters(this._filters)) {
|
||||
return targetEntities.length ? targetEntities : undefined;
|
||||
}
|
||||
return entities;
|
||||
|
||||
return this.__filterEntityIds(
|
||||
targetEntities.length
|
||||
? targetEntities
|
||||
: this.__logbookEntityIds(this.hass.states),
|
||||
this._filters,
|
||||
// Only the device class filter reads the states, so they stay out of the
|
||||
// memoization key while it is not used.
|
||||
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
|
||||
this._entitySources
|
||||
);
|
||||
}
|
||||
|
||||
private __getEntityIds = memoizeOne(
|
||||
@@ -202,6 +337,15 @@ export class HaPanelLogbook extends LitElement {
|
||||
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
|
||||
);
|
||||
|
||||
private __logbookEntityIds = memoizeOne(
|
||||
(states: HomeAssistant["states"]): string[] =>
|
||||
Object.values(states)
|
||||
.filter((stateObj) => filterLogbookCompatibleEntities(stateObj))
|
||||
.map((stateObj) => stateObj.entity_id)
|
||||
);
|
||||
|
||||
private __filterEntityIds = memoizeOne(applySourceFilters);
|
||||
|
||||
private _applyURLParams() {
|
||||
const queryParams = decodeHistoryLogbookQueryParams(
|
||||
extractSearchParamsObject()
|
||||
@@ -213,6 +357,12 @@ export class HaPanelLogbook extends LitElement {
|
||||
this._targetPickerValue = this._storedTargetPickerValue;
|
||||
}
|
||||
|
||||
// A target in the URL describes the whole selection. Restoring the stored
|
||||
// filters on top of it could narrow it down to nothing.
|
||||
if (!this.hasUpdated && !targetPickerValue && this._storedFilters) {
|
||||
this._filters = this._storedFilters;
|
||||
}
|
||||
|
||||
if (queryParams.start_date || queryParams.end_date) {
|
||||
const startDate = queryParams.start_date ?? this._time.range[0];
|
||||
const endDate = queryParams.end_date ?? this._time.range[1];
|
||||
@@ -273,9 +423,12 @@ export class HaPanelLogbook extends LitElement {
|
||||
}
|
||||
|
||||
private _isDefaultState(): boolean {
|
||||
return deepEqual(
|
||||
{ time: this._time, targetPickerValue: this._targetPickerValue },
|
||||
this._defaultState
|
||||
return (
|
||||
!countSourceFilters(this._filters) &&
|
||||
deepEqual(
|
||||
{ time: this._time, targetPickerValue: this._targetPickerValue },
|
||||
this._defaultState
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,6 +437,8 @@ export class HaPanelLogbook extends LitElement {
|
||||
this._time = defaultState.time;
|
||||
this._targetPickerValue = defaultState.targetPickerValue;
|
||||
this._storedTargetPickerValue = undefined;
|
||||
this._filters = {};
|
||||
this._storedFilters = undefined;
|
||||
navigate("/logbook", { replace: true });
|
||||
}
|
||||
|
||||
@@ -300,6 +455,9 @@ export class HaPanelLogbook extends LitElement {
|
||||
case "refresh":
|
||||
this._refreshLogbook();
|
||||
break;
|
||||
case "reset":
|
||||
this._resetLogbook();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +521,8 @@ export class HaPanelLogbook extends LitElement {
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
/* The picker of the target picker is wider than the pane. */
|
||||
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
|
||||
--ha-generic-picker-max-width: 400px;
|
||||
}
|
||||
|
||||
@@ -375,59 +535,52 @@ export class HaPanelLogbook extends LitElement {
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
overflow-x: hidden;
|
||||
padding: 0 0 16px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Sits in the content column, so it shifts along with the pane. */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
direction: var(--direction);
|
||||
/* Keep the controls at their size and scroll them if they do not fit. */
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.toolbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
ha-logbook {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
ha-date-range-picker {
|
||||
margin-right: 16px;
|
||||
margin-inline-end: 16px;
|
||||
margin-inline-start: initial;
|
||||
max-width: 100%;
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
@media all and (max-width: 870px) {
|
||||
ha-date-range-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
:host([narrow]) ha-date-range-picker {
|
||||
margin-right: 0;
|
||||
margin-inline-end: 0;
|
||||
margin-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
:host([narrow]) .filters {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
ha-target-picker {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -11,6 +11,11 @@ import type { HomeAssistant } from "../../../../../types";
|
||||
// Devices below this fraction of the home total are grouped into an "Other" node
|
||||
export const MIN_SANKEY_THRESHOLD_FACTOR = 0.001; // 0.1% of home total
|
||||
|
||||
// Readable capacity of the default 400px card: ~18px per node including the
|
||||
// 6px nodeGap. Past this, bars collapse toward the 1px minimum and the labels
|
||||
// shrink with them.
|
||||
export const DEFAULT_MAX_SANKEY_DEVICES = 20;
|
||||
|
||||
// While the graph is assembled, device nodes carry an ad-hoc `parent` field
|
||||
// that is not part of the chart's Node interface. The chart ignores it.
|
||||
export type SankeyDeviceNode = Node & { parent?: string };
|
||||
@@ -36,6 +41,103 @@ export const fireSankeyNodeMoreInfo = (el: HTMLElement, node: Node): void => {
|
||||
}
|
||||
};
|
||||
|
||||
export interface FindDevicesOverCapOptions {
|
||||
devices: DeviceConsumptionEnergyPreference[];
|
||||
/** Max named children per parent. Falsy or non-positive disables the cap. */
|
||||
maxDevices: number | undefined;
|
||||
rootNodeId: string;
|
||||
renderedIds: ReadonlySet<string>;
|
||||
deviceValues: ReadonlyMap<string, number>;
|
||||
getId: (device: DeviceConsumptionEnergyPreference) => string | undefined;
|
||||
/** First ancestor rendered as its own node, else undefined. */
|
||||
getEffectiveParent: (
|
||||
device: DeviceConsumptionEnergyPreference
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node ids to drop from the rendered set because their parent has more rendered
|
||||
* children than `maxDevices`. Callers group the result into the parent's "Other"
|
||||
* node, so the smallest devices merge instead of disappearing.
|
||||
*/
|
||||
export const findDevicesOverCap = ({
|
||||
devices,
|
||||
maxDevices,
|
||||
rootNodeId,
|
||||
renderedIds,
|
||||
deviceValues,
|
||||
getId,
|
||||
getEffectiveParent,
|
||||
}: FindDevicesOverCapOptions): Set<string> => {
|
||||
const grouped = new Set<string>();
|
||||
if (!maxDevices || !Number.isFinite(maxDevices) || maxDevices <= 0) {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<
|
||||
string,
|
||||
{ id: string; value: number; idx: number }[]
|
||||
>();
|
||||
const seenIds = new Set<string>();
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
if (!id || !renderedIds.has(id) || seenIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
seenIds.add(id);
|
||||
const parentKey = getEffectiveParent(device) ?? rootNodeId;
|
||||
if (!childrenByParent.has(parentKey)) {
|
||||
childrenByParent.set(parentKey, []);
|
||||
}
|
||||
childrenByParent.get(parentKey)!.push({
|
||||
id,
|
||||
value: deviceValues.get(id)!,
|
||||
idx,
|
||||
});
|
||||
});
|
||||
|
||||
// Taking the whole subtree stops a grouped device's children from re-parenting
|
||||
// upward while its value, which already includes them, rolls into "Other".
|
||||
const groupSubtree = (id: string): void => {
|
||||
if (grouped.has(id)) {
|
||||
return;
|
||||
}
|
||||
grouped.add(id);
|
||||
childrenByParent.get(id)?.forEach((child) => groupSubtree(child.id));
|
||||
};
|
||||
|
||||
const queue = [rootNodeId];
|
||||
const visited = new Set(queue);
|
||||
while (queue.length) {
|
||||
const children = childrenByParent.get(queue.shift()!);
|
||||
if (!children) {
|
||||
continue;
|
||||
}
|
||||
let kept = children;
|
||||
if (children.length > maxDevices) {
|
||||
// Raw value includes children, so a parent never sorts below its own
|
||||
// child; declaration order breaks ties so the layout is stable.
|
||||
const ranked = [...children].sort(
|
||||
(a, b) => a.value - b.value || a.idx - b.idx
|
||||
);
|
||||
// At least two, because a lone grouped device is rendered by name instead
|
||||
// and would void the cap.
|
||||
ranked
|
||||
.slice(0, Math.max(children.length - maxDevices, 2))
|
||||
.forEach((child) => groupSubtree(child.id));
|
||||
kept = children.filter((child) => !grouped.has(child.id));
|
||||
}
|
||||
kept.forEach((child) => {
|
||||
if (!visited.has(child.id)) {
|
||||
visited.add(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return grouped;
|
||||
};
|
||||
|
||||
export interface BuildSankeyDeviceNodesOptions {
|
||||
devices: DeviceConsumptionEnergyPreference[];
|
||||
computedStyle: CSSStyleDeclaration;
|
||||
@@ -44,6 +146,12 @@ export interface BuildSankeyDeviceNodesOptions {
|
||||
rootNodeId: string;
|
||||
/** Flows below this value are grouped into an "Other" node. */
|
||||
minThreshold: number;
|
||||
/**
|
||||
* Max named child nodes per parent. Once a parent has more rendered children
|
||||
* than this, the smallest are grouped into that parent's "Other" node.
|
||||
* Undefined means no cap.
|
||||
*/
|
||||
maxDevices?: number;
|
||||
/** Per-parent untracked residual only shown when above this value. */
|
||||
untrackedFloor: number;
|
||||
/** Round the "Other" node value up (instantaneous cards only). */
|
||||
@@ -76,6 +184,7 @@ export const buildSankeyDeviceNodes = (
|
||||
localize,
|
||||
rootNodeId,
|
||||
minThreshold,
|
||||
maxDevices,
|
||||
untrackedFloor,
|
||||
ceilOtherValue,
|
||||
initialUntracked,
|
||||
@@ -146,6 +255,19 @@ export const buildSankeyDeviceNodes = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// The value threshold only catches devices that are a negligible share of the
|
||||
// home total, so a panel of dozens of similar-sized circuits never groups.
|
||||
findDevicesOverCap({
|
||||
devices,
|
||||
maxDevices,
|
||||
rootNodeId,
|
||||
renderedIds,
|
||||
deviceValues,
|
||||
getId,
|
||||
getEffectiveParent: (device) =>
|
||||
findEffectiveParent(device.included_in_stat),
|
||||
}).forEach((id) => renderedIds.delete(id));
|
||||
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
if (!id) {
|
||||
@@ -154,8 +276,9 @@ export const buildSankeyDeviceNodes = (
|
||||
const value = deviceValues.get(id)!;
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minThreshold) {
|
||||
// Collect small consumers instead of folding them into untracked
|
||||
if (!renderedIds.has(id)) {
|
||||
// Below the value threshold or demoted by the count cap. Collect it
|
||||
// instead of folding it into untracked.
|
||||
const parentKey = effectiveParent ?? rootNodeId;
|
||||
if (!smallConsumersByParent.has(parentKey)) {
|
||||
smallConsumersByParent.set(parentKey, []);
|
||||
|
||||
@@ -30,6 +30,7 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
@@ -298,6 +299,7 @@ class HuiEnergySankeyCard
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minEnergyThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
|
||||
@@ -271,6 +271,10 @@ export class HuiEnergyUsageGraphCard
|
||||
);
|
||||
|
||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const datasets: BarSeriesOption[] = [];
|
||||
|
||||
let yMin = Infinity;
|
||||
|
||||
@@ -23,6 +23,7 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "./common/sankey";
|
||||
@@ -297,6 +298,7 @@ class HuiPowerSankeyCard
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minPowerThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: homeNode.value,
|
||||
|
||||
@@ -175,6 +175,10 @@ export class HuiPowerSourcesGraphCard
|
||||
);
|
||||
|
||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = generatePowerSourcesGraphData({
|
||||
localize: this.hass.localize,
|
||||
states: this.hass.states,
|
||||
|
||||
@@ -36,7 +36,7 @@ import type { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction } from "../common/has-action";
|
||||
import { hasAction, hasAnyAction } from "../common/has-action";
|
||||
import { hasConfigOrEntityChanged } from "../common/has-changed";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
import type {
|
||||
@@ -300,6 +300,7 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
||||
return html`
|
||||
<ha-card
|
||||
class=${classMap({
|
||||
"no-action": !hasAnyAction(this._config!),
|
||||
[this._sizeController.value?.height]: Boolean(
|
||||
this._sizeController.value
|
||||
),
|
||||
@@ -730,6 +731,10 @@ class HuiWeatherForecastCard extends LitElement implements LovelaceCard {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
ha-card.no-action {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
|
||||
@@ -174,6 +174,7 @@ export interface EnergyCardSankeyConfig extends EnergyCardConfig {
|
||||
layout?: "auto" | "vertical" | "horizontal";
|
||||
group_by_floor?: boolean;
|
||||
group_by_area?: boolean;
|
||||
max_devices?: number;
|
||||
}
|
||||
|
||||
export interface EnergyDateSelectorCardConfig extends EnergyCardBaseConfig {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
@@ -260,6 +261,7 @@ class HuiWaterFlowSankeyCard
|
||||
localize: this.hass.localize,
|
||||
rootNodeId,
|
||||
minThreshold: minFlowThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 1,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: effectiveTotalInflow,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
fireSankeyNodeMoreInfo,
|
||||
MIN_SANKEY_THRESHOLD_FACTOR,
|
||||
} from "../energy/common/sankey";
|
||||
@@ -241,6 +242,7 @@ class HuiWaterSankeyCard
|
||||
localize: this.hass.localize,
|
||||
rootNodeId: "home",
|
||||
minThreshold: minWaterThreshold,
|
||||
maxDevices: this._config.max_devices ?? DEFAULT_MAX_SANKEY_DEVICES,
|
||||
untrackedFloor: 0,
|
||||
ceilOtherValue: false,
|
||||
initialUntracked: homeNode.value,
|
||||
|
||||
@@ -15,13 +15,22 @@ const _loadLovelaceResource = (
|
||||
hass.auth.data.hassUrl
|
||||
).toString();
|
||||
|
||||
const logLoadError = (err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Failed to load Lovelace resource ${normalizedUrl} (type: ${resource.type}). Check that the URL is correct and the file exists.`,
|
||||
err
|
||||
);
|
||||
};
|
||||
|
||||
switch (resource.type) {
|
||||
case "css": {
|
||||
if (normalizedUrl in CSS_CACHE) {
|
||||
return CSS_CACHE[normalizedUrl];
|
||||
}
|
||||
|
||||
const loadTask = loadCSS(normalizedUrl);
|
||||
// Catch before caching, so a cache hit cannot log the failure again
|
||||
const loadTask = loadCSS(normalizedUrl).catch(logLoadError);
|
||||
CSS_CACHE[normalizedUrl] = loadTask;
|
||||
return loadTask;
|
||||
}
|
||||
@@ -31,13 +40,13 @@ const _loadLovelaceResource = (
|
||||
return JS_CACHE[normalizedUrl];
|
||||
}
|
||||
|
||||
const loadTask = loadJS(normalizedUrl);
|
||||
const loadTask = loadJS(normalizedUrl).catch(logLoadError);
|
||||
JS_CACHE[normalizedUrl] = loadTask;
|
||||
return loadTask;
|
||||
}
|
||||
|
||||
case "module":
|
||||
return loadModule(normalizedUrl);
|
||||
return loadModule(normalizedUrl).catch(logLoadError);
|
||||
|
||||
default:
|
||||
// eslint-disable-next-line
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
assign,
|
||||
boolean,
|
||||
literal,
|
||||
number,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
@@ -36,6 +37,7 @@ const cardConfigStruct = assign(
|
||||
),
|
||||
group_by_floor: optional(boolean()),
|
||||
group_by_area: optional(boolean()),
|
||||
max_devices: optional(number()),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -92,6 +94,11 @@ export class HuiEnergySankeyCardEditor
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "max_devices",
|
||||
required: false,
|
||||
selector: { number: { min: 1, mode: "box" } },
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
name: "collection_key",
|
||||
@@ -134,6 +141,10 @@ export class HuiEnergySankeyCardEditor
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.generic.collection_key_description`
|
||||
);
|
||||
case "max_devices":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.energy-sankey.max_devices_description`
|
||||
);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -144,6 +155,7 @@ export class HuiEnergySankeyCardEditor
|
||||
case "layout":
|
||||
case "group_by_floor":
|
||||
case "group_by_area":
|
||||
case "max_devices":
|
||||
return this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.card.energy-sankey.${schema.name}`
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -790,11 +790,6 @@
|
||||
}
|
||||
},
|
||||
"target-picker": {
|
||||
"expand": "Expand",
|
||||
"expand_floor_id": "Split this floor into separate areas",
|
||||
"expand_area_id": "Split this area into separate devices and entities",
|
||||
"expand_device_id": "Split this device into separate entities",
|
||||
"expand_label_id": "Split this label into separate areas, devices and entities",
|
||||
"add_target": "Add target",
|
||||
"remove": "Remove",
|
||||
"remove_floor_id": "Remove floor",
|
||||
@@ -807,7 +802,6 @@
|
||||
"device_not_found": "Device not found",
|
||||
"entity_not_found": "Entity not found",
|
||||
"label_not_found": "Label not found",
|
||||
"replaced_device": "Replaced device",
|
||||
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
"replace_device": "Replace",
|
||||
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
|
||||
@@ -841,6 +835,9 @@
|
||||
},
|
||||
"style": "Time format style"
|
||||
},
|
||||
"filter-device-classes": {
|
||||
"caption": "Device class"
|
||||
},
|
||||
"subpage-data-table": {
|
||||
"filters": "Filters",
|
||||
"show_results": "Show {number} results",
|
||||
@@ -2054,6 +2051,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",
|
||||
@@ -7032,6 +7038,8 @@
|
||||
"confirm_delete_user_text": "The user account for ''{name}'' will be permanently deleted. You can still track the user, but the person will no longer be able to log in.",
|
||||
"allow_login": "Allow login",
|
||||
"allow_login_description": "Allow access using username and password.",
|
||||
"select_user": "Select user account",
|
||||
"create_new_user": "Create a new username",
|
||||
"username": "[%key:ui::panel::config::users::editor::username%]",
|
||||
"password": "[%key:ui::panel::config::users::editor::password%]",
|
||||
"admin": "[%key:ui::panel::config::users::editor::admin%]",
|
||||
@@ -9983,6 +9991,8 @@
|
||||
"description": "This card shows the distribution of energy in your home on a Sankey graph.",
|
||||
"group_by_floor": "Group by floor",
|
||||
"group_by_area": "Group by area",
|
||||
"max_devices": "Maximum devices per parent",
|
||||
"max_devices_description": "Devices beyond this limit are grouped into an \"Other\" node, smallest first. The limit applies per upstream device, not per floor or area. Defaults to 20.",
|
||||
"layout": "Graph direction",
|
||||
"layout_directions": {
|
||||
"auto": "Automatic",
|
||||
@@ -11707,9 +11717,14 @@
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"start_search": "Select areas, devices, entities or labels above",
|
||||
"sources": "Sources",
|
||||
"add_targets": "Add targets",
|
||||
"no_targets": "No targets selected",
|
||||
"start_search_title": "Nothing to show yet",
|
||||
"start_search": "Select areas, devices, entities or labels to see their history",
|
||||
"no_results_title": "No results found",
|
||||
"no_results": "No history for the current selection. Try changing the targets, filters, or time range.",
|
||||
"add_all": "Add all entities",
|
||||
"remove_all": "Remove all selections",
|
||||
"download_data": "Download data",
|
||||
"download_data_error": "Unable to download data",
|
||||
"add_card": "Add current view as card",
|
||||
@@ -11717,6 +11732,11 @@
|
||||
"error_no_data": "You need to select some data sources first."
|
||||
},
|
||||
"logbook": {
|
||||
"sources": "[%key:ui::panel::history::sources%]",
|
||||
"change_sources": "Change sources",
|
||||
"no_targets": "Showing all activity. Add targets to narrow it down.",
|
||||
"no_results_title": "[%key:ui::panel::history::no_results_title%]",
|
||||
"no_results": "No activity for the current selection. Try changing the targets, filters, or time range.",
|
||||
"download_data": "[%key:ui::panel::history::download_data%]",
|
||||
"download_data_error": "[%key:ui::panel::history::download_data_error%]",
|
||||
"error_no_data": "No activity for the selected target in the selected time range."
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+138
-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"],
|
||||
},
|
||||
{
|
||||
@@ -279,6 +279,85 @@ test.describe("Lovelace dashboard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Energy dashboard", () => {
|
||||
test("returns to Energy after repeatedly opening the device dialog", async ({
|
||||
page,
|
||||
}) => {
|
||||
const errors = trackPageErrors(page);
|
||||
await goToPanel(
|
||||
page,
|
||||
"/?historyBack=1&backPath=%2Flovelace#/energy/overview"
|
||||
);
|
||||
const energyRoot = page.locator("ha-panel-energy hui-root");
|
||||
await expect(energyRoot).toBeAttached({ timeout: PANEL_TIMEOUT });
|
||||
|
||||
const editDashboard = energyRoot.getByRole("button", {
|
||||
name: /^Edit dashboard\b/,
|
||||
});
|
||||
const dashboardMenu = energyRoot.getByRole("button", {
|
||||
name: "Open dashboard menu",
|
||||
});
|
||||
await expect(editDashboard.or(dashboardMenu)).toBeVisible({
|
||||
timeout: QUICK_TIMEOUT,
|
||||
});
|
||||
if (await editDashboard.isVisible()) {
|
||||
await editDashboard.click();
|
||||
} else {
|
||||
await dashboardMenu.click();
|
||||
await page.getByRole("menuitem", { name: /^Edit dashboard\b/ }).click();
|
||||
}
|
||||
|
||||
await expect(page.locator("ha-config-energy")).toBeAttached({
|
||||
timeout: PANEL_TIMEOUT,
|
||||
});
|
||||
|
||||
const backLink = page
|
||||
.locator("ha-config-energy hass-tabs-subpage")
|
||||
.getByRole("link", { name: "Back" });
|
||||
await expect(backLink).toHaveAttribute(
|
||||
"href",
|
||||
"/config/lovelace/dashboards"
|
||||
);
|
||||
|
||||
const addDevice = page
|
||||
.locator("ha-energy-device-settings")
|
||||
.locator("ha-button")
|
||||
.first();
|
||||
const openAndCancelDeviceDialog = async () => {
|
||||
await addDevice.click();
|
||||
const dialog = page.locator("dialog-energy-device-settings");
|
||||
const cancel = dialog.locator("ha-dialog-footer ha-button").first();
|
||||
await expect(cancel).toBeVisible({ timeout: QUICK_TIMEOUT });
|
||||
await cancel.click();
|
||||
await expect(cancel).toBeHidden({ timeout: QUICK_TIMEOUT });
|
||||
};
|
||||
await openAndCancelDeviceDialog();
|
||||
await openAndCancelDeviceDialog();
|
||||
await openAndCancelDeviceDialog();
|
||||
|
||||
await backLink.click();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => ({
|
||||
hash: window.location.hash,
|
||||
search: window.location.search,
|
||||
})),
|
||||
{ timeout: PANEL_TIMEOUT }
|
||||
)
|
||||
.toEqual({
|
||||
hash: "#/energy/overview",
|
||||
search: "?historyBack=1&backPath=%2Flovelace",
|
||||
});
|
||||
await expect(page.locator("ha-panel-energy")).toBeAttached();
|
||||
await expect(
|
||||
energyRoot.getByRole("link", { name: "Back" })
|
||||
).toHaveAttribute("href", "/lovelace");
|
||||
expectNoPageErrors(errors);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// More-info dialog (light)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -327,6 +406,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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// HomeAssistantAppEl registers this path; keep it inert in E2E tests.
|
||||
void 0;
|
||||
@@ -1 +1,2 @@
|
||||
import "../../../../src/resources/append-ha-style";
|
||||
import "./ha-test";
|
||||
|
||||
@@ -247,16 +247,12 @@ export class HaTest extends HomeAssistantAppEl {
|
||||
window.__mockHass = hass;
|
||||
|
||||
// SPA navigation
|
||||
document.body.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
const href = isNavigationClick(e);
|
||||
if (!href) return;
|
||||
e.preventDefault();
|
||||
window.addEventListener("click", (e) => {
|
||||
const href = isNavigationClick(e);
|
||||
if (href) {
|
||||
navigate(href);
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.hassConnected();
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
import {
|
||||
buildSankeyDeviceNodes,
|
||||
buildSankeyLayout,
|
||||
DEFAULT_MAX_SANKEY_DEVICES,
|
||||
findDevicesOverCap,
|
||||
getSankeyDeviceSections,
|
||||
groupSankeyDevicesByFloorAndArea,
|
||||
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
|
||||
@@ -81,6 +83,86 @@ describe("getSankeyDeviceSections", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDevicesOverCap", () => {
|
||||
// Values keyed by node id, hierarchy by included_in_stat.
|
||||
const overCapOpts = (
|
||||
values: Record<string, number>,
|
||||
parents: Record<string, string>,
|
||||
// No default: passing `undefined` must mean "no cap", not "fall back to 3".
|
||||
maxDevices: number | undefined,
|
||||
rendered = Object.keys(values)
|
||||
) => {
|
||||
const list: DeviceConsumptionEnergyPreference[] = Object.keys(values).map(
|
||||
(id) => ({ stat_consumption: id, included_in_stat: parents[id] })
|
||||
);
|
||||
const renderedIds = new Set(rendered);
|
||||
return {
|
||||
devices: list,
|
||||
maxDevices,
|
||||
rootNodeId: "home",
|
||||
renderedIds,
|
||||
deviceValues: new Map(Object.entries(values)),
|
||||
getId: (device: DeviceConsumptionEnergyPreference) =>
|
||||
device.stat_consumption,
|
||||
getEffectiveParent: (device: DeviceConsumptionEnergyPreference) => {
|
||||
let current = device.included_in_stat;
|
||||
for (let hops = 0; current && hops < list.length; hops++) {
|
||||
if (renderedIds.has(current)) {
|
||||
return current;
|
||||
}
|
||||
current = parents[current];
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it.each([undefined, 0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
"groups nothing for a cap of %s",
|
||||
(maxDevices) => {
|
||||
expect(
|
||||
findDevicesOverCap(
|
||||
overCapOpts({ a: 5, b: 4, c: 3, d: 2 }, {}, maxDevices)
|
||||
)
|
||||
).toEqual(new Set());
|
||||
}
|
||||
);
|
||||
|
||||
it("groups the smallest children of an over-cap parent", () => {
|
||||
expect(
|
||||
findDevicesOverCap(overCapOpts({ a: 5, b: 4, c: 3, d: 2, e: 1 }, {}, 3))
|
||||
).toEqual(new Set(["d", "e"]));
|
||||
});
|
||||
|
||||
it("groups a whole subtree, not just the parent", () => {
|
||||
expect(
|
||||
findDevicesOverCap(
|
||||
overCapOpts(
|
||||
{ a: 50, b: 40, c: 30, small: 10, child: 9 },
|
||||
{ child: "small" },
|
||||
3
|
||||
)
|
||||
)
|
||||
).toEqual(new Set(["small", "child", "c"]));
|
||||
});
|
||||
|
||||
it("counts only rendered devices", () => {
|
||||
// four devices, three rendered, so a cap of three does not fire
|
||||
expect(
|
||||
findDevicesOverCap(
|
||||
overCapOpts({ a: 5, b: 4, c: 3, tiny: 0.001 }, {}, 3, ["a", "b", "c"])
|
||||
)
|
||||
).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("terminates on a cyclic parent chain", () => {
|
||||
// both devices parent each other, so neither is reachable from the root
|
||||
expect(
|
||||
findDevicesOverCap(overCapOpts({ a: 5, b: 4 }, { a: "b", b: "a" }, 1))
|
||||
).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSankeyDeviceNodes", () => {
|
||||
it("renders top-level devices and subtracts them from untracked", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
@@ -423,6 +505,385 @@ describe("buildSankeyDeviceNodes", () => {
|
||||
expect(result.deviceNodes).toEqual([]);
|
||||
expect(result.untrackedConsumption).toBe(10);
|
||||
});
|
||||
|
||||
describe("device count cap", () => {
|
||||
// Five devices all comfortably above minThreshold, so only the count cap
|
||||
// can group any of them.
|
||||
const fiveDevices = () =>
|
||||
devices(
|
||||
{ stat_consumption: "a" },
|
||||
{ stat_consumption: "b" },
|
||||
{ stat_consumption: "c" },
|
||||
{ stat_consumption: "d" },
|
||||
{ stat_consumption: "e" }
|
||||
);
|
||||
const fiveValues = { a: 50, b: 40, c: 30, d: 20, e: 10 };
|
||||
|
||||
const namedIds = (result: { deviceNodes: SankeyDeviceNode[] }) =>
|
||||
result.deviceNodes
|
||||
.filter(
|
||||
(n) => !n.id.startsWith("other_") && !n.id.startsWith("untracked_")
|
||||
)
|
||||
.map((n) => n.id);
|
||||
|
||||
it("does not group anything when maxDevices is unset", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: fiveValues,
|
||||
initialUntracked: 150,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
|
||||
false
|
||||
);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("does nothing at exactly the cap", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a" },
|
||||
{ stat_consumption: "b" },
|
||||
{ stat_consumption: "c" }
|
||||
),
|
||||
values: { a: 50, b: 40, c: 30 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 120,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toEqual(["a", "b", "c"]);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("groups the smallest above the cap, keeping the cap many by name", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: fiveValues,
|
||||
maxDevices: 3,
|
||||
initialUntracked: 150,
|
||||
})
|
||||
);
|
||||
// the cap budgets named devices; Other is overhead on top
|
||||
expect(namedIds(result)).toEqual(["a", "b", "c"]);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBeCloseTo(30, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("never demotes exactly one device, which would render it by name", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a" },
|
||||
{ stat_consumption: "b" },
|
||||
{ stat_consumption: "c" },
|
||||
{ stat_consumption: "d" }
|
||||
),
|
||||
values: { a: 40, b: 30, c: 20, d: 10 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 100,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toEqual(["a", "b"]);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBeCloseTo(30, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("demotes a whole subtree so children never re-parent or double count", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "g" },
|
||||
{ stat_consumption: "a", included_in_stat: "g" },
|
||||
{ stat_consumption: "c", included_in_stat: "a" },
|
||||
{ stat_consumption: "b1", included_in_stat: "g" },
|
||||
{ stat_consumption: "b2", included_in_stat: "g" },
|
||||
{ stat_consumption: "b3", included_in_stat: "g" },
|
||||
{ stat_consumption: "b4", included_in_stat: "g" }
|
||||
),
|
||||
values: { g: 100, a: 10, c: 9, b1: 30, b2: 25, b3: 20, b4: 8 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 100,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual([
|
||||
"g",
|
||||
"b1",
|
||||
"b2",
|
||||
"b3",
|
||||
"other_g",
|
||||
"untracked_g",
|
||||
]);
|
||||
// a's value already contains c, so Other is 10 + 8 and not 27
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_g");
|
||||
expect(other?.value).toBeCloseTo(18, 10);
|
||||
expect(result.deviceNodes.some((n) => n.id === "c")).toBe(false);
|
||||
const untracked = result.deviceNodes.find((n) => n.id === "untracked_g");
|
||||
expect(untracked?.value).toBeCloseTo(7, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("ranks by raw value, so a parent is never demoted before its own child", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "d" },
|
||||
{ stat_consumption: "e", included_in_stat: "d" },
|
||||
{ stat_consumption: "f" },
|
||||
{ stat_consumption: "h" },
|
||||
{ stat_consumption: "i" }
|
||||
),
|
||||
values: { d: 50, e: 45, f: 10, h: 40, i: 30 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 130,
|
||||
})
|
||||
);
|
||||
// Ranking on value-excluding-children would demote d (50 - 45 = 5) while
|
||||
// keeping e, counting e both on its own and inside d's rolled-up value.
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual([
|
||||
"d",
|
||||
"e",
|
||||
"h",
|
||||
"other_home",
|
||||
"untracked_d",
|
||||
]);
|
||||
expect(result.parentLinks.e).toBe("d");
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBeCloseTo(40, 10);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("applies the cap per parent and links Other to that parent", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "p" },
|
||||
{ stat_consumption: "c1", included_in_stat: "p" },
|
||||
{ stat_consumption: "c2", included_in_stat: "p" },
|
||||
{ stat_consumption: "c3", included_in_stat: "p" },
|
||||
{ stat_consumption: "c4", included_in_stat: "p" },
|
||||
{ stat_consumption: "c5", included_in_stat: "p" },
|
||||
{ stat_consumption: "r1" },
|
||||
{ stat_consumption: "r2" }
|
||||
),
|
||||
values: {
|
||||
p: 100,
|
||||
c1: 30,
|
||||
c2: 25,
|
||||
c3: 20,
|
||||
c4: 15,
|
||||
c5: 10,
|
||||
r1: 50,
|
||||
r2: 40,
|
||||
},
|
||||
maxDevices: 3,
|
||||
initialUntracked: 190,
|
||||
})
|
||||
);
|
||||
// p is over the cap; the three root-level nodes are exactly at it
|
||||
expect(namedIds(result)).toEqual(["p", "c1", "c2", "c3", "r1", "r2"]);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_p");
|
||||
expect(other?.value).toBeCloseTo(25, 10);
|
||||
expect(result.parentLinks.other_p).toBe("p");
|
||||
expect(result.links).toContainEqual({ source: "p", target: "other_p" });
|
||||
expect(result.deviceNodes.some((n) => n.id === "other_home")).toBe(false);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("counts only rendered children, not ones already below the threshold", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "p" },
|
||||
{ stat_consumption: "c1", included_in_stat: "p" },
|
||||
{ stat_consumption: "c2", included_in_stat: "p" },
|
||||
{ stat_consumption: "c3", included_in_stat: "p" },
|
||||
{ stat_consumption: "s1", included_in_stat: "p" },
|
||||
{ stat_consumption: "s2", included_in_stat: "p" },
|
||||
{ stat_consumption: "s3", included_in_stat: "p" }
|
||||
),
|
||||
values: {
|
||||
p: 100,
|
||||
c1: 30,
|
||||
c2: 25,
|
||||
c3: 20,
|
||||
s1: 0.001,
|
||||
s2: 0.002,
|
||||
s3: 0.003,
|
||||
},
|
||||
maxDevices: 3,
|
||||
initialUntracked: 100,
|
||||
})
|
||||
);
|
||||
// three rendered children is at the cap, so no demotion happens
|
||||
expect(namedIds(result)).toEqual(["p", "c1", "c2", "c3"]);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_p");
|
||||
expect(other?.value).toBeCloseTo(0.006, 10);
|
||||
});
|
||||
|
||||
it("terminates on a cyclic included_in_stat and leaves it unchanged", () => {
|
||||
const cyclic = () =>
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a", included_in_stat: "b" },
|
||||
{ stat_consumption: "b", included_in_stat: "a" }
|
||||
),
|
||||
values: { a: 5, b: 4 },
|
||||
initialUntracked: 10,
|
||||
});
|
||||
// A cycle member always has a rendered parent inside the cycle, so it is
|
||||
// never reachable from the root and the cap cannot touch it.
|
||||
expect(
|
||||
buildSankeyDeviceNodes({ ...cyclic(), maxDevices: 1 })
|
||||
).toStrictEqual(buildSankeyDeviceNodes(cyclic()));
|
||||
});
|
||||
|
||||
it("treats a zero or non-finite cap as no cap", () => {
|
||||
[0, Number.NaN, Number.POSITIVE_INFINITY].forEach((maxDevices) => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: fiveValues,
|
||||
maxDevices,
|
||||
initialUntracked: 150,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toEqual(["a", "b", "c", "d", "e"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not count devices without a node id (instantaneous cards)", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "a", stat_rate: "ra" },
|
||||
{ stat_consumption: "b", stat_rate: "rb" },
|
||||
{ stat_consumption: "c", stat_rate: "rc" },
|
||||
{ stat_consumption: "d" }
|
||||
),
|
||||
values: { ra: 30, rb: 25, rc: 20 },
|
||||
getId: (device) => device.stat_rate,
|
||||
maxDevices: 3,
|
||||
initialUntracked: 75,
|
||||
})
|
||||
);
|
||||
// d has no stat_rate, so the three rendered nodes are at the cap
|
||||
expect(namedIds(result)).toEqual(["ra", "rb", "rc"]);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("other_"))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("ceils the capped Other value but still subtracts the raw total", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: { ...fiveValues, e: 10.5 },
|
||||
maxDevices: 3,
|
||||
ceilOtherValue: true,
|
||||
initialUntracked: 150.5,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBe(31);
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
});
|
||||
|
||||
it("breaks value ties on declaration order, stably across runs", () => {
|
||||
const opts = () =>
|
||||
cumulativeOpts({
|
||||
devices: fiveDevices(),
|
||||
values: { a: 50, b: 20, c: 20, d: 20, e: 10 },
|
||||
maxDevices: 3,
|
||||
initialUntracked: 120,
|
||||
});
|
||||
expect(namedIds(buildSankeyDeviceNodes(opts()))).toEqual(["a", "c", "d"]);
|
||||
expect(namedIds(buildSankeyDeviceNodes(opts()))).toEqual(["a", "c", "d"]);
|
||||
});
|
||||
|
||||
// The documented promise is "more than 20 devices start grouping", so pin
|
||||
// the boundary against the real default rather than a test-local cap.
|
||||
it.each([
|
||||
[DEFAULT_MAX_SANKEY_DEVICES, DEFAULT_MAX_SANKEY_DEVICES, false],
|
||||
// one over the cap still demotes two, because a lone demoted device would
|
||||
// be rendered by name and void the cap
|
||||
[DEFAULT_MAX_SANKEY_DEVICES + 1, DEFAULT_MAX_SANKEY_DEVICES - 1, true],
|
||||
[DEFAULT_MAX_SANKEY_DEVICES + 2, DEFAULT_MAX_SANKEY_DEVICES, true],
|
||||
[DEFAULT_MAX_SANKEY_DEVICES + 12, DEFAULT_MAX_SANKEY_DEVICES, true],
|
||||
])(
|
||||
"with %i devices at the default cap renders %i named nodes (grouped: %s)",
|
||||
(deviceCount, expectedNamed, expectOther) => {
|
||||
const list = Array.from({ length: deviceCount }, (_, i) => ({
|
||||
stat_consumption: `d${i}`,
|
||||
}));
|
||||
const values = Object.fromEntries(
|
||||
list.map((d, i) => [d.stat_consumption, 10 + i])
|
||||
);
|
||||
const total = Object.values(values).reduce((s, v) => s + v, 0);
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: list,
|
||||
values,
|
||||
maxDevices: DEFAULT_MAX_SANKEY_DEVICES,
|
||||
initialUntracked: total,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toHaveLength(expectedNamed);
|
||||
expect(result.deviceNodes.some((n) => n.id === "other_home")).toBe(
|
||||
expectOther
|
||||
);
|
||||
// grouping never changes the total that comes off the root
|
||||
expect(result.untrackedConsumption).toBeCloseTo(0, 10);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps 32 circuit clamps readable at the default cap", () => {
|
||||
const clamps = Array.from({ length: 32 }, (_, i) => ({
|
||||
stat_consumption: `clamp_${i}`,
|
||||
}));
|
||||
const values = Object.fromEntries(
|
||||
clamps.map((clamp, i) => [clamp.stat_consumption, 20 + i])
|
||||
);
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: clamps,
|
||||
values,
|
||||
maxDevices: DEFAULT_MAX_SANKEY_DEVICES,
|
||||
initialUntracked: 1200,
|
||||
})
|
||||
);
|
||||
expect(namedIds(result)).toHaveLength(DEFAULT_MAX_SANKEY_DEVICES);
|
||||
expect(result.deviceNodes).toHaveLength(DEFAULT_MAX_SANKEY_DEVICES + 1);
|
||||
// the 12 smallest clamps, values 20 through 31
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_home");
|
||||
expect(other?.value).toBeCloseTo(306, 10);
|
||||
|
||||
// flow conservation: every device node plus untracked accounts for home
|
||||
const { nodes } = buildSankeyLayout({
|
||||
hass: createMockHass(),
|
||||
computedStyle,
|
||||
localize: mockLocalize,
|
||||
deviceNodes: result.deviceNodes,
|
||||
parentLinks: result.parentLinks,
|
||||
rootNodeId: "home",
|
||||
groupByFloor: false,
|
||||
groupByArea: false,
|
||||
untrackedConsumption: result.untrackedConsumption,
|
||||
untrackedFloor: 0,
|
||||
});
|
||||
const deviceTotal = nodes
|
||||
.filter((n) => n.index === 4)
|
||||
.reduce((sum, n) => sum + n.value, 0);
|
||||
expect(deviceTotal).toBeCloseTo(1200, 10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSankeyDevicesByFloorAndArea", () => {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { loadJS, loadModule } from "../../../../src/common/dom/load_resource";
|
||||
import { loadLovelaceResourcesAndWait } from "../../../../src/panels/lovelace/common/load-resources";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
vi.mock("../../../../src/common/dom/load_resource", () => ({
|
||||
loadCSS: vi.fn(),
|
||||
loadJS: vi.fn(),
|
||||
loadModule: vi.fn(),
|
||||
}));
|
||||
|
||||
const hass = {
|
||||
auth: { data: { hassUrl: "http://localhost:8123" } },
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
describe("loadLovelaceResourcesAndWait", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("logs an error naming the resource that failed to load", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
const loadError = new Error("load failed");
|
||||
vi.mocked(loadModule).mockImplementation(() => Promise.reject(loadError));
|
||||
|
||||
await loadLovelaceResourcesAndWait(
|
||||
[{ id: "1", url: "/local/missing.js", type: "module" }],
|
||||
hass
|
||||
);
|
||||
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
expect(consoleError.mock.calls[0][0]).toContain(
|
||||
"http://localhost:8123/local/missing.js"
|
||||
);
|
||||
expect(consoleError.mock.calls[0][0]).toContain("module");
|
||||
// the rejection reason is forwarded, so any detail it carries is not lost
|
||||
expect(consoleError.mock.calls[0][1]).toBe(loadError);
|
||||
});
|
||||
|
||||
it("logs only once for a cached resource that failed to load", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
vi.mocked(loadJS).mockImplementation((url) => Promise.reject(url));
|
||||
const resources = [
|
||||
{ id: "2", url: "/local/broken.js", type: "js" as const },
|
||||
];
|
||||
|
||||
await loadLovelaceResourcesAndWait(resources, hass);
|
||||
await loadLovelaceResourcesAndWait(resources, hass);
|
||||
|
||||
expect(loadJS).toHaveBeenCalledTimes(1);
|
||||
expect(consoleError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -4648,22 +4648,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/client@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/client@npm:1.6.1"
|
||||
checksum: 10/aefc3a378e3ecdc9f03c21272dfb06d4eb68ca5395d83edb0a44fd816ea96b45e894de8c862e605fcb02611d306014ffef4ca09723945cf75c9a33b7062d5d4b
|
||||
"@rsdoctor/client@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/client@npm:1.6.2"
|
||||
checksum: 10/7b57cf3a9c6e3dc1b64b0f07969330579fd0e84963843cb3517b7c1160bc9b2fa08e709eaf835139d8a7e8c32977af8aae050d100df80daed44371ced8272642
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/core@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/core@npm:1.6.1"
|
||||
"@rsdoctor/core@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/core@npm:1.6.2"
|
||||
dependencies:
|
||||
"@rsbuild/plugin-check-syntax": "npm:^1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.6.1"
|
||||
"@rsdoctor/sdk": "npm:1.6.1"
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@rsdoctor/utils": "npm:1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.6.2"
|
||||
"@rsdoctor/sdk": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
"@rspack/resolver": "npm:^0.2.8"
|
||||
browserslist-load-config: "npm:^1.0.2"
|
||||
es-toolkit: "npm:^1.49.0"
|
||||
@@ -4671,60 +4671,60 @@ __metadata:
|
||||
fs-extra: "npm:^11.1.1"
|
||||
semver: "npm:^7.8.5"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/de5708e15e1efd67a23d753ed906e573ca6a6857428dbdfea0aff2f85f1ed3f631ff34e23bf40e955855ed878ed548c073c620a4edbd12959c20a3f9ff9c1cfd
|
||||
checksum: 10/803bb41042b2af07b096f2dde6383d516bb22b62705aeb9693959788e8a63df80e6d25514f1d92639a267e46e73732689abf5ba2c25679fbd80da9dd126f7ed9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/graph@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/graph@npm:1.6.1"
|
||||
"@rsdoctor/graph@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/graph@npm:1.6.2"
|
||||
dependencies:
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@rsdoctor/utils": "npm:1.6.1"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
es-toolkit: "npm:^1.49.0"
|
||||
path-browserify: "npm:1.0.1"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/065c485f11939143021c12a1acff47a2539c22e9a9d5f8501c906578213ca16b71a35d08bc0a83035529d42a7906fc098b6e3c536d895cd13ee74b5340a39e40
|
||||
checksum: 10/f28aaedc529a6cff782f1813faa6d19d10f76ab8f529bbe2899385b5659d247f0543149e2fcd700264a00e9568605a85fde04cbf634e821f24e9bf239eb1b009
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/rspack-plugin@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.6.1"
|
||||
"@rsdoctor/rspack-plugin@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.6.2"
|
||||
dependencies:
|
||||
"@rsdoctor/core": "npm:1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.6.1"
|
||||
"@rsdoctor/sdk": "npm:1.6.1"
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@rsdoctor/utils": "npm:1.6.1"
|
||||
"@rsdoctor/core": "npm:1.6.2"
|
||||
"@rsdoctor/graph": "npm:1.6.2"
|
||||
"@rsdoctor/sdk": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
peerDependencies:
|
||||
"@rspack/core": "*"
|
||||
peerDependenciesMeta:
|
||||
"@rspack/core":
|
||||
optional: true
|
||||
checksum: 10/8908f4a3c2b46fdaba2c6d39dd535623ddcc411fb50d358de949142a5628a512827f5206a93ae975bd93e5de6b72d5cd71f63463c65a37ba718727567187ce8e
|
||||
checksum: 10/dec9f0424d14ec7ed03cd6cbc5e0b11cd74c29be12c3f79466bf8da0f317a4bdba858a58daf64e4a7c16d20a1d58433b1d2c038b62ff771c1d045bca3d71c3ad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/sdk@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/sdk@npm:1.6.1"
|
||||
"@rsdoctor/sdk@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/sdk@npm:1.6.2"
|
||||
dependencies:
|
||||
"@rsdoctor/client": "npm:1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.6.1"
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@rsdoctor/utils": "npm:1.6.1"
|
||||
"@rsdoctor/client": "npm:1.6.2"
|
||||
"@rsdoctor/graph": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
launch-editor: "npm:^2.13.2"
|
||||
safer-buffer: "npm:2.1.2"
|
||||
socket.io: "npm:4.8.1"
|
||||
tapable: "npm:2.3.3"
|
||||
checksum: 10/d667d62d730bed7606bcf1ed2063fb7597024e31d9108c3ba9729313764866407c216499ccb3c7102f239d7d82463e510f2b2ec71270af6a40039cbe6f876d23
|
||||
checksum: 10/89d5850145b490bbf3f43486176ebdd1f9eb67cdbe96a07549bb0b400ab76d85f3b6d5667be7cf8304434281f0db74fbaaab3360228eb1650f0a8ce640101208
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/types@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/types@npm:1.6.1"
|
||||
"@rsdoctor/types@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/types@npm:1.6.2"
|
||||
dependencies:
|
||||
"@types/connect": "npm:3.4.38"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
@@ -4738,16 +4738,16 @@ __metadata:
|
||||
optional: true
|
||||
webpack:
|
||||
optional: true
|
||||
checksum: 10/1c4ae6c1aa6fa525d65ab78396954d86358880959ee3fdb68247c2f3215a35e4cb2aab3836cb82f6c208c2c3834cfe0dc8bf575ba59fd8e2ff89ffc92322fcad
|
||||
checksum: 10/39328739b31f7df46b663a9f6b5ac8c1e290c6b7ce0f68d9b45a423b89a1a296d32fbee0c26f555fc40d60b356a3605909f7bbe19c77d9c6bca3603d6928aef2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/utils@npm:1.6.1":
|
||||
version: 1.6.1
|
||||
resolution: "@rsdoctor/utils@npm:1.6.1"
|
||||
"@rsdoctor/utils@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/utils@npm:1.6.2"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.26.2"
|
||||
"@rsdoctor/types": "npm:1.6.1"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
acorn: "npm:^8.10.0"
|
||||
acorn-import-attributes: "npm:^1.9.5"
|
||||
@@ -4761,69 +4761,83 @@ __metadata:
|
||||
picocolors: "npm:^1.1.1"
|
||||
rslog: "npm:^2.1.2"
|
||||
strip-ansi: "npm:^7.2.0"
|
||||
checksum: 10/11f4e9136dd849fa05b0ab56ab0de3be3de43880d530dcbfb1835780dccad9976636f6a743852893948ae053e71fb7cd6144cac3ffac27929a87710e3981c048
|
||||
checksum: 10/fb6a147e452756018e93789e07dbe015354fc2690699755ef411748a393c7db86fdff2614cdf9ce07822434abcac3eef362aea8c63cbca9fe3f96c958fde56dc
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-darwin-arm64@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-darwin-x64@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-linux-arm64-gnu@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-linux-arm64-musl@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-linux-ppc64-gnu@npm:2.1.10"
|
||||
conditions: os=linux & cpu=ppc64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-riscv64-gnu@npm:2.1.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-linux-riscv64-gnu@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-linux-riscv64-musl@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-linux-s390x-gnu@npm:2.1.10"
|
||||
conditions: os=linux & cpu=s390x & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rspack/binding-linux-x64-gnu@npm:2.1.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-linux-x64-gnu@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-linux-x64-musl@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-wasm32-wasi@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-win32-arm64-msvc@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-win32-ia32-msvc@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding-win32-x64-msvc@npm:2.1.10"
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/binding@npm:2.1.10"
|
||||
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.10"
|
||||
"@rspack/binding-darwin-x64": "npm:2.1.10"
|
||||
"@rspack/binding-linux-arm64-gnu": "npm:2.1.10"
|
||||
"@rspack/binding-linux-arm64-musl": "npm:2.1.10"
|
||||
"@rspack/binding-linux-ppc64-gnu": "npm:2.1.10"
|
||||
"@rspack/binding-linux-riscv64-gnu": "npm:2.1.10"
|
||||
"@rspack/binding-linux-riscv64-musl": "npm:2.1.10"
|
||||
"@rspack/binding-linux-s390x-gnu": "npm:2.1.10"
|
||||
"@rspack/binding-linux-x64-gnu": "npm:2.1.10"
|
||||
"@rspack/binding-linux-x64-musl": "npm:2.1.10"
|
||||
"@rspack/binding-wasm32-wasi": "npm:2.1.10"
|
||||
"@rspack/binding-win32-arm64-msvc": "npm:2.1.10"
|
||||
"@rspack/binding-win32-ia32-msvc": "npm:2.1.10"
|
||||
"@rspack/binding-win32-x64-msvc": "npm:2.1.10"
|
||||
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/b15410b23aac7bf934d271a17994fc13dd10ddf65eeaa20a84dedb2b2baf753cd411c4b56e971728c3ee054be5bafd46238e7fc9721ae883a0a8a5c5e121bd66
|
||||
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.10":
|
||||
version: 2.1.10
|
||||
resolution: "@rspack/core@npm:2.1.10"
|
||||
dependencies:
|
||||
"@rspack/binding": "npm:2.1.8"
|
||||
"@rspack/binding": "npm:2.1.10"
|
||||
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/bf1e7183aa5e507668cc3233562a74d2d61ddbfb7279748ae9569094f43e041ced5893fe7191de3e2d44306cc017c6db0c0c4d823bc2fd9c6fbc53fbb18a8f22
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9678,10 +9698,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"globals@npm:17.9.0":
|
||||
version: 17.9.0
|
||||
resolution: "globals@npm:17.9.0"
|
||||
checksum: 10/8619386d449e8cbb81e242eced8bca0a42e5c3633a3a35996b8b1618beb17ff9fb33bfe703d60d83d4b7f6f8a996dede4faf02f9e966e50ff895e0cf6122ae15
|
||||
"globals@npm:17.11.0":
|
||||
version: 17.11.0
|
||||
resolution: "globals@npm:17.11.0"
|
||||
checksum: 10/9cff5b8099b585862ab0d31e8b93dc1c3e7b59239dc58b59447a38709715751df8794ee0d28f434f8e421b01aee3cc10051e2455b5c967d960e79f882e68aa0f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9868,10 +9888,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hls.js@npm:1.6.17":
|
||||
version: 1.6.17
|
||||
resolution: "hls.js@npm:1.6.17"
|
||||
checksum: 10/345d6c98f7f32f25f1924d34d4177f37aa9ae10c95fe6518c4ca10b295acbf174fbeba113696fbce673a1ee90461b530e101dd2b2f4d958af82577706d6e24c2
|
||||
"hls.js@npm:1.7.0":
|
||||
version: 1.7.0
|
||||
resolution: "hls.js@npm:1.7.0"
|
||||
checksum: 10/8bb97ba86b72a3be057bd0036b01df83adfe7dc10c76a5e177b24bae3f380e19d4180e061d8140b3b826e10c9f61a7b230e10019dcb81619ab2fc41e4d292ed9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9935,8 +9955,8 @@ __metadata:
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@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"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.2"
|
||||
"@rspack/core": "npm:2.1.10"
|
||||
"@rspack/dev-server": "npm:2.2.0"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
"@thomasloven/round-slider": "npm:0.6.0"
|
||||
@@ -9992,17 +10012,17 @@ __metadata:
|
||||
fuse.js: "npm:7.5.0"
|
||||
generate-license-file: "npm:4.2.1"
|
||||
glob: "npm:13.0.6"
|
||||
globals: "npm:17.9.0"
|
||||
globals: "npm:17.11.0"
|
||||
gulp: "npm:5.0.1"
|
||||
gulp-json-transform: "npm:0.5.0"
|
||||
gulp-rename: "npm:2.1.0"
|
||||
hls.js: "npm:1.6.17"
|
||||
hls.js: "npm:1.7.0"
|
||||
home-assistant-js-websocket: "npm:9.6.0"
|
||||
html-minifier-terser: "npm:7.2.0"
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.13"
|
||||
js-yaml: "npm:5.2.3"
|
||||
js-yaml: "npm:5.3.0"
|
||||
jsdom: "npm:30.0.1"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
@@ -10936,14 +10956,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:5.2.3":
|
||||
version: 5.2.3
|
||||
resolution: "js-yaml@npm:5.2.3"
|
||||
"js-yaml@npm:5.3.0":
|
||||
version: 5.3.0
|
||||
resolution: "js-yaml@npm:5.3.0"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.mjs
|
||||
checksum: 10/5d2562f2d7e7bc51bd678b43d2dbc3965129ac854222c794157369c8904d249cfc78325dff8fc64becb505a2506242548c54c8fa50cdb24554bb1a557e460004
|
||||
checksum: 10/6c7e8ea8dd5643d9df73f4aa796f3572537284e9b819b74ef919467452bfa4624f8f7477cfc775c91db7e2f32387133b1b6c4e95aef9702be6bb042ae554884a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user