mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-18 04:27:45 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c4d6b50b0 |
+10
-3
@@ -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 } }),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user