Compare commits

..
Author SHA1 Message Date
Petar Petrov 6c4d6b50b0 Show per-mount storage usage on the storage page
Each active mount now fetches its own usage from the Supervisor and renders
a bar with a used-of-total line under its address. The requests are not
awaited, so a slow or unreachable server cannot hold up the rows, and a
failed one simply leaves that row without usage.

fetchHostDisksUsage now takes a disk and an optional max_depth. The data
disk callers keep their depth of 3; mounts send no depth at all, since
walking one costs a round trip per directory and the Supervisor already
has a sensible per-target default.
2026-08-17 15:09:01 +03:00
36 changed files with 1491 additions and 2386 deletions
@@ -1,3 +1,4 @@
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";
@@ -8,6 +9,7 @@ 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";
@@ -153,6 +155,9 @@ 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[] = [
@@ -163,6 +168,14 @@ 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:
@@ -261,13 +274,23 @@ class DemoHaSelectorReplacedDevice
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>
${
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-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.3.0",
"js-yaml": "5.2.3",
"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,7 +1,6 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
@@ -23,10 +22,6 @@ import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
// Rows with their name drawn above the bar need room for both.
const ROW_HEIGHT_INSIDE_LABELS = 64;
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -43,14 +38,6 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/**
* Draw each row's name inside the plot, above its bar, instead of in a label
* column next to it. Gives long names the full width, at the cost of taller
* rows.
*/
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -73,20 +60,6 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
// Inside labels are truncated to the plot, so their width follows the chart.
@state() private _width = 0;
// The host is an inline element, which a resize observer skips, so the chart
// itself is the one being observed.
@query("ha-chart-base") private _chartBase?: HTMLElement;
private _resizeController = new ResizeController<void>(this, {
target: null,
callback: (entries) => {
this._width = entries[0]?.contentRect.width ?? 0;
},
});
private _chartTime: Date = new Date();
protected render() {
@@ -94,7 +67,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + 30}px`}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -190,12 +163,6 @@ export class StateHistoryChartTimeline extends LitElement {
)}<br />${formattedDuration}`;
};
protected firstUpdated() {
if (this._chartBase) {
this._resizeController.observe(this._chartBase);
}
}
public willUpdate(changedProps: PropertyValues) {
if (
this.isConnected &&
@@ -215,10 +182,8 @@ export class StateHistoryChartTimeline extends LitElement {
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth") ||
changedProps.has("_width")
changedProps.has("_yWidth")
) {
this._createOptions();
}
@@ -228,24 +193,14 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
// Inside labels take no width of their own, but the plot still lines up
// with the line charts that share the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// Before the first resize observation the width is unknown, and a zero
// width would hide the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -269,54 +224,37 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: insideLabels
? {
show: showNames,
inside: true,
margin: 0,
// Sits on the row's baseline, lifted above the bar.
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
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,
});
}
: {
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,
},
return label;
},
hideOverlap: true,
},
},
grid: {
// Room for the first row's name above its bar.
top: insideLabels ? 20 : 10,
top: 10,
bottom: 30,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
},
tooltip: {
renderMode: "html",
@@ -79,10 +79,6 @@ 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;
@@ -231,7 +227,6 @@ 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}
@@ -429,12 +424,6 @@ 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;
}
@@ -1,79 +0,0 @@
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;
}
}
+123 -114
View File
@@ -42,67 +42,18 @@ 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 })
protected _i18n!: ContextType<typeof internationalizationContext>;
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
protected _hassConfig!: HassConfig;
private _hassConfig!: HassConfig;
@property({ attribute: false }) public startDate!: Date;
@@ -192,7 +143,73 @@ export class HaDateRangePicker extends LitElement {
protected render(): TemplateResult {
return html`
<div class="container">
<div class="date-range-inputs">${this._renderField()}</div>
<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>
${
this._pickerWrapperOpen || this._opened
? this._openedNarrow
@@ -231,63 +248,6 @@ export class HaDateRangePicker extends LitElement {
`;
}
/**
* The control that opens the picker. It has to carry `id="field"`, which the
* popover anchors to.
*/
protected _renderField() {
if (this.minimal) {
return html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`;
}
return html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
.label=${
this._i18n.localize("ui.components.date-range-picker.start_date") +
" - " +
this._i18n.localize("ui.components.date-range-picker.end_date")
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`;
}
/** The selected range as text, with the year only when it is not this year. */
protected _formatRange(separator: string): string {
const format = (date: Date) =>
isThisYear(date)
? formatShortDateTime(date, this._i18n.locale, this._hassConfig)
: formatShortDateTimeWithYear(
date,
this._i18n.locale,
this._hassConfig
);
return format(this.startDate) + separator + format(this.endDate);
}
private _renderPicker() {
if (!this._opened) {
return nothing;
@@ -343,12 +303,12 @@ export class HaDateRangePicker extends LitElement {
this._opened = false;
};
protected _handleNext(ev: MouseEvent): void {
private _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
protected _handlePrev(ev: MouseEvent): void {
private _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
@@ -376,7 +336,7 @@ export class HaDateRangePicker extends LitElement {
this._pickerWrapperOpen = false;
}
protected _openPicker(ev?: Event) {
private _openPicker(ev?: Event) {
if (this.disabled) {
return;
}
@@ -392,7 +352,7 @@ export class HaDateRangePicker extends LitElement {
});
}
protected _handleKeydown(ev: KeyboardEvent) {
private _handleKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._openPicker(ev);
@@ -409,7 +369,56 @@ export class HaDateRangePicker extends LitElement {
}
}
static styles = [haDateRangePickerStyles];
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;
}
`,
];
}
declare global {
-79
View File
@@ -1,79 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-svg-icon";
/**
* Centered placeholder for a surface that has nothing to show, with an icon, a
* heading, an optional description and optional actions.
*
* @slot - Actions that help the user fill the surface, e.g. a button.
*/
@customElement("ha-empty-state")
export class HaEmptyState extends LitElement {
/** SVG path of the icon shown above the heading. */
@property() public icon?: string;
@property() public heading?: string;
@property() public description?: string;
protected render() {
return html`
<div class="content">
${
this.icon
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
: nothing
}
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
${this.description ? html`<p>${this.description}</p>` : nothing}
<slot></slot>
</div>
`;
}
static styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 100%;
width: 100%;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
max-width: 500px;
padding: var(--ha-space-8) var(--ha-space-4);
text-align: center;
}
ha-svg-icon {
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
color: var(--secondary-text-color);
}
h2 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
p {
margin: 0;
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-empty-state": HaEmptyState;
}
}
-256
View File
@@ -1,256 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
interface DeviceClassItem {
deviceClass: string;
name: string;
}
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-device-classes.caption")}
${
this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
),
(item) => item.deviceClass,
(item) =>
html`<ha-check-list-item
.value=${item.deviceClass}
.selected=${(this.value || []).includes(item.deviceClass)}
>
${item.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined
): DeviceClassItem[] => {
// The same device class can be used by multiple domains; label it with
// the first domain that has a translation for it.
const names = new Map<string, string | undefined>();
Object.values(states).forEach((stateObj) => {
const deviceClass = stateObj.attributes.device_class;
if (!deviceClass || names.get(deviceClass)) {
return;
}
names.set(
deviceClass,
localize(
`component.${computeStateDomain(stateObj)}.entity_component.${deviceClass}.name`
)
);
});
return Array.from(names.entries())
.map(([deviceClass, name]) => ({
deviceClass,
name: name || deviceClass,
}))
.filter(
(item) =>
!filter ||
item.deviceClass.toLowerCase().includes(filter) ||
item.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language));
}
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const deviceClasses = this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
);
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
const preserved = (this.value || []).filter((d) => !visible.has(d));
const selected = [...ev.detail.index]
.map((i) => deviceClasses[i]?.deviceClass)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+1 -1
View File
@@ -216,7 +216,7 @@ export class HaFilterDomains extends LitElement {
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-start: initial;
margin-inline-end: 8px;
}
ha-check-list-item {
-73
View File
@@ -1,73 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./chips/ha-assist-chip";
import "./ha-svg-icon";
/**
* Chip that opens a filter pane, with a badge showing how many filters are
* active.
*/
@customElement("ha-filter-pane-chip")
export class HaFilterPaneChip extends LitElement {
@property() public label = "";
/** SVG path of the leading icon. */
@property() public path?: string;
/** Number of active filters, shown as a badge when there is at least one. */
@property({ type: Number }) public count = 0;
@property({ type: Boolean }) public active = false;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`
<ha-assist-chip
.label=${this.label}
.active=${this.active}
.disabled=${this.disabled}
>
${
this.path
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
: nothing
}
</ha-assist-chip>
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
`;
}
static styles = css`
:host {
position: relative;
display: inline-block;
--ha-assist-chip-container-shape: 10px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
inset-inline-end: -4px;
inset-inline-start: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0 2px;
color: var(--text-primary-color);
pointer-events: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane-chip": HaFilterPaneChip;
}
}
-183
View File
@@ -1,183 +0,0 @@
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-adaptive-dialog";
import "./ha-button";
import "./ha-dialog-footer";
import "./ha-filter-pane-chip";
import "./ha-icon-button";
/**
* Filter pane for a filtered page: a column next to the content on wide
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
* `hass-tabs-subpage-data-table` for pages that are not a data table.
*
* The page keeps ownership of whether the pane is shown, so that it can also
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
* while it is open.
*
* @slot - Filter panels, e.g. `ha-filter-domains`.
*/
@customElement("ha-filter-pane")
export class HaFilterPane extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
/** Header label, defaults to "Filters". */
@property() public label?: string;
/** SVG path of the header chip icon. */
@property() public path = mdiFilterVariant;
/** Number of active filters, shown as a badge. */
@property({ type: Number }) public count = 0;
/**
* Number of results the current filters resolve to, shown on the narrow
* confirm button. Leave undefined when the page shows everything.
*/
@property({ attribute: false }) public resultCount?: number;
@property({ type: Boolean }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
const label =
this.label ?? this._localize("ui.components.subpage-data-table.filters");
if (this.narrow) {
return html`
<ha-adaptive-dialog
open
flexcontent
.headerTitle=${label}
@closed=${this._close}
>
${this._renderClearButton("headerActionItems")}
<div class="sheet-content">
<slot></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this._close}>
${
this.resultCount === undefined
? this._localize("ui.common.close")
: this._localize(
"ui.components.subpage-data-table.show_results",
{ number: this.resultCount }
)
}
</ha-button>
</ha-dialog-footer>
</ha-adaptive-dialog>
`;
}
return html`
<div class="header">
<ha-filter-pane-chip
active
.label=${label}
.path=${this.path}
.count=${this.count}
.disabled=${this.disabled}
@click=${this._close}
></ha-filter-pane-chip>
${this._renderClearButton()}
</div>
<div class="content ha-scrollbar">
<slot></slot>
</div>
`;
}
private _renderClearButton(slot?: string) {
if (!this.count) {
return nothing;
}
return html`
<ha-icon-button
slot=${ifDefined(slot)}
.path=${mdiFilterVariantRemove}
.disabled=${this.disabled}
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
@click=${this._clear}
></ha-icon-button>
`;
}
private _close() {
fireEvent(this, "close-filter-pane");
}
private _clear() {
fireEvent(this, "clear-filter");
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
flex: 0 0 var(--ha-filter-pane-width, 320px);
width: var(--ha-filter-pane-width, 320px);
box-sizing: border-box;
overflow: hidden;
border-inline-end: 1px solid var(--divider-color);
}
/* The bottom sheet positions itself, so the pane takes no space. */
:host([narrow]) {
display: contents;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
.content,
.sheet-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-adaptive-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane": HaFilterPane;
}
interface HASSDomEvents {
"close-filter-pane": undefined;
}
}
-269
View File
@@ -1,269 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-device-classes";
import "./ha-filter-domains";
import "./ha-filter-integrations";
import "./ha-target-picker";
/**
* Ways to narrow down the entities a target selection resolves to. Not to be
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
domains?: string[];
deviceClasses?: string[];
integrations?: string[];
}
const TARGET_KEYS = [
"floor_id",
"area_id",
"device_id",
"entity_id",
"label_id",
] as const;
/** Number of picked targets, no matter which type they are. */
export const countTargets = (target: HassServiceTarget): number =>
TARGET_KEYS.reduce(
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
0
);
/** Number of filters that have at least one option selected. */
export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
/**
* Narrows entity IDs down by the selected filters: an entity is kept when it
* matches every filter that has a selection. The integration filter is skipped
* while the entity sources are still loading.
*/
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entitySources?: EntitySources
): string[] => {
const domains = filters.domains?.length ? filters.domains : undefined;
const deviceClasses = filters.deviceClasses?.length
? filters.deviceClasses
: undefined;
const integrations =
filters.integrations?.length && entitySources
? filters.integrations
: undefined;
if (!domains && !deviceClasses && !integrations) {
return entityIds;
}
return entityIds.filter((entityId) => {
if (domains && !domains.includes(computeDomain(entityId))) {
return false;
}
if (deviceClasses) {
const deviceClass = states[entityId]?.attributes.device_class;
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
return false;
}
}
if (integrations) {
const integration = entitySources![entityId]?.domain;
if (!integration || !integrations.includes(integration)) {
return false;
}
}
return true;
});
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* domain, device class and integration. Meant to be placed in an
* `ha-filter-pane`.
*/
@customElement("ha-sources-picker")
export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value: HassServiceTarget = {};
@property({ attribute: false }) public filters: SourceFilters = {};
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public disabled = false;
// Only one filter panel is expanded at a time, so that the expanded one can
// use the height that is left in the pane.
@state() private _expandedFilter?: keyof SourceFilters;
protected render() {
const noTargets = countTargets(this.value) === 0;
return html`
${
this.description && noTargets
? html`<div class="description">${this.description}</div>`
: nothing
}
<ha-target-picker
class=${classMap({ "no-padding-top": noTargets })}
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
></ha-target-picker>
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-domains
.value=${this.filters.domains}
.narrow=${this.narrow}
.expanded=${this._expandedFilter === "domains"}
@data-table-filter-changed=${this._domainsChanged}
@expanded-changed=${this._domainsExpanded}
></ha-filter-domains>
<ha-filter-device-classes
.value=${this.filters.deviceClasses}
.narrow=${this.narrow}
.expanded=${this._expandedFilter === "deviceClasses"}
@data-table-filter-changed=${this._deviceClassesChanged}
@expanded-changed=${this._deviceClassesExpanded}
></ha-filter-device-classes>
<ha-filter-integrations
.value=${this.filters.integrations}
.narrow=${this.narrow}
.expanded=${this._expandedFilter === "integrations"}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpanded}
></ha-filter-integrations>
</div>
`;
}
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
}
private _targetsChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _domainsChanged(ev: CustomEvent) {
this._filterChanged("domains", ev);
}
private _deviceClassesChanged(ev: CustomEvent) {
this._filterChanged("deviceClasses", ev);
}
private _integrationsChanged(ev: CustomEvent) {
this._filterChanged("integrations", ev);
}
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as DataTableFiltersValue;
fireEvent(this, "source-filters-changed", {
value: {
...this.filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
},
});
}
private _domainsExpanded(ev: CustomEvent) {
this._filterExpanded("domains", ev);
}
private _deviceClassesExpanded(ev: CustomEvent) {
this._filterExpanded("deviceClasses", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
this._filterExpanded("integrations", ev);
}
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
if (ev.detail.expanded) {
this._expandedFilter = key;
} else if (this._expandedFilter === key) {
this._expandedFilter = undefined;
}
}
static styles = css`
/* The sections are laid out by the pane, so that an expanded filter
panel can use the height that is left. */
:host {
display: contents;
}
.description {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
margin: var(--ha-space-4) var(--ha-space-4) 0;
padding: 0 var(--ha-space-6);
border-radius: var(--ha-border-radius-lg);
background-color: var(--ha-color-fill-neutral-quiet-resting);
text-align: center;
color: var(--secondary-text-color);
}
ha-target-picker {
display: block;
flex: none;
padding: var(--ha-space-4);
}
/* The description already spaces the picker from the pane header. */
ha-target-picker.no-padding-top {
padding-top: 0;
}
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over. */
.filters.expanded {
flex: 1 1 auto;
min-height: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-sources-picker": HaSourcesPicker;
}
interface HASSDomEvents {
"source-filters-changed": { value: SourceFilters };
}
}
+297 -1
View File
@@ -28,6 +28,7 @@ import {
type DevicePickerItem,
} from "../data/device/device_picker";
import {
devicesInEffectiveArea,
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
@@ -41,6 +42,9 @@ 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,
@@ -63,6 +67,7 @@ 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___";
@@ -81,6 +86,8 @@ 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" })
@@ -110,6 +117,8 @@ 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;
@@ -245,9 +254,119 @@ 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)
@@ -361,7 +480,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
private _renderItems() {
return html` ${this._renderValueGroups()} `;
return html`
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
`;
}
private _renderPicker() {
@@ -536,6 +657,162 @@ 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"]>
) {
@@ -575,6 +852,17 @@ 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,
@@ -1150,6 +1438,13 @@ 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);
@@ -1165,6 +1460,7 @@ 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;
@@ -0,0 +1,314 @@
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;
}
}
+10 -3
View File
@@ -126,11 +126,18 @@ export const listDatadisks = async (
timeout: null,
});
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
// `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
) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
endpoint: `/host/disks/${disk}/usage`,
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
...(maxDepth === undefined ? {} : { params: { max_depth: maxDepth } }),
});
+44 -13
View File
@@ -34,7 +34,6 @@ 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";
@@ -238,13 +237,20 @@ 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`<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>`
? 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>`
: nothing;
const selectModeBtn =
@@ -465,14 +471,18 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? nothing
: html`<div class="pane" slot="pane">
<div class="table-header">
<ha-filter-pane-chip
<ha-assist-chip
.label=${localize(
"ui.components.subpage-data-table.filters"
)}
.path=${mdiFilterVariant}
active
@click=${this._toggleFilters}
></ha-filter-pane-chip>
>
<ha-svg-icon
slot="icon"
.path=${mdiFilterVariant}
></ha-svg-icon>
</ha-assist-chip>
${
this.filters
? html`<ha-icon-button
@@ -875,6 +885,24 @@ 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;
@@ -923,8 +951,11 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
gap: var(--ha-space-2);
}
ha-assist-chip,
ha-filter-pane-chip {
.relative {
position: relative;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
--ha-assist-chip-container-color: var(--card-background-color);
}
@@ -49,7 +49,6 @@ const SUPPORTED_UI_TYPES = [
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
identify: () => false,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
@@ -124,7 +124,7 @@ class HaBackupConfigData extends LitElement {
private async _fetchStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass);
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
} catch (_err: any) {
this._storageInfo = null;
}
@@ -291,17 +291,11 @@ 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)
) ||
// Newer backends can offer options this frontend has no
// translation for yet — show the raw option key instead of
// an empty menu entry
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)
);
},
@@ -9,10 +9,12 @@ 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";
@@ -20,6 +22,7 @@ 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";
@@ -41,6 +44,7 @@ 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";
@@ -62,6 +66,13 @@ 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")) {
@@ -173,6 +184,7 @@ class HaConfigSectionStorage extends LitElement {
graphic="avatar"
.mount=${mount}
twoline
multiline-secondary
hasMeta
@click=${this._changeMount}
>
@@ -193,13 +205,16 @@ class HaConfigSectionStorage extends LitElement {
${mount.name}
</span>
<span slot="secondary">
${mount.server}${
mount.port ? `:${mount.port}` : nothing
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
<span class="mount-address">
${mount.server}${
mount.port ? `:${mount.port}` : ""
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
</span>
${this._renderMountUsage(mount)}
</span>
${
mount.state !== SupervisorMountState.ACTIVE
@@ -289,6 +304,39 @@ 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 {
@@ -305,7 +353,7 @@ class HaConfigSectionStorage extends LitElement {
private async _loadStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass);
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
} catch (err: any) {
this._error = err.message || err;
this._storageInfo = null;
@@ -361,6 +409,34 @@ 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`
@@ -409,6 +485,41 @@ 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;
}
+99 -265
View File
@@ -1,9 +1,8 @@
import {
mdiChartBoxOutline,
mdiDotsVertical,
mdiDownload,
mdiFilterRemove,
mdiImagePlus,
mdiTuneVariant,
} from "@mdi/js";
import { differenceInHours } from "date-fns";
import type {
@@ -11,11 +10,10 @@ import type {
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -31,25 +29,14 @@ 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-nav";
import "../../components/ha-button";
import "../../components/date-picker/ha-date-range-picker";
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,
@@ -65,8 +52,6 @@ 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;
@@ -93,21 +78,6 @@ class HaPanelHistory extends LitElement {
@state() private _isLoading = false;
@state() private _filters: SourceFilters = {};
// Restored on the next visit, like the target selection the filters narrow
// down.
@storage({
key: "historySourceFilters",
state: false,
subscribe: false,
})
private _storedFilters?: SourceFilters;
@state() private _showSources = false;
@state() private _entitySources?: EntitySources;
@state() private _stateHistory?: HistoryResult;
private _mungedStateHistory?: HistoryResult;
@@ -149,22 +119,7 @@ class HaPanelHistory extends LitElement {
}
protected render() {
const targetCount = countTargets(this._targetPickerValue);
const sourceCount = targetCount + countSourceFilters(this._filters);
// History only shows something once a target is picked, so narrowing it
// down further does not make it any less empty.
const hasTargets = targetCount > 0;
// Keyed on the entities the selection resolves to, not on the targets
// themselves: a target whose entities are all filtered out fetches nothing
// and would otherwise load forever.
const loading =
this._isLoading ||
(this._getEntityIds().length > 0 && !this._mungedStateHistory);
const hasResults =
!!this._mungedStateHistory &&
(this._mungedStateHistory.line.length > 0 ||
this._mungedStateHistory.timeline.length > 0);
const entitiesSelected = this._getEntityIds().length > 0;
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
@@ -173,6 +128,13 @@ 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"
@@ -191,109 +153,51 @@ class HaPanelHistory extends LitElement {
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<div class="main">
${
this._showSources
? html`<ha-filter-pane
.narrow=${this.narrow}
.label=${this.hass.localize("ui.panel.history.sources")}
.path=${mdiTuneVariant}
.count=${sourceCount}
.resultCount=${this._getEntityIds().length}
.disabled=${this._isLoading}
@close-filter-pane=${this._closeSources}
@clear-filter=${this._clearSources}
>
<ha-sources-picker
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.narrow=${this.narrow}
.disabled=${this._isLoading}
.description=${this.hass.localize(
"ui.panel.history.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
</ha-filter-pane>`
: nothing
}
<div class="content-column">
<div class="toolbar">
${
this._showSources && !this.narrow
? nothing
: html`<ha-filter-pane-chip
.label=${this.hass.localize("ui.panel.history.sources")}
.path=${mdiTuneVariant}
.count=${sourceCount}
.active=${sourceCount > 0}
.disabled=${this._isLoading}
@click=${this._toggleSources}
></ha-filter-pane-chip>`
}
<ha-date-range-nav
.disabled=${this._isLoading}
.startDate=${this._startDate}
.endDate=${this._endDate}
extended-presets
time-picker
@value-changed=${this._dateRangeChanged}
></ha-date-range-nav>
</div>
<div class="results ha-scrollbar">
${
loading
? html`<div class="progress-wrapper">
<ha-spinner></ha-spinner>
</div>`
: !hasTargets || !hasResults
? this._renderEmptyState(hasTargets)
: html`
<state-history-charts
.hass=${this.hass}
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
`
}
</div>
</div>
<div 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
.hass=${this.hass}
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
sync-charts
>
</state-history-charts>
`
}
</div>
</ha-top-app-bar-fixed>
`;
}
private _renderEmptyState(hasTargets: boolean) {
return html`
<ha-empty-state
.icon=${mdiChartBoxOutline}
.heading=${this.hass.localize(
hasTargets
? "ui.panel.history.no_results_title"
: "ui.panel.history.start_search_title"
)}
.description=${this.hass.localize(
hasTargets
? "ui.panel.history.no_results"
: "ui.panel.history.start_search"
)}
>
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize("ui.panel.history.add_targets")}
</ha-button>
</ha-empty-state>
`;
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -322,16 +226,12 @@ class HaPanelHistory extends LitElement {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const urlTarget = historyLogbookTargetFromQueryParams(queryParams);
const initialValue = urlTarget ?? this._storedTargetPickerValue;
const initialValue =
historyLogbookTargetFromQueryParams(queryParams) ??
this._storedTargetPickerValue;
if (initialValue) {
this._targetPickerValue = initialValue;
}
// A target in the URL describes the whole selection. Restoring the stored
// filters on top of it could narrow it down to nothing.
if (!urlTarget && this._storedFilters) {
this._filters = this._storedFilters;
}
if (queryParams.start_date) {
this._startDate = queryParams.start_date;
}
@@ -342,10 +242,6 @@ class HaPanelHistory extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
// Needed to map entities to their integration for the integration filter.
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
this._showBack = true;
@@ -360,8 +256,6 @@ class HaPanelHistory extends LitElement {
changedProps.has("_startDate") ||
changedProps.has("_endDate") ||
changedProps.has("_targetPickerValue") ||
changedProps.has("_filters") ||
changedProps.has("_entitySources") ||
(!this._stateHistory &&
(changedProps.has("_deviceEntityLookup") ||
changedProps.has("_areaEntityLookup") ||
@@ -372,28 +266,7 @@ class HaPanelHistory extends LitElement {
}
}
private _toggleSources() {
this._showSources = !this._showSources;
}
private _openSources() {
this._showSources = true;
}
private _closeSources() {
this._showSources = false;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
) {
this._filters = ev.detail.value;
this._storedFilters = this._filters;
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
private _removeAll() {
this._targetPickerValue = {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
@@ -439,11 +312,7 @@ class HaPanelHistory extends LitElement {
const entityIds = this._getEntityIds();
if (entityIds.length === 0) {
// The running subscription still holds the previous entities, so it would
// keep pushing the ones the selection no longer covers.
this._unsubscribeHistory();
this._stateHistory = undefined;
this._isLoading = false;
return;
}
@@ -510,39 +379,20 @@ class HaPanelHistory extends LitElement {
private _getEntityIds(): string[] {
return this.__getEntityIds(
this._targetPickerValue,
this._filters,
this.hass.entities,
this.hass.devices,
this.hass.areas,
// Only the device class filter reads the states, so they stay out of the
// memoization key while it is not used.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
this._entitySources
this.hass.areas
);
}
private __getEntityIds = memoizeOne(
(
targetPickerValue: HassServiceTarget,
filters: SourceFilters,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
states: HomeAssistant["states"],
entitySources: EntitySources | undefined
areas: HomeAssistant["areas"]
): string[] =>
applySourceFilters(
resolveEntityIDs(
this.hass,
targetPickerValue,
entities,
devices,
areas
),
filters,
states,
entitySources
)
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
);
private _dateRangeChanged(ev) {
@@ -723,15 +573,7 @@ class HaPanelHistory extends LitElement {
line-height: inherit;
}
:host {
/* The picker of the target picker is wider than the pane. */
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
}
.content {
display: flex;
flex-direction: column;
height: calc(
100vh - var(--header-height, 0px) - var(
--safe-area-inset-top,
@@ -739,57 +581,13 @@ class HaPanelHistory extends LitElement {
) - var(--safe-area-inset-bottom, 0px)
);
box-sizing: border-box;
overflow: hidden;
overflow-x: hidden;
padding: 0 16px 16px;
}
.main {
display: flex;
flex: 1;
min-height: 0;
}
.content-column {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
/* Sits in the content column, so it shifts along with the pane. */
.toolbar {
display: flex;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
/* Keep the controls at their size and scroll them if they do not fit. */
overflow-x: auto;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar {
display: none;
}
.toolbar > * {
flex-shrink: 0;
}
.results {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
:host([virtualize]) {
height: 100%;
--ha-generic-picker-max-width: 400px;
}
.progress-wrapper {
@@ -799,6 +597,42 @@ 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);
}
`,
];
}
+1 -35
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, state } from "lit/decorators";
import { customElement, eventOptions, property } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { restoreScroll } from "../../common/decorators/restore-scroll";
@@ -60,10 +60,6 @@ class HaLogbookRenderer extends LitElement {
// @ts-ignore
@restoreScroll(".container") private _savedScrollPos?: number;
// Index of the row at the top of the list, which the floating date header
// takes its day from.
@state() private _firstVisibleIndex = 0;
protected willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && this.virtualize) ||
@@ -84,11 +80,9 @@ 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
);
}
@@ -102,26 +96,12 @@ class HaLogbookRenderer extends LitElement {
`;
}
// The virtualizer positions its rows, so an inline date header cannot
// stick. Instead one header floats above the list and follows the day of
// the row that is at the top.
const floatingEntry = this.virtualize
? this.entries[this._firstVisibleIndex]
: undefined;
return html`
<div
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
@logbook-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
@@ -220,7 +200,6 @@ class HaLogbookRenderer extends LitElement {
@eventOptions({ passive: true })
private _visibilityChanged(e: VisibilityChangedEvent) {
this._firstVisibleIndex = Math.max(0, e.first);
fireEvent(this, "hass-logbook-live", {
enable: e.first === 0,
});
@@ -247,25 +226,12 @@ class HaLogbookRenderer extends LitElement {
font-weight: var(--ha-font-weight-medium);
}
/* Floats above the virtualized list and lines up with the inline date
headers, so they scroll underneath it. */
.floating-date {
position: absolute;
top: 0;
inset-inline: 0;
z-index: 2;
margin: 0;
padding-bottom: var(--ha-space-2);
background-color: var(--card-background-color);
}
.no-entries {
text-align: center;
color: var(--secondary-text-color);
}
.container {
position: relative;
max-height: var(--logbook-max-height);
}
+4 -10
View File
@@ -38,10 +38,6 @@ const idsChanged = (oldIds?: string[], newIds?: string[]) => {
);
};
/**
* @slot empty - Shown instead of the default text when there is no activity,
* for surfaces that can offer a way out (e.g. changing the filters).
*/
@customElement("ha-logbook")
export class HaLogbook extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -130,11 +126,9 @@ export class HaLogbook extends LitElement {
}
if (this._logbookEntries.length === 0) {
return html`<slot name="empty">
<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>
</slot>`;
return html`<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>`;
}
return html`
@@ -249,9 +243,9 @@ export class HaLogbook extends LitElement {
if (this._unsubLogbook) {
this._unsubLogbook.then((unsub) => unsub());
this._unsubLogbook = undefined;
this._logbookEntries = loading ? undefined : [];
this._pendingStreamMessages = [];
}
this._logbookEntries = loading ? undefined : [];
}
public connectedCallback() {
+88 -241
View File
@@ -3,17 +3,14 @@ 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, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fromUnixTime } from "date-fns";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -26,25 +23,14 @@ import {
removeSearchParam,
} from "../../common/url/search-params";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/date-picker/ha-date-range-nav";
import "../../components/ha-button";
import "../../components/date-picker/ha-date-range-picker";
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-target-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";
@@ -53,8 +39,6 @@ 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;
@@ -73,12 +57,6 @@ export class HaPanelLogbook extends LitElement {
@state()
private _showBack?: boolean;
@state() private _filters: SourceFilters = {};
@state() private _showSources = false;
@state() private _entitySources?: EntitySources;
@state() private _targetPickerValue: HassServiceTarget = {};
// Remembers the last user-picked selection as a fallback for visits without
@@ -91,31 +69,25 @@ export class HaPanelLogbook extends LitElement {
})
private _storedTargetPickerValue?: HassServiceTarget;
// Restored on the next visit, like the target selection the filters narrow
// down.
@storage({
key: "logbookSourceFilters",
state: false,
subscribe: false,
})
private _storedFilters?: SourceFilters;
public constructor() {
super();
this._time = this._defaultState.time;
}
protected render() {
const entityIds = this._getEntityIds();
const sourceCount =
countTargets(this._targetPickerValue) + countSourceFilters(this._filters);
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!!this._showBack}
>
<div slot="title">${this.hass.localize("panel.logbook")}</div>
<ha-icon-button
slot="actionItems"
@click=${this._resetLogbook}
.disabled=${this._isDefaultState()}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.common.reset")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
@@ -133,128 +105,40 @@ 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="main">
${
this._showSources
? html`<ha-filter-pane
.narrow=${this.narrow}
.label=${this.hass.localize("ui.panel.logbook.sources")}
.path=${mdiTuneVariant}
.count=${sourceCount}
.resultCount=${entityIds?.length}
@close-filter-pane=${this._closeSources}
@clear-filter=${this._clearSources}
>
<ha-sources-picker
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.entityFilter=${this._filterFunc}
.narrow=${this.narrow}
.description=${this.hass.localize(
"ui.panel.logbook.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
</ha-filter-pane>`
: nothing
}
<div class="content-column">
<div class="toolbar">
${
this._showSources && !this.narrow
? nothing
: html`<ha-filter-pane-chip
.label=${this.hass.localize("ui.panel.logbook.sources")}
.path=${mdiTuneVariant}
.count=${sourceCount}
.active=${sourceCount > 0}
@click=${this._toggleSources}
></ha-filter-pane-chip>`
}
<ha-date-range-nav
.startDate=${this._time.range[0]}
.endDate=${this._time.range[1]}
@value-changed=${this._dateRangeChanged}
time-picker
></ha-date-range-nav>
</div>
<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>
<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>
<ha-target-picker
.hass=${this.hass}
.entityFilter=${this._filterFunc}
.value=${this._targetPickerValue}
add-on-top
@value-changed=${this._targetsChanged}
compact
></ha-target-picker>
</div>
<ha-logbook
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._getEntityIds()}
.narrow=${this.narrow}
show-cause
virtualize
></ha-logbook>
</div>
</ha-top-app-bar-fixed>
`;
}
private _toggleSources() {
this._showSources = !this._showSources;
}
private _openSources() {
this._showSources = true;
}
private _closeSources() {
this._showSources = false;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
) {
this._filters = ev.detail.value;
this._storedFilters = this._filters;
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
this._targetPickerValue = {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
}
private _filterFunc: HaEntityPickerEntityFilterFunc = (entity) =>
filterLogbookCompatibleEntities(entity);
@@ -271,10 +155,6 @@ export class HaPanelLogbook extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
// Needed to map entities to their integration for the integration filter.
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
@@ -299,32 +179,17 @@ export class HaPanelLogbook extends LitElement {
this._applyURLParams();
};
/**
* The entities to show activity for, or undefined for all of them. Filters
* without a target narrow down every entity the logbook can show.
*/
private _getEntityIds(): string[] | undefined {
const targetEntities = this.__getEntityIds(
const entities = this.__getEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
);
if (!countSourceFilters(this._filters)) {
return targetEntities.length ? targetEntities : undefined;
if (entities.length === 0) {
return undefined;
}
return this.__filterEntityIds(
targetEntities.length
? targetEntities
: this.__logbookEntityIds(this.hass.states),
this._filters,
// Only the device class filter reads the states, so they stay out of the
// memoization key while it is not used.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
this._entitySources
);
return entities;
}
private __getEntityIds = memoizeOne(
@@ -337,15 +202,6 @@ export class HaPanelLogbook extends LitElement {
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
);
private __logbookEntityIds = memoizeOne(
(states: HomeAssistant["states"]): string[] =>
Object.values(states)
.filter((stateObj) => filterLogbookCompatibleEntities(stateObj))
.map((stateObj) => stateObj.entity_id)
);
private __filterEntityIds = memoizeOne(applySourceFilters);
private _applyURLParams() {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
@@ -357,12 +213,6 @@ export class HaPanelLogbook extends LitElement {
this._targetPickerValue = this._storedTargetPickerValue;
}
// A target in the URL describes the whole selection. Restoring the stored
// filters on top of it could narrow it down to nothing.
if (!this.hasUpdated && !targetPickerValue && this._storedFilters) {
this._filters = this._storedFilters;
}
if (queryParams.start_date || queryParams.end_date) {
const startDate = queryParams.start_date ?? this._time.range[0];
const endDate = queryParams.end_date ?? this._time.range[1];
@@ -423,12 +273,9 @@ export class HaPanelLogbook extends LitElement {
}
private _isDefaultState(): boolean {
return (
!countSourceFilters(this._filters) &&
deepEqual(
{ time: this._time, targetPickerValue: this._targetPickerValue },
this._defaultState
)
return deepEqual(
{ time: this._time, targetPickerValue: this._targetPickerValue },
this._defaultState
);
}
@@ -437,8 +284,6 @@ 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 });
}
@@ -455,9 +300,6 @@ export class HaPanelLogbook extends LitElement {
case "refresh":
this._refreshLogbook();
break;
case "reset":
this._resetLogbook();
break;
}
}
@@ -521,8 +363,6 @@ export class HaPanelLogbook extends LitElement {
haStyle,
css`
:host {
/* The picker of the target picker is wider than the pane. */
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
}
@@ -535,52 +375,59 @@ export class HaPanelLogbook extends LitElement {
0px
) - var(--safe-area-inset-bottom, 0px)
);
box-sizing: border-box;
overflow: hidden;
}
.main {
display: flex;
flex: 1;
min-height: 0;
}
.content-column {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
/* Sits in the content column, so it shifts along with the pane. */
.toolbar {
display: flex;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
/* Keep the controls at their size and scroll them if they do not fit. */
overflow-x: auto;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar {
display: none;
}
.toolbar > * {
flex-shrink: 0;
overflow-x: hidden;
padding: 0 0 16px;
}
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,11 +11,6 @@ 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 };
@@ -41,103 +36,6 @@ 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;
@@ -146,12 +44,6 @@ 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). */
@@ -184,7 +76,6 @@ export const buildSankeyDeviceNodes = (
localize,
rootNodeId,
minThreshold,
maxDevices,
untrackedFloor,
ceilOtherValue,
initialUntracked,
@@ -255,19 +146,6 @@ 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) {
@@ -276,9 +154,8 @@ export const buildSankeyDeviceNodes = (
const value = deviceValues.get(id)!;
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (!renderedIds.has(id)) {
// Below the value threshold or demoted by the count cap. Collect it
// instead of folding it into untracked.
if (value < minThreshold) {
// Collect small consumers instead of folding them into untracked
const parentKey = effectiveParent ?? rootNodeId;
if (!smallConsumersByParent.has(parentKey)) {
smallConsumersByParent.set(parentKey, []);
@@ -30,7 +30,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "./common/sankey";
@@ -299,7 +298,6 @@ 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,7 +23,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "./common/sankey";
@@ -298,7 +297,6 @@ 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,7 +174,6 @@ 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,7 +22,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "../energy/common/sankey";
@@ -261,7 +260,6 @@ 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,7 +27,6 @@ import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
fireSankeyNodeMoreInfo,
MIN_SANKEY_THRESHOLD_FACTOR,
} from "../energy/common/sankey";
@@ -242,7 +241,6 @@ 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,7 +5,6 @@ import {
assign,
boolean,
literal,
number,
object,
optional,
string,
@@ -37,7 +36,6 @@ const cardConfigStruct = assign(
),
group_by_floor: optional(boolean()),
group_by_area: optional(boolean()),
max_devices: optional(number()),
})
);
@@ -94,11 +92,6 @@ export class HuiEnergySankeyCardEditor
},
],
},
{
name: "max_devices",
required: false,
selector: { number: { min: 1, mode: "box" } },
},
{
type: "string",
name: "collection_key",
@@ -141,10 +134,6 @@ 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;
}
@@ -155,7 +144,6 @@ 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}`
);
+8 -17
View File
@@ -790,6 +790,11 @@
}
},
"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",
@@ -802,6 +807,7 @@
"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}",
@@ -835,9 +841,6 @@
},
"style": "Time format style"
},
"filter-device-classes": {
"caption": "Device class"
},
"subpage-data-table": {
"filters": "Filters",
"show_results": "Show {number} results",
@@ -9991,8 +9994,6 @@
"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",
@@ -11717,14 +11718,9 @@
}
},
"history": {
"sources": "Sources",
"add_targets": "Add targets",
"no_targets": "No targets selected",
"start_search_title": "Nothing to show yet",
"start_search": "Select areas, devices, entities or labels to see their history",
"no_results_title": "No results found",
"no_results": "No history for the current selection. Try changing the targets, filters, or time range.",
"start_search": "Select areas, devices, entities or labels above",
"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",
@@ -11732,11 +11728,6 @@
"error_no_data": "You need to select some data sources first."
},
"logbook": {
"sources": "[%key:ui::panel::history::sources%]",
"change_sources": "Change sources",
"no_targets": "Showing all activity. Add targets to narrow it down.",
"no_results_title": "[%key:ui::panel::history::no_results_title%]",
"no_results": "No activity for the current selection. Try changing the targets, filters, or time range.",
"download_data": "[%key:ui::panel::history::download_data%]",
"download_data_error": "[%key:ui::panel::history::download_data_error%]",
"error_no_data": "No activity for the selected target in the selected time range."
+65
View File
@@ -0,0 +1,65 @@
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,
}
);
});
});
@@ -0,0 +1,241 @@
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,8 +6,6 @@ import type {
import {
buildSankeyDeviceNodes,
buildSankeyLayout,
DEFAULT_MAX_SANKEY_DEVICES,
findDevicesOverCap,
getSankeyDeviceSections,
groupSankeyDevicesByFloorAndArea,
} from "../../../../../../src/panels/lovelace/cards/energy/common/sankey";
@@ -83,86 +81,6 @@ 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(
@@ -505,385 +423,6 @@ 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.3.0"
js-yaml: "npm:5.2.3"
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.3.0":
version: 5.3.0
resolution: "js-yaml@npm:5.3.0"
"js-yaml@npm:5.2.3":
version: 5.2.3
resolution: "js-yaml@npm:5.2.3"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/6c7e8ea8dd5643d9df73f4aa796f3572537284e9b819b74ef919467452bfa4624f8f7477cfc775c91db7e2f32387133b1b6c4e95aef9702be6bb042ae554884a
checksum: 10/5d2562f2d7e7bc51bd678b43d2dbc3965129ac854222c794157369c8904d249cfc78325dff8fc64becb505a2506242548c54c8fa50cdb24554bb1a557e460004
languageName: node
linkType: hard