Compare commits

...
Author SHA1 Message Date
Paul Bottein bb479a1ccb Remove the unused compact mode from the target picker 2026-08-17 19:50:21 +02:00
Paul Bottein 8ebe2111b0 Truncate timeline names that do not fit the plot 2026-08-17 19:50:06 +02:00
Paul Bottein 49a7c1aadd Remember the source filters across visits 2026-08-17 19:50:06 +02:00
Paul Bottein 53be309e34 Keep the target picker out of the redesign 2026-08-17 19:50:06 +02:00
Paul Bottein a3aa5d38dc Fix stuck loading state when the selection resolves to no entity 2026-08-17 19:50:06 +02:00
Paul Bottein ba9e696e96 Remove dead code breaking the type check 2026-08-17 19:50:06 +02:00
3fc41198c7 Redesign History and Activity filtering into a sources pane
Give both panels the toolbar + left pane layout of the data tables, and
merge target picking and filtering into one "Sources" surface, per the UX
discussion.

- ha-filter-pane: pane on wide screens, bottom sheet on narrow ones,
  mirroring the filter pane of hass-tabs-subpage-data-table
- ha-sources-picker: target picker plus domain, device class and
  integration filters, shared by both panels
- ha-filter-device-classes: new filter panel, labelled with the backend
  device class names
- ha-filter-pane-chip and ha-empty-state: the chip with a filter count
  badge and the centered placeholder both panels need
- ha-date-range-nav: the date range picker as one pill with previous and
  next steppers, for the toolbar
- History draws timeline names above their bar (inside-labels), which
  gives long names the full width
- Activity keeps a floating date header while scrolling, and reset moved
  into the overflow menu next to refresh and download
- Drop the now unused compact and add-on-top modes of ha-target-picker,
  including ha-target-picker-value-chip
- Right-align the clear button in the domains filter header, matching the
  other filter panels

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 19:50:05 +02:00
Paul BotteinandGitHub cd52f7b193 Fix the clear button alignment in the domains filter (#53687) 2026-08-17 17:47:35 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
86dea86b6c Update dependency js-yaml to v5.3.0 (#53679)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-17 17:16:53 +00:00
Paul BotteinandGitHub 04035016b0 Extract the data table filter chip into ha-filter-pane-chip (#53682) 2026-08-17 19:16:46 +02:00
Petar PetrovandGitHub 52b0b46991 Group sankey devices by count as well as by value (#53680)
* Group sankey devices by count as well as by value

The 0.1%-of-total value threshold only catches devices that are a
negligible share of the home total. A breaker panel with dozens of
similar-sized circuit clamps sits entirely above it, so nothing groups and
every node collapses toward the 1px minimum with labels shrinking to match.

Cap the number of named children per parent too, defaulting to 20 (the
readable capacity of the default 400px card). Over-cap devices are grouped
into the same per-parent "Other" node the value threshold already produces,
so the flow arithmetic is unchanged, and the cap is configurable per card
via max_devices.

* Clarify the max_devices label is per upstream device, not per floor or area

* Make max_devices budget named devices, not nodes

A cap of 20 rendered 19 devices because a slot was reserved for the Other
node, and a cap of 1 rendered none. The option now means what its name and
its namesake on the devices graph mean: at most this many devices are shown
by name, with Other as overhead on top.

* Extract the sankey device cap into findDevicesOverCap

Moves the count-cap logic out of buildSankeyDeviceNodes into an exported
function that takes the graph it needs, so it can be unit tested directly,
and trims the commentary to the two non-obvious rules (why the whole subtree
is grouped, and why at least two devices are).
2026-08-17 19:14:48 +02:00
d5389b5bef Fall back to option key for untranslated repair menu options (#53681)
Repair fix flows build their menu from the suggestions the backend
reports. A suggestion this frontend has no translation for yet (e.g. a
newer Supervisor offering a new repair suggestion) rendered as an
empty, unlabeled menu entry. Show the raw option key instead, matching
the fallback the form step header already uses.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-17 15:23:02 +02:00
f828c636a2 Fix Energy configuration back navigation (#53574)
* Fix Energy configuration back navigation

Co-authored-by: Copilot App <[email protected]>

* Align app E2E navigation with production

Co-authored-by: Copilot App <[email protected]>

---------

Co-authored-by: Copilot App <[email protected]>
2026-08-17 12:45:08 +03:00
Petar PetrovandGitHub e91337b320 Log console error when a dashboard resource fails to load (#53675)
* Log console error when a dashboard resource fails to load

* Include the rejection reason in the resource load error
2026-08-17 10:04:51 +02:00
karwostsandGitHub 8ab7c118a9 Fix custom weather icons (#53667) 2026-08-17 06:52:16 +02:00
karwostsandGitHub 0eafb1f4b4 Add themes to yaml reload menu (#53673) 2026-08-17 06:03:12 +02:00
9a7722a02e Remove weather card pointer cursor when it has no action (#53654)
* Remove weather card pointer cursor when it has no action

* use hasAnyAction helper

---------

Co-authored-by: karwosts <[email protected]>
2026-08-16 09:38:05 -07:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3e184eeb39 Update dependency @rspack/core to v2.1.10 (#53663)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 12:37:16 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
61be4e2952 Update dependency @rsdoctor/rspack-plugin to v1.6.2 (#53662)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 12:13:08 +02:00
Paul BotteinandGitHub 315cf97e56 Prevent the sidebar from scrolling the dashboard (#53644) 2026-08-16 06:27:47 +00:00
karwostsandGitHub 771a8aa4c9 Fix negative button of duration selector (#53646) 2026-08-16 06:27:17 +00:00
d9aa649854 Remove deprecated battery props from vacuum (#53005)
Co-authored-by: Simon Lamon <[email protected]>
2026-08-16 06:26:54 +00:00
7a087cd6d0 Ability to link a person to an existing user (#53550)
* Support linking a person to an existing user

* Don't delete preexisting user on cancel submit

* retry ci

* Don't show choice when no floating users exist

* collapse to single dialog

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-08-16 06:25:03 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
32a30468bb Update dependency globals to v17.10.0 (#53647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 06:24:56 +00:00
karwostsandGitHub 0ace3cedfb Add disabled to button group (#53651) 2026-08-16 06:24:33 +00:00
karwostsandGitHub aba7478e26 Fix choose selector minor issues (#53648) 2026-08-16 06:24:28 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c8b2e5160a Update dependency hls.js to v1.6.18 (#53649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-16 06:21:52 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4c46ca0918 Bump the codeql-action group across 1 directory with 2 updates (#53661)
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

Updates `github/codeql-action/analyze` from 4.37.4 to 4.37.6
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-16 06:20:49 +00:00
0483106f61 Restore bundle size budget headroom (#53659)
Co-authored-by: Copilot App <[email protected]>
2026-08-16 08:16:29 +02:00
Paul BotteinandGitHub 7e0c572f69 Replace automation state color with a badge (#53627)
* Replace state color with a badge in automation picker

* Fix data table spacing and text truncation on mobile
2026-08-14 15:31:14 +02:00
renovate[bot]andGitHub e4438a20cb Update dependency @rspack/core to v2.1.9 (#53642) 2026-08-14 14:13:06 +01:00
Aidan TimsonandGitHub ec418498a7 Make date picker navigation arrows themeable (#53639) 2026-08-14 16:05:46 +03:00
8122baca00 Add more deep links to more-info dialogs (#53628)
* Add deep links to more-info dialogs

* Fix

* Preserve existing URL hash in createMoreInfoUrl and removeMoreInfoUrl

Co-authored-by: timmo001 <[email protected]>

* Preserve explicit empty hash and move more-info restoration to app owner

Co-authored-by: timmo001 <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-14 16:02:58 +03:00
Paul BotteinandGitHub 79738dd257 Add activity detail dialog (#53362)
* Add activity detail dialog

* Apply review fixes to activity detail dialog

* Resolve previous state from history in logbook detail dialog

* Disable text selection on clickable logbook rows

* Document run discovery as a fallback for missing context ids

* Scope the detail chain to the subject's cause path

* Replace clickable logbook rows with explicit detail affordances

* Show the cause badge on automation and integration rows

* Map scheduled and Home Assistant causes to their trigger icons

* Replace logbook detail affordances with full-width rows

* Frame the activity detail dialog with ha-grouped-list

* Add missing import

* Fix milliseconds placement in activity chain times
2026-08-14 15:57:03 +03:00
83 changed files with 5391 additions and 1748 deletions
+2 -2
View File
@@ -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"
+8 -8
View File
@@ -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
}
}
+5
View File
@@ -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 => ({
+6
View File
@@ -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>
`
)}
+1 -11
View File
@@ -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
View File
@@ -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"
},
+19
View File
@@ -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,
+64
View File
@@ -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;
}
+17 -15
View File
@@ -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;
}
}
+114 -123
View File
@@ -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 {
+1
View File
@@ -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);
+3
View File
@@ -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"}
>
${
+3 -2
View File
@@ -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]);
+79
View File
@@ -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;
}
}
+256
View File
@@ -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;
}
}
+1 -1
View File
@@ -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 {
+73
View File
@@ -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;
}
}
+183
View File
@@ -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,
+1
View File
@@ -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;
+269
View File
@@ -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 };
}
}
+1 -297
View File
@@ -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;
}
}
+1
View File
@@ -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
View File
@@ -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,
-2
View File
@@ -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
View File
@@ -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>
`;
}
+5 -15
View File
@@ -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"];
/**
+9
View File
@@ -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 {
+62 -2
View File
@@ -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 = () => {
+15
View File
@@ -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);
-5
View File
@@ -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;
}
+13 -44
View File
@@ -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);
}
+40
View File
@@ -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>
+264 -98
View File
@@ -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);
}
`,
];
}
+406
View File
@@ -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;
}
}
+510
View File
@@ -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;
}
}
+154 -380
View File
@@ -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;
}
`,
];
}
+62 -11
View File
@@ -6,6 +6,7 @@ import { customElement, eventOptions, property, state } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type { LogbookEntry } from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
@@ -13,13 +14,14 @@ import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "./ha-logbook-entry";
import type { LogbookEntrySelectedDetail } from "./ha-logbook-entry";
import type { LogbookNameDetail } from "./logbook-entry-model";
import { sameDay } from "./logbook-entry-model";
import { 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);
}
+10 -4
View File
@@ -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() {
+241 -88
View File
@@ -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 };
};
+68 -21
View File
@@ -12,13 +12,14 @@ import {
localizeStateMessage,
parseTriggerSource,
} from "../../data/logbook";
import type { TraceContexts } from "../../data/trace";
import type { HomeAssistant } from "../../types";
export type LogbookEntryCategory = "entity" | "automation" | "integration";
export const TRIGGER_DOMAINS = ["automation", "script"];
const TRIGGER_DOMAINS = ["automation", "script"];
export const stripEntityId = (message: string, entityId?: string) =>
const stripEntityId = (message: string, entityId?: string) =>
entityId ? message.replace(entityId, " ") : message;
export const classifyLogbookEntry = (
@@ -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;
+1
View File
@@ -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,
+12 -3
View File
@@ -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}`
);
-17
View File
@@ -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,
});
});
}
}
+26 -2
View File
@@ -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,
})
);
}
}
};
+28 -8
View File
@@ -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."
+53
View File
@@ -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);
+69
View File
@@ -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
View File
@@ -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
// ---------------------------------------------------------------------------
+2
View File
@@ -0,0 +1,2 @@
// HomeAssistantAppEl registers this path; keep it inert in E2E tests.
void 0;
+1
View File
@@ -1 +1,2 @@
import "../../../../src/resources/append-ha-style";
import "./ha-test";
+5 -9
View File
@@ -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();
}
+50
View File
@@ -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");
});
});
+144 -1
View File
@@ -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);
});
});
+138 -118
View File
@@ -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