diff --git a/src/data/hassio/hardware.ts b/src/data/hassio/hardware.ts index eb7f562428..adf2c8ba12 100644 --- a/src/data/hassio/hardware.ts +++ b/src/data/hassio/hardware.ts @@ -15,7 +15,7 @@ interface HassioHardwareAudioList { }; } -interface HardwareDevice { +export interface HardwareDevice { attributes: Record; by_id: null | string; dev_path: string; diff --git a/src/dialogs/generic/dialog-box.ts b/src/dialogs/generic/dialog-box.ts index b3c3d60548..54126377cc 100644 --- a/src/dialogs/generic/dialog-box.ts +++ b/src/dialogs/generic/dialog-box.ts @@ -73,7 +73,6 @@ class DialogBox extends LitElement { return html` + ${this._params.subtitle + ? html`${this._params.subtitle}` + : nothing}
${this._params.text ? html`

${this._params.text}

` : ""} diff --git a/src/dialogs/generic/show-dialog-box.ts b/src/dialogs/generic/show-dialog-box.ts index 3ced5e5588..e60315ab3b 100644 --- a/src/dialogs/generic/show-dialog-box.ts +++ b/src/dialogs/generic/show-dialog-box.ts @@ -5,6 +5,7 @@ interface BaseDialogBoxParams { confirmText?: string; text?: string | TemplateResult; title?: string; + subtitle?: string; warning?: boolean; } diff --git a/src/panels/config/hardware/dialog-hardware-available.ts b/src/panels/config/hardware/dialog-hardware-available.ts deleted file mode 100644 index 068f561ab0..0000000000 --- a/src/panels/config/hardware/dialog-hardware-available.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { dump } from "js-yaml"; -import type { CSSResultGroup } from "lit"; -import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import memoizeOne from "memoize-one"; -import { fireEvent } from "../../../common/dom/fire_event"; -import { stringCompare } from "../../../common/string/compare"; -import "../../../components/ha-dialog"; -import "../../../components/ha-expansion-panel"; -import "../../../components/ha-icon-next"; -import "../../../components/input/ha-input-search"; -import type { HaInputSearch } from "../../../components/input/ha-input-search"; -import { extractApiErrorMessage } from "../../../data/hassio/common"; -import type { HassioHardwareInfo } from "../../../data/hassio/hardware"; -import { fetchHassioHardwareInfo } from "../../../data/hassio/hardware"; -import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; -import type { HassDialog } from "../../../dialogs/make-dialog-manager"; -import { haStyleScrollbar } from "../../../resources/styles"; -import type { HomeAssistant } from "../../../types"; - -const _filterDevices = memoizeOne( - ( - showAdvanced: boolean, - hardware: HassioHardwareInfo, - filter: string, - language: string - ) => - hardware.devices - .filter( - (device) => - (showAdvanced || - ["tty", "gpio", "input"].includes(device.subsystem)) && - (device.by_id?.toLowerCase().includes(filter) || - device.name.toLowerCase().includes(filter) || - device.dev_path.toLocaleLowerCase().includes(filter) || - JSON.stringify(device.attributes) - .toLocaleLowerCase() - .includes(filter)) - ) - .sort((a, b) => stringCompare(a.name, b.name, language)) -); - -@customElement("ha-dialog-hardware-available") -class DialogHardwareAvailable extends LitElement implements HassDialog { - @property({ attribute: false }) public hass!: HomeAssistant; - - @state() private _hardware?: HassioHardwareInfo; - - @state() private _filter?: string; - - @state() private _open = false; - - public async showDialog(): Promise> { - try { - this._hardware = await fetchHassioHardwareInfo(this.hass); - this._open = true; - } catch (err: any) { - await showAlertDialog(this, { - title: this.hass.localize( - "ui.panel.config.hardware.available_hardware.failed_to_get" - ), - text: extractApiErrorMessage(err), - }); - } - } - - public closeDialog(): boolean { - this._open = false; - return true; - } - - private _dialogClosed() { - this._open = false; - this._hardware = undefined; - fireEvent(this, "dialog-closed", { dialog: this.localName }); - } - - protected render() { - if (!this._hardware) { - return nothing; - } - - const devices = _filterDevices( - this.hass.userData?.showAdvanced || false, - this._hardware, - (this._filter || "").toLowerCase(), - this.hass.locale.language - ); - - return html` - -
- - -
- ${devices.map( - (device) => html` - -
- - ${this.hass.localize( - "ui.panel.config.hardware.available_hardware.subsystem" - )}: - - ${device.subsystem} -
-
- - ${this.hass.localize( - "ui.panel.config.hardware.available_hardware.device_path" - )}: - - ${device.dev_path} -
- ${device.by_id - ? html` -
- - ${this.hass.localize( - "ui.panel.config.hardware.available_hardware.id" - )}: - - ${device.by_id} -
- ` - : nothing} -
- - ${this.hass.localize( - "ui.panel.config.hardware.available_hardware.attributes" - )}: - -
${dump(device.attributes, { indent: 2 })}
-
-
- ` - )} -
-
-
- `; - } - - private _handleSearchChange(ev: InputEvent) { - this._filter = (ev.target as HaInputSearch).value ?? ""; - } - - static get styles(): CSSResultGroup { - return [ - haStyleScrollbar, - css` - ha-dialog { - --dialog-content-padding: 0; - } - .content-container { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - overflow: hidden; - } - .devices-container { - padding: var(--ha-space-6); - overflow-y: auto; - flex: 1; - min-height: 0; - } - ha-expansion-panel { - flex: 1; - margin: 4px 0; - } - pre, - code { - background-color: var(--markdown-code-background-color, none); - border-radius: var(--ha-border-radius-sm); - } - pre { - padding: 16px; - overflow: auto; - line-height: var(--ha-line-height-normal); - font-family: var(--ha-font-family-code); - } - code { - font-size: var(--ha-font-size-s); - padding: 0.2em 0.4em; - } - ha-input-search { - padding: 0 var(--ha-space-5); - } - .device-property { - display: flex; - justify-content: space-between; - } - .attributes { - margin-top: 12px; - } - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ha-dialog-hardware-available": DialogHardwareAvailable; - } -} diff --git a/src/panels/config/hardware/ha-config-hardware-all.ts b/src/panels/config/hardware/ha-config-hardware-all.ts new file mode 100644 index 0000000000..d24398938e --- /dev/null +++ b/src/panels/config/hardware/ha-config-hardware-all.ts @@ -0,0 +1,166 @@ +import { dump } from "js-yaml"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { until } from "lit/directives/until"; +import memoizeOne from "memoize-one"; +import type { LocalizeFunc } from "../../../common/translations/localize"; +import "../../../components/data-table/ha-data-table"; +import type { + DataTableColumnContainer, + DataTableRowData, + RowClickedEvent, +} from "../../../components/data-table/ha-data-table"; +import { extractApiErrorMessage } from "../../../data/hassio/common"; +import type { + HardwareDevice, + HassioHardwareInfo, +} from "../../../data/hassio/hardware"; +import { fetchHassioHardwareInfo } from "../../../data/hassio/hardware"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-tabs-subpage-data-table"; +import type { HomeAssistant, Route } from "../../../types"; +import { hardwareTabs } from "./ha-config-hardware"; + +interface HardwareDeviceRow extends HardwareDevice { + id: string; + attributes_string: string; +} + +@customElement("ha-config-hardware-all") +class HaConfigHardwareAll extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: false }) public route!: Route; + + @state() private _hardware?: HassioHardwareInfo; + + @state() private _error?: string; + + private _columns = memoizeOne( + (localize: LocalizeFunc): DataTableColumnContainer => ({ + name: { + title: localize("ui.panel.config.hardware.system_hardware.name"), + main: true, + sortable: true, + filterable: true, + flex: 2, + }, + dev_path: { + title: localize("ui.panel.config.hardware.system_hardware.device_path"), + sortable: true, + filterable: true, + flex: 2, + }, + by_id: { + title: localize("ui.panel.config.hardware.system_hardware.id"), + sortable: true, + filterable: true, + flex: 2, + }, + subsystem: { + title: localize("ui.panel.config.hardware.system_hardware.subsystem"), + sortable: true, + filterable: true, + flex: 1, + }, + attributes_string: { + title: "", + filterable: true, + hidden: true, + }, + }) + ); + + private _data = memoizeOne( + (showAdvanced: boolean, hardware: HassioHardwareInfo): DataTableRowData[] => + hardware.devices + .filter( + (device) => + showAdvanced || ["tty", "gpio", "input"].includes(device.subsystem) + ) + .map((device) => ({ + ...device, + id: device.dev_path, + attributes_string: Object.entries(device.attributes) + .map(([key, value]) => `${key}: ${value}`) + .join(" "), + })) + ); + + protected firstUpdated(): void { + this._load(); + } + + protected render() { + return html` + + `; + } + + private async _load() { + try { + this._hardware = await fetchHassioHardwareInfo(this.hass); + } catch (err: any) { + this._error = extractApiErrorMessage(err); + } + } + + private _handleRowClicked(ev: CustomEvent) { + const id = ev.detail.id; + const device = this._hardware?.devices.find((dev) => dev.dev_path === id); + if (!device) { + return; + } + + showAlertDialog(this, { + title: device.name, + subtitle: this.hass.localize( + "ui.panel.config.hardware.system_hardware.attributes" + ), + text: html`${until(this._renderHaCodeEditor(device))}`, + }); + } + + private async _renderHaCodeEditor(device: HardwareDevice) { + await import("../../../components/ha-code-editor"); + + return html``; + } + + static styles: CSSResultGroup = css` + ha-code-editor { + direction: var(--direction); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-hardware-all": HaConfigHardwareAll; + } +} diff --git a/src/panels/config/hardware/ha-config-hardware-overview.ts b/src/panels/config/hardware/ha-config-hardware-overview.ts new file mode 100644 index 0000000000..d6716d55fd --- /dev/null +++ b/src/panels/config/hardware/ha-config-hardware-overview.ts @@ -0,0 +1,571 @@ +import { mdiPower } from "@mdi/js"; +import type { SeriesOption } from "echarts/types/dist/shared"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { round } from "../../../common/number/round"; +import { blankBeforePercent } from "../../../common/translations/blank_before_percent"; +import "../../../components/chart/ha-chart-base"; +import "../../../components/ha-alert"; +import "../../../components/ha-button"; +import "../../../components/ha-card"; +import "../../../components/ha-fade-in"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-icon-next"; +import "../../../components/ha-md-list-item"; +import "../../../components/ha-spinner"; +import type { ConfigEntry } from "../../../data/config_entries"; +import { subscribeConfigEntries } from "../../../data/config_entries"; +import type { + HardwareInfo, + SystemStatusStreamMessage, +} from "../../../data/hardware"; +import { BOARD_NAMES } from "../../../data/hardware"; +import { extractApiErrorMessage } from "../../../data/hassio/common"; +import type { HassioHassOSInfo } from "../../../data/hassio/host"; +import { fetchHassioHassOsInfo } from "../../../data/hassio/host"; +import { scanUSBDevices } from "../../../data/usb"; +import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; +import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"; +import "../../../layouts/hass-tabs-subpage"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; +import type { ECOption } from "../../../resources/echarts/echarts"; +import { haStyle } from "../../../resources/styles"; +import { DefaultPrimaryColor } from "../../../resources/theme/color/color.globals"; +import type { HomeAssistant, Route } from "../../../types"; +import { hardwareBrandsUrl } from "../../../util/brands-url"; +import { hardwareTabs } from "./ha-config-hardware"; + +const DATASAMPLES = 60; + +const DATA_SET_CONFIG: SeriesOption = { + type: "line", + color: DefaultPrimaryColor, + areaStyle: { + color: DefaultPrimaryColor + "2B", + }, + symbolSize: 0, + lineStyle: { + width: 1, + }, + smooth: 0.25, +}; + +@customElement("ha-config-hardware-overview") +class HaConfigHardwareOverview extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: false }) public route!: Route; + + @state() private _error?: string; + + @state() private _OSData?: HassioHassOSInfo; + + @state() private _hardwareInfo?: HardwareInfo; + + @state() private _chartOptions?: ECOption; + + @state() private _systemStatusData?: SystemStatusStreamMessage; + + @state() private _configEntries?: Record; + + private _memoryEntries: [number, number | null][] = []; + + private _cpuEntries: [number, number | null][] = []; + + public hassSubscribe(): (UnsubscribeFunc | Promise)[] { + const subs = [ + subscribeConfigEntries( + this.hass, + (messages) => { + let fullUpdate = false; + const newEntries: ConfigEntry[] = []; + messages.forEach((message) => { + if (message.type === null || message.type === "added") { + newEntries.push(message.entry); + if (message.type === null) { + fullUpdate = true; + } + } else if (message.type === "removed") { + if (this._configEntries) { + delete this._configEntries[message.entry.entry_id]; + } + } else if (message.type === "updated") { + if (this._configEntries) { + const newEntry = message.entry; + this._configEntries[message.entry.entry_id] = newEntry; + } + } + }); + if (!newEntries.length && !fullUpdate) { + return; + } + const entries = [ + ...(fullUpdate ? [] : Object.values(this._configEntries || {})), + ...newEntries, + ]; + const configEntries: Record = {}; + for (const entry of entries) { + configEntries[entry.entry_id] = entry; + } + this._configEntries = configEntries; + }, + { type: ["hardware"] } + ), + ]; + + if (isComponentLoaded(this.hass, "hardware")) { + subs.push( + this.hass.connection.subscribeMessage( + (message) => { + // Only store the last 60 entries + this._memoryEntries.shift(); + this._cpuEntries.shift(); + + this._memoryEntries.push([ + new Date(message.timestamp).getTime(), + message.memory_used_percent, + ]); + this._cpuEntries.push([ + new Date(message.timestamp).getTime(), + message.cpu_percent, + ]); + + this._systemStatusData = message; + }, + { + type: "hardware/subscribe_system_status", + } + ) + ); + } + + return subs; + } + + protected willUpdate(): void { + if (!this.hasUpdated && !this._chartOptions) { + this._chartOptions = { + xAxis: { + type: "time", + }, + yAxis: { + type: "value", + min: 0, + max: 100, + splitLine: { + show: true, + }, + axisLabel: { + formatter: (value: number) => + value + blankBeforePercent(this.hass.locale) + "%", + }, + axisLine: { + show: false, + }, + scale: true, + }, + grid: { + top: 10, + bottom: 10, + left: 10, + right: 10, + containLabel: true, + }, + tooltip: { + trigger: "axis", + valueFormatter: (value) => + value + blankBeforePercent(this.hass.locale) + "%", + }, + }; + } + } + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this._load(); + + const date = new Date(); + // Force graph to start drawing from the right + for (let i = 0; i < DATASAMPLES; i++) { + const t = new Date(date); + t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i)); + this._memoryEntries.push([t.getTime(), null]); + this._cpuEntries.push([t.getTime(), null]); + } + } + + protected render() { + let boardId: string | undefined; + let boardName: string | undefined; + let imageURL: string | undefined; + let documentationURL: string | undefined; + let boardConfigEntries: ConfigEntry[] = []; + + const boardData = this._hardwareInfo?.hardware.find( + (hw) => hw.board !== null + ); + + const dongles = this._hardwareInfo?.hardware.filter( + (hw) => + hw.dongle !== null && + (!hw.config_entries.length || + hw.config_entries.some( + (entryId) => + this._configEntries?.[entryId] && + !this._configEntries[entryId].disabled_by + )) + ); + + if (boardData) { + boardConfigEntries = boardData.config_entries + .map((id) => this._configEntries?.[id]) + .filter( + (entry) => entry?.supports_options && !entry.disabled_by + ) as ConfigEntry[]; + boardId = boardData.board!.hassio_board_id; + boardName = boardData.name; + documentationURL = boardData.url; + imageURL = hardwareBrandsUrl( + { + category: "boards", + manufacturer: boardData.board!.manufacturer, + model: boardData.board!.model, + darkOptimized: this.hass.themes?.darkMode, + }, + this.hass.auth.data.hassUrl + ); + } else if (this._OSData?.board) { + boardId = this._OSData.board; + boardName = BOARD_NAMES[this._OSData.board]; + } + + return html` + + ${isComponentLoaded(this.hass, "hassio") + ? html` + + ` + : nothing} + ${this._error + ? html`${this._error}` + : nothing} +
+ ${boardName || isComponentLoaded(this.hass, "hassio") + ? html` + +
+ ${imageURL + ? html`` + : nothing} +
+

+ ${boardName || + this.hass.localize( + "ui.panel.config.hardware.generic_hardware" + )} +

+ ${boardId + ? html`

${boardId}

` + : nothing} +
+
+ ${documentationURL + ? html` + + ${this.hass.localize( + "ui.panel.config.hardware.documentation" + )} + ${this.hass.localize( + "ui.panel.config.hardware.documentation_description" + )} + + + ` + : nothing} + ${boardConfigEntries.length + ? html`
+ + ${this.hass.localize( + "ui.panel.config.hardware.configure" + )} + +
` + : nothing} +
+ ` + : nothing} + ${dongles?.length + ? html` + ${dongles.map((dongle) => { + const configEntry = dongle.config_entries + .map((id) => this._configEntries?.[id]) + .filter( + (entry) => entry?.supports_options && !entry.disabled_by + )[0]; + return html`
+ ${dongle.name}${configEntry + ? html` + ${this.hass.localize( + "ui.panel.config.hardware.configure" + )} + ` + : nothing} +
`; + })} +
` + : nothing} + ${isComponentLoaded(this.hass, "hardware") + ? html` +
+
+ ${this.hass.localize( + "ui.panel.config.hardware.processor" + )} +
+
+ ${this._systemStatusData + ? html`${this._systemStatusData + .cpu_percent}${blankBeforePercent( + this.hass.locale + )}%` + : "-"} +
+
+
+ + ${!this._systemStatusData + ? html` + + ` + : nothing} +
+
+ +
+
+ ${this.hass.localize("ui.panel.config.hardware.memory")} +
+
+ ${this._systemStatusData + ? html`${round( + this._systemStatusData.memory_used_mb / 1024, + 1 + )} + GB / + ${round( + (this._systemStatusData.memory_used_mb + + this._systemStatusData.memory_free_mb) / + 1024, + 0 + )} + GB` + : "-"} +
+
+
+ + ${!this._systemStatusData + ? html` + + + + ` + : nothing} +
+
` + : nothing} +
+
+ `; + } + + private async _load() { + if (isComponentLoaded(this.hass, "usb")) { + await scanUSBDevices(this.hass); + } + + const isHassioLoaded = isComponentLoaded(this.hass, "hassio"); + try { + if (isComponentLoaded(this.hass, "hardware")) { + this._hardwareInfo = await this.hass.callWS({ type: "hardware/info" }); + } + + if (isHassioLoaded && !this._hardwareInfo?.hardware.length) { + this._OSData = await fetchHassioHassOsInfo(this.hass); + } + } catch (err: any) { + this._error = extractApiErrorMessage(err); + } + } + + private async _openOptionsFlow(ev) { + const entry = ev.currentTarget.entry; + if (!entry) { + return; + } + showOptionsFlowDialog(this, entry); + } + + private async _showRestartDialog() { + showRestartDialog(this); + } + + private _getChartData = memoizeOne( + (entries: [number, number | null][]): SeriesOption[] => [ + { + ...DATA_SET_CONFIG, + id: entries === this._cpuEntries ? "cpu" : "memory", + name: + entries === this._cpuEntries + ? this.hass.localize("ui.panel.config.hardware.processor") + : this.hass.localize("ui.panel.config.hardware.memory"), + data: entries, + } as SeriesOption, + ] + ); + + static styles = [ + haStyle, + css` + .content { + padding: 28px 20px 0; + max-width: 1040px; + margin: 0 auto; + --mdc-list-side-padding: 24px; + --mdc-list-vertical-padding: 0; + } + ha-card { + max-width: 600px; + margin: 0 auto; + height: 100%; + justify-content: space-between; + flex-direction: column; + display: flex; + margin-bottom: 16px; + } + .card-content { + display: flex; + justify-content: space-between; + flex-direction: column; + padding: 16px; + } + + .loading-container { + position: relative; + } + + .loading-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: rgba(var(--rgb-card-background-color), 0.75); + display: flex; + justify-content: center; + align-items: center; + } + .card-content img { + max-width: 300px; + margin: auto; + } + .board-info { + text-align: center; + } + .primary-text { + font-size: var(--ha-font-size-l); + margin: 0; + } + .secondary-text { + font-size: var(--ha-font-size-m); + margin-bottom: 0; + color: var(--secondary-text-color); + } + + .header { + padding: 16px; + display: flex; + justify-content: space-between; + } + + .header .title { + color: var(--secondary-text-color); + font-size: var(--ha-font-size-l); + } + + .header .value { + font-size: var(--ha-font-size-l); + } + .row { + display: flex; + justify-content: space-between; + align-items: center; + height: 48px; + padding: 8px 16px; + } + .card-actions { + display: flex; + justify-content: space-between; + } + + ha-alert { + --ha-alert-icon-size: 24px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-hardware-overview": HaConfigHardwareOverview; + } +} diff --git a/src/panels/config/hardware/ha-config-hardware.ts b/src/panels/config/hardware/ha-config-hardware.ts index 8d37d9b871..4742782a84 100644 --- a/src/panels/config/hardware/ha-config-hardware.ts +++ b/src/panels/config/hardware/ha-config-hardware.ts @@ -1,585 +1,66 @@ -import { mdiPower } from "@mdi/js"; -import type { SeriesOption } from "echarts/types/dist/shared"; -import type { UnsubscribeFunc } from "home-assistant-js-websocket"; -import type { PropertyValues } from "lit"; -import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import memoizeOne from "memoize-one"; +import { mdiFormatListBulletedType, mdiMemory } from "@mdi/js"; +import { customElement, property } from "lit/decorators"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import { round } from "../../../common/number/round"; -import { blankBeforePercent } from "../../../common/translations/blank_before_percent"; -import "../../../components/chart/ha-chart-base"; -import "../../../components/ha-alert"; -import "../../../components/ha-button"; -import "../../../components/ha-card"; -import "../../../components/ha-fade-in"; -import "../../../components/ha-icon-button"; -import "../../../components/ha-icon-next"; -import "../../../components/ha-md-list-item"; -import "../../../components/ha-spinner"; -import type { ConfigEntry } from "../../../data/config_entries"; -import { subscribeConfigEntries } from "../../../data/config_entries"; -import type { - HardwareInfo, - SystemStatusStreamMessage, -} from "../../../data/hardware"; -import { BOARD_NAMES } from "../../../data/hardware"; -import { extractApiErrorMessage } from "../../../data/hassio/common"; -import type { HassioHassOSInfo } from "../../../data/hassio/host"; -import { fetchHassioHassOsInfo } from "../../../data/hassio/host"; -import { scanUSBDevices } from "../../../data/usb"; -import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; -import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"; -import "../../../layouts/hass-subpage"; -import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; -import type { ECOption } from "../../../resources/echarts/echarts"; -import { haStyle } from "../../../resources/styles"; -import { DefaultPrimaryColor } from "../../../resources/theme/color/color.globals"; +import type { RouterOptions } from "../../../layouts/hass-router-page"; +import { HassRouterPage } from "../../../layouts/hass-router-page"; +import type { PageNavigation } from "../../../layouts/hass-tabs-subpage"; import type { HomeAssistant } from "../../../types"; -import { hardwareBrandsUrl } from "../../../util/brands-url"; -import { showhardwareAvailableDialog } from "./show-dialog-hardware-available"; -const DATASAMPLES = 60; +export const hardwareTabs = (hass: HomeAssistant): PageNavigation[] => { + const tabs: PageNavigation[] = [ + { + path: "/config/hardware/overview", + translationKey: "ui.panel.config.hardware.overview", + iconPath: mdiMemory, + }, + ]; -const DATA_SET_CONFIG: SeriesOption = { - type: "line", - color: DefaultPrimaryColor, - areaStyle: { - color: DefaultPrimaryColor + "2B", - }, - symbolSize: 0, - lineStyle: { - width: 1, - }, - smooth: 0.25, + if (isComponentLoaded(hass, "hassio")) { + tabs.push({ + path: "/config/hardware/all", + translationKey: "ui.panel.config.hardware.system_hardware.title", + iconPath: mdiFormatListBulletedType, + }); + } + + return tabs; }; @customElement("ha-config-hardware") -class HaConfigHardware extends SubscribeMixin(LitElement) { +class HaConfigHardware extends HassRouterPage { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public narrow = false; - @state() private _error?: string; + protected routerOptions: RouterOptions = { + defaultPage: "overview", + routes: { + overview: { + tag: "ha-config-hardware-overview", + load: () => import("./ha-config-hardware-overview"), + cache: true, + }, + all: { + tag: "ha-config-hardware-all", + load: () => import("./ha-config-hardware-all"), + }, + }, + beforeRender: (page) => { + if ( + page === "all" && + (!this.hass || !isComponentLoaded(this.hass, "hassio")) + ) { + return "overview"; + } + return undefined; + }, + }; - @state() private _OSData?: HassioHassOSInfo; - - @state() private _hardwareInfo?: HardwareInfo; - - @state() private _chartOptions?: ECOption; - - @state() private _systemStatusData?: SystemStatusStreamMessage; - - @state() private _configEntries?: Record; - - private _memoryEntries: [number, number | null][] = []; - - private _cpuEntries: [number, number | null][] = []; - - public hassSubscribe(): (UnsubscribeFunc | Promise)[] { - const subs = [ - subscribeConfigEntries( - this.hass, - (messages) => { - let fullUpdate = false; - const newEntries: ConfigEntry[] = []; - messages.forEach((message) => { - if (message.type === null || message.type === "added") { - newEntries.push(message.entry); - if (message.type === null) { - fullUpdate = true; - } - } else if (message.type === "removed") { - if (this._configEntries) { - delete this._configEntries[message.entry.entry_id]; - } - } else if (message.type === "updated") { - if (this._configEntries) { - const newEntry = message.entry; - this._configEntries[message.entry.entry_id] = newEntry; - } - } - }); - if (!newEntries.length && !fullUpdate) { - return; - } - const entries = [ - ...(fullUpdate ? [] : Object.values(this._configEntries || {})), - ...newEntries, - ]; - const configEntries: Record = {}; - for (const entry of entries) { - configEntries[entry.entry_id] = entry; - } - this._configEntries = configEntries; - }, - { type: ["hardware"] } - ), - ]; - - if (isComponentLoaded(this.hass, "hardware")) { - subs.push( - this.hass.connection.subscribeMessage( - (message) => { - // Only store the last 60 entries - this._memoryEntries.shift(); - this._cpuEntries.shift(); - - this._memoryEntries.push([ - new Date(message.timestamp).getTime(), - message.memory_used_percent, - ]); - this._cpuEntries.push([ - new Date(message.timestamp).getTime(), - message.cpu_percent, - ]); - - this._systemStatusData = message; - }, - { - type: "hardware/subscribe_system_status", - } - ) - ); - } - - return subs; + protected updatePageEl(pageEl) { + pageEl.hass = this.hass; + pageEl.narrow = this.narrow; + pageEl.route = this.routeTail; } - - protected willUpdate(): void { - if (!this.hasUpdated && !this._chartOptions) { - this._chartOptions = { - xAxis: { - type: "time", - }, - yAxis: { - type: "value", - min: 0, - max: 100, - splitLine: { - show: true, - }, - axisLabel: { - formatter: (value: number) => - value + blankBeforePercent(this.hass.locale) + "%", - }, - axisLine: { - show: false, - }, - scale: true, - }, - grid: { - top: 10, - bottom: 10, - left: 10, - right: 10, - containLabel: true, - }, - tooltip: { - trigger: "axis", - valueFormatter: (value) => - value + blankBeforePercent(this.hass.locale) + "%", - }, - }; - } - } - - protected firstUpdated(changedProps: PropertyValues) { - super.firstUpdated(changedProps); - this._load(); - - const date = new Date(); - // Force graph to start drawing from the right - for (let i = 0; i < DATASAMPLES; i++) { - const t = new Date(date); - t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i)); - this._memoryEntries.push([t.getTime(), null]); - this._cpuEntries.push([t.getTime(), null]); - } - } - - protected render() { - let boardId: string | undefined; - let boardName: string | undefined; - let imageURL: string | undefined; - let documentationURL: string | undefined; - let boardConfigEntries: ConfigEntry[] = []; - - const boardData = this._hardwareInfo?.hardware.find( - (hw) => hw.board !== null - ); - - const dongles = this._hardwareInfo?.hardware.filter( - (hw) => - hw.dongle !== null && - (!hw.config_entries.length || - hw.config_entries.some( - (entryId) => - this._configEntries?.[entryId] && - !this._configEntries[entryId].disabled_by - )) - ); - - if (boardData) { - boardConfigEntries = boardData.config_entries - .map((id) => this._configEntries?.[id]) - .filter( - (entry) => entry?.supports_options && !entry.disabled_by - ) as ConfigEntry[]; - boardId = boardData.board!.hassio_board_id; - boardName = boardData.name; - documentationURL = boardData.url; - imageURL = hardwareBrandsUrl( - { - category: "boards", - manufacturer: boardData.board!.manufacturer, - model: boardData.board!.model, - darkOptimized: this.hass.themes?.darkMode, - }, - this.hass.auth.data.hassUrl - ); - } else if (this._OSData?.board) { - boardId = this._OSData.board; - boardName = BOARD_NAMES[this._OSData.board]; - } - - return html` - - ${isComponentLoaded(this.hass, "hassio") - ? html` - - ` - : nothing} - ${this._error - ? html`${this._error}` - : nothing} -
- ${boardName || isComponentLoaded(this.hass, "hassio") - ? html` - -
- ${imageURL - ? html`` - : nothing} -
-

- ${boardName || - this.hass.localize( - "ui.panel.config.hardware.generic_hardware" - )} -

- ${boardId - ? html`

${boardId}

` - : nothing} -
-
- ${documentationURL - ? html` - - ${this.hass.localize( - "ui.panel.config.hardware.documentation" - )} - ${this.hass.localize( - "ui.panel.config.hardware.documentation_description" - )} - - - ` - : nothing} - ${boardConfigEntries.length || - isComponentLoaded(this.hass, "hassio") - ? html`
- ${boardConfigEntries.length - ? html` - - ${this.hass.localize( - "ui.panel.config.hardware.configure" - )} - - ` - : nothing} - ${isComponentLoaded(this.hass, "hassio") - ? html` - - ${this.hass.localize( - "ui.panel.config.hardware.available_hardware.title" - )} - - ` - : nothing} -
` - : nothing} -
- ` - : nothing} - ${dongles?.length - ? html` - ${dongles.map((dongle) => { - const configEntry = dongle.config_entries - .map((id) => this._configEntries?.[id]) - .filter( - (entry) => entry?.supports_options && !entry.disabled_by - )[0]; - return html`
- ${dongle.name}${configEntry - ? html` - ${this.hass.localize( - "ui.panel.config.hardware.configure" - )} - ` - : nothing} -
`; - })} -
` - : nothing} - ${isComponentLoaded(this.hass, "hardware") - ? html` -
-
- ${this.hass.localize( - "ui.panel.config.hardware.processor" - )} -
-
- ${this._systemStatusData - ? html`${this._systemStatusData - .cpu_percent}${blankBeforePercent( - this.hass.locale - )}%` - : "-"} -
-
-
- - ${!this._systemStatusData - ? html` - - ` - : nothing} -
-
- -
-
- ${this.hass.localize("ui.panel.config.hardware.memory")} -
-
- ${this._systemStatusData - ? html`${round( - this._systemStatusData.memory_used_mb / 1024, - 1 - )} - GB / - ${round( - (this._systemStatusData.memory_used_mb + - this._systemStatusData.memory_free_mb) / - 1024, - 0 - )} - GB` - : "-"} -
-
-
- - ${!this._systemStatusData - ? html` - - - - ` - : nothing} -
-
` - : nothing} -
-
- `; - } - - private async _load() { - if (isComponentLoaded(this.hass, "usb")) { - await scanUSBDevices(this.hass); - } - - const isHassioLoaded = isComponentLoaded(this.hass, "hassio"); - try { - if (isComponentLoaded(this.hass, "hardware")) { - this._hardwareInfo = await this.hass.callWS({ type: "hardware/info" }); - } - - if (isHassioLoaded && !this._hardwareInfo?.hardware.length) { - this._OSData = await fetchHassioHassOsInfo(this.hass); - } - } catch (err: any) { - this._error = extractApiErrorMessage(err); - } - } - - private async _openOptionsFlow(ev) { - const entry = ev.currentTarget.entry; - if (!entry) { - return; - } - showOptionsFlowDialog(this, entry); - } - - private async _openHardware() { - showhardwareAvailableDialog(this); - } - - private async _showRestartDialog() { - showRestartDialog(this); - } - - private _getChartData = memoizeOne( - (entries: [number, number | null][]): SeriesOption[] => [ - { - ...DATA_SET_CONFIG, - id: entries === this._cpuEntries ? "cpu" : "memory", - name: - entries === this._cpuEntries - ? this.hass.localize("ui.panel.config.hardware.processor") - : this.hass.localize("ui.panel.config.hardware.memory"), - data: entries, - } as SeriesOption, - ] - ); - - static styles = [ - haStyle, - css` - .content { - padding: 28px 20px 0; - max-width: 1040px; - margin: 0 auto; - --mdc-list-side-padding: 24px; - --mdc-list-vertical-padding: 0; - } - ha-card { - max-width: 600px; - margin: 0 auto; - height: 100%; - justify-content: space-between; - flex-direction: column; - display: flex; - margin-bottom: 16px; - } - .card-content { - display: flex; - justify-content: space-between; - flex-direction: column; - padding: 16px; - } - - .loading-container { - position: relative; - } - - .loading-overlay { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - background-color: rgba(var(--rgb-card-background-color), 0.75); - display: flex; - justify-content: center; - align-items: center; - } - .card-content img { - max-width: 300px; - margin: auto; - } - .board-info { - text-align: center; - } - .primary-text { - font-size: var(--ha-font-size-l); - margin: 0; - } - .secondary-text { - font-size: var(--ha-font-size-m); - margin-bottom: 0; - color: var(--secondary-text-color); - } - - .header { - padding: 16px; - display: flex; - justify-content: space-between; - } - - .header .title { - color: var(--secondary-text-color); - font-size: var(--ha-font-size-l); - } - - .header .value { - font-size: var(--ha-font-size-l); - } - .row { - display: flex; - justify-content: space-between; - align-items: center; - height: 48px; - padding: 8px 16px; - } - .card-actions { - display: flex; - justify-content: space-between; - } - - ha-alert { - --ha-alert-icon-size: 24px; - } - `, - ]; } declare global { diff --git a/src/panels/config/hardware/show-dialog-hardware-available.ts b/src/panels/config/hardware/show-dialog-hardware-available.ts deleted file mode 100644 index c11356ae8b..0000000000 --- a/src/panels/config/hardware/show-dialog-hardware-available.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { fireEvent } from "../../../common/dom/fire_event"; - -export const loadHardwareAvailableDialog = () => - import("./dialog-hardware-available"); - -export const showhardwareAvailableDialog = (element: HTMLElement): void => { - fireEvent(element, "show-dialog", { - dialogTag: "ha-dialog-hardware-available", - dialogImport: loadHardwareAvailableDialog, - dialogParams: {}, - }); -}; diff --git a/src/panels/config/storage/dialog-move-datadisk.ts b/src/panels/config/storage/dialog-move-datadisk.ts index 82ceab4150..84acb6711a 100644 --- a/src/panels/config/storage/dialog-move-datadisk.ts +++ b/src/panels/config/storage/dialog-move-datadisk.ts @@ -4,11 +4,11 @@ import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-button"; +import "../../../components/ha-dialog"; import "../../../components/ha-dialog-footer"; import "../../../components/ha-select"; import type { HaSelectSelectEvent } from "../../../components/ha-select"; import "../../../components/ha-spinner"; -import "../../../components/ha-dialog"; import { extractApiErrorMessage, ignoreSupervisorError, @@ -79,7 +79,7 @@ class MoveDatadiskDialog extends LitElement { this.closeDialog(); await showAlertDialog(this, { title: this.hass.localize( - "ui.panel.config.hardware.available_hardware.failed_to_get" + "ui.panel.config.hardware.system_hardware.failed_to_get" ), text: extractApiErrorMessage(err), }); diff --git a/src/translations/en.json b/src/translations/en.json index 9f7fd42daa..1f21b59099 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1489,7 +1489,7 @@ "network": "[%key:ui::panel::config::network::caption%]", "updates": "[%key:ui::panel::config::updates::caption%]", "repairs": "[%key:ui::panel::config::repairs::caption%]", - "hardware": "[%key:ui::panel::config::hardware::caption%]", + "hardware": "Hardware", "storage": "[%key:ui::panel::config::storage::caption%]", "general": "[%key:ui::panel::config::core::caption%]", "backups": "[%key:ui::panel::config::backup::caption%]", @@ -4222,12 +4222,13 @@ "invalid_url": "Invalid URL" }, "hardware": { - "caption": "Hardware", + "overview": "Overview", "description": "Configure your hub and connected hardware", - "available_hardware": { + "system_hardware": { "failed_to_get": "Failed to get available hardware", - "title": "All hardware", + "title": "System hardware", "search": "Search hardware", + "name": "Name", "subsystem": "Subsystem", "device_path": "Device path", "id": "ID",