Add prevent restart when backup is ongoing (#25010)

* Add prevent restart when backup is ongoing

* Fix types

* Add error handling

* Improve review issues
pull/25150/head^2
Wendelin 2025-04-24 12:45:42 +02:00 committed by GitHub
parent 93485d8b57
commit c40bf8f3cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 381 additions and 141 deletions

View File

@ -58,6 +58,12 @@ interface RestoreBackupEvent {
state: RestoreBackupState; state: RestoreBackupState;
} }
export type ManagerState =
| "idle"
| "create_backup"
| "receive_backup"
| "restore_backup";
export type ManagerStateEvent = export type ManagerStateEvent =
| IdleEvent | IdleEvent
| CreateBackupEvent | CreateBackupEvent

View File

@ -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<void>;
@state()
private _error?: string;
@state()
private _backupState?: ManagerState;
private _backupEventsSubscription?: Promise<UnsubscribeFunc>;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public async showDialog(params: RestartWaitDialogParams): Promise<void> {
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`
<ha-md-dialog
open
@closed=${this._dialogClosed}
.disableCancelAction=${true}
>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.cancel")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title" .title=${this._title}> ${this._title} </span>
</ha-dialog-header>
<div slot="content" class="content">
${this._error
? html`<ha-alert alert-type="error"
>${this.hass.localize("ui.dialogs.restart.error_backup_state", {
error: this._error,
})}</ha-alert
> `
: html`
<ha-spinner></ha-spinner>
${waitMessage}
`}
</div>
</ha-md-dialog>
`;
}
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;
}
}

View File

@ -1,24 +1,29 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { import {
mdiAutoFix, mdiAutoFix,
mdiClose,
mdiLifebuoy, mdiLifebuoy,
mdiPower, mdiPower,
mdiPowerCycle, mdiPowerCycle,
mdiRefresh, mdiRefresh,
mdiClose,
} from "@mdi/js"; } from "@mdi/js";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } 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 { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event"; 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 "../../components/ha-md-dialog";
import type { HaMdDialog } from "../../components/ha-md-dialog"; import type { HaMdDialog } from "../../components/ha-md-dialog";
import "../../components/ha-md-list"; import "../../components/ha-md-list";
import "../../components/ha-expansion-panel";
import "../../components/ha-md-list-item"; import "../../components/ha-md-list-item";
import "../../components/ha-icon-button"; import "../../components/ha-spinner";
import "../../components/ha-icon-next"; import { fetchBackupInfo } from "../../data/backup";
import type { BackupManagerState } from "../../data/backup_manager";
import { import {
extractApiErrorMessage, extractApiErrorMessage,
ignoreSupervisorError, ignoreSupervisorError,
@ -30,12 +35,13 @@ import {
shutdownHost, shutdownHost,
} from "../../data/hassio/host"; } from "../../data/hassio/host";
import { haStyle, haStyleDialog } from "../../resources/styles"; import { haStyle, haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant, ServiceCallRequest } from "../../types";
import { showToast } from "../../util/toast"; import { showToast } from "../../util/toast";
import { import {
showAlertDialog, showAlertDialog,
showConfirmationDialog, showConfirmationDialog,
} from "../generic/show-dialog-box"; } from "../generic/show-dialog-box";
import { showRestartWaitDialog } from "./show-dialog-restart";
@customElement("dialog-restart") @customElement("dialog-restart")
class DialogRestart extends LitElement { class DialogRestart extends LitElement {
@ -46,6 +52,9 @@ class DialogRestart extends LitElement {
@state() @state()
private _loadingHostInfo = false; private _loadingHostInfo = false;
@state()
private _loadingBackupInfo = false;
@state() @state()
private _hostInfo?: HassioHostInfo; private _hostInfo?: HassioHostInfo;
@ -57,6 +66,21 @@ class DialogRestart extends LitElement {
this._open = true; this._open = true;
if (isHassioLoaded && !this._hostInfo) { if (isHassioLoaded && !this._hostInfo) {
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; this._loadingHostInfo = true;
try { try {
this._hostInfo = await fetchHassioHostInfo(this.hass); this._hostInfo = await fetchHassioHostInfo(this.hass);
@ -66,11 +90,11 @@ class DialogRestart extends LitElement {
this._loadingHostInfo = false; this._loadingHostInfo = false;
} }
} }
}
private _dialogClosed(): void { private _dialogClosed(): void {
this._open = false; this._open = false;
this._loadingHostInfo = false; this._loadingHostInfo = false;
this._loadingBackupInfo = false;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@ -100,6 +124,15 @@ class DialogRestart extends LitElement {
<span slot="title" .title=${dialogTitle}> ${dialogTitle} </span> <span slot="title" .title=${dialogTitle}> ${dialogTitle} </span>
</ha-dialog-header> </ha-dialog-header>
<div slot="content" class="content"> <div slot="content" class="content">
<div class="action-loader">
${this._loadingBackupInfo
? html`<ha-fade-in .delay=${250}>
<mwc-linear-progress
.indeterminate=${true}
></mwc-linear-progress>
</ha-fade-in>`
: nothing}
</div>
${this._loadingHostInfo ${this._loadingHostInfo
? html` ? html`
<div class="loader"> <div class="loader">
@ -110,7 +143,11 @@ class DialogRestart extends LitElement {
<ha-md-list dialogInitialFocus> <ha-md-list dialogInitialFocus>
${showReload ${showReload
? html` ? html`
<ha-md-list-item type="button" @click=${this._reload}> <ha-md-list-item
type="button"
@click=${this._reload}
.disabled=${this._loadingBackupInfo}
>
<div slot="headline"> <div slot="headline">
${this.hass.localize( ${this.hass.localize(
"ui.dialogs.restart.reload.title" "ui.dialogs.restart.reload.title"
@ -130,7 +167,9 @@ class DialogRestart extends LitElement {
: nothing} : nothing}
<ha-md-list-item <ha-md-list-item
type="button" type="button"
@click=${this._showRestartDialog} .action=${"restart"}
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
> >
<div slot="start" class="icon-background restart"> <div slot="start" class="icon-background restart">
<ha-svg-icon .path=${mdiRefresh}></ha-svg-icon> <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
@ -156,7 +195,9 @@ class DialogRestart extends LitElement {
? html` ? html`
<ha-md-list-item <ha-md-list-item
type="button" type="button"
@click=${this._hostReboot} .action=${"reboot"}
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
> >
<div slot="start" class="icon-background reboot"> <div slot="start" class="icon-background reboot">
<ha-svg-icon .path=${mdiPowerCycle}></ha-svg-icon> <ha-svg-icon .path=${mdiPowerCycle}></ha-svg-icon>
@ -175,7 +216,9 @@ class DialogRestart extends LitElement {
</ha-md-list-item> </ha-md-list-item>
<ha-md-list-item <ha-md-list-item
type="button" type="button"
@click=${this._hostShutdown} .action=${"shutdown"}
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
> >
<div slot="start" class="icon-background shutdown"> <div slot="start" class="icon-background shutdown">
<ha-svg-icon .path=${mdiPower}></ha-svg-icon> <ha-svg-icon .path=${mdiPower}></ha-svg-icon>
@ -196,7 +239,9 @@ class DialogRestart extends LitElement {
: nothing} : nothing}
<ha-md-list-item <ha-md-list-item
type="button" type="button"
@click=${this._showRestartSafeModeDialog} .action=${"restart-safe-mode"}
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
> >
<div <div
slot="start" slot="start"
@ -232,120 +277,95 @@ class DialogRestart extends LitElement {
duration: 1000, duration: 1000,
}); });
this._restartAction(
"homeassistant",
"reload_all",
this.hass.localize("ui.dialogs.restart.reload.failed")
)();
}
private _getBackupProgressMessage(backupState: BackupManagerState) {
switch (backupState) {
case "create_backup":
return this.hass.localize("ui.dialogs.restart.backup_in_progress");
case "receive_backup":
return this.hass.localize("ui.dialogs.restart.upload_in_progress");
case "restore_backup":
return this.hass.localize("ui.dialogs.restart.restore_in_progress");
default:
return "";
}
}
private _restartAction =
(
domain: ServiceCallRequest["domain"],
service: ServiceCallRequest["service"],
errorTitle: string,
serviceData?: ServiceCallRequest["serviceData"]
) =>
async () => {
try { try {
await this.hass.callService("homeassistant", "reload_all"); await this.hass.callService(domain, service, serviceData);
} catch (err: any) { } catch (err: any) {
showAlertDialog(this, { showAlertDialog(this, {
title: this.hass.localize("ui.dialogs.restart.reload.failed"), title: errorTitle,
text: err.message, text: err.message,
}); });
} }
} };
private async _showRestartDialog() {
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"
),
confirmText: this.hass.localize(
"ui.dialogs.restart.restart.confirm_action"
),
destructive: true,
});
if (!confirmed) {
return;
}
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,
});
}
}
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"
),
text: err.message,
});
}
}
private async _hostReboot(): Promise<void> {
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();
private _hostAction =
(toastMessage: string, action: "reboot" | "shutdown") => async () => {
showToast(this, { showToast(this, {
message: this.hass.localize("ui.dialogs.restart.reboot.rebooting"), message: toastMessage,
duration: -1, duration: -1,
}); });
try { try {
if (action === "reboot") {
await rebootHost(this.hass); await rebootHost(this.hass);
} else {
await shutdownHost(this.hass);
}
} catch (err: any) { } catch (err: any) {
// Ignore connection errors, these are all expected // Ignore connection errors, these are all expected
if (this.hass.connection.connected && !ignoreSupervisorError(err)) { if (this.hass.connection.connected && !ignoreSupervisorError(err)) {
showAlertDialog(this, { showAlertDialog(this, {
title: this.hass.localize("ui.dialogs.restart.reboot.failed"), title: this.hass.localize(`ui.dialogs.restart.${action}.failed`),
text: extractApiErrorMessage(err), 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;
private async _hostShutdown(): Promise<void> {
const confirmed = await showConfirmationDialog(this, { const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize("ui.dialogs.restart.shutdown.confirm_title"), title: this.hass.localize(`ui.dialogs.restart.${action}.confirm_title`),
text: this.hass.localize( text: html`${this.hass.localize(
"ui.dialogs.restart.shutdown.confirm_description" `ui.dialogs.restart.${action}.confirm_description`
), )}${backupProgressMessage
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
: nothing}`,
confirmText: this.hass.localize( confirmText: this.hass.localize(
"ui.dialogs.restart.shutdown.confirm_action" `ui.dialogs.restart.${action}.confirm_action${backupState === "idle" ? "" : "_backup"}`
), ),
destructive: true, destructive: true,
}); });
@ -356,22 +376,36 @@ class DialogRestart extends LitElement {
this.closeDialog(); this.closeDialog();
showToast(this, { let actionFunc;
message: this.hass.localize("ui.dialogs.restart.shutdown.shutting_down"),
duration: -1,
});
try { if (["restart", "restart-safe-mode"].includes(action)) {
await shutdownHost(this.hass); const serviceData =
} catch (err: any) { action === "restart-safe-mode" ? { safe_mode: true } : undefined;
// Ignore connection errors, these are all expected actionFunc = this._restartAction(
if (this.hass.connection.connected && !ignoreSupervisorError(err)) { "homeassistant",
showAlertDialog(this, { "restart",
title: this.hass.localize("ui.dialogs.restart.shutdown.failed"), this.hass.localize(`ui.dialogs.restart.${action}.failed`),
text: extractApiErrorMessage(err), serviceData
);
} else {
actionFunc = this._hostAction(
this.hass.localize(
`ui.dialogs.restart.${action as "reboot" | "shutdown"}.action_toast`
),
action as "reboot" | "shutdown"
);
}
if (backupState !== "idle") {
showRestartWaitDialog(this, {
title: this.hass.localize(`ui.dialogs.restart.${action}.title`),
initialBackupState: backupState,
action: actionFunc,
}); });
return;
} }
}
actionFunc();
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
@ -448,6 +482,9 @@ class DialogRestart extends LitElement {
justify-content: center; justify-content: center;
padding: 24px; padding: 24px;
} }
.action-loader {
height: 4px;
}
`, `,
]; ];
} }

View File

@ -1,8 +1,10 @@
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { ManagerState } from "../../data/backup_manager";
export interface RestartDialogParams {} export interface RestartDialogParams {}
export const loadRestartDialog = () => import("./dialog-restart"); export const loadRestartDialog = () => import("./dialog-restart");
export const loadRestartWaitDialog = () => import("./dialog-restart-wait");
export const showRestartDialog = (element: HTMLElement): void => { export const showRestartDialog = (element: HTMLElement): void => {
fireEvent(element, "show-dialog", { fireEvent(element, "show-dialog", {
@ -11,3 +13,20 @@ export const showRestartDialog = (element: HTMLElement): void => {
dialogParams: {}, dialogParams: {},
}); });
}; };
export interface RestartWaitDialogParams {
title: string;
initialBackupState: ManagerState;
action: () => Promise<void>;
}
export const showRestartWaitDialog = (
element: HTMLElement,
dialogParams: RestartWaitDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-restart-wait",
dialogImport: loadRestartWaitDialog,
dialogParams,
});
};

View File

@ -1548,6 +1548,13 @@
"restart": { "restart": {
"heading": "Restart Home Assistant", "heading": "Restart Home Assistant",
"advanced_options": "Advanced options", "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": { "reload": {
"title": "Quick reload", "title": "Quick reload",
"description": "Loads new YAML configurations without a restart.", "description": "Loads new YAML configurations without a restart.",
@ -1560,6 +1567,7 @@
"confirm_title": "Restart Home Assistant?", "confirm_title": "Restart Home Assistant?",
"confirm_description": "This will interrupt all running automations and scripts.", "confirm_description": "This will interrupt all running automations and scripts.",
"confirm_action": "Restart", "confirm_action": "Restart",
"confirm_action_backup": "Wait and restart",
"failed": "Failed to restart Home Assistant" "failed": "Failed to restart Home Assistant"
}, },
"stop": { "stop": {
@ -1573,7 +1581,8 @@
"confirm_title": "Reboot system?", "confirm_title": "Reboot system?",
"confirm_description": "This will reboot the complete system which includes Home Assistant and all the add-ons.", "confirm_description": "This will reboot the complete system which includes Home Assistant and all the add-ons.",
"confirm_action": "Reboot", "confirm_action": "Reboot",
"rebooting": "Rebooting system", "action_toast": "Rebooting system",
"confirm_action_backup": "Wait and reboot",
"failed": "Failed to reboot system" "failed": "Failed to reboot system"
}, },
"shutdown": { "shutdown": {
@ -1582,7 +1591,8 @@
"confirm_title": "Shut down system?", "confirm_title": "Shut down system?",
"confirm_description": "This will shut down the complete system which includes Home Assistant and all add-ons.", "confirm_description": "This will shut down the complete system which includes Home Assistant and all add-ons.",
"confirm_action": "Shut down", "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" "failed": "Failed to shut down system"
}, },
"restart-safe-mode": { "restart-safe-mode": {
@ -1591,6 +1601,7 @@
"confirm_title": "Restart Home Assistant in safe mode?", "confirm_title": "Restart Home Assistant in safe mode?",
"confirm_description": "This will restart Home Assistant without loading any custom integrations and frontend modules.", "confirm_description": "This will restart Home Assistant without loading any custom integrations and frontend modules.",
"confirm_action": "Restart", "confirm_action": "Restart",
"confirm_action_backup": "Wait and Restart",
"failed": "Failed to restart Home Assistant" "failed": "Failed to restart Home Assistant"
} }
}, },