diff --git a/src/components/chart/ha-sunburst-chart.ts b/src/components/chart/ha-sunburst-chart.ts new file mode 100644 index 0000000000..bfb35da27a --- /dev/null +++ b/src/components/chart/ha-sunburst-chart.ts @@ -0,0 +1,207 @@ +import type { EChartsType } from "echarts/core"; +import type { SunburstSeriesOption } from "echarts/types/dist/echarts"; +import type { CallbackDataParams } from "echarts/types/src/util/types"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { getGraphColorByIndex } from "../../common/color/colors"; +import { filterXSS } from "../../common/util/xss"; +import type { ECOption } from "../../resources/echarts/echarts"; +import type { HomeAssistant } from "../../types"; +import "./ha-chart-base"; + +// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports +let SunburstChart: typeof import("echarts/lib/chart/sunburst/install"); + +export interface SunburstNode { + id: string; + name?: string; + value: number; + itemStyle?: { + color?: string; + }; + children?: SunburstNode[]; +} + +@customElement("ha-sunburst-chart") +export class HaSunburstChart extends LitElement { + public hass!: HomeAssistant; + + @property({ attribute: false }) public data?: SunburstNode; + + @property({ type: String, attribute: false }) public valueFormatter?: ( + value: number + ) => string; + + public chart?: EChartsType; + + constructor() { + super(); + if (!SunburstChart) { + import("echarts/lib/chart/sunburst/install").then((module) => { + SunburstChart = module; + this.requestUpdate(); + }); + } + } + + render() { + if (!SunburstChart || !this.data) { + return nothing; + } + + const options = { + tooltip: { + trigger: "item", + formatter: this._renderTooltip, + appendTo: document.body, + }, + } as ECOption; + + return html``; + } + + private _renderTooltip = (params: CallbackDataParams) => { + const data = params.data as { name: string; value: number }; + const value = this.valueFormatter + ? this.valueFormatter(data.value) + : data.value; + return `${params.marker} ${filterXSS(data.name)}
${value}`; + }; + + private _createData = memoizeOne( + (data: SunburstNode): SunburstSeriesOption => { + const computedStyles = getComputedStyle(this); + + // Transform to echarts format (uses 'name' instead of 'id') + const transformNode = ( + node: SunburstNode, + index: number, + depth: number, + parentColor?: string + ) => { + const result = { + ...node, + name: node.name || node.id, + }; + + if (depth > 0 && !node.itemStyle?.color) { + // Don't assign color to root node + result.itemStyle = { + color: parentColor ?? getGraphColorByIndex(index, computedStyles), + }; + } + + if (node.children && node.children.length > 0) { + result.children = node.children.map((child, i) => + transformNode(child, i, depth + 1, result.itemStyle?.color) + ); + } + + return result; + }; + + const transformedData = transformNode(data, 0, 0); + + return { + type: "sunburst", + data: transformedData.children || [transformedData], + radius: [0, "90%"], + sort: undefined, // Keep original order + label: { + show: false, + align: "center", + rotate: "radial", + minAngle: 15, + hideOverlap: true, + }, + emphasis: { + focus: "ancestor", + label: { + show: false, + }, + }, + itemStyle: { + borderRadius: 2, + }, + levels: this._generateLevels(this._getMaxDepth(data)), + } as SunburstSeriesOption; + } + ); + + private _getMaxDepth(node: SunburstNode, currentDepth = 0): number { + if (!node.children || node.children.length === 0) { + return currentDepth; + } + return Math.max( + ...node.children.map((child) => + this._getMaxDepth(child, currentDepth + 1) + ) + ); + } + + private _generateLevels(depth: number): SunburstSeriesOption["levels"] { + const levels: SunburstSeriesOption["levels"] = []; + + // Root level (center) - transparent, small fixed size + const rootRadius = 15; + const outerRadius = 95; + const availableRadius = outerRadius - rootRadius; + + levels.push({ + r0: "0%", + r: `${rootRadius}%`, + itemStyle: { + color: "transparent", + }, + }); + + if (depth === 0) { + return levels; + } + + // Distribute remaining radius among data levels using weighted distribution + // First level gets most space, each subsequent level gets progressively smaller + const weights = Array.from({ length: depth }, (_, i) => depth - i); + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + + let currentRadius = rootRadius; + for (let i = 0; i < depth; i++) { + const levelRadius = (weights[i] / totalWeight) * availableRadius; + const r0 = currentRadius; + const r = currentRadius + levelRadius; + currentRadius = r; + + levels.push({ + r0: `${r0}%`, + r: `${r}%`, + // Show labels only on first level + ...(i === 0 ? { label: { show: true } } : {}), + }); + } + + return levels; + } + + static styles = css` + :host { + display: block; + flex: 1; + } + ha-chart-base { + width: 100%; + height: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-sunburst-chart": HaSunburstChart; + } +} diff --git a/src/data/hassio/host.ts b/src/data/hassio/host.ts index 33e4a2d0a8..790ed93324 100644 --- a/src/data/hassio/host.ts +++ b/src/data/hassio/host.ts @@ -196,6 +196,7 @@ export const fetchHostDisksUsage = async (hass: HomeAssistant) => { endpoint: "/host/disks/default/usage", method: "get", timeout: 3600, // seconds. This can take a while + params: { max_depth: 3 }, }); } diff --git a/src/panels/config/storage/ha-config-section-storage.ts b/src/panels/config/storage/ha-config-section-storage.ts index 917081e8ca..d435a20e41 100644 --- a/src/panels/config/storage/ha-config-section-storage.ts +++ b/src/panels/config/storage/ha-config-section-storage.ts @@ -9,8 +9,6 @@ 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 { getGraphColorByIndex } from "../../../common/color/colors"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { navigate } from "../../../common/navigate"; import { blankBeforePercent } from "../../../common/translations/blank_before_percent"; @@ -44,10 +42,10 @@ import { import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-subpage"; import type { HomeAssistant, Route } from "../../../types"; -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 "./storage-breakdown-chart"; @customElement("ha-config-section-storage") class HaConfigSectionStorage extends LitElement { @@ -104,10 +102,11 @@ class HaConfigSectionStorage extends LitElement { )} >
- ${this._renderStorageMetrics( - this._hostInfo, - this._storageInfo - )} + ${this._renderDiskLifeTime(this._hostInfo.disk_life_time)}
${this._hostInfo @@ -269,95 +268,6 @@ 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`, - }); - return html` - - ${!storageInfo || storageInfo === null - ? html` - - ${this.hass.localize( - "ui.panel.config.storage.loading_detailed" - )}` - : nothing}`; - } - ); - - private _bytesToGB(bytes: number) { - return bytes / 1024 / 1024 / 1024; - } - - private _gbToBytes(GB: number) { - return GB * 1024 * 1024 * 1024; - } - private async _load() { this._loadStorageInfo(); try { @@ -523,10 +433,6 @@ class HaConfigSectionStorage extends LitElement { ha-alert { --ha-alert-icon-size: 24px; } - - ha-alert ha-spinner { - --ha-spinner-size: 24px; - } `; } diff --git a/src/panels/config/storage/storage-breakdown-chart.ts b/src/panels/config/storage/storage-breakdown-chart.ts new file mode 100644 index 0000000000..230b3dc2c7 --- /dev/null +++ b/src/panels/config/storage/storage-breakdown-chart.ts @@ -0,0 +1,293 @@ +import { mdiChartDonutVariant, mdiViewArray } from "@mdi/js"; +import type { TemplateResult } from "lit"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { getGraphColorByIndex } from "../../../common/color/colors"; +import "../../../components/chart/ha-sunburst-chart"; +import type { SunburstNode } from "../../../components/chart/ha-sunburst-chart"; +import "../../../components/ha-alert"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-segmented-bar"; +import "../../../components/ha-spinner"; +import type { Segment } from "../../../components/ha-segmented-bar"; +import type { HassioHostInfo, HostDisksUsage } from "../../../data/hassio/host"; +import type { HomeAssistant } from "../../../types"; +import { roundWithOneDecimal } from "../../../util/calculate"; + +@customElement("storage-breakdown-chart") +export class StorageBreakdownChart extends LitElement { + @property({ attribute: false }) + public hass!: HomeAssistant; + + @property({ attribute: false }) + public hostInfo?: HassioHostInfo; + + @property({ attribute: false }) + public storageInfo?: HostDisksUsage | null; + + @state() + private _chartType: "bar" | "sunburst" = "bar"; + + protected render(): TemplateResult | typeof nothing { + if (!this.hostInfo) { + return nothing; + } + const { totalSpaceGB, usedSpaceGB, freeSpaceGB } = this._computeSpaceValues( + this.hostInfo, + this.storageInfo + ); + + const hasChildren = Boolean(this.storageInfo?.children?.length); + const heading = this.hass.localize("ui.panel.config.storage.used_space"); + const description = this.hass.localize( + "ui.panel.config.storage.detailed_description", + { + used: `${roundWithOneDecimal(usedSpaceGB)} GB`, + total: `${roundWithOneDecimal(totalSpaceGB)} GB`, + } + ); + const showBarChart = this._chartType === "bar" || !hasChildren; + + return html` +
+
+ ${heading} + ${description} +
+ ${hasChildren + ? html`` + : nothing} +
+ +
+ ${showBarChart + ? html`` + : html``} +
+ + ${!this.storageInfo || this.storageInfo === null + ? html` + + ${this.hass.localize( + "ui.panel.config.storage.loading_detailed" + )}` + : nothing} + `; + } + + private _handleChartTypeChange(): void { + this._chartType = this._chartType === "bar" ? "sunburst" : "bar"; + } + + private _computeSpaceValues = memoizeOne( + ( + hostInfo: HassioHostInfo, + storageInfo: HostDisksUsage | null | undefined + ) => { + let totalSpaceGB = hostInfo.disk_total; + let usedSpaceGB = hostInfo.disk_used; + let freeSpaceGB = + hostInfo.disk_free || hostInfo.disk_total - hostInfo.disk_used; + + 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); + } + + return { totalSpaceGB, usedSpaceGB, freeSpaceGB }; + } + ); + + private _computeSegments = memoizeOne( + ( + storageInfo: HostDisksUsage | null | undefined, + usedSpaceGB: number, + freeSpaceGB: number + ): Segment[] => { + const computedStyles = getComputedStyle(this); + const segments: Segment[] = []; + + if (storageInfo) { + 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`, + }); + + return segments; + } + ); + + private _transformToSunburstData = memoizeOne( + (storageInfo: HostDisksUsage): SunburstNode => { + const transform = ( + node: HostDisksUsage, + parentNode?: HostDisksUsage + ): SunburstNode => ({ + // prefix with parent id to avoid duplicate ids + id: parentNode ? `${parentNode.id}.${node.id}` : node.id, + name: this._formatLabel(node.id) || node.label, + value: node.used_bytes, + children: node.children?.map((child) => transform(child, node)), + }); + return transform(storageInfo); + } + ); + + private _formatBytes = (bytes: number): string => { + const gb = this._bytesToGB(bytes); + return `${roundWithOneDecimal(gb)} GB`; + }; + + private _formatLabel = (id: string): string => + this.hass.localize(`ui.panel.config.storage.segments.${id}`) || id; + + private _bytesToGB(bytes: number): number { + return bytes / 1024 / 1024 / 1024; + } + + private _gbToBytes(GB: number): number { + return GB * 1024 * 1024 * 1024; + } + + static styles = css` + .header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: var(--ha-space-2); + } + + .heading-text { + display: flex; + flex-direction: column; + gap: var(--ha-space-1); + } + + .heading { + font-weight: 500; + font-size: var(--ha-font-size-m); + color: var(--primary-text-color); + } + + .description { + font-size: var(--ha-font-size-s); + color: var(--secondary-text-color); + } + + ha-icon-button { + --mdc-icon-button-size: 36px; + --mdc-icon-size: 20px; + color: var(--secondary-text-color); + } + + .chart-container { + transition: height var(--ha-animation-base-duration) ease; + overflow: hidden; + } + + .chart-container.bar { + height: calc-size(auto, size); + } + + .chart-container.sunburst { + height: 400px; + } + + ha-segmented-bar { + display: block; + } + + ha-sunburst-chart { + height: 400px; + } + + ha-segmented-bar, + ha-sunburst-chart { + animation: fade-in var(--ha-animation-base-duration) ease; + } + + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + ha-alert { + --ha-alert-icon-size: 24px; + } + + ha-alert ha-spinner { + --ha-spinner-size: 24px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "storage-breakdown-chart": StorageBreakdownChart; + } +} diff --git a/src/resources/echarts/echarts.ts b/src/resources/echarts/echarts.ts index 57bab911e3..114fe9212d 100644 --- a/src/resources/echarts/echarts.ts +++ b/src/resources/echarts/echarts.ts @@ -36,6 +36,7 @@ import type { CustomSeriesOption, SankeySeriesOption, GraphSeriesOption, + SunburstSeriesOption, } from "echarts/charts"; import type { // The component option types are defined with the ComponentOption suffix @@ -61,6 +62,7 @@ export type ECOption = ComposeOption< | VisualMapComponentOption | SankeySeriesOption | GraphSeriesOption + | SunburstSeriesOption >; // Register the required components diff --git a/src/translations/en.json b/src/translations/en.json index 057620a1ed..1c45b6a134 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7023,6 +7023,7 @@ "lifetime_description": "{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", + "change_chart_type": "Change chart type", "datadisk": { "title": "Move data disk", "description": "You are currently using ''{current_path}'' as data disk. Moving the data disk will reboot your device and it's estimated to take {time} minutes. Your Home Assistant installation will not be accessible during this period. Do not disconnect the power during the move!",