Compare commits

...

5 Commits

Author SHA1 Message Date
Petar Petrov
2864fbdcf1 Update icons 2025-12-03 15:35:53 +02:00
Petar Petrov
fae2ea21ac Refactor 2025-12-03 15:08:33 +02:00
Petar Petrov
b21a6a0ce3 Add sunburst chart component and integrate into storage breakdown chart 2025-11-25 17:15:57 +02:00
Petar Petrov
d69bc121cb fix 2025-11-25 15:08:56 +02:00
Petar Petrov
f3127ba1f6 Add storage breakdown chart component 2025-11-25 14:52:59 +02:00
6 changed files with 481 additions and 100 deletions

View File

@@ -0,0 +1,178 @@
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`<ha-chart-base
.data=${this._createData(this.data)}
.options=${options}
height="100%"
.extraComponents=${[SunburstChart]}
></ha-chart-base>`;
}
private _renderTooltip = (params: CallbackDataParams) => {
const data = params.data as Record<string, any>;
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return `${params.marker} ${filterXSS(data.name)}<br>${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: [
{
// Root level (center)
r0: "0%",
r: "15%",
itemStyle: {
color: "transparent",
},
},
{
// First level
r0: "15%",
r: "55%",
label: { show: true },
},
{
// Second level
r0: "55%",
r: "80%",
},
{
// Third level
r0: "80%",
r: "95%",
},
],
} as SunburstSeriesOption;
}
);
static styles = css`
:host {
display: block;
flex: 1;
}
ha-chart-base {
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-sunburst-chart": HaSunburstChart;
}
}

View File

@@ -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
data: { max_depth: 3 },
});
}

View File

@@ -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 {
)}
>
<div class="card-content">
${this._renderStorageMetrics(
this._hostInfo,
this._storageInfo
)}
<storage-breakdown-chart
.hass=${this.hass}
.hostInfo=${this._hostInfo}
.storageInfo=${this._storageInfo}
></storage-breakdown-chart>
${this._renderDiskLifeTime(this._hostInfo.disk_life_time)}
</div>
${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}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(space)} GB</span
>`,
});
}
});
} else {
segments.push({
value: usedSpaceGB,
color: "var(--primary-color)",
label: html`${this.hass.localize(
"ui.panel.config.storage.segments.used"
)}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(usedSpaceGB)} GB</span
>`,
});
}
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"
)}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(freeSpaceGB)} GB</span
>`,
});
return html`<ha-segmented-bar
.heading=${this.hass.localize("ui.panel.config.storage.used_space")}
.description=${this.hass.localize(
"ui.panel.config.storage.detailed_description",
{
used: `${roundWithOneDecimal(usedSpaceGB)} GB`,
total: `${roundWithOneDecimal(totalSpaceGB)} GB`,
}
)}
.segments=${segments}
></ha-segmented-bar>
${!storageInfo || storageInfo === null
? html`<ha-alert alert-type="info">
<ha-spinner slot="icon"></ha-spinner>
${this.hass.localize(
"ui.panel.config.storage.loading_detailed"
)}</ha-alert
>`
: 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;
}
`;
}

View File

@@ -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`
<div class="header">
<div class="heading-text">
<span class="heading">${heading}</span>
<span class="description">${description}</span>
</div>
${hasChildren
? html`<ha-icon-button
.path=${this._chartType === "sunburst"
? mdiViewArray
: mdiChartDonutVariant}
.label=${this.hass.localize(
"ui.panel.config.storage.change_chart_type"
)}
@click=${this._handleChartTypeChange}
></ha-icon-button>`
: nothing}
</div>
<div class="chart-container ${this._chartType}">
${showBarChart
? html`<ha-segmented-bar
.heading=${""}
.segments=${this._computeSegments(
this.storageInfo,
usedSpaceGB,
freeSpaceGB
)}
></ha-segmented-bar>`
: html`<ha-sunburst-chart
.hass=${this.hass}
.data=${this._transformToSunburstData(this.storageInfo!)}
.valueFormatter=${this._formatBytes}
></ha-sunburst-chart>`}
</div>
${!this.storageInfo || this.storageInfo === null
? html`<ha-alert alert-type="info">
<ha-spinner slot="icon"></ha-spinner>
${this.hass.localize(
"ui.panel.config.storage.loading_detailed"
)}</ha-alert
>`
: 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}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(space)} GB</span
>`,
});
}
});
} else {
segments.push({
value: usedSpaceGB,
color: "var(--primary-color)",
label: html`${this.hass.localize(
"ui.panel.config.storage.segments.used"
)}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(usedSpaceGB)} GB</span
>`,
});
}
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"
)}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(freeSpaceGB)} GB</span
>`,
});
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: 8px;
}
.heading-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.heading {
font-weight: 500;
font-size: 14px;
color: var(--primary-text-color);
}
.description {
font-size: 12px;
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 0.3s ease;
overflow: hidden;
}
.chart-container.bar {
height: 100px;
}
.chart-container.sunburst {
height: 400px;
}
ha-segmented-bar {
display: block;
}
ha-sunburst-chart {
height: 400px;
}
ha-segmented-bar,
ha-sunburst-chart {
animation: fade-in 0.3s 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;
}
}

View File

@@ -30,6 +30,7 @@ import type {
CustomSeriesOption,
SankeySeriesOption,
GraphSeriesOption,
SunburstSeriesOption,
} from "echarts/charts";
import type {
// The component option types are defined with the ComponentOption suffix
@@ -55,6 +56,7 @@ export type ECOption = ComposeOption<
| VisualMapComponentOption
| SankeySeriesOption
| GraphSeriesOption
| SunburstSeriesOption
>;
// Register the required components

View File

@@ -6934,6 +6934,7 @@
"lifetime_description": "{lifetime} used",
"lifetime_used_description": "The drives 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!",