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`
+
+
+
+ ${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!",