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;
}
export type ManagerState =
| "idle"
| "create_backup"
| "receive_backup"
| "restore_backup";
export type ManagerStateEvent =
| IdleEvent
| 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 {
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,6 +66,21 @@ class DialogRestart extends LitElement {
this._open = true;
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;
try {
this._hostInfo = await fetchHassioHostInfo(this.hass);
@ -66,11 +90,11 @@ class DialogRestart extends LitElement {
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 {
<span slot="title" .title=${dialogTitle}> ${dialogTitle} </span>
</ha-dialog-header>
<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
? html`
<div class="loader">
@ -110,7 +143,11 @@ class DialogRestart extends LitElement {
<ha-md-list dialogInitialFocus>
${showReload
? 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">
${this.hass.localize(
"ui.dialogs.restart.reload.title"
@ -130,7 +167,9 @@ class DialogRestart extends LitElement {
: nothing}
<ha-md-list-item
type="button"
@click=${this._showRestartDialog}
.action=${"restart"}
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<div slot="start" class="icon-background restart">
<ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
@ -156,7 +195,9 @@ class DialogRestart extends LitElement {
? html`
<ha-md-list-item
type="button"
@click=${this._hostReboot}
.action=${"reboot"}
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<div slot="start" class="icon-background reboot">
<ha-svg-icon .path=${mdiPowerCycle}></ha-svg-icon>
@ -175,7 +216,9 @@ class DialogRestart extends LitElement {
</ha-md-list-item>
<ha-md-list-item
type="button"
@click=${this._hostShutdown}
.action=${"shutdown"}
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<div slot="start" class="icon-background shutdown">
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
@ -196,7 +239,9 @@ class DialogRestart extends LitElement {
: nothing}
<ha-md-list-item
type="button"
@click=${this._showRestartSafeModeDialog}
.action=${"restart-safe-mode"}
@click=${this._handleAction}
.disabled=${this._loadingBackupInfo}
>
<div
slot="start"
@ -232,120 +277,95 @@ class DialogRestart extends LitElement {
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 {
await this.hass.callService("homeassistant", "reload_all");
await this.hass.callService(domain, service, serviceData);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.dialogs.restart.reload.failed"),
title: errorTitle,
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, {
message: this.hass.localize("ui.dialogs.restart.reboot.rebooting"),
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.reboot.failed"),
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;
private async _hostShutdown(): Promise<void> {
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"
),
title: this.hass.localize(`ui.dialogs.restart.${action}.confirm_title`),
text: html`${this.hass.localize(
`ui.dialogs.restart.${action}.confirm_description`
)}${backupProgressMessage
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
: nothing}`,
confirmText: this.hass.localize(
"ui.dialogs.restart.shutdown.confirm_action"
`ui.dialogs.restart.${action}.confirm_action${backupState === "idle" ? "" : "_backup"}`
),
destructive: true,
});
@ -356,22 +376,36 @@ class DialogRestart extends LitElement {
this.closeDialog();
showToast(this, {
message: this.hass.localize("ui.dialogs.restart.shutdown.shutting_down"),
duration: -1,
});
let actionFunc;
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),
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`
),
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 {
@ -448,6 +482,9 @@ class DialogRestart extends LitElement {
justify-content: center;
padding: 24px;
}
.action-loader {
height: 4px;
}
`,
];
}

View File

@ -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<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": {
"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"
}
},