diff --git a/src/data/backup_manager.ts b/src/data/backup_manager.ts index fe127e2868..69fdf0266a 100644 --- a/src/data/backup_manager.ts +++ b/src/data/backup_manager.ts @@ -58,6 +58,12 @@ interface RestoreBackupEvent { state: RestoreBackupState; } +export type ManagerState = + | "idle" + | "create_backup" + | "receive_backup" + | "restore_backup"; + export type ManagerStateEvent = | IdleEvent | CreateBackupEvent diff --git a/src/dialogs/restart/dialog-restart-wait.ts b/src/dialogs/restart/dialog-restart-wait.ts new file mode 100644 index 0000000000..24718dbb1f --- /dev/null +++ b/src/dialogs/restart/dialog-restart-wait.ts @@ -0,0 +1,167 @@ +import { mdiClose } from "@mdi/js"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { CSSResultGroup } from "lit"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-alert"; +import "../../components/ha-dialog-header"; +import "../../components/ha-icon-button"; +import "../../components/ha-md-dialog"; +import type { HaMdDialog } from "../../components/ha-md-dialog"; +import "../../components/ha-spinner"; +import { + subscribeBackupEvents, + type ManagerState, +} from "../../data/backup_manager"; +import { haStyle, haStyleDialog } from "../../resources/styles"; +import type { HomeAssistant } from "../../types"; +import type { RestartWaitDialogParams } from "./show-dialog-restart"; + +@customElement("dialog-restart-wait") +class DialogRestartWait extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _open = false; + + @state() + private _title = ""; + + private _actionOnIdle?: () => Promise; + + @state() + private _error?: string; + + @state() + private _backupState?: ManagerState; + + private _backupEventsSubscription?: Promise; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public async showDialog(params: RestartWaitDialogParams): Promise { + this._open = true; + this._loadBackupState(); + + this._title = params.title; + this._backupState = params.initialBackupState; + + this._actionOnIdle = params.action; + } + + private _dialogClosed(): void { + this._open = false; + + if (this._backupEventsSubscription) { + this._backupEventsSubscription.then((unsub) => { + unsub(); + }); + this._backupEventsSubscription = undefined; + } + + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + public closeDialog(): void { + this._dialog?.close(); + } + + private _getWaitMessage() { + switch (this._backupState) { + case "create_backup": + return this.hass.localize("ui.dialogs.restart.wait_for_backup"); + case "receive_backup": + return this.hass.localize("ui.dialogs.restart.wait_for_upload"); + case "restore_backup": + return this.hass.localize("ui.dialogs.restart.wait_for_restore"); + default: + return ""; + } + } + + protected render() { + if (!this._open) { + return nothing; + } + + const waitMessage = this._getWaitMessage(); + + return html` + + + + ${this._title} + +
+ ${this._error + ? html`${this.hass.localize("ui.dialogs.restart.error_backup_state", { + error: this._error, + })} ` + : html` + + ${waitMessage} + `} +
+
+ `; + } + + private async _loadBackupState() { + try { + this._backupEventsSubscription = subscribeBackupEvents( + this.hass, + async (event) => { + this._backupState = event.manager_state; + if (this._backupState === "idle") { + this.closeDialog(); + await this._actionOnIdle?.(); + } + } + ); + } catch (err: any) { + this._error = err.message || err; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + --dialog-content-padding: 0; + } + @media all and (min-width: 550px) { + ha-md-dialog { + min-width: 500px; + max-width: 500px; + } + } + .content { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px; + gap: 32px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-restart-wait": DialogRestartWait; + } +} diff --git a/src/dialogs/restart/dialog-restart.ts b/src/dialogs/restart/dialog-restart.ts index 728783251b..ee8c9164fa 100644 --- a/src/dialogs/restart/dialog-restart.ts +++ b/src/dialogs/restart/dialog-restart.ts @@ -1,24 +1,29 @@ +import "@material/mwc-linear-progress/mwc-linear-progress"; import { mdiAutoFix, + mdiClose, mdiLifebuoy, mdiPower, mdiPowerCycle, mdiRefresh, - mdiClose, } from "@mdi/js"; import type { CSSResultGroup } from "lit"; import { LitElement, css, html, nothing } from "lit"; -import { customElement, property, state, query } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { fireEvent } from "../../common/dom/fire_event"; -import "../../components/ha-spinner"; +import "../../components/ha-alert"; +import "../../components/ha-expansion-panel"; +import "../../components/ha-fade-in"; +import "../../components/ha-icon-button"; +import "../../components/ha-icon-next"; import "../../components/ha-md-dialog"; import type { HaMdDialog } from "../../components/ha-md-dialog"; import "../../components/ha-md-list"; -import "../../components/ha-expansion-panel"; import "../../components/ha-md-list-item"; -import "../../components/ha-icon-button"; -import "../../components/ha-icon-next"; +import "../../components/ha-spinner"; +import { fetchBackupInfo } from "../../data/backup"; +import type { BackupManagerState } from "../../data/backup_manager"; import { extractApiErrorMessage, ignoreSupervisorError, @@ -30,12 +35,13 @@ import { shutdownHost, } from "../../data/hassio/host"; import { haStyle, haStyleDialog } from "../../resources/styles"; -import type { HomeAssistant } from "../../types"; +import type { HomeAssistant, ServiceCallRequest } from "../../types"; import { showToast } from "../../util/toast"; import { showAlertDialog, showConfirmationDialog, } from "../generic/show-dialog-box"; +import { showRestartWaitDialog } from "./show-dialog-restart"; @customElement("dialog-restart") class DialogRestart extends LitElement { @@ -46,6 +52,9 @@ class DialogRestart extends LitElement { @state() private _loadingHostInfo = false; + @state() + private _loadingBackupInfo = false; + @state() private _hostInfo?: HassioHostInfo; @@ -57,20 +66,35 @@ class DialogRestart extends LitElement { this._open = true; if (isHassioLoaded && !this._hostInfo) { - this._loadingHostInfo = true; - try { - this._hostInfo = await fetchHassioHostInfo(this.hass); - } catch (_err) { - // Do nothing - } finally { - this._loadingHostInfo = false; - } + this._loadHostInfo(); + } + } + + private async _loadBackupState() { + try { + const { state: backupState } = await fetchBackupInfo(this.hass); + return backupState; + } catch (_err) { + // Do nothing + return "idle"; + } + } + + private async _loadHostInfo() { + this._loadingHostInfo = true; + try { + this._hostInfo = await fetchHassioHostInfo(this.hass); + } catch (_err) { + // Do nothing + } finally { + this._loadingHostInfo = false; } } private _dialogClosed(): void { this._open = false; this._loadingHostInfo = false; + this._loadingBackupInfo = false; fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -100,6 +124,15 @@ class DialogRestart extends LitElement { ${dialogTitle}
+
+ ${this._loadingBackupInfo + ? html` + + ` + : nothing} +
${this._loadingHostInfo ? html`
@@ -110,7 +143,11 @@ class DialogRestart extends LitElement { ${showReload ? html` - +
${this.hass.localize( "ui.dialogs.restart.reload.title" @@ -130,7 +167,9 @@ class DialogRestart extends LitElement { : nothing}
@@ -156,7 +195,9 @@ class DialogRestart extends LitElement { ? html`
@@ -175,7 +216,9 @@ class DialogRestart extends LitElement {
@@ -196,7 +239,9 @@ class DialogRestart extends LitElement { : nothing}
+ async () => { + try { + await this.hass.callService(domain, service, serviceData); + } catch (err: any) { + showAlertDialog(this, { + title: errorTitle, + text: err.message, + }); + } + }; + + private _hostAction = + (toastMessage: string, action: "reboot" | "shutdown") => async () => { + showToast(this, { + message: toastMessage, + duration: -1, + }); + + try { + if (action === "reboot") { + await rebootHost(this.hass); + } else { + await shutdownHost(this.hass); + } + } catch (err: any) { + // Ignore connection errors, these are all expected + if (this.hass.connection.connected && !ignoreSupervisorError(err)) { + showAlertDialog(this, { + title: this.hass.localize(`ui.dialogs.restart.${action}.failed`), + text: extractApiErrorMessage(err), + }); + } + } + }; + + private async _handleAction(ev) { + if (this._loadingBackupInfo) { + return; + } + this._loadingBackupInfo = true; + const action = ev.currentTarget.action as + | "restart" + | "reboot" + | "shutdown" + | "restart-safe-mode"; + + const backupState = await this._loadBackupState(); + + const backupProgressMessage = this._getBackupProgressMessage(backupState); + + this._loadingBackupInfo = false; + const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize("ui.dialogs.restart.restart.confirm_title"), - text: this.hass.localize( - "ui.dialogs.restart.restart.confirm_description" - ), + title: this.hass.localize(`ui.dialogs.restart.${action}.confirm_title`), + text: html`${this.hass.localize( + `ui.dialogs.restart.${action}.confirm_description` + )}${backupProgressMessage + ? html`

${backupProgressMessage}` + : nothing}`, confirmText: this.hass.localize( - "ui.dialogs.restart.restart.confirm_action" + `ui.dialogs.restart.${action}.confirm_action${backupState === "idle" ? "" : "_backup"}` ), destructive: true, }); @@ -260,118 +376,36 @@ class DialogRestart extends LitElement { this.closeDialog(); - try { - await this.hass.callService("homeassistant", "restart"); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize("ui.dialogs.restart.restart.failed"), - text: err.message, - }); - } - } + let actionFunc; - private async _showRestartSafeModeDialog() { - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.dialogs.restart.restart-safe-mode.confirm_title" - ), - text: this.hass.localize( - "ui.dialogs.restart.restart-safe-mode.confirm_description" - ), - confirmText: this.hass.localize( - "ui.dialogs.restart.restart-safe-mode.confirm_action" - ), - destructive: true, - }); - - if (!confirmed) { - return; - } - - this.closeDialog(); - - try { - await this.hass.callService("homeassistant", "restart", { - safe_mode: true, - }); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.dialogs.restart.restart-safe-mode.failed" + if (["restart", "restart-safe-mode"].includes(action)) { + const serviceData = + action === "restart-safe-mode" ? { safe_mode: true } : undefined; + actionFunc = this._restartAction( + "homeassistant", + "restart", + this.hass.localize(`ui.dialogs.restart.${action}.failed`), + serviceData + ); + } else { + actionFunc = this._hostAction( + this.hass.localize( + `ui.dialogs.restart.${action as "reboot" | "shutdown"}.action_toast` ), - text: err.message, + action as "reboot" | "shutdown" + ); + } + + if (backupState !== "idle") { + showRestartWaitDialog(this, { + title: this.hass.localize(`ui.dialogs.restart.${action}.title`), + initialBackupState: backupState, + action: actionFunc, }); - } - } - - private async _hostReboot(): Promise { - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize("ui.dialogs.restart.reboot.confirm_title"), - text: this.hass.localize("ui.dialogs.restart.reboot.confirm_description"), - confirmText: this.hass.localize( - "ui.dialogs.restart.reboot.confirm_action" - ), - destructive: true, - }); - - if (!confirmed) { return; } - this.closeDialog(); - - showToast(this, { - message: this.hass.localize("ui.dialogs.restart.reboot.rebooting"), - duration: -1, - }); - - try { - await rebootHost(this.hass); - } catch (err: any) { - // Ignore connection errors, these are all expected - if (this.hass.connection.connected && !ignoreSupervisorError(err)) { - showAlertDialog(this, { - title: this.hass.localize("ui.dialogs.restart.reboot.failed"), - text: extractApiErrorMessage(err), - }); - } - } - } - - private async _hostShutdown(): Promise { - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize("ui.dialogs.restart.shutdown.confirm_title"), - text: this.hass.localize( - "ui.dialogs.restart.shutdown.confirm_description" - ), - confirmText: this.hass.localize( - "ui.dialogs.restart.shutdown.confirm_action" - ), - destructive: true, - }); - - if (!confirmed) { - return; - } - - this.closeDialog(); - - showToast(this, { - message: this.hass.localize("ui.dialogs.restart.shutdown.shutting_down"), - duration: -1, - }); - - try { - await shutdownHost(this.hass); - } catch (err: any) { - // Ignore connection errors, these are all expected - if (this.hass.connection.connected && !ignoreSupervisorError(err)) { - showAlertDialog(this, { - title: this.hass.localize("ui.dialogs.restart.shutdown.failed"), - text: extractApiErrorMessage(err), - }); - } - } + actionFunc(); } static get styles(): CSSResultGroup { @@ -448,6 +482,9 @@ class DialogRestart extends LitElement { justify-content: center; padding: 24px; } + .action-loader { + height: 4px; + } `, ]; } diff --git a/src/dialogs/restart/show-dialog-restart.ts b/src/dialogs/restart/show-dialog-restart.ts index d78633b81b..5fd7c1c9f4 100644 --- a/src/dialogs/restart/show-dialog-restart.ts +++ b/src/dialogs/restart/show-dialog-restart.ts @@ -1,8 +1,10 @@ import { fireEvent } from "../../common/dom/fire_event"; +import type { ManagerState } from "../../data/backup_manager"; export interface RestartDialogParams {} export const loadRestartDialog = () => import("./dialog-restart"); +export const loadRestartWaitDialog = () => import("./dialog-restart-wait"); export const showRestartDialog = (element: HTMLElement): void => { fireEvent(element, "show-dialog", { @@ -11,3 +13,20 @@ export const showRestartDialog = (element: HTMLElement): void => { dialogParams: {}, }); }; + +export interface RestartWaitDialogParams { + title: string; + initialBackupState: ManagerState; + action: () => Promise; +} + +export const showRestartWaitDialog = ( + element: HTMLElement, + dialogParams: RestartWaitDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-restart-wait", + dialogImport: loadRestartWaitDialog, + dialogParams, + }); +}; diff --git a/src/translations/en.json b/src/translations/en.json index 8d88c80001..8b5953acf3 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1548,6 +1548,13 @@ "restart": { "heading": "Restart Home Assistant", "advanced_options": "Advanced options", + "backup_in_progress": "A backup is currently being created. The action will automatically proceed once the backup process is complete.", + "upload_in_progress": "A backup upload is currently in progress. The action will automatically proceed once the upload process is complete.", + "restore_in_progress": "A backup restore is currently in progress. The action will automatically proceed once the restore process is complete.", + "wait_for_backup": "Wait for the backup creation to finish", + "error_backup_state": "An error occured while getting the current backup state. Error: {error}", + "wait_for_upload": "Wait for backup upload to finish", + "wait_for_restore": "Wait for backup restore to finish", "reload": { "title": "Quick reload", "description": "Loads new YAML configurations without a restart.", @@ -1560,6 +1567,7 @@ "confirm_title": "Restart Home Assistant?", "confirm_description": "This will interrupt all running automations and scripts.", "confirm_action": "Restart", + "confirm_action_backup": "Wait and restart", "failed": "Failed to restart Home Assistant" }, "stop": { @@ -1573,7 +1581,8 @@ "confirm_title": "Reboot system?", "confirm_description": "This will reboot the complete system which includes Home Assistant and all the add-ons.", "confirm_action": "Reboot", - "rebooting": "Rebooting system", + "action_toast": "Rebooting system", + "confirm_action_backup": "Wait and reboot", "failed": "Failed to reboot system" }, "shutdown": { @@ -1582,7 +1591,8 @@ "confirm_title": "Shut down system?", "confirm_description": "This will shut down the complete system which includes Home Assistant and all add-ons.", "confirm_action": "Shut down", - "shutting_down": "Shutting down system", + "confirm_action_backup": "Wait and shut down", + "action_toast": "Shutting down system", "failed": "Failed to shut down system" }, "restart-safe-mode": { @@ -1591,6 +1601,7 @@ "confirm_title": "Restart Home Assistant in safe mode?", "confirm_description": "This will restart Home Assistant without loading any custom integrations and frontend modules.", "confirm_action": "Restart", + "confirm_action_backup": "Wait and Restart", "failed": "Failed to restart Home Assistant" } },