diff --git a/src/data/supervisor/mounts.ts b/src/data/supervisor/mounts.ts new file mode 100644 index 0000000000..6c2f19a01a --- /dev/null +++ b/src/data/supervisor/mounts.ts @@ -0,0 +1,113 @@ +import { HomeAssistant } from "../../types"; + +export enum SupervisorMountType { + BIND = "bind", + CIFS = "cifs", + NFS = "nfs", +} + +export enum SupervisorMountUsage { + BACKUP = "backup", + MEDIA = "media", +} + +export enum SupervisorMountState { + ACTIVE = "active", + FAILED = "failed", + UNKNOWN = "unknown", +} + +interface SupervisorMountBase { + name: string; + usage: SupervisorMountUsage; + type: SupervisorMountType; + server: string; + port: number; +} + +export interface SupervisorMountResponse extends SupervisorMountBase { + state: SupervisorMountState | null; +} + +export interface SupervisorNFSMount extends SupervisorMountResponse { + type: SupervisorMountType.NFS; + path: string; +} + +export interface SupervisorCIFSMount extends SupervisorMountResponse { + type: SupervisorMountType.CIFS; + share: string; +} + +export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount; + +export type SupervisorNFSMountRequestParams = SupervisorNFSMount; + +export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount { + username?: string; + password?: string; +} + +export type SupervisorMountRequestParams = + | SupervisorNFSMountRequestParams + | SupervisorCIFSMountRequestParams; + +export interface SupervisorMounts { + mounts: SupervisorMount[]; +} + +export const fetchSupervisorMounts = async ( + hass: HomeAssistant +): Promise => + hass.callWS({ + type: "supervisor/api", + endpoint: `/mounts`, + method: "get", + timeout: null, + }); + +export const createSupervisorMount = async ( + hass: HomeAssistant, + data: SupervisorMountRequestParams +): Promise => + hass.callWS({ + type: "supervisor/api", + endpoint: `/mounts`, + method: "post", + timeout: null, + data, + }); + +export const updateSupervisorMount = async ( + hass: HomeAssistant, + data: Partial +): Promise => + hass.callWS({ + type: "supervisor/api", + endpoint: `/mounts/${data.name}`, + method: "put", + timeout: null, + data, + }); + +export const removeSupervisorMount = async ( + hass: HomeAssistant, + name: string +): Promise => + hass.callWS({ + type: "supervisor/api", + endpoint: `/mounts/${name}`, + method: "delete", + timeout: null, + }); + +export const reloadSupervisorMount = async ( + hass: HomeAssistant, + data: SupervisorMount +): Promise => + hass.callWS({ + type: "supervisor/api", + endpoint: `/mounts/${data.name}/reload`, + method: "post", + timeout: null, + }); diff --git a/src/panels/config/storage/dialog-mount-view.ts b/src/panels/config/storage/dialog-mount-view.ts new file mode 100644 index 0000000000..2e55b60202 --- /dev/null +++ b/src/panels/config/storage/dialog-mount-view.ts @@ -0,0 +1,283 @@ +import { css, CSSResultGroup, 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 "../../../components/ha-form/ha-form"; +import { extractApiErrorMessage } from "../../../data/hassio/common"; +import { + createSupervisorMount, + removeSupervisorMount, + SupervisorMountRequestParams, + SupervisorMountType, + SupervisorMountUsage, + updateSupervisorMount, +} from "../../../data/supervisor/mounts"; +import { haStyle, haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { MountViewDialogParams } from "./show-dialog-view-mount"; +import { LocalizeFunc } from "../../../common/translations/localize"; +import type { SchemaUnion } from "../../../components/ha-form/types"; + +const mountSchema = memoizeOne( + ( + localize: LocalizeFunc, + existing?: boolean, + mountType?: SupervisorMountType + ) => + [ + { + name: "name", + required: true, + disabled: existing, + selector: { text: {} }, + }, + { + name: "usage", + required: true, + type: "select", + options: [ + [ + SupervisorMountUsage.BACKUP, + localize( + "ui.panel.config.storage.network_mounts.mount_usage.backup" + ), + ], + [ + SupervisorMountUsage.MEDIA, + localize( + "ui.panel.config.storage.network_mounts.mount_usage.media" + ), + ], + ] as const, + }, + { + name: "server", + required: true, + selector: { text: {} }, + }, + { + name: "type", + required: true, + type: "select", + options: [ + [ + SupervisorMountType.CIFS, + localize("ui.panel.config.storage.network_mounts.mount_type.cifs"), + ], + [ + SupervisorMountType.NFS, + localize("ui.panel.config.storage.network_mounts.mount_type.nfs"), + ], + ], + }, + ...(mountType === "nfs" + ? ([ + { + name: "path", + required: true, + selector: { text: {} }, + }, + ] as const) + : mountType === "cifs" + ? ([ + { + name: "share", + required: true, + selector: { text: {} }, + }, + { + name: "username", + required: false, + selector: { text: {} }, + }, + { + name: "password", + required: false, + selector: { text: { type: "password" } }, + }, + ] as const) + : ([] as const)), + ] as const +); + +@customElement("dialog-mount-view") +class ViewMountDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _data?: SupervisorMountRequestParams; + + @state() private _waiting?: boolean; + + @state() private _error?: string; + + @state() private _validationError?: Record; + + @state() private _existing?: boolean; + + @state() private _reloadMounts?: () => void; + + public async showDialog( + dialogParams: MountViewDialogParams + ): Promise> { + this._data = dialogParams.mount; + this._existing = dialogParams.mount !== undefined; + this._reloadMounts = dialogParams.reloadMounts; + } + + public closeDialog(): void { + this._data = undefined; + this._waiting = undefined; + this._error = undefined; + this._validationError = undefined; + this._existing = undefined; + this._reloadMounts = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (this._existing === undefined) { + return nothing; + } + return html` + + ${this._error + ? html`${this._error}` + : nothing} + +
+ + ${this.hass.localize("ui.common.cancel")} + + ${this._existing + ? html` + ${this.hass.localize("ui.common.delete")} + ` + : nothing} +
+ + + ${this._existing + ? this.hass.localize( + "ui.panel.config.storage.network_mounts.update" + ) + : this.hass.localize( + "ui.panel.config.storage.network_mounts.connect" + )} + +
+ `; + } + + private _computeLabelCallback = ( + // @ts-ignore + schema: SchemaUnion> + ): string => + this.hass.localize( + `ui.panel.config.storage.network_mounts.options.${schema.name}.title` + ); + + private _computeHelperCallback = ( + // @ts-ignore + schema: SchemaUnion> + ): string => + this.hass.localize( + `ui.panel.config.storage.network_mounts.options.${schema.name}.description` + ); + + private _computeErrorCallback = (error: string): string => + this.hass.localize( + // @ts-ignore + `ui.panel.config.storage.network_mounts.errors.${error}` + ) || error; + + private _valueChanged(ev: CustomEvent) { + this._validationError = {}; + this._data = ev.detail.value; + if (this._data?.name && !/^\w+$/.test(this._data.name)) { + this._validationError.name = "invalid_name"; + } + } + + private async _connectMount() { + this._error = undefined; + this._waiting = true; + try { + if (this._existing) { + await updateSupervisorMount(this.hass, this._data!); + } else { + await createSupervisorMount(this.hass, this._data!); + } + } catch (err: any) { + this._error = extractApiErrorMessage(err); + this._waiting = false; + return; + } + if (this._reloadMounts) { + this._reloadMounts(); + } + this.closeDialog(); + } + + private async _deleteMount() { + this._error = undefined; + this._waiting = true; + try { + await removeSupervisorMount(this.hass, this._data!.name); + } catch (err: any) { + this._error = extractApiErrorMessage(err); + this._waiting = false; + return; + } + if (this._reloadMounts) { + this._reloadMounts(); + } + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + .delete-btn { + --mdc-theme-primary: var(--error-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-mount-view": ViewMountDialog; + } +} diff --git a/src/panels/config/storage/ha-config-section-storage.ts b/src/panels/config/storage/ha-config-section-storage.ts index 3f7baa67f2..a70454576d 100644 --- a/src/panels/config/storage/ha-config-section-storage.ts +++ b/src/panels/config/storage/ha-config-section-storage.ts @@ -1,11 +1,31 @@ -import { mdiDotsVertical } from "@mdi/js"; -import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import "@material/mwc-list"; +import { mdiBackupRestore, mdiNas, mdiPlayBox, mdiReload } from "@mdi/js"; +import { + LitElement, + PropertyValues, + TemplateResult, + css, + html, + nothing, +} from "lit"; import { customElement, property, state } from "lit/decorators"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import "../../../components/ha-alert"; import "../../../components/ha-button-menu"; +import "../../../components/ha-icon-button"; import "../../../components/ha-metric"; -import { fetchHassioHostInfo, HassioHostInfo } from "../../../data/hassio/host"; +import "../../../components/ha-svg-icon"; +import { extractApiErrorMessage } from "../../../data/hassio/common"; +import { HassioHostInfo, fetchHassioHostInfo } from "../../../data/hassio/host"; +import { + SupervisorMount, + SupervisorMountState, + SupervisorMountType, + SupervisorMountUsage, + fetchSupervisorMounts, + reloadSupervisorMount, +} from "../../../data/supervisor/mounts"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-subpage"; import type { HomeAssistant, Route } from "../../../types"; import { @@ -14,6 +34,7 @@ import { } from "../../../util/calculate"; import "../core/ha-config-analytics"; import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk"; +import { showMountViewDialog } from "./show-dialog-view-mount"; @customElement("ha-config-section-storage") class HaConfigSectionStorage extends LitElement { @@ -27,6 +48,8 @@ class HaConfigSectionStorage extends LitElement { @state() private _hostInfo?: HassioHostInfo; + @state() private _mounts?: SupervisorMount[]; + protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); if (isComponentLoaded(this.hass, "hassio")) { @@ -42,22 +65,6 @@ class HaConfigSectionStorage extends LitElement { .narrow=${this.narrow} .header=${this.hass.localize("ui.panel.config.storage.caption")} > - ${this._hostInfo - ? html` - - - - ${this.hass.localize( - "ui.panel.config.storage.datadisk.title" - )} - - - ` - : ""}
${this._error ? html` @@ -68,7 +75,12 @@ class HaConfigSectionStorage extends LitElement { : ""} ${this._hostInfo ? html` - +
+ ${this._hostInfo + ? html`
+ + ${this.hass.localize( + "ui.panel.config.storage.datadisk.title" + )} + +
` + : nothing} ` : ""} + + ${this._mounts?.length + ? html` + ${this._mounts.map( + (mount) => html` + +
+ +
+ + ${mount.name} + + + ${mount.server}${mount.port + ? `:${mount.port}` + : nothing}${mount.type === SupervisorMountType.NFS + ? mount.path + : ` :${mount.share}`} + + +
+ ` + )} +
` + : html`
+ +

+ ${this.hass.localize( + "ui.panel.config.storage.network_mounts.no_mounts" + )} +

+
`} + +
+ + ${this.hass.localize( + "ui.panel.config.storage.network_mounts.add_title" + )} + +
+
`; @@ -107,7 +190,10 @@ class HaConfigSectionStorage extends LitElement { private async _load() { try { - this._hostInfo = await fetchHassioHostInfo(this.hass); + [this._hostInfo] = await Promise.all([ + fetchHassioHostInfo(this.hass), + this._reloadMounts(), + ]); } catch (err: any) { this._error = err.message || err; } @@ -119,6 +205,47 @@ class HaConfigSectionStorage extends LitElement { }); } + private async _reloadMount(ev: Event): Promise { + ev.stopPropagation(); + const mount: SupervisorMount = (ev.currentTarget as any).mount; + try { + await reloadSupervisorMount(this.hass, mount); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.storage.network_mounts.errors.reload", + { mount: mount.name } + ), + text: extractApiErrorMessage(err), + }); + return; + } + await this._reloadMounts(); + } + + private _addMount(): void { + showMountViewDialog(this, { reloadMounts: this._reloadMounts }); + } + + private _changeMount(ev: Event): void { + ev.stopPropagation(); + showMountViewDialog(this, { + mount: (ev.currentTarget as any).mount, + reloadMounts: this._reloadMounts, + }); + } + + private async _reloadMounts(): Promise { + try { + const allMounts = await fetchSupervisorMounts(this.hass); + this._mounts = allMounts.mounts.filter((mount) => + [SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type) + ); + } catch (err: any) { + this._error = err.message || err; + } + } + private _getUsedSpace = (used: number, total: number) => roundWithOneDecimal(getValueInPercentage(used, 0, total)); @@ -130,7 +257,7 @@ class HaConfigSectionStorage extends LitElement { } ha-card { max-width: 600px; - margin: 0 auto; + margin: 0 auto 12px; justify-content: space-between; flex-direction: column; display: flex; @@ -140,6 +267,34 @@ class HaConfigSectionStorage extends LitElement { justify-content: space-between; flex-direction: column; } + .mount-state-failed { + color: var(--error-color); + } + .mount-state-unknown { + color: var(--warning-color); + } + + .reload-btn { + float: right; + position: relative; + top: -10px; + right: 10px; + } + + .no-mounts { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + } + + .no-mounts ha-svg-icon { + background-color: var(--light-primary-color); + color: var(--secondary-text-color); + padding: 16px; + border-radius: 50%; + margin-bottom: 8px; + } `; } diff --git a/src/panels/config/storage/show-dialog-view-mount.ts b/src/panels/config/storage/show-dialog-view-mount.ts new file mode 100644 index 0000000000..ad760b05c4 --- /dev/null +++ b/src/panels/config/storage/show-dialog-view-mount.ts @@ -0,0 +1,18 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { SupervisorMount } from "../../../data/supervisor/mounts"; + +export interface MountViewDialogParams { + mount?: SupervisorMount; + reloadMounts: () => void; +} + +export const showMountViewDialog = ( + element: HTMLElement, + dialogParams: MountViewDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-mount-view", + dialogImport: () => import("./dialog-mount-view"), + dialogParams, + }); +}; diff --git a/src/translations/en.json b/src/translations/en.json index ad9c5f4b24..05cc547c4d 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -478,7 +478,10 @@ } } }, - "stt-picker": { "stt": "Speech-to-text", "none": "None" }, + "stt-picker": { + "stt": "Speech-to-text", + "none": "None" + }, "related-filter-menu": { "filter": "Filter", "filter_by_entity": "Filter by entity", @@ -3970,6 +3973,7 @@ "description": "{percent_used} used - {free_space} free", "used_space": "Used Space", "emmc_lifetime_used": "eMMC Lifetime Used", + "disk_metrics": "Disk metrics", "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!", @@ -3983,6 +3987,60 @@ "cancel": "[%key:ui::common::cancel%]", "failed_to_move": "Failed to move data disk", "move": "Move" + }, + "network_mounts": { + "title": "Network storage", + "add_title": "Add network storage", + "update_title": "Update network storage", + "no_mounts": "No connected network storage", + "mount_usage": { + "backup": "Backup", + "media": "Media" + }, + "mount_type": { + "nfs": "Network File Share (NFS)", + "cifs": "Samba/Windows (CIFS)" + }, + "options": { + "name": { + "title": "Name", + "description": "This name will be shown to you in the UI, and will also be the name of the folder created on your system" + }, + "share": { + "title": "Remote share", + "description": "This is the name of the share you created on your storage server" + }, + "server": { + "title": "Server", + "description": "This is the domain name (FQDN) or IP address of the storage server you want to connect to" + }, + "path": { + "title": "Remote share path", + "description": "This is the path of the remote share on your storage server" + }, + "type": { + "title": "Protocol", + "description": "This determines how to communicate with the storage server" + }, + "usage": { + "title": "Usage", + "description": "This determines how the share is intended to be used" + }, + "username": { + "title": "Username", + "description": "This is your username for the samba share" + }, + "password": { + "title": "Password", + "description": "This is your password for the samba share" + } + }, + "connect": "Connect", + "update": "Update", + "errors": { + "reload": "Could not reload mount {mount}", + "invalid_name": "Invalid name, can only contain alphanumeric characters and underscores" + } } }, "system_health": {