Compare commits

..
Author SHA1 Message Date
Paul BotteinandGitHub 99e40357a5 Remove the unused compact mode from the target picker 2026-08-18 18:35:57 +02:00
Paul BotteinandGitHub 83fcccf138 Fix sources resolution, fetching and filter badge in History and Activity 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub dbaa219db6 Count the same entities in the sources chip and the target picker 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub 57dff15962 Show the entity count in the sources chip 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub dea46ee63a Open the sources pane by default on wide screens 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub c8527aa8d9 Show device class icons in the sources filter 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub 5dced10578 Truncate timeline names that do not fit the plot 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub c125101d43 Remember the source filters across visits 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub 897c10d937 Keep the target picker out of the redesign 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub ca1a651aa9 Fix stuck loading state when the selection resolves to no entity 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub 272ce3ed08 Remove dead code breaking the type check 2026-08-18 18:35:56 +02:00
64c58cc152 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-18 18:35:56 +02:00
Yosi LevyandGitHub bb7ec34532 Addons RTL fix (#53692)
Addon RTL fix
2026-08-18 17:01:41 +02:00
89015bc135 Fix backup progress segment order to match backend execution order (#53652)
* Fix backup progress segment order to match backend execution order

The segmented backup progress bar showed Apps, Media, Home Assistant,
but Supervisor backs up Home Assistant first since 2024 (supervisor#5203),
then add-ons, then folders. The Home Assistant segment activated first
while Apps/Media incorrectly showed as completed, and the bar regressed
once the apps stage started.

Also move the await-addon-restart stages into the last creation group,
since Supervisor awaits add-on restarts after the backup file is
finished, keeping the progress bar monotonic.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Keep await restart stages out of MEDIA_STAGES

Give them their own constant and merge them into the last creation
group only where the ordering constraint applies.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Group backup setup stages with the first progress segment

addon_repositories/app_repositories (and docker_config on older
Supervisors) are emitted while the backup is initialized, before the
home_assistant stage, so grouping them with the apps segment made the
bar regress at the start of a backup.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-18 13:52:07 +03:00
Petar PetrovandGitHub 343f1e840a Fix Z-Wave network visualization showing wrong route edges (#53676)
Fix repeater mapping and route direction in Z-Wave network graph
2026-08-18 08:07:24 +03:00
G JohanssonandGitHub 1369f80eb0 Allow using description in create_entry for repair flows (#53671) 2026-08-17 22:08:57 +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
42 changed files with 2740 additions and 1598 deletions
@@ -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 -1
View File
@@ -107,7 +107,7 @@
"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",
@@ -1,4 +1,5 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { deepEqual } from "../util/deep-equal";
import {
createQueryString,
decodeQueryParams,
@@ -35,6 +36,15 @@ export const historyLogbookTargetFromQueryParams = (
): HassServiceTarget | undefined =>
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
export const historyLogbookTargetsEqual = (
a: HassServiceTarget,
b: HassServiceTarget
): boolean =>
deepEqual(
queryParamsFromServiceTarget(a, historyLogbookTargetParamKeys),
queryParamsFromServiceTarget(b, historyLogbookTargetParamKeys)
);
export const createHistoryLogbookUrl = (
path: string,
target: HassServiceTarget,
@@ -1,3 +1,4 @@
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";
@@ -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;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -38,6 +43,10 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -60,6 +69,13 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -67,7 +83,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) + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -177,13 +193,19 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
changedProps.has("_yWidth") ||
widthChanged
) {
this._createOptions();
}
@@ -193,14 +215,22 @@ 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
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides 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 +254,52 @@ 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,
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,
bottom: 30,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
},
tooltip: {
renderMode: "html",
@@ -398,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -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;
}
@@ -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;
}
}
+112 -128
View File
@@ -2,7 +2,6 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -11,10 +10,7 @@ import { tinykeys } from "tinykeys";
import { shiftDateRange } from "../../common/datetime/calc_date";
import type { DateRange } from "../../common/datetime/calc_date_range";
import { calcDateRange } from "../../common/datetime/calc_date_range";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
@@ -42,18 +38,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 +188,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 +227,60 @@ 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>`;
}
protected _formatRange(separator: string): string {
const format = (date: Date) =>
formatShortDateTimeWithConditionalYear(
date,
this._i18n.locale,
this._hassConfig
);
return format(this.startDate) + separator + format(this.endDate);
}
private _renderPicker() {
if (!this._opened) {
return nothing;
@@ -303,12 +336,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 +369,7 @@ export class HaDateRangePicker extends LitElement {
this._pickerWrapperOpen = false;
}
private _openPicker(ev?: Event) {
protected _openPicker(ev?: Event) {
if (this.disabled) {
return;
}
@@ -352,7 +385,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 +402,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 {
+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;
}
}
+287
View File
@@ -0,0 +1,287 @@
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, type HASSDomEvent } 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-domain-icon";
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;
domain: 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, 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)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${item.domain}
.deviceClass=${item.deviceClass}
.state=${item.domain === "binary_sensor" ? "on" : undefined}
></ha-domain-icon>
${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[] =>
this._deviceClassItems(this._deviceClassDomains(states), localize)
.filter(
(item) =>
!filter ||
item.deviceClass.toLowerCase().includes(filter) ||
item.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _deviceClassDomains = memoizeOne(
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
const domains = new Map<string, string[]>();
Object.values(states).forEach((stateObj) => {
const deviceClass = stateObj.attributes.device_class;
if (!deviceClass) {
return;
}
const domain = computeStateDomain(stateObj);
const known = domains.get(deviceClass);
if (!known) {
domains.set(deviceClass, [domain]);
} else if (!known.includes(domain)) {
known.push(domain);
}
});
return domains;
}
);
private _deviceClassItems = memoizeOne(
(
deviceClassDomains: Map<string, string[]>,
localize: LocalizeFunc
): DeviceClassItem[] =>
[...deviceClassDomains].map(([deviceClass, domains]) => {
for (const domain of domains) {
const name = localize(
`component.${domain}.entity_component.${deviceClass}.name`
);
if (name) {
return { deviceClass, domain, name };
}
}
return { deviceClass, domain: domains[0], name: deviceClass };
}),
([domainsA, localizeA], [domainsB, localizeB]) =>
localizeA === localizeB &&
domainsA.size === domainsB.size &&
[...domainsA].every(
([deviceClass, domains]) =>
domainsB.get(deviceClass)?.join() === domains.join()
)
);
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: HASSDomEvent<{ expanded: boolean }>) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
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: Event) {
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;
}
}
+182
View File
@@ -0,0 +1,182 @@
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, shows the clear button when above zero. */
@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" data-dialog="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}
.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;
}
}
+268
View File
@@ -0,0 +1,268 @@
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.
*/
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const domains = filters.domains?.length ? filters.domains : undefined;
const deviceClasses = filters.deviceClasses?.length
? filters.deviceClasses
: undefined;
const integrations = filters.integrations?.length
? 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 =
entities[entityId]?.platform ?? 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`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
*/
@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 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}
.primaryEntitiesOnly=${false}
.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}
.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}
.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}
.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;
}
}
+3 -10
View File
@@ -126,18 +126,11 @@ export const listDatadisks = async (
timeout: null,
});
// `disk` is "default" for the data disk, or a mount name. Omitting maxDepth
// leaves the depth to the Supervisor, which defaults per target — walking a
// mount costs a round trip per directory, so mounts want no depth at all.
export const fetchHostDisksUsage = async (
hass: HomeAssistant,
disk = "default",
maxDepth?: number
) =>
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: `/host/disks/${disk}/usage`,
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
...(maxDepth === undefined ? {} : { params: { max_depth: maxDepth } }),
params: { max_depth: 3 },
});
+22
View File
@@ -1,6 +1,7 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import { callWS } from "../util/websocket";
import type { DeviceRegistryEntry } from "./device/device_registry";
export enum InclusionState {
/** The controller isn't doing anything regarding inclusion. */
@@ -465,6 +466,27 @@ export interface RequestedGrant {
clientSideAuth: boolean;
}
/**
* Get the Z-Wave node ID of a device from its registry identifiers, which have
* the form `<home id>-<node id>[-<manufacturer>:<product type>:<product id>]`.
* Returns undefined for devices without a node, e.g. provisioning entries.
*/
export const getNodeIdFromDevice = (
device: DeviceRegistryEntry
): number | undefined => {
for (const [domain, identifier] of device.identifiers) {
// a provisioning entry is identified by its DSK, whose blocks parse as numbers
if (domain !== "zwave_js" || identifier.startsWith("provision_")) {
continue;
}
const nodeId = parseInt(identifier.split("-")[1]);
if (!isNaN(nodeId)) {
return nodeId;
}
}
return undefined;
};
export const invokeZWaveCCApi = <T = unknown>(
hass: HomeAssistant,
device_id: string,
+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);
}
@@ -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);
@@ -545,7 +545,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${this._currentAddon.description}.<br />
<div class="description-text">${this._currentAddon.description}</div>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.visit_app_page",
{
@@ -1658,6 +1658,15 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--primary-color);
}
.description:dir(rtl) > .description-text {
text-align: right;
direction: ltr;
}
.long-description {
direction: ltr;
}
img.logo {
max-width: 100%;
max-height: 40px;
@@ -124,7 +124,7 @@ class HaBackupConfigData extends LitElement {
private async _fetchStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
this._storageInfo = await fetchHostDisksUsage(this.hass);
} catch (_err: any) {
this._storageInfo = null;
}
@@ -33,23 +33,31 @@ interface ProgressSegment {
const HA_STAGES: CreateBackupStage[] = ["home_assistant"];
const ADDON_STAGES: CreateBackupStage[] = [
"addons",
"apps",
// Quick metadata writes emitted while the backup is initialized, before the
// Home Assistant stage (docker_config only by older Supervisors)
const SETUP_STAGES: CreateBackupStage[] = [
"addon_repositories",
"app_repositories",
"docker_config",
];
const ADDON_STAGES: CreateBackupStage[] = ["addons", "apps"];
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Emitted after the backup file is finished, when the backend waits for
// cold-backup add-ons to come back up
const AWAIT_RESTART_STAGES: CreateBackupStage[] = [
"await_addon_restarts",
"await_app_restarts",
];
const MEDIA_STAGES: CreateBackupStage[] = ["folders", "finishing_file"];
// Ordered groups matching actual backend execution order
// Ordered groups matching actual backend execution order. The await restart
// stages share the last creation group to keep the progress monotonic.
const STAGE_ORDER: CreateBackupStage[][] = [
[...SETUP_STAGES, ...HA_STAGES],
ADDON_STAGES,
MEDIA_STAGES,
HA_STAGES,
[...MEDIA_STAGES, ...AWAIT_RESTART_STAGES],
["upload_to_agents"],
["cleaning_up"],
];
@@ -165,21 +173,21 @@ export class HaBackupOverviewProgress extends LitElement {
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.apps"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(0, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.apps"
),
state: this._getSegmentState(1, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
@@ -201,18 +209,18 @@ export class HaBackupOverviewProgress extends LitElement {
];
}
// Non-HAOS: No app segment, just Media, HA, Upload and Cleaning up
// Non-HAOS: No app segment, just HA, Media, Upload and Cleaning up
return [
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.media"
"ui.panel.config.backup.overview.progress.segments.home_assistant"
),
state: this._getSegmentState(1, currentGroupIndex),
state: this._getSegmentState(0, currentGroupIndex),
flex: 2,
},
{
label: this.hass.localize(
"ui.panel.config.backup.overview.progress.segments.home_assistant"
"ui.panel.config.backup.overview.progress.segments.media"
),
state: this._getSegmentState(2, currentGroupIndex),
flex: 2,
@@ -2,6 +2,7 @@ import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -19,11 +20,13 @@ import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import type {
RssiError,
ZWaveJSNodeStatisticsUpdatedMessage,
ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js";
import {
fetchZwaveNetworkStatus,
getNodeIdFromDevice,
NodeStatus,
subscribeZwaveNodeStatistics,
} from "../../../../../data/zwave_js";
@@ -54,19 +57,36 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _searchFilter = "";
public hassSubscribe() {
const devices = Object.values(this.hass.devices).filter((device) =>
device.config_entries.some((entry) => entry === this.configEntryId)
);
// Route statistics reference repeaters by device registry ID
private _nodeIdsByDeviceId: Record<string, number> = {};
return devices.map((device) =>
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
const nodeId = message.nodeId ?? message.node_id;
this._devices[nodeId!] = device;
this._nodeStatistics[nodeId!] = message;
this._handleUpdatedNodeStatistics();
})
);
public hassSubscribe() {
const subscriptions: Promise<UnsubscribeFunc>[] = [];
const devices: Record<number, DeviceRegistryEntry> = {};
const nodeIdsByDeviceId: Record<string, number> = {};
Object.values(this.hass.devices).forEach((device) => {
if (!device.config_entries.includes(this.configEntryId)) {
return;
}
const nodeId = getNodeIdFromDevice(device);
if (nodeId === undefined) {
return;
}
devices[nodeId] = device;
nodeIdsByDeviceId[device.id] = nodeId;
subscriptions.push(
subscribeZwaveNodeStatistics(this.hass!, device.id, (message) => {
this._nodeStatistics[nodeId] = message;
this._handleUpdatedNodeStatistics();
})
);
});
this._nodeIdsByDeviceId = nodeIdsByDeviceId;
this._devices = devices;
return subscriptions;
}
public connectedCallback() {
@@ -164,8 +184,10 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
const route =
this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr;
// links point away from the controller, so the route belongs to the target
const stats =
this._nodeStatistics[target] ?? this._nodeStatistics[source];
const route = stats?.lwr || stats?.nlwr;
return html`${sourceName}
${targetName}${
route?.protocol_data_rate
@@ -333,55 +355,69 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
});
});
if (controllerNode === undefined) {
return { nodes, links, categories };
}
const controllerId = String(controllerNode);
Object.entries(nodeStatistics).forEach(([nodeId, stats]) => {
const route = stats.lwr || stats.nlwr;
if (route) {
const hops = [
...route.repeaters.map((id, i) => [
Object.keys(this._devices).find(
(_nodeId) => this._devices[_nodeId]?.id === id
)?.[0],
route.repeater_rssi[i],
]),
[controllerNode!, route.rssi],
];
let sourceNode: string = nodeId;
hops.forEach(([repeater, rssi]) => {
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) =>
link.source === sourceNode && link.target === String(repeater)
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target: String(repeater),
value: RSSI,
lineStyle: {
width,
color:
repeater === controllerNode
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = String(repeater);
});
if (!route) {
return;
}
// Routes go from the controller to the node via the repeaters, in order.
// Each station measures the hop leaving it: the controller reports
// `rssi`, repeater i reports `repeater_rssi[i]`.
const hops: [string, RssiError | number | null][] = [];
let hopRssi = route.rssi;
route.repeaters.forEach((deviceId, i) => {
const repeaterNodeId = this._nodeIdsByDeviceId[deviceId];
// skip repeaters we can't resolve, so the chain stays connected
if (repeaterNodeId !== undefined) {
hops.push([String(repeaterNodeId), hopRssi]);
}
hopRssi = route.repeater_rssi[i];
});
hops.push([nodeId, hopRssi]);
let sourceNode = controllerId;
hops.forEach(([target, rssi]) => {
if (target === sourceNode) {
return;
}
const RSSI = typeof rssi === "number" && rssi <= 0 ? rssi : -100;
const existingLink = links.find(
(link) => link.source === sourceNode && link.target === target
);
const width = this._getLineWidth(RSSI);
if (existingLink) {
existingLink.value = Math.max(existingLink.value!, RSSI);
existingLink.lineStyle = {
...existingLink.lineStyle,
width: Math.max(existingLink.lineStyle!.width!, width),
type:
route.protocol_data_rate > 1
? "solid"
: existingLink.lineStyle!.type,
};
} else {
links.push({
source: sourceNode,
target,
value: RSSI,
lineStyle: {
width,
color:
sourceNode === controllerId
? style.getPropertyValue("--primary-color")
: style.getPropertyValue("--disabled-color"),
type: route.protocol_data_rate > 1 ? "solid" : "dotted",
},
symbolSize: width * 3,
});
}
sourceNode = target;
});
});
return { nodes, links, categories };
@@ -203,9 +203,28 @@ export const showRepairsFlowDialog = (
return "";
},
renderCreateEntryDescription(hass, _step) {
renderCreateEntryDescription(hass, step) {
const description = hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.create_entry.${step.description || "default"}`,
step.description_placeholders
);
return html`
<p>${hass.localize("ui.dialogs.repair_flow.success.description")}</p>
${
description
? html`
<ha-markdown
allow-svg
breaks
.content=${description}
></ha-markdown>
`
: html`<p>
${hass.localize("ui.dialogs.repair_flow.success.description")}
</p>`
}
`;
},
@@ -291,11 +310,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
);
},
@@ -9,12 +9,10 @@ import {
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { navigate } from "../../../common/navigate";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
@@ -22,7 +20,6 @@ import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-segmented-bar";
import type { Segment } from "../../../components/ha-segmented-bar";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHostInfo, HostDisksUsage } from "../../../data/hassio/host";
@@ -44,7 +41,6 @@ import {
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { bytesToString } from "../../../util/bytes-to-string";
import "../core/ha-config-analytics";
import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk";
import { showMountViewDialog } from "./show-dialog-view-mount";
@@ -66,13 +62,6 @@ class HaConfigSectionStorage extends LitElement {
@state() private _mountsInfo?: SupervisorMounts | null;
// Keyed by mount name. A missing key means the request is still in flight;
// null means it failed, and that row simply shows no usage.
@state() private _mountUsage: Record<string, HostDisksUsage | null> = {};
// Guards against a slow response from a previous reload landing in a newer one.
private _mountUsageGeneration = 0;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass.config, "hassio")) {
@@ -184,7 +173,6 @@ class HaConfigSectionStorage extends LitElement {
graphic="avatar"
.mount=${mount}
twoline
multiline-secondary
hasMeta
@click=${this._changeMount}
>
@@ -205,16 +193,13 @@ class HaConfigSectionStorage extends LitElement {
${mount.name}
</span>
<span slot="secondary">
<span class="mount-address">
${mount.server}${
mount.port ? `:${mount.port}` : ""
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
</span>
${this._renderMountUsage(mount)}
${mount.server}${
mount.port ? `:${mount.port}` : nothing
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
</span>
${
mount.state !== SupervisorMountState.ACTIVE
@@ -304,39 +289,6 @@ class HaConfigSectionStorage extends LitElement {
`;
}
private _renderMountUsage(mount: SupervisorMount) {
if (mount.state !== SupervisorMountState.ACTIVE) {
return nothing;
}
if (!(mount.name in this._mountUsage)) {
return html`<div class="mount-usage">
<ha-spinner size="tiny"></ha-spinner>
</div>`;
}
const usage = this._mountUsage[mount.name];
// Without a total there is no ratio to show, so show nothing rather than a
// bar that means something else.
if (!usage?.total_bytes) {
return nothing;
}
const percent = (usage.used_bytes / usage.total_bytes) * 100;
return html`<div class="mount-usage">
<ha-bar
class=${classMap({
"target-warning": percent > 85,
"target-critical": percent > 95,
})}
.value=${percent}
></ha-bar>
<span>
${this.hass.localize("ui.panel.config.storage.detailed_description", {
used: bytesToString(usage.used_bytes),
total: bytesToString(usage.total_bytes),
})}
</span>
</div>`;
}
private async _load() {
this._loadStorageInfo();
try {
@@ -353,7 +305,7 @@ class HaConfigSectionStorage extends LitElement {
private async _loadStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
this._storageInfo = await fetchHostDisksUsage(this.hass);
} catch (err: any) {
this._error = err.message || err;
this._storageInfo = null;
@@ -409,34 +361,6 @@ class HaConfigSectionStorage extends LitElement {
this._error = err.message || err;
this._mountsInfo = null;
}
this._loadMountUsage();
}
// Deliberately not awaited: a mount on a slow or unreachable server can take
// ~30 s to answer, and the rows must paint before then. Only active mounts are
// asked, since the endpoint has nothing to report for the others.
private _loadMountUsage(): void {
const generation = ++this._mountUsageGeneration;
this._mountUsage = {};
this._mountsInfo?.mounts
.filter((mount) => mount.state === SupervisorMountState.ACTIVE)
.forEach((mount) => {
fetchHostDisksUsage(this.hass, mount.name).then(
(usage) => this._setMountUsage(generation, mount.name, usage),
() => this._setMountUsage(generation, mount.name, null)
);
});
}
private _setMountUsage(
generation: number,
name: string,
usage: HostDisksUsage | null
): void {
if (generation !== this._mountUsageGeneration) {
return;
}
this._mountUsage = { ...this._mountUsage, [name]: usage };
}
static styles = css`
@@ -485,41 +409,6 @@ class HaConfigSectionStorage extends LitElement {
color: var(--warning-color);
}
/* multiline-secondary lets the secondary slot wrap, so the address keeps its
own single ellipsized line and only the usage sits below it. */
.mount-address {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mount-usage {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-top: var(--ha-space-1);
}
.mount-usage ha-bar {
flex: 0 0 72px;
--ha-bar-primary-color: var(--metric-bar-ok-color, var(--success-color));
}
.mount-usage ha-bar.target-warning {
--ha-bar-primary-color: var(
--metric-bar-warning-color,
var(--warning-color)
);
}
.mount-usage ha-bar.target-critical {
--ha-bar-primary-color: var(
--metric-bar-critical-color,
var(--error-color)
);
}
.mounts-not-supported {
padding: 0 16px 16px;
}
+285 -108
View File
@@ -1,8 +1,9 @@
import {
mdiChartBoxOutline,
mdiDotsVertical,
mdiDownload,
mdiFilterRemove,
mdiImagePlus,
mdiTuneVariant,
} from "@mdi/js";
import { differenceInHours } from "date-fns";
import type {
@@ -10,17 +11,21 @@ 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 { ensureArray } from "../../common/array/ensure-array";
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";
import { shallowEqual } from "../../common/util/shallow-equal";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
historyLogbookTargetsEqual,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
@@ -29,14 +34,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 +68,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 +96,19 @@ class HaPanelHistory extends LitElement {
@state() private _isLoading = false;
@state() private _filters: SourceFilters = {};
@storage({
key: "historySourceFilters",
state: false,
subscribe: false,
})
private _storedFilters?: SourceFilters;
@state() private _showSources?: boolean;
@state() private _entitySources?: EntitySources;
@state() private _stateHistory?: HistoryResult;
private _mungedStateHistory?: HistoryResult;
@@ -92,6 +123,8 @@ class HaPanelHistory extends LitElement {
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _fetchedEntityIds?: string[];
private _interval?: number;
public constructor() {
@@ -119,7 +152,24 @@ class HaPanelHistory extends LitElement {
}
protected render() {
const entitiesSelected = this._getEntityIds().length > 0;
const entityIds = this._getEntityIds();
const targetCount = countTargets(this._targetPickerValue);
const filterCount = countSourceFilters(this._filters);
const sourceCount = targetCount + filterCount;
const hasTargets = targetCount > 0;
// A target whose entities are all filtered out fetches nothing.
const loading =
this._isLoading || (entityIds.length > 0 && !this._mungedStateHistory);
const hasResults =
!!this._mungedStateHistory &&
(this._mungedStateHistory.line.length > 0 ||
this._mungedStateHistory.timeline.length > 0);
const sourcesLabel = sourceCount
? this.hass.localize("ui.panel.history.sources_count", {
count: entityIds.length,
})
: this.hass.localize("ui.panel.history.sources");
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
@@ -128,13 +178,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 +196,112 @@ 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._sourcesShown()
? html`<ha-filter-pane
.narrow=${this.narrow}
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${sourceCount}
.resultCount=${entityIds.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}
.narrow=${this.narrow}
sync-charts
>
</state-history-charts>
`
}
.value=${this._targetPickerValue}
.filters=${this._filters}
.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._sourcesShown() && !this.narrow
? nothing
: html`<ha-filter-pane-chip
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${filterCount}
.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(
hasTargets
? "ui.panel.history.change_sources"
: "ui.panel.history.add_targets"
)}
</ha-button>
</ha-empty-state>
`;
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -226,12 +330,22 @@ 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 linked from another page must not be narrowed by stored filters.
if (
this._storedFilters &&
(!urlTarget ||
historyLogbookTargetsEqual(
urlTarget,
this._storedTargetPickerValue ?? {}
))
) {
this._filters = this._storedFilters;
}
if (queryParams.start_date) {
this._startDate = queryParams.start_date;
}
@@ -242,6 +356,9 @@ class HaPanelHistory extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
this._showBack = true;
@@ -255,18 +372,39 @@ class HaPanelHistory extends LitElement {
if (
changedProps.has("_startDate") ||
changedProps.has("_endDate") ||
changedProps.has("_targetPickerValue") ||
(!this._stateHistory &&
(changedProps.has("_deviceEntityLookup") ||
changedProps.has("_areaEntityLookup") ||
changedProps.has("_areaDeviceLookup")))
!shallowEqual(this._getEntityIds(), this._fetchedEntityIds)
) {
this._getHistory();
this._getStats();
}
}
private _removeAll() {
private _sourcesShown(): boolean {
return this._showSources ?? !this.narrow;
}
private _toggleSources() {
this._showSources = !this._sourcesShown();
}
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();
@@ -274,6 +412,7 @@ class HaPanelHistory extends LitElement {
private async _getStats() {
const statisticIds = this._getEntityIds();
this._fetchedEntityIds = statisticIds;
if (statisticIds.length === 0) {
this._statisticsHistory = undefined;
@@ -310,9 +449,13 @@ class HaPanelHistory extends LitElement {
private async _getHistory() {
const entityIds = this._getEntityIds();
this._fetchedEntityIds = entityIds;
if (entityIds.length === 0) {
// The running subscription would keep pushing the previous entities.
this._unsubscribeHistory();
this._stateHistory = undefined;
this._isLoading = false;
return;
}
@@ -342,6 +485,7 @@ class HaPanelHistory extends LitElement {
);
this._subscribed.catch(() => {
this._isLoading = false;
this._stateHistory = { line: [], timeline: [] };
this._unsubscribeHistory();
});
if (this._endDate > now) {
@@ -377,24 +521,44 @@ class HaPanelHistory extends LitElement {
}
private _getEntityIds(): string[] {
return this.__getEntityIds(
this._targetPickerValue,
return this.__filterEntityIds(
this.__resolveTargetEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
this.hass.entities,
this.hass.devices,
this.hass.areas
this._entitySources
);
}
private __getEntityIds = memoizeOne(
// Same rules as the target picker, so that the chip and the picker agree.
private __resolveTargetEntityIds = memoizeOne(
(
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
): string[] =>
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
): string[] => {
const picked = new Set(ensureArray(targetPickerValue.entity_id));
return resolveEntityIDs(
this.hass,
targetPickerValue,
entities,
devices,
areas
).filter(
(entityId) => picked.has(entityId) || !entities[entityId]?.hidden
);
}
);
private __filterEntityIds = memoizeOne(applySourceFilters);
private _dateRangeChanged(ev) {
this._startDate = ev.detail.value.startDate;
this._endDate = ev.detail.value.endDate;
@@ -573,7 +737,14 @@ class HaPanelHistory extends LitElement {
line-height: inherit;
}
:host {
--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 +752,55 @@ 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;
}
.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);
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 +810,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);
}
`,
];
}
+37 -1
View File
@@ -2,7 +2,7 @@ import type { VisibilityChangedEvent } from "@lit-labs/virtualizer";
import memoizeOne from "memoize-one";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
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";
@@ -60,6 +60,8 @@ class HaLogbookRenderer extends LitElement {
// @ts-ignore
@restoreScroll(".container") private _savedScrollPos?: number;
@state() private _firstVisibleIndex = 0;
protected willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && this.virtualize) ||
@@ -80,9 +82,11 @@ class HaLogbookRenderer extends LitElement {
return (
changedProps.has("entries") ||
changedProps.has("traceContexts") ||
changedProps.has("noDetail") ||
changedProps.has("userIdToName") ||
changedProps.has("systemUserIds") ||
changedProps.has("_firstVisibleIndex" as never) ||
languageChanged
);
}
@@ -96,12 +100,24 @@ class HaLogbookRenderer extends LitElement {
`;
}
// Rows positioned by the virtualizer cannot carry a sticky date header.
const floatingEntry = this.virtualize
? this.entries[this._firstVisibleIndex]
: undefined;
return html`
<div
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
@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
@@ -176,6 +192,11 @@ class HaLogbookRenderer extends LitElement {
});
}
private _dayOf(index: number): number | undefined {
const entry = this.entries[index];
return entry ? new Date(entry.when * 1000).setHours(0, 0, 0, 0) : undefined;
}
private _formatDateHeader(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -200,6 +221,10 @@ class HaLogbookRenderer extends LitElement {
@eventOptions({ passive: true })
private _visibilityChanged(e: VisibilityChangedEvent) {
const first = Math.max(0, e.first);
if (this._dayOf(first) !== this._dayOf(this._firstVisibleIndex)) {
this._firstVisibleIndex = first;
}
fireEvent(this, "hass-logbook-live", {
enable: e.first === 0,
});
@@ -226,12 +251,23 @@ class HaLogbookRenderer extends LitElement {
font-weight: var(--ha-font-weight-medium);
}
.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);
}
+21 -13
View File
@@ -29,15 +29,17 @@ const idsChanged = (oldIds?: string[], newIds?: string[]) => {
if (oldIds === undefined && newIds === undefined) {
return false;
}
return (
!oldIds ||
!newIds ||
oldIds.length !== newIds.length ||
oldIds.some((val) => !newIds.includes(val)) ||
newIds.some((val) => !oldIds.includes(val))
);
if (!oldIds || !newIds || oldIds.length !== newIds.length) {
return true;
}
const newIdSet = new Set(newIds);
return oldIds.some((val) => !newIdSet.has(val));
};
/**
* @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 +128,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,17 +247,21 @@ 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() {
super.connectedCallback();
this._attachReadyListener();
if (this.hasUpdated) {
// Ensure clean state before subscribing
this._subscribeLogbookPeriod(this._calculateLogbookPeriod());
if (this._filterAlwaysEmptyResults) {
this._unsubscribe(false);
} else {
// Ensure clean state before subscribing
this._subscribeLogbookPeriod(this._calculateLogbookPeriod());
}
}
}
+300 -94
View File
@@ -3,34 +3,51 @@ 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 { ensureArray } from "../../common/array/ensure-array";
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 {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
historyLogbookTargetsEqual,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/date-picker/ha-date-range-picker";
import { shallowEqual } from "../../common/util/shallow-equal";
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 +56,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 +76,12 @@ export class HaPanelLogbook extends LitElement {
@state()
private _showBack?: boolean;
@state() private _filters: SourceFilters = {};
@state() private _showSources?: boolean;
@state() private _entitySources?: EntitySources;
@state() private _targetPickerValue: HassServiceTarget = {};
// Remembers the last user-picked selection as a fallback for visits without
@@ -69,25 +94,34 @@ export class HaPanelLogbook extends LitElement {
})
private _storedTargetPickerValue?: HassServiceTarget;
@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 filterCount = countSourceFilters(this._filters);
const sourceCount = countTargets(this._targetPickerValue) + filterCount;
const sourcesLabel = sourceCount
? this.hass.localize("ui.panel.logbook.sources_count", {
count: entityIds?.length ?? 0,
})
: this.hass.localize("ui.panel.logbook.sources");
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 +139,131 @@ 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._sourcesShown()
? html`<ha-filter-pane
.narrow=${this.narrow}
.label=${sourcesLabel}
.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}
.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._sourcesShown() && !this.narrow
? nothing
: html`<ha-filter-pane-chip
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${filterCount}
.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 _sourcesShown(): boolean {
return this._showSources ?? !this.narrow;
}
private _toggleSources() {
this._showSources = !this._sourcesShown();
}
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 +280,9 @@ export class HaPanelLogbook extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
@@ -179,20 +307,38 @@ export class HaPanelLogbook extends LitElement {
this._applyURLParams();
};
/** The entities to show activity for, or undefined for all of them. */
private _getEntityIds(): string[] | undefined {
const entities = this.__getEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
);
if (entities.length === 0) {
return undefined;
const hasTargets = countTargets(this._targetPickerValue) > 0;
const targetEntities = hasTargets
? this.__filterTargetEntityIds(
this.__resolveTargetEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
),
this._targetPickerValue.entity_id,
this.hass.entities,
this.hass.states
)
: undefined;
if (!countSourceFilters(this._filters)) {
return targetEntities;
}
return entities;
return this.__filterEntityIds(
targetEntities ?? this.__logbookEntityIds(this.hass.states),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
this.hass.entities,
this._entitySources
);
}
private __getEntityIds = memoizeOne(
private __resolveTargetEntityIds = memoizeOne(
(
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
@@ -202,6 +348,53 @@ export class HaPanelLogbook extends LitElement {
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
);
// Same rules as the target picker, so that the chip and the picker agree.
private __filterTargetEntityIds = memoizeOne(
(
entityIds: string[],
pickedEntityIds: string | string[] | undefined,
entities: HomeAssistant["entities"],
states: HomeAssistant["states"]
): string[] => {
const picked = new Set(ensureArray(pickedEntityIds));
return this._stableEntityIds(
entityIds.filter((entityId) => {
if (picked.has(entityId)) {
return true;
}
const stateObj = states[entityId];
return (
!entities[entityId]?.hidden &&
stateObj &&
filterLogbookCompatibleEntities(stateObj)
);
})
);
}
);
private __logbookEntityIds = memoizeOne(
(states: HomeAssistant["states"]): string[] =>
this._stableEntityIds(
Object.values(states)
.filter((stateObj) => filterLogbookCompatibleEntities(stateObj))
.map((stateObj) => stateObj.entity_id)
)
);
private __filterEntityIds = memoizeOne(applySourceFilters);
private _lastEntityIds?: string[];
// A list keyed on the states must keep its identity or ha-logbook resubscribes.
private _stableEntityIds(entityIds: string[]): string[] {
if (this._lastEntityIds && shallowEqual(this._lastEntityIds, entityIds)) {
return this._lastEntityIds;
}
this._lastEntityIds = entityIds;
return entityIds;
}
private _applyURLParams() {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
@@ -213,6 +406,19 @@ export class HaPanelLogbook extends LitElement {
this._targetPickerValue = this._storedTargetPickerValue;
}
// A target linked from another page must not be narrowed by the filters.
if (
targetPickerValue &&
!historyLogbookTargetsEqual(
targetPickerValue,
this._storedTargetPickerValue ?? {}
)
) {
this._filters = {};
} else if (!this.hasUpdated && 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 +479,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 +493,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 +511,9 @@ export class HaPanelLogbook extends LitElement {
case "refresh":
this._refreshLogbook();
break;
case "reset":
this._resetLogbook();
break;
}
}
@@ -363,6 +577,7 @@ export class HaPanelLogbook extends LitElement {
haStyle,
css`
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
}
@@ -375,59 +590,50 @@ 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;
}
.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);
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;
}
`,
];
}
@@ -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,
@@ -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,
+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,
@@ -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}`
);
+20 -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",
@@ -9994,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",
@@ -11718,9 +11717,16 @@
}
},
"history": {
"start_search": "Select areas, devices, entities or labels above",
"sources": "Sources",
"sources_count": "Sources: {count}",
"add_targets": "Add targets",
"change_sources": "Change sources",
"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",
@@ -11728,6 +11734,12 @@
"error_no_data": "You need to select some data sources first."
},
"logbook": {
"sources": "[%key:ui::panel::history::sources%]",
"sources_count": "[%key:ui::panel::history::sources_count%]",
"change_sources": "[%key:ui::panel::history::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."
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { getNodeIdFromDevice } from "../../src/data/zwave_js";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
const mockDevice = (identifiers: [string, string][]) =>
({ identifiers }) as DeviceRegistryEntry;
describe("getNodeIdFromDevice", () => {
it("reads the node ID from the identifier", () => {
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-25"]]))
).toBe(25);
});
it("reads the node ID from the extended identifier", () => {
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-25-798:5:20"]]))
).toBe(25);
});
it("returns multi-digit node IDs in full", () => {
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787-108"]]))
).toBe(108);
});
it("ignores provisioning entries, whose DSK blocks parse as numbers", () => {
expect(
getNodeIdFromDevice(
mockDevice([
[
"zwave_js",
"provision_50285-00042-09924-30691-15973-33711-04005-03623",
],
])
)
).toBeUndefined();
});
it("ignores identifiers of other integrations", () => {
expect(
getNodeIdFromDevice(mockDevice([["mqtt", "3245146787-25"]]))
).toBeUndefined();
});
it("picks the Z-Wave identifier when a device has several", () => {
expect(
getNodeIdFromDevice(
mockDevice([
["matter", "some-other-id"],
["zwave_js", "3245146787-14"],
])
)
).toBe(14);
});
it("returns undefined when there is no node ID", () => {
expect(getNodeIdFromDevice(mockDevice([]))).toBeUndefined();
expect(
getNodeIdFromDevice(mockDevice([["zwave_js", "3245146787"]]))
).toBeUndefined();
});
});
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { fetchHostDisksUsage } from "../../src/data/hassio/host";
import type { HomeAssistant } from "../../src/types";
const mockHass = () => {
const callWS = vi.fn().mockResolvedValue({
id: "root",
label: "Total",
total_bytes: 2000398934016,
used_bytes: 1240247081779,
});
return { hass: { callWS } as unknown as HomeAssistant, callWS };
};
const sentMessage = (callWS: ReturnType<typeof vi.fn>) =>
callWS.mock.calls[0][0];
describe("fetchHostDisksUsage", () => {
it("targets the data disk and sends no depth by default", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass);
expect(callWS).toHaveBeenCalledWith({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600,
});
});
it("addresses a mount by name", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas");
expect(sentMessage(callWS).endpoint).toBe("/host/disks/media_nas/usage");
});
// Walking a mount costs a round trip per directory, so the row fetch must let
// the Supervisor apply its own per-target default rather than pinning one.
it("omits max_depth for a mount", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas");
expect(sentMessage(callWS)).not.toHaveProperty("params");
});
it("sends max_depth only when a caller asks for it", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "default", 3);
expect(sentMessage(callWS).params).toEqual({ max_depth: 3 });
});
it("sends max_depth 0 when explicitly asked, rather than dropping it", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas", 0);
expect(sentMessage(callWS).params).toEqual({ max_depth: 0 });
});
it("returns the usage tree", async () => {
const { hass } = mockHass();
await expect(fetchHostDisksUsage(hass, "media_nas")).resolves.toMatchObject(
{
total_bytes: 2000398934016,
used_bytes: 1240247081779,
}
);
});
});
@@ -1,241 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../../../src/types";
import type * as HostModule from "../../../../src/data/hassio/host";
import type * as MountsModule from "../../../../src/data/supervisor/mounts";
import {
fetchHassioHostInfo,
fetchHostDisksUsage,
} from "../../../../src/data/hassio/host";
import {
fetchSupervisorMounts,
SupervisorMountState,
SupervisorMountType,
SupervisorMountUsage,
} from "../../../../src/data/supervisor/mounts";
import "../../../../src/panels/config/storage/ha-config-section-storage";
// Heavy children are irrelevant here and drag in echarts / ResizeObserver.
vi.mock("../../../../src/panels/config/storage/storage-breakdown-chart", () => {
customElements.define(
"storage-breakdown-chart",
class extends HTMLElement {}
);
return {};
});
vi.mock("../../../../src/layouts/hass-subpage", () => {
customElements.define("hass-subpage", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/panels/config/core/ha-config-analytics", () => ({}));
vi.mock("../../../../src/components/ha-segmented-bar", () => {
customElements.define("ha-segmented-bar", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-list", () => {
customElements.define("ha-list", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-list-item", () => {
customElements.define("ha-list-item", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-card", () => {
customElements.define("ha-card", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-alert", () => {
customElements.define("ha-alert", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-icon-button", () => {
customElements.define("ha-icon-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-icon-next", () => {
customElements.define("ha-icon-next", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-svg-icon", () => {
customElements.define("ha-svg-icon", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-bar", () => {
customElements.define("ha-bar", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-spinner", () => {
customElements.define("ha-spinner", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/data/hassio/host", async (importOriginal) => ({
...(await importOriginal<typeof HostModule>()),
fetchHassioHostInfo: vi.fn(),
fetchHostDisksUsage: vi.fn(),
}));
vi.mock("../../../../src/data/supervisor/mounts", async (importOriginal) => ({
...(await importOriginal<typeof MountsModule>()),
fetchSupervisorMounts: vi.fn(),
reloadSupervisorMount: vi.fn(),
}));
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
const deferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const mount = (name: string, state: SupervisorMountState) => ({
name,
usage: SupervisorMountUsage.MEDIA,
type: SupervisorMountType.CIFS,
server: `${name}.local`,
port: 445,
share: "media",
state,
});
const hass = {
localize: (key: string, params?: Record<string, unknown>) =>
key === "ui.panel.config.storage.detailed_description"
? `${params!.used} of ${params!.total} used`
: key,
config: { components: ["hassio"] },
// _renderDiskLifeTime formats a percentage, which needs the locale.
locale: { language: "en" },
} as unknown as HomeAssistant;
const nextTask = () =>
new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const rows = (el: HTMLElement) =>
Array.from(el.shadowRoot!.querySelectorAll("ha-list-item"));
const rowFor = (el: HTMLElement, name: string) =>
rows(el).find((row) => row.textContent?.includes(name));
describe("ha-config-section-storage per-mount usage", () => {
let usageCalls: Record<string, Deferred<any>>;
beforeEach(() => {
usageCalls = {};
vi.mocked(fetchHassioHostInfo).mockResolvedValue({
features: ["mount"],
disk_life_time: 5,
} as any);
vi.mocked(fetchSupervisorMounts).mockResolvedValue({
default_backup_mount: null,
mounts: [
mount("alpha", SupervisorMountState.ACTIVE),
mount("beta", SupervisorMountState.ACTIVE),
mount("broken", SupervisorMountState.FAILED),
],
} as any);
vi.mocked(fetchHostDisksUsage).mockImplementation(
(_hass, disk = "default") => {
// The panel's own disk-metrics call must not interfere with the row calls.
if (disk === "default") {
return new Promise(() => {
// never settles
});
}
usageCalls[disk] = deferred<any>();
return usageCalls[disk].promise;
}
);
});
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
const render = async () => {
const el = document.createElement("ha-config-section-storage");
(el as any).hass = hass;
document.body.append(el);
await nextTask();
await (el as any).updateComplete;
return el as HTMLElement;
};
it("asks only active mounts for usage", async () => {
await render();
const asked = vi
.mocked(fetchHostDisksUsage)
.mock.calls.map((call) => call[1])
.filter((disk) => disk !== "default");
expect(asked).toEqual(["alpha", "beta"]);
expect(asked).not.toContain("broken");
});
it("renders the rows before any usage arrives", async () => {
const el = await render();
expect(rows(el)).toHaveLength(3);
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).not.toBeNull();
});
it("fills in each row as its own request settles", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
await nextTask();
await (el as any).updateComplete;
expect(rowFor(el, "alpha")!.textContent).toContain(
"500 Bytes of 1000 Bytes used"
);
// beta has not answered yet, so it must still be pending, not blanked.
expect(rowFor(el, "beta")!.querySelector("ha-spinner")).not.toBeNull();
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).toBeNull();
});
it("leaves a row without usage when its request fails, and keeps the others", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
usageCalls.beta.reject(new Error("dead server"));
await nextTask();
await (el as any).updateComplete;
const beta = rowFor(el, "beta")!;
expect(beta.querySelector("ha-spinner")).toBeNull();
expect(beta.querySelector("ha-bar")).toBeNull();
expect(beta.textContent).not.toContain("used");
expect(rowFor(el, "alpha")!.querySelector("ha-bar")).not.toBeNull();
});
it("never shows usage for a mount that is not active", async () => {
const el = await render();
const broken = rowFor(el, "broken")!;
expect(broken.querySelector("ha-spinner")).toBeNull();
expect(broken.querySelector("ha-bar")).toBeNull();
});
});
@@ -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", () => {
+5 -5
View File
@@ -10022,7 +10022,7 @@ __metadata:
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"
@@ -10956,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