Compare commits

..
Author SHA1 Message Date
Paul Bottein c70e974759 Skip entities assigned to another area when resolving area targets 2026-08-18 14:13:53 +02:00
d5389b5bef Fall back to option key for untranslated repair menu options (#53681)
Repair fix flows build their menu from the suggestions the backend
reports. A suggestion this frontend has no translation for yet (e.g. a
newer Supervisor offering a new repair suggestion) rendered as an
empty, unlabeled menu entry. Show the raw option key instead, matching
the fallback the form step header already uses.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-17 15:23:02 +02:00
8 changed files with 96 additions and 443 deletions
+3 -10
View File
@@ -126,18 +126,11 @@ export const listDatadisks = async (
timeout: null,
});
// `disk` is "default" for the data disk, or a mount name. Omitting maxDepth
// leaves the depth to the Supervisor, which defaults per target — walking a
// mount costs a round trip per directory, so mounts want no depth at all.
export const fetchHostDisksUsage = async (
hass: HomeAssistant,
disk = "default",
maxDepth?: number
) =>
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: `/host/disks/${disk}/usage`,
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
...(maxDepth === undefined ? {} : { params: { max_depth: maxDepth } }),
params: { max_depth: 3 },
});
+10 -1
View File
@@ -1135,6 +1135,10 @@ export const resolveEntityIDs = (
expanded.areas.forEach((id) => targetAreas.add(id));
});
// Devices only reached through an area do not pull in entities that are
// explicitly assigned to another area, matching core.
const devicesNotViaArea = new Set(targetDevices);
targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget(
hass,
@@ -1153,6 +1157,7 @@ export const resolveEntityIDs = (
Object.values(devices).forEach((device) => {
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
targetDevices.add(device.id);
devicesNotViaArea.add(device.id);
}
});
@@ -1163,7 +1168,11 @@ export const resolveEntityIDs = (
entities,
targetSelector
);
expanded.entities.forEach((id) => targetEntities.add(id));
expanded.entities.forEach((id) => {
if (devicesNotViaArea.has(deviceId) || !entities[id]?.area_id) {
targetEntities.add(id);
}
});
});
return Array.from(targetEntities);
@@ -124,7 +124,7 @@ class HaBackupConfigData extends LitElement {
private async _fetchStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
this._storageInfo = await fetchHostDisksUsage(this.hass);
} catch (_err: any) {
this._storageInfo = null;
}
@@ -291,11 +291,17 @@ export const showRepairsFlowDialog = (
},
renderMenuOption(hass, step, option) {
return hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
mergePlaceholders(issue, step)
return (
hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.step.${step.step_id}.menu_options.${option}`,
mergePlaceholders(issue, step)
) ||
// Newer backends can offer options this frontend has no
// translation for yet — show the raw option key instead of
// an empty menu entry
option
);
},
@@ -9,12 +9,10 @@ import {
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { navigate } from "../../../common/navigate";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
@@ -22,7 +20,6 @@ import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-segmented-bar";
import type { Segment } from "../../../components/ha-segmented-bar";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHostInfo, HostDisksUsage } from "../../../data/hassio/host";
@@ -44,7 +41,6 @@ import {
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { bytesToString } from "../../../util/bytes-to-string";
import "../core/ha-config-analytics";
import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk";
import { showMountViewDialog } from "./show-dialog-view-mount";
@@ -66,13 +62,6 @@ class HaConfigSectionStorage extends LitElement {
@state() private _mountsInfo?: SupervisorMounts | null;
// Keyed by mount name. A missing key means the request is still in flight;
// null means it failed, and that row simply shows no usage.
@state() private _mountUsage: Record<string, HostDisksUsage | null> = {};
// Guards against a slow response from a previous reload landing in a newer one.
private _mountUsageGeneration = 0;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass.config, "hassio")) {
@@ -184,7 +173,6 @@ class HaConfigSectionStorage extends LitElement {
graphic="avatar"
.mount=${mount}
twoline
multiline-secondary
hasMeta
@click=${this._changeMount}
>
@@ -205,16 +193,13 @@ class HaConfigSectionStorage extends LitElement {
${mount.name}
</span>
<span slot="secondary">
<span class="mount-address">
${mount.server}${
mount.port ? `:${mount.port}` : ""
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
</span>
${this._renderMountUsage(mount)}
${mount.server}${
mount.port ? `:${mount.port}` : nothing
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
</span>
${
mount.state !== SupervisorMountState.ACTIVE
@@ -304,39 +289,6 @@ class HaConfigSectionStorage extends LitElement {
`;
}
private _renderMountUsage(mount: SupervisorMount) {
if (mount.state !== SupervisorMountState.ACTIVE) {
return nothing;
}
if (!(mount.name in this._mountUsage)) {
return html`<div class="mount-usage">
<ha-spinner size="tiny"></ha-spinner>
</div>`;
}
const usage = this._mountUsage[mount.name];
// Without a total there is no ratio to show, so show nothing rather than a
// bar that means something else.
if (!usage?.total_bytes) {
return nothing;
}
const percent = (usage.used_bytes / usage.total_bytes) * 100;
return html`<div class="mount-usage">
<ha-bar
class=${classMap({
"target-warning": percent > 85,
"target-critical": percent > 95,
})}
.value=${percent}
></ha-bar>
<span>
${this.hass.localize("ui.panel.config.storage.detailed_description", {
used: bytesToString(usage.used_bytes),
total: bytesToString(usage.total_bytes),
})}
</span>
</div>`;
}
private async _load() {
this._loadStorageInfo();
try {
@@ -353,7 +305,7 @@ class HaConfigSectionStorage extends LitElement {
private async _loadStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
this._storageInfo = await fetchHostDisksUsage(this.hass);
} catch (err: any) {
this._error = err.message || err;
this._storageInfo = null;
@@ -409,34 +361,6 @@ class HaConfigSectionStorage extends LitElement {
this._error = err.message || err;
this._mountsInfo = null;
}
this._loadMountUsage();
}
// Deliberately not awaited: a mount on a slow or unreachable server can take
// ~30 s to answer, and the rows must paint before then. Only active mounts are
// asked, since the endpoint has nothing to report for the others.
private _loadMountUsage(): void {
const generation = ++this._mountUsageGeneration;
this._mountUsage = {};
this._mountsInfo?.mounts
.filter((mount) => mount.state === SupervisorMountState.ACTIVE)
.forEach((mount) => {
fetchHostDisksUsage(this.hass, mount.name).then(
(usage) => this._setMountUsage(generation, mount.name, usage),
() => this._setMountUsage(generation, mount.name, null)
);
});
}
private _setMountUsage(
generation: number,
name: string,
usage: HostDisksUsage | null
): void {
if (generation !== this._mountUsageGeneration) {
return;
}
this._mountUsage = { ...this._mountUsage, [name]: usage };
}
static styles = css`
@@ -485,41 +409,6 @@ class HaConfigSectionStorage extends LitElement {
color: var(--warning-color);
}
/* multiline-secondary lets the secondary slot wrap, so the address keeps its
own single ellipsized line and only the usage sits below it. */
.mount-address {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mount-usage {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-top: var(--ha-space-1);
}
.mount-usage ha-bar {
flex: 0 0 72px;
--ha-bar-primary-color: var(--metric-bar-ok-color, var(--success-color));
}
.mount-usage ha-bar.target-warning {
--ha-bar-primary-color: var(
--metric-bar-warning-color,
var(--warning-color)
);
}
.mount-usage ha-bar.target-critical {
--ha-bar-primary-color: var(
--metric-bar-critical-color,
var(--error-color)
);
}
.mounts-not-supported {
padding: 0 16px 16px;
}
+63 -1
View File
@@ -1,7 +1,10 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import { filterSelectorEntities } from "../../src/data/selector";
import {
filterSelectorEntities,
resolveEntityIDs,
} from "../../src/data/selector";
import type { HomeAssistant } from "../../src/types";
const entity = {
@@ -132,3 +135,62 @@ describe("filterSelectorEntities device filter", () => {
).toBe(false);
});
});
describe("resolveEntityIDs", () => {
const areaHass = {
states: {
"light.kitchen": { entity_id: "light.kitchen", state: "on" },
"sensor.kitchen_hub_temp": {
entity_id: "sensor.kitchen_hub_temp",
state: "20",
},
"sensor.hallway_probe": { entity_id: "sensor.hallway_probe", state: "5" },
},
entities: {
"light.kitchen": { entity_id: "light.kitchen", device_id: "hub" },
"sensor.kitchen_hub_temp": {
entity_id: "sensor.kitchen_hub_temp",
device_id: "hub",
},
"sensor.hallway_probe": {
entity_id: "sensor.hallway_probe",
device_id: "hub",
area_id: "hallway",
},
},
devices: { hub: { id: "hub", area_id: "kitchen" } },
areas: { kitchen: { area_id: "kitchen" }, hallway: { area_id: "hallway" } },
} as unknown as HomeAssistant;
const resolve = (target) =>
resolveEntityIDs(
areaHass,
target,
areaHass.entities,
areaHass.devices,
areaHass.areas
).sort();
it("skips entities of an area device that are assigned to another area", () => {
expect(resolve({ area_id: "kitchen" })).toEqual([
"light.kitchen",
"sensor.kitchen_hub_temp",
]);
});
it("keeps every entity of a directly targeted device", () => {
expect(resolve({ device_id: "hub" })).toEqual([
"light.kitchen",
"sensor.hallway_probe",
"sensor.kitchen_hub_temp",
]);
});
it("keeps the entity when its own area is targeted as well", () => {
expect(resolve({ area_id: ["kitchen", "hallway"] })).toEqual([
"light.kitchen",
"sensor.hallway_probe",
"sensor.kitchen_hub_temp",
]);
});
});
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { fetchHostDisksUsage } from "../../src/data/hassio/host";
import type { HomeAssistant } from "../../src/types";
const mockHass = () => {
const callWS = vi.fn().mockResolvedValue({
id: "root",
label: "Total",
total_bytes: 2000398934016,
used_bytes: 1240247081779,
});
return { hass: { callWS } as unknown as HomeAssistant, callWS };
};
const sentMessage = (callWS: ReturnType<typeof vi.fn>) =>
callWS.mock.calls[0][0];
describe("fetchHostDisksUsage", () => {
it("targets the data disk and sends no depth by default", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass);
expect(callWS).toHaveBeenCalledWith({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600,
});
});
it("addresses a mount by name", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas");
expect(sentMessage(callWS).endpoint).toBe("/host/disks/media_nas/usage");
});
// Walking a mount costs a round trip per directory, so the row fetch must let
// the Supervisor apply its own per-target default rather than pinning one.
it("omits max_depth for a mount", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas");
expect(sentMessage(callWS)).not.toHaveProperty("params");
});
it("sends max_depth only when a caller asks for it", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "default", 3);
expect(sentMessage(callWS).params).toEqual({ max_depth: 3 });
});
it("sends max_depth 0 when explicitly asked, rather than dropping it", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas", 0);
expect(sentMessage(callWS).params).toEqual({ max_depth: 0 });
});
it("returns the usage tree", async () => {
const { hass } = mockHass();
await expect(fetchHostDisksUsage(hass, "media_nas")).resolves.toMatchObject(
{
total_bytes: 2000398934016,
used_bytes: 1240247081779,
}
);
});
});
@@ -1,241 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../../../src/types";
import type * as HostModule from "../../../../src/data/hassio/host";
import type * as MountsModule from "../../../../src/data/supervisor/mounts";
import {
fetchHassioHostInfo,
fetchHostDisksUsage,
} from "../../../../src/data/hassio/host";
import {
fetchSupervisorMounts,
SupervisorMountState,
SupervisorMountType,
SupervisorMountUsage,
} from "../../../../src/data/supervisor/mounts";
import "../../../../src/panels/config/storage/ha-config-section-storage";
// Heavy children are irrelevant here and drag in echarts / ResizeObserver.
vi.mock("../../../../src/panels/config/storage/storage-breakdown-chart", () => {
customElements.define(
"storage-breakdown-chart",
class extends HTMLElement {}
);
return {};
});
vi.mock("../../../../src/layouts/hass-subpage", () => {
customElements.define("hass-subpage", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/panels/config/core/ha-config-analytics", () => ({}));
vi.mock("../../../../src/components/ha-segmented-bar", () => {
customElements.define("ha-segmented-bar", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-list", () => {
customElements.define("ha-list", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-list-item", () => {
customElements.define("ha-list-item", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-card", () => {
customElements.define("ha-card", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-alert", () => {
customElements.define("ha-alert", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-icon-button", () => {
customElements.define("ha-icon-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-icon-next", () => {
customElements.define("ha-icon-next", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-svg-icon", () => {
customElements.define("ha-svg-icon", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-bar", () => {
customElements.define("ha-bar", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-spinner", () => {
customElements.define("ha-spinner", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/data/hassio/host", async (importOriginal) => ({
...(await importOriginal<typeof HostModule>()),
fetchHassioHostInfo: vi.fn(),
fetchHostDisksUsage: vi.fn(),
}));
vi.mock("../../../../src/data/supervisor/mounts", async (importOriginal) => ({
...(await importOriginal<typeof MountsModule>()),
fetchSupervisorMounts: vi.fn(),
reloadSupervisorMount: vi.fn(),
}));
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
const deferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const mount = (name: string, state: SupervisorMountState) => ({
name,
usage: SupervisorMountUsage.MEDIA,
type: SupervisorMountType.CIFS,
server: `${name}.local`,
port: 445,
share: "media",
state,
});
const hass = {
localize: (key: string, params?: Record<string, unknown>) =>
key === "ui.panel.config.storage.detailed_description"
? `${params!.used} of ${params!.total} used`
: key,
config: { components: ["hassio"] },
// _renderDiskLifeTime formats a percentage, which needs the locale.
locale: { language: "en" },
} as unknown as HomeAssistant;
const nextTask = () =>
new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const rows = (el: HTMLElement) =>
Array.from(el.shadowRoot!.querySelectorAll("ha-list-item"));
const rowFor = (el: HTMLElement, name: string) =>
rows(el).find((row) => row.textContent?.includes(name));
describe("ha-config-section-storage per-mount usage", () => {
let usageCalls: Record<string, Deferred<any>>;
beforeEach(() => {
usageCalls = {};
vi.mocked(fetchHassioHostInfo).mockResolvedValue({
features: ["mount"],
disk_life_time: 5,
} as any);
vi.mocked(fetchSupervisorMounts).mockResolvedValue({
default_backup_mount: null,
mounts: [
mount("alpha", SupervisorMountState.ACTIVE),
mount("beta", SupervisorMountState.ACTIVE),
mount("broken", SupervisorMountState.FAILED),
],
} as any);
vi.mocked(fetchHostDisksUsage).mockImplementation(
(_hass, disk = "default") => {
// The panel's own disk-metrics call must not interfere with the row calls.
if (disk === "default") {
return new Promise(() => {
// never settles
});
}
usageCalls[disk] = deferred<any>();
return usageCalls[disk].promise;
}
);
});
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
const render = async () => {
const el = document.createElement("ha-config-section-storage");
(el as any).hass = hass;
document.body.append(el);
await nextTask();
await (el as any).updateComplete;
return el as HTMLElement;
};
it("asks only active mounts for usage", async () => {
await render();
const asked = vi
.mocked(fetchHostDisksUsage)
.mock.calls.map((call) => call[1])
.filter((disk) => disk !== "default");
expect(asked).toEqual(["alpha", "beta"]);
expect(asked).not.toContain("broken");
});
it("renders the rows before any usage arrives", async () => {
const el = await render();
expect(rows(el)).toHaveLength(3);
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).not.toBeNull();
});
it("fills in each row as its own request settles", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
await nextTask();
await (el as any).updateComplete;
expect(rowFor(el, "alpha")!.textContent).toContain(
"500 Bytes of 1000 Bytes used"
);
// beta has not answered yet, so it must still be pending, not blanked.
expect(rowFor(el, "beta")!.querySelector("ha-spinner")).not.toBeNull();
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).toBeNull();
});
it("leaves a row without usage when its request fails, and keeps the others", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
usageCalls.beta.reject(new Error("dead server"));
await nextTask();
await (el as any).updateComplete;
const beta = rowFor(el, "beta")!;
expect(beta.querySelector("ha-spinner")).toBeNull();
expect(beta.querySelector("ha-bar")).toBeNull();
expect(beta.textContent).not.toContain("used");
expect(rowFor(el, "alpha")!.querySelector("ha-bar")).not.toBeNull();
});
it("never shows usage for a mount that is not active", async () => {
const el = await render();
const broken = rowFor(el, "broken")!;
expect(broken.querySelector("ha-spinner")).toBeNull();
expect(broken.querySelector("ha-bar")).toBeNull();
});
});