Change backup restore flow (#23354)

* Change backup restore flow

* adapt and finish

* Update dialog-restore-backup.ts
This commit is contained in:
Bram Kragten 2024-12-20 16:39:44 +01:00 committed by GitHub
parent dc799bf691
commit b35f9944ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 352 additions and 328 deletions

View File

@ -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`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}>${dialogTitle}</span>
</ha-dialog-header>
<div slot="content" class="content">
<p>
${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."}
</p>
<ha-form
.schema=${schema}
.data=${this._formData}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
>
</ha-form>
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog}>Cancel</ha-button>
<ha-button
@click=${this._submit}
class="danger"
.disabled=${!this._getKey()}
>
Restore
</ha-button>
</div>
</ha-md-dialog>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
this._formData = ev.detail.value;
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
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;
}
}

View File

@ -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<UnsubscribeFunc>;
@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`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}>${dialogTitle}</span>
</ha-dialog-header>
<div slot="content" class="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: this._step === "confirm"
? this._renderConfirm()
: this._step === "encryption"
? this._renderEncryption()
: this._renderProgress()}
</div>
<div slot="actions">
${this._error
? html`<ha-button @click=${this.closeDialog}>Close</ha-button>`
: this._step === "confirm" || this._step === "encryption"
? this._renderConfirmActions()
: nothing}
</div>
</ha-md-dialog>
`;
}
private _renderConfirm() {
return html`<p>
Your backup will be restored and all current data will be overwritten.
Depending on the size of the backup, this can take a while.
</p>`;
}
private _renderEncryption() {
return html`<p>
${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."}
</p>
<ha-password-field
@change=${this._passwordChanged}
.value=${this._userPassword || ""}
></ha-password-field>`;
}
private _renderConfirmActions() {
return html`<ha-button @click=${this.closeDialog}>Cancel</ha-button>
<ha-button @click=${this._restoreBackup} class="destructive"
>Restore</ha-button
>`;
}
private _renderProgress() {
return html`<div class="centered">
<ha-circular-progress indeterminate></ha-circular-progress>
<p>
${this.hass.connected
? this._restoreState()
: "Restarting Home Asssistant"}
</p>
</div>`;
}
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;
}
}

View File

@ -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<string | null>((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);
}
},
},
});
});

View File

@ -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,
});
};

View File

@ -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 {
<ha-backup-data-picker
.hass=${this.hass}
.data=${this._backup}
.value=${this._selectedBackup}
.value=${this._selectedData}
@value-changed=${this._selectedBackupChanged}
.addonsInfo=${this._addonsInfo}
>
@ -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,
});
}