diff --git a/src/common/translations/localize.ts b/src/common/translations/localize.ts index b3ccbb52ee..639fa19934 100644 --- a/src/common/translations/localize.ts +++ b/src/common/translations/localize.ts @@ -27,6 +27,7 @@ export type LocalizeKeys = | `ui.dialogs.unsupported.reasons.${string}` | `ui.panel.config.${string}.${"caption" | "description"}` | `ui.panel.config.dashboard.${string}` + | `ui.panel.config.storage.segments.${string}` | `ui.panel.config.zha.${string}` | `ui.panel.config.zwave_js.${string}` | `ui.panel.lovelace.card.${string}` diff --git a/src/components/ha-segmented-bar.ts b/src/components/ha-segmented-bar.ts new file mode 100644 index 0000000000..7aa3806c1f --- /dev/null +++ b/src/components/ha-segmented-bar.ts @@ -0,0 +1,123 @@ +import type { TemplateResult } from "lit"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import "./ha-tooltip"; + +export interface Segment { + value: number; + color: string; + label: TemplateResult | string; +} + +@customElement("ha-segmented-bar") +class HaSegmentedBar extends LitElement { + @property({ attribute: false }) public segments!: Segment[]; + + @property({ type: String }) public heading!: string; + + @property({ type: String }) public description?: string; + + protected render(): TemplateResult { + const totalValue = this.segments.reduce( + (acc, segment) => acc + segment.value, + 0 + ); + return html` +
+
+ ${this.heading} + ${this.description} +
+
+ ${this.segments.map( + (segment) => html` + + ${segment.label} +
+
+ ` + )} +
+ +
+ `; + } + + static styles = css` + .container { + width: 100%; + } + .heading span { + color: var(--secondary-text-color); + line-height: var(--ha-line-height-expanded); + margin-right: 8px; + } + .heading span:first-child { + color: var(--primary-text-color); + } + .bar { + display: flex; + overflow: hidden; + border-radius: var(--ha-bar-border-radius, 4px); + width: 100%; + height: 12px; + margin: 2px 0; + background-color: var( + --ha-bar-background-color, + var(--secondary-background-color) + ); + } + .bar div { + height: 100%; + } + .bar div:hover { + opacity: 0.8; + } + .legend { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 12px; + margin: 12px 0; + padding: 0; + list-style: none; + } + .legend li { + display: flex; + align-items: center; + gap: 4px; + font-size: var(--ha-font-size-s); + } + .legend li .bullet { + width: 12px; + height: 12px; + border-radius: 50%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-segmented-bar": HaSegmentedBar; + } +} diff --git a/src/data/hassio/host.ts b/src/data/hassio/host.ts index 432a5afe21..3f927cf787 100644 --- a/src/data/hassio/host.ts +++ b/src/data/hassio/host.ts @@ -44,6 +44,14 @@ export interface DatadiskList { disks: Datadisk[]; } +export interface HostDisksUsage { + total_bytes?: number; + used_bytes: number; + id: string; + label: string; + children?: HostDisksUsage[]; +} + export const fetchHassioHostInfo = async ( hass: HomeAssistant ): Promise => { @@ -180,3 +188,20 @@ export const listDatadisks = async ( await hass.callApi>("GET", "/os/datadisk/list") ); }; + +export const fetchHostDisksUsage = async (hass: HomeAssistant) => { + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return hass.callWS({ + type: "supervisor/api", + endpoint: "/host/disks/default/usage", + method: "get", + }); + } + + return hassioApiResultExtractor( + await hass.callApi>( + "GET", + "hassio/host/disks/default/usage" + ) + ); +}; diff --git a/src/panels/config/storage/ha-config-section-storage.ts b/src/panels/config/storage/ha-config-section-storage.ts index 94f594ebe2..b8f1f300b8 100644 --- a/src/panels/config/storage/ha-config-section-storage.ts +++ b/src/panels/config/storage/ha-config-section-storage.ts @@ -8,6 +8,7 @@ import { import type { PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { navigate } from "../../../common/navigate"; import "../../../components/ha-alert"; @@ -18,10 +19,14 @@ import "../../../components/ha-icon-next"; import "../../../components/ha-list"; import "../../../components/ha-list-item"; import "../../../components/ha-metric"; +import "../../../components/ha-segmented-bar"; import "../../../components/ha-svg-icon"; import { extractApiErrorMessage } from "../../../data/hassio/common"; -import type { HassioHostInfo } from "../../../data/hassio/host"; -import { fetchHassioHostInfo } from "../../../data/hassio/host"; +import type { HassioHostInfo, HostDisksUsage } from "../../../data/hassio/host"; +import { + fetchHassioHostInfo, + fetchHostDisksUsage, +} from "../../../data/hassio/host"; import type { SupervisorMount, SupervisorMounts, @@ -36,13 +41,12 @@ import { import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-subpage"; import type { HomeAssistant, Route } from "../../../types"; -import { - getValueInPercentage, - roundWithOneDecimal, -} from "../../../util/calculate"; +import { roundWithOneDecimal } from "../../../util/calculate"; import "../core/ha-config-analytics"; import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk"; import { showMountViewDialog } from "./show-dialog-view-mount"; +import type { Segment } from "../../../components/ha-segmented-bar"; +import { getGraphColorByIndex } from "../../../common/color/colors"; @customElement("ha-config-section-storage") class HaConfigSectionStorage extends LitElement { @@ -56,6 +60,8 @@ class HaConfigSectionStorage extends LitElement { @state() private _hostInfo?: HassioHostInfo; + @state() private _storageInfo?: HostDisksUsage | null; + @state() private _mountsInfo?: SupervisorMounts | null; protected firstUpdated(changedProps: PropertyValues) { @@ -97,26 +103,10 @@ class HaConfigSectionStorage extends LitElement { )} >
- -
- ${this.hass.localize( - "ui.panel.config.storage.detailed_description", - { - used: `${this._hostInfo?.disk_used} GB`, - total: `${this._hostInfo?.disk_total} GB`, - free_space: `${this._hostInfo.disk_free} GB`, - } - )} -
+ ${this._renderStorageMetrics( + this._hostInfo, + this._storageInfo + )} ${this._hostInfo.disk_life_time !== null ? // prettier-ignore html` @@ -247,7 +237,100 @@ class HaConfigSectionStorage extends LitElement { `; } + private _renderStorageMetrics = memoizeOne( + (hostInfo?: HassioHostInfo, storageInfo?: HostDisksUsage | null) => { + if (!hostInfo) { + return nothing; + } + const computedStyles = getComputedStyle(this); + let totalSpaceGB = hostInfo.disk_total; + let usedSpaceGB = hostInfo.disk_used; + // hostInfo.disk_free is sometimes 0, so we may need to calculate it + let freeSpaceGB = + hostInfo.disk_free || hostInfo.disk_total - hostInfo.disk_used; + const segments: Segment[] = []; + if (storageInfo) { + const totalSpace = + storageInfo.total_bytes ?? this._gbToBytes(hostInfo.disk_total); + totalSpaceGB = this._bytesToGB(totalSpace); + usedSpaceGB = this._bytesToGB(storageInfo.used_bytes); + freeSpaceGB = this._bytesToGB(totalSpace - storageInfo.used_bytes); + storageInfo.children?.forEach((child, index) => { + if (child.used_bytes > 0) { + const space = this._bytesToGB(child.used_bytes); + segments.push({ + value: space, + color: getGraphColorByIndex(index, computedStyles), + label: html`${this.hass.localize( + `ui.panel.config.storage.segments.${child.id}` + ) || + child.label || + child.id} + ${roundWithOneDecimal(space)} GB`, + }); + } + }); + } else { + segments.push({ + value: usedSpaceGB, + color: "var(--primary-color)", + label: html`${this.hass.localize( + "ui.panel.config.storage.segments.used" + )} + ${roundWithOneDecimal(usedSpaceGB)} GB`, + }); + } + segments.push({ + value: freeSpaceGB, + color: + "var(--ha-bar-background-color, var(--secondary-background-color))", + label: html`${this.hass.localize( + "ui.panel.config.storage.segments.free" + )} + ${roundWithOneDecimal(freeSpaceGB)} GB`, + }); + const chart = html` + + `; + return storageInfo || storageInfo === null + ? chart + : html` +
+ ${chart} +
+ +
+
+ `; + } + ); + + private _bytesToGB(bytes: number) { + return bytes / 1024 / 1024 / 1024; + } + + private _gbToBytes(GB: number) { + return GB * 1024 * 1024 * 1024; + } + private async _load() { + this._loadStorageInfo(); try { this._hostInfo = await fetchHassioHostInfo(this.hass); } catch (err: any) { @@ -260,6 +343,15 @@ class HaConfigSectionStorage extends LitElement { } } + private async _loadStorageInfo() { + try { + this._storageInfo = await fetchHostDisksUsage(this.hass); + } catch (err: any) { + this._error = err.message || err; + this._storageInfo = null; + } + } + private _moveDatadisk(): void { showMoveDatadiskDialog(this, { hostInfo: this._hostInfo!, @@ -311,9 +403,6 @@ class HaConfigSectionStorage extends LitElement { } } - private _getUsedSpace = (used: number, total: number) => - roundWithOneDecimal(getValueInPercentage(used, 0, total)); - static styles = css` .content { padding: 28px 20px 0; @@ -337,10 +426,22 @@ class HaConfigSectionStorage extends LitElement { flex-direction: column; } - .detailed-storage-info { - font-size: var(--ha-font-size-s); - color: var(--secondary-text-color); + .loading-container { + position: relative; } + + .loading-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(var(--rgb-card-background-color), 0.75); + display: flex; + justify-content: center; + align-items: center; + } + .mount-state-failed { color: var(--error-color); } diff --git a/src/translations/en.json b/src/translations/en.json index 1c61467050..8346d740a6 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6661,8 +6661,20 @@ "storage": { "caption": "Storage", "description": "{percent_used} used - {free_space} free", - "used_space": "Used space", - "detailed_description": "{used} used of {total} total, {free_space} remaining", + "used_space": "Storage", + "detailed_description": "{used} of {total} used", + "segments": { + "used": "Used space", + "free": "Free space", + "system": "System", + "addons_data": "Add-on data", + "addons_config": "Add-on configuration", + "media": "Media", + "share": "Share", + "backup": "Backups", + "homeassistant": "Home Assistant", + "ssl": "SSL" + }, "lifetime_used": "Lifetime used", "lifetime_used_description": "The drive’s wear level is shown as a percentage, based on endurance indicators reported by the device via NVMe SMART or eMMC lifetime estimate fields.", "disk_metrics": "Disk metrics",