From b35f9944ea7e01170ba9d8590a60f0f32de19601 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 20 Dec 2024 16:39:44 +0100 Subject: [PATCH] Change backup restore flow (#23354) * Change backup restore flow * adapt and finish * Update dialog-restore-backup.ts --- .../dialog-restore-backup-encryption-key.ts | 245 -------------- .../backup/dialogs/dialog-restore-backup.ts | 313 ++++++++++++++++++ ...ow-dialog-restore-backup-encryption-key.ts | 37 --- .../dialogs/show-dialog-restore-backup.ts | 23 ++ .../config/backup/ha-config-backup-details.ts | 62 +--- 5 files changed, 352 insertions(+), 328 deletions(-) delete mode 100644 src/panels/config/backup/dialogs/dialog-restore-backup-encryption-key.ts create mode 100644 src/panels/config/backup/dialogs/dialog-restore-backup.ts delete mode 100644 src/panels/config/backup/dialogs/show-dialog-restore-backup-encryption-key.ts create mode 100644 src/panels/config/backup/dialogs/show-dialog-restore-backup.ts diff --git a/src/panels/config/backup/dialogs/dialog-restore-backup-encryption-key.ts b/src/panels/config/backup/dialogs/dialog-restore-backup-encryption-key.ts deleted file mode 100644 index fca3c987ed..0000000000 --- a/src/panels/config/backup/dialogs/dialog-restore-backup-encryption-key.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { mdiClose } from "@mdi/js"; -import type { CSSResultGroup } from "lit"; -import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; -import memoizeOne from "memoize-one"; -import { fireEvent } from "../../../../common/dom/fire_event"; -import "../../../../components/ha-button"; -import "../../../../components/ha-dialog-header"; -import "../../../../components/ha-form/ha-form"; -import type { - HaFormSchema, - SchemaUnion, -} from "../../../../components/ha-form/types"; -import "../../../../components/ha-icon-button"; -import "../../../../components/ha-md-dialog"; -import type { HaMdDialog } from "../../../../components/ha-md-dialog"; -import "../../../../components/ha-svg-icon"; -import { fetchBackupConfig } from "../../../../data/backup"; -import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; -import { haStyle, haStyleDialog } from "../../../../resources/styles"; -import type { HomeAssistant } from "../../../../types"; -import type { RestoreBackupEncryptionKeyDialogParams } from "./show-dialog-restore-backup-encryption-key"; - -type FormData = { - encryption_key_type: "config" | "custom"; - custom_encryption_key: string; -}; - -const INITIAL_DATA: FormData = { - encryption_key_type: "config", - custom_encryption_key: "", -}; - -@customElement("ha-dialog-restore-backup-encryption-key") -class DialogRestoreBackupEncryptionKey - extends LitElement - implements HassDialog -{ - @property({ attribute: false }) public hass!: HomeAssistant; - - @state() private _params?: RestoreBackupEncryptionKeyDialogParams; - - @state() private _formData?: FormData; - - @state() private _backupEncryptionKey?: string; - - @query("ha-md-dialog") private _dialog?: HaMdDialog; - - public showDialog(_params: RestoreBackupEncryptionKeyDialogParams): void { - this._params = _params; - this._formData = INITIAL_DATA; - this._fetchEncryptionKey(); - } - - private _dialogClosed() { - if (this._params!.cancel) { - this._params!.cancel(); - } - this._formData = undefined; - this._params = undefined; - this._backupEncryptionKey = undefined; - fireEvent(this, "dialog-closed", { dialog: this.localName }); - } - - private async _fetchEncryptionKey() { - try { - const { config } = await fetchBackupConfig(this.hass); - this._backupEncryptionKey = config.create_backup.password || undefined; - } catch (e) { - // eslint-disable-next-line no-console - console.error(e); - } - } - - public closeDialog() { - this._dialog?.close(); - } - - private _schema = memoizeOne( - (hasEncryptionKey: boolean, type: "config" | "custom") => - [ - ...(hasEncryptionKey - ? [ - { - name: "encryption_key_type", - selector: { - select: { - options: [ - { - value: "config", - label: "Use backup encryption key", - }, - { - value: "custom", - label: "Enter encryption key", - }, - ], - }, - }, - context: { - filter_entity: "entity", - }, - }, - ] - : []), - ...(!hasEncryptionKey || type === "custom" - ? ([ - { - name: "custom_encryption_key", - selector: { - text: {}, - }, - }, - ] as const satisfies readonly HaFormSchema[]) - : []), - ] as const satisfies readonly HaFormSchema[] - ); - - protected render() { - if (!this._params || !this._formData) { - return nothing; - } - - const dialogTitle = "Restore backup"; - - const hasEncryptionKey = this._backupEncryptionKey != null; - - const schema = this._schema( - hasEncryptionKey, - this._formData.encryption_key_type - ); - - return html` - - - - ${dialogTitle} - -
-

- ${hasEncryptionKey - ? "The backup is encrypted. Which encryption key would you like to use to decrypt the backup?" - : "The backup is encrypted. Provide the encryption key to decrypt the backup."} -

- - -
-
- Cancel - - Restore - -
-
- `; - } - - private _valueChanged(ev: CustomEvent): void { - ev.stopPropagation(); - this._formData = ev.detail.value; - } - - private _computeLabelCallback = ( - schema: SchemaUnion> - ) => { - switch (schema.name) { - case "encryption_key_type": - return ""; - case "custom_encryption_key": - return "Encryption key"; - default: - return ""; - } - }; - - private _getKey() { - if (!this._formData) { - return undefined; - } - const hasEncryptionKey = this._backupEncryptionKey != null; - - if (hasEncryptionKey) { - return this._formData.encryption_key_type === "config" - ? this._backupEncryptionKey - : this._formData.custom_encryption_key; - } - - return this._formData.custom_encryption_key; - } - - private async _submit() { - if (!this._formData) { - return; - } - - const key = this._getKey(); - - if (!key) { - return; - } - - this._params!.submit?.(key!); - this.closeDialog(); - } - - static get styles(): CSSResultGroup { - return [ - haStyle, - haStyleDialog, - css` - ha-md-dialog { - max-width: 500px; - width: 100%; - --dialog-content-padding: 8px 24px; - } - .content p { - margin: 0 0 16px; - } - ha-button.danger { - --mdc-theme-primary: var(--error-color); - } - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ha-dialog-restore-backup-encryption-key": DialogRestoreBackupEncryptionKey; - } -} diff --git a/src/panels/config/backup/dialogs/dialog-restore-backup.ts b/src/panels/config/backup/dialogs/dialog-restore-backup.ts new file mode 100644 index 0000000000..9c55ef783e --- /dev/null +++ b/src/panels/config/backup/dialogs/dialog-restore-backup.ts @@ -0,0 +1,313 @@ +import { mdiClose } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-button"; +import "../../../../components/ha-circular-progress"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-password-field"; + +import "../../../../components/ha-alert"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-md-dialog"; +import type { HaMdDialog } from "../../../../components/ha-md-dialog"; +import "../../../../components/ha-svg-icon"; +import { + fetchBackupConfig, + getPreferredAgentForDownload, + restoreBackup, +} from "../../../../data/backup"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import type { RestoreBackupDialogParams } from "./show-dialog-restore-backup"; +import type { RestoreBackupStage } from "../../../../data/backup_manager"; +import { subscribeBackupEvents } from "../../../../data/backup_manager"; + +type FormData = { + encryption_key_type: "config" | "custom"; + custom_encryption_key: string; +}; + +const INITIAL_DATA: FormData = { + encryption_key_type: "config", + custom_encryption_key: "", +}; + +const STEPS = ["confirm", "encryption", "progress"] as const; + +@customElement("ha-dialog-restore-backup") +class DialogRestoreBackup extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _step?: "confirm" | "encryption" | "progress"; + + @state() private _params?: RestoreBackupDialogParams; + + @state() private _formData?: FormData; + + @state() private _backupEncryptionKey?: string; + + @state() private _userPassword?: string; + + @state() private _error?: string; + + @state() private _stage?: RestoreBackupStage | null; + + @state() private _unsub?: Promise; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public async showDialog(params: RestoreBackupDialogParams) { + this._params = params; + + this._formData = INITIAL_DATA; + if (this._params.backup.protected) { + this._backupEncryptionKey = await this._fetchEncryptionKey(); + if (!this._backupEncryptionKey) { + this._step = STEPS[1]; + } else { + this._step = STEPS[0]; + } + } else { + this._step = STEPS[0]; + } + } + + public closeDialog() { + this._dialog?.close(); + } + + private _dialogClosed() { + this._formData = undefined; + this._params = undefined; + this._backupEncryptionKey = undefined; + this._userPassword = undefined; + this._error = undefined; + this._stage = undefined; + this._step = undefined; + if (this._unsub) { + this._unsub.then((unsub) => unsub()); + this._unsub = undefined; + } + window.removeEventListener("connection-status", this._connectionStatus); + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private async _fetchEncryptionKey() { + try { + const { config } = await fetchBackupConfig(this.hass); + return config.create_backup.password || undefined; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return undefined; + } + } + + protected render() { + if (!this._step || !this._params || !this._formData) { + return nothing; + } + + const dialogTitle = "Restore backup"; + + return html` + + + + ${dialogTitle} + +
+ ${this._error + ? html`${this._error}` + : this._step === "confirm" + ? this._renderConfirm() + : this._step === "encryption" + ? this._renderEncryption() + : this._renderProgress()} +
+
+ ${this._error + ? html`Close` + : this._step === "confirm" || this._step === "encryption" + ? this._renderConfirmActions() + : nothing} +
+
+ `; + } + + private _renderConfirm() { + return html`

+ Your backup will be restored and all current data will be overwritten. + Depending on the size of the backup, this can take a while. +

`; + } + + private _renderEncryption() { + return html`

+ ${this._userPassword + ? "The provided encryption key was incorrect, please try again." + : this._backupEncryptionKey + ? "The backup is encrypted with a different key or password than that is saved on this system. Please enter the key for this backup." + : "The backup is encrypted. Provide the encryption key to decrypt the backup."} +

+ `; + } + + private _renderConfirmActions() { + return html`Cancel + Restore`; + } + + private _renderProgress() { + return html`
+ +

+ ${this.hass.connected + ? this._restoreState() + : "Restarting Home Asssistant"} +

+
`; + } + + private _passwordChanged(ev): void { + this._userPassword = ev.target.value; + } + + private async _restoreBackup() { + try { + this._step = "progress"; + window.addEventListener("connection-status", this._connectionStatus); + await this._doRestoreBackup( + this._userPassword || this._backupEncryptionKey + ); + this._subscribeBackupEvents(); + } catch (e: any) { + window.removeEventListener("connection-status", this._connectionStatus); + if (e.code === "password_incorrect") { + this._step = "encryption"; + } else { + this._error = e.message; + } + } + } + + private _connectionStatus = (ev) => { + if (ev.detail === "connected") { + this.closeDialog(); + } + }; + + private _subscribeBackupEvents() { + this._unsub = subscribeBackupEvents(this.hass!, (event) => { + if (event.manager_state !== "restore_backup") { + return; + } + if (event.state === "completed") { + this.closeDialog(); + } + if (event.state === "failed") { + this._error = "Backup restore failed"; + } + if (event.state === "in_progress") { + this._stage = event.stage; + } + }); + } + + private _restoreState() { + switch (this._stage) { + case "addon_repositories": + return "Restoring add-on repositories"; + case "addons": + return "Restoring add-ons"; + case "await_addon_restarts": + return "Waiting for add-ons to restart"; + case "await_home_assistant_restart": + return "Waiting for Home Assistant to restart"; + case "check_home_assistant": + return "Checking Home Assistant configuration"; + case "docker_config": + return "Restoring Docker configuration"; + case "download_from_agent": + return "Downloading backup"; + case "folders": + return "Restoring folders"; + case "home_assistant": + return "Restoring Home Assistant"; + case "remove_delta_addons": + return "Removing add-ons that are no longer in the backup"; + } + return "Restoring backup"; + } + + private async _doRestoreBackup(password?: string) { + if (!this._params) { + return; + } + + const preferedAgent = getPreferredAgentForDownload( + this._params.backup.agent_ids! + ); + + const { addons, database_included, homeassistant_included, folders } = + this._params.selectedData; + + await restoreBackup(this.hass, { + backup_id: this._params.backup.backup_id, + agent_id: preferedAgent, + password, + restore_addons: addons.map((addon) => addon.slug), + restore_database: database_included, + restore_folders: folders, + restore_homeassistant: homeassistant_included, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + max-width: 500px; + width: 100%; + } + .content p { + margin: 0 0 16px; + } + .destructive { + --mdc-theme-primary: var(--error-color); + } + .centered { + display: flex; + flex-direction: column; + align-items: center; + } + ha-circular-progress { + margin-bottom: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-restore-backup": DialogRestoreBackup; + } +} diff --git a/src/panels/config/backup/dialogs/show-dialog-restore-backup-encryption-key.ts b/src/panels/config/backup/dialogs/show-dialog-restore-backup-encryption-key.ts deleted file mode 100644 index bf824e0cb6..0000000000 --- a/src/panels/config/backup/dialogs/show-dialog-restore-backup-encryption-key.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { fireEvent } from "../../../../common/dom/fire_event"; - -export interface RestoreBackupEncryptionKeyDialogParams { - submit?: (value: string) => void; - cancel?: () => void; -} - -export const loadRestoreBackupEncryptionKeyDialog = () => - import("./dialog-restore-backup-encryption-key"); - -export const showRestoreBackupEncryptionKeyDialog = ( - element: HTMLElement, - params: RestoreBackupEncryptionKeyDialogParams -) => - new Promise((resolve) => { - const origCancel = params.cancel; - const origSubmit = params.submit; - fireEvent(element, "show-dialog", { - dialogTag: "ha-dialog-restore-backup-encryption-key", - dialogImport: loadRestoreBackupEncryptionKeyDialog, - dialogParams: { - ...params, - cancel: () => { - resolve(null); - if (origCancel) { - origCancel(); - } - }, - submit: (response) => { - resolve(response); - if (origSubmit) { - origSubmit(response); - } - }, - }, - }); - }); diff --git a/src/panels/config/backup/dialogs/show-dialog-restore-backup.ts b/src/panels/config/backup/dialogs/show-dialog-restore-backup.ts new file mode 100644 index 0000000000..67d329988a --- /dev/null +++ b/src/panels/config/backup/dialogs/show-dialog-restore-backup.ts @@ -0,0 +1,23 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { + BackupContentExtended, + BackupData, +} from "../../../../data/backup"; + +export interface RestoreBackupDialogParams { + backup: BackupContentExtended; + selectedData: BackupData; +} + +export const loadRestoreBackupDialog = () => import("./dialog-restore-backup"); + +export const showRestoreBackupDialog = ( + element: HTMLElement, + params: RestoreBackupDialogParams +) => { + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-restore-backup", + dialogImport: loadRestoreBackupDialog, + dialogParams: params, + }); +}; diff --git a/src/panels/config/backup/ha-config-backup-details.ts b/src/panels/config/backup/ha-config-backup-details.ts index 74a4dd74b8..5e0e3db814 100644 --- a/src/panels/config/backup/ha-config-backup-details.ts +++ b/src/panels/config/backup/ha-config-backup-details.ts @@ -15,7 +15,7 @@ import "../../../components/ha-list-item"; import "../../../components/ha-md-list"; import "../../../components/ha-md-list-item"; import { getSignedPath } from "../../../data/auth"; -import type { BackupContentExtended } from "../../../data/backup"; +import type { BackupContentExtended, BackupData } from "../../../data/backup"; import { compareAgents, computeBackupAgentName, @@ -24,7 +24,6 @@ import { getBackupDownloadUrl, getPreferredAgentForDownload, isLocalAgent, - restoreBackup, } from "../../../data/backup"; import type { HassioAddonInfo } from "../../../data/hassio/addon"; import "../../../layouts/hass-subpage"; @@ -34,7 +33,7 @@ import { bytesToString } from "../../../util/bytes-to-string"; import { fileDownload } from "../../../util/file_download"; import { showConfirmationDialog } from "../../lovelace/custom-card-helpers"; import "./components/ha-backup-data-picker"; -import { showRestoreBackupEncryptionKeyDialog } from "./dialogs/show-dialog-restore-backup-encryption-key"; +import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup"; type Agent = { id: string; @@ -66,7 +65,7 @@ class HaConfigBackupDetails extends LitElement { @state() private _error?: string; - @state() private _selectedBackup?: BackupContentExtended; + @state() private _selectedData?: BackupData; @state() private _addonsInfo?: HassioAddonInfo[]; @@ -144,7 +143,7 @@ class HaConfigBackupDetails extends LitElement { @@ -251,57 +250,28 @@ class HaConfigBackupDetails extends LitElement { private _selectedBackupChanged(ev: CustomEvent) { ev.stopPropagation(); - this._selectedBackup = ev.detail.value; + this._selectedData = ev.detail.value; } private _isRestoreDisabled() { return ( - !this._selectedBackup || + !this._selectedData || !( - this._selectedBackup?.database_included || - this._selectedBackup?.homeassistant_included || - this._selectedBackup.addons.length || - this._selectedBackup.folders.length + this._selectedData?.database_included || + this._selectedData?.homeassistant_included || + this._selectedData.addons.length || + this._selectedData.folders.length ) ); } - private async _restore() { - let password: string | undefined; - if (this._backup?.protected) { - const response = await showRestoreBackupEncryptionKeyDialog(this, {}); - if (!response) { - return; - } - password = response; - } else { - const response = await showConfirmationDialog(this, { - title: "Restore backup", - text: "The backup will be restored to your instance.", - confirmText: "Restore", - dismissText: "Cancel", - destructive: true, - }); - if (!response) { - return; - } + private _restore() { + if (!this._backup || !this._selectedData) { + return; } - - const preferedAgent = getPreferredAgentForDownload( - this._backup!.agent_ids! - ); - - const { addons, database_included, homeassistant_included, folders } = - this._selectedBackup!; - - await restoreBackup(this.hass, { - backup_id: this._backup!.backup_id, - agent_id: preferedAgent, - password: password, - restore_addons: addons.map((addon) => addon.slug), - restore_database: database_included, - restore_folders: folders, - restore_homeassistant: homeassistant_included, + showRestoreBackupDialog(this, { + backup: this._backup, + selectedData: this._selectedData, }); }