From 03a415beff6e6f9c87a95287804f6c03c8fef3d5 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Mon, 10 Feb 2025 16:40:08 +0100 Subject: [PATCH] Onboarding restore use core api (#23920) * Fix type issues * Extract backup-upload * Add onboarding upload section * Extract and use ha-backup-details * Implement backup details and restore * remove unused hassio onboarding calls * Require hass in dialog-hassio-backup * Add restore view * Add formatDateTime without locale and config * Add restore status * Fix prettier * Fix styles of backup details * Remove unused localize * Fix onboarding restore translations * Hide data-picker on core only instance * Split uploadBackup into 2 separate funcs * Add formatDateTimeWithBrowserDefaults * Fix selected data for core only * Show error reasons on status page * Use new backup info agents * Add mem function for formatDateTimeWithBrowserDefaults * Fix overflow on mobile * Handle errors when in hassio mode * Fix cancel restore texts * Fix hassio localize type issue * Remove unused onboarding localize in hassio backup restore * improve format_date_time * Fix tests * Fix and simplify backup upload issues * Use foreach instead of reduce * Fix attributes, unused styles and properties * Simplify supervisor warning * Fix language type issues * Fix ha-backup-data-picker * Improve backup-details-restore * Fix onboarding-restore issues * Improve loadBackupInfo * Revert uploadBackup changes * Improve cancel restore * Use destructive * Update src/panels/config/backup/dialogs/dialog-upload-backup.ts Co-authored-by: Bram Kragten * Show backup type not at onboarding * Only show backup type in correct translationPanel * Fix quotes --------- Co-authored-by: Bram Kragten --- hassio/src/components/hassio-upload-backup.ts | 4 +- .../components/supervisor-backup-content.ts | 40 +- .../backup/dialog-hassio-backup-upload.ts | 2 +- .../dialogs/backup/dialog-hassio-backup.ts | 54 +-- .../backup/show-dialog-hassio-backup.ts | 2 - hassio/src/util/translations.ts | 4 - src/common/datetime/format_date_time.ts | 14 + src/components/ha-file-upload.ts | 42 +- src/data/backup.ts | 37 +- src/data/backup_onboarding.ts | 66 +++ src/data/hassio/backup.ts | 60 +-- src/mixins/lit-localize-lite-mixin.ts | 2 +- src/onboarding/ha-onboarding.ts | 14 +- src/onboarding/onboarding-restore-backup.ts | 388 +++++++++++++----- src/onboarding/onboarding-welcome.ts | 12 +- .../onboarding-restore-backup-details.ts | 57 +++ .../onboarding-restore-backup-restore.ts | 174 ++++++++ .../onboarding-restore-backup-status.ts | 119 ++++++ .../onboarding-restore-backup-upload.ts | 126 ++++++ .../components/ha-backup-addons-picker.ts | 4 +- .../components/ha-backup-data-picker.ts | 56 ++- .../components/ha-backup-details-restore.ts | 148 +++++++ .../components/ha-backup-details-summary.ts | 153 +++++++ .../backup/dialogs/dialog-upload-backup.ts | 51 ++- .../config/backup/ha-config-backup-details.ts | 133 +----- src/translations/en.json | 57 ++- test/common/datetime/format_date_time.test.ts | 14 + 27 files changed, 1425 insertions(+), 408 deletions(-) delete mode 100644 hassio/src/util/translations.ts create mode 100644 src/data/backup_onboarding.ts create mode 100644 src/onboarding/restore-backup/onboarding-restore-backup-details.ts create mode 100644 src/onboarding/restore-backup/onboarding-restore-backup-restore.ts create mode 100644 src/onboarding/restore-backup/onboarding-restore-backup-status.ts create mode 100644 src/onboarding/restore-backup/onboarding-restore-backup-upload.ts create mode 100644 src/panels/config/backup/components/ha-backup-details-restore.ts create mode 100644 src/panels/config/backup/components/ha-backup-details-summary.ts diff --git a/hassio/src/components/hassio-upload-backup.ts b/hassio/src/components/hassio-upload-backup.ts index c09b8af823..4344204c92 100644 --- a/hassio/src/components/hassio-upload-backup.ts +++ b/hassio/src/components/hassio-upload-backup.ts @@ -14,7 +14,7 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize"; declare global { interface HASSDomEvents { - "backup-uploaded": { backup: HassioBackup }; + "hassio-backup-uploaded": { backup: HassioBackup }; "backup-cleared": undefined; } } @@ -70,7 +70,7 @@ export class HassioUploadBackup extends LitElement { this._uploading = true; try { const backup = await uploadBackup(this.hass, file); - fireEvent(this, "backup-uploaded", { backup: backup.data }); + fireEvent(this, "hassio-backup-uploaded", { backup: backup.data }); } catch (err: any) { showAlertDialog(this, { title: "Upload failed", diff --git a/hassio/src/components/supervisor-backup-content.ts b/hassio/src/components/supervisor-backup-content.ts index 15fde4f3b0..422d8452eb 100644 --- a/hassio/src/components/supervisor-backup-content.ts +++ b/hassio/src/components/supervisor-backup-content.ts @@ -5,7 +5,6 @@ import { customElement, property, query } from "lit/decorators"; import { atLeastVersion } from "../../../src/common/config/version"; import { formatDate } from "../../../src/common/datetime/format_date"; import { formatDateTime } from "../../../src/common/datetime/format_date_time"; -import type { LocalizeFunc } from "../../../src/common/translations/localize"; import "../../../src/components/ha-checkbox"; import "../../../src/components/ha-formfield"; import "../../../src/components/ha-textfield"; @@ -19,13 +18,10 @@ import type { } from "../../../src/data/hassio/backup"; import type { Supervisor } from "../../../src/data/supervisor/supervisor"; import { mdiHomeAssistant } from "../../../src/resources/home-assistant-logo-svg"; -import type { HomeAssistant, TranslationDict } from "../../../src/types"; +import type { HomeAssistant } from "../../../src/types"; import "./supervisor-formfield-label"; import type { HaTextField } from "../../../src/components/ha-textfield"; -type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] & - keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"]; - interface CheckboxItem { slug: string; checked: boolean; @@ -67,8 +63,6 @@ const _computeAddons = (addons): AddonCheckboxItem[] => export class SupervisorBackupContent extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; - @property({ attribute: false }) public localize?: LocalizeFunc; - @property({ attribute: false }) public supervisor?: Supervisor; @property({ attribute: false }) public backup?: HassioBackupDetail; @@ -115,10 +109,6 @@ export class SupervisorBackupContent extends LitElement { this._focusTarget?.focus(); } - private _localize = (key: BackupOrRestoreKey) => - this.supervisor?.localize(`backup.${key}`) || - this.localize!(`ui.panel.page-onboarding.restore.${key}`); - protected render() { if (!this.onboarding && !this.supervisor) { return nothing; @@ -132,8 +122,8 @@ export class SupervisorBackupContent extends LitElement { ${this.backup ? html`
${this.backup.type === "full" - ? this._localize("full_backup") - : this._localize("partial_backup")} + ? this.supervisor?.localize("backup.full_backup") + : this.supervisor?.localize("backup.partial_backup")} (${Math.ceil(this.backup.size * 10) / 10 + " MB"})
${this.hass ? formatDateTime( @@ -145,7 +135,7 @@ export class SupervisorBackupContent extends LitElement {
` : html` @@ -153,11 +143,13 @@ export class SupervisorBackupContent extends LitElement { ${!this.backup || this.backup.type === "full" ? html`
${!this.backup - ? this._localize("type") - : this._localize("select_type")} + ? this.supervisor?.localize("backup.type") + : this.supervisor?.localize("backup.select_type")}
- + - + `} @@ -222,7 +216,7 @@ export class SupervisorBackupContent extends LitElement { ? html` `} @@ -247,7 +241,7 @@ export class SupervisorBackupContent extends LitElement { ${!this.backup ? html` ${!this.backup ? html`
diff --git a/hassio/src/dialogs/backup/dialog-hassio-backup.ts b/hassio/src/dialogs/backup/dialog-hassio-backup.ts index b83ee02ad1..55ccb0bec6 100644 --- a/hassio/src/dialogs/backup/dialog-hassio-backup.ts +++ b/hassio/src/dialogs/backup/dialog-hassio-backup.ts @@ -35,7 +35,6 @@ import { fileDownload } from "../../../../src/util/file_download"; import "../../components/supervisor-backup-content"; import type { SupervisorBackupContent } from "../../components/supervisor-backup-content"; import type { HassioBackupDialogParams } from "./show-dialog-hassio-backup"; -import type { BackupOrRestoreKey } from "../../util/translations"; import type { HaMdDialog } from "../../../../src/components/ha-md-dialog"; @customElement("dialog-hassio-backup") @@ -43,7 +42,7 @@ class HassioBackupDialog extends LitElement implements HassDialog { - @property({ attribute: false }) public hass?: HomeAssistant; + @property({ attribute: false }) public hass!: HomeAssistant; @state() private _error?: string; @@ -62,9 +61,13 @@ class HassioBackupDialog this._dialogParams = dialogParams; this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug); if (!this._backup) { - this._error = this._localize("no_backup_found"); + this._error = this._dialogParams.supervisor?.localize( + "backup.no_backup_found" + ); } else if (this._dialogParams.onboarding && !this._backup.homeassistant) { - this._error = this._localize("restore_no_home_assistant"); + this._error = this._dialogParams.supervisor?.localize( + "backup.restore_no_home_assistant" + ); } this._restoringBackup = false; } @@ -82,13 +85,6 @@ class HassioBackupDialog return true; } - private _localize(key: BackupOrRestoreKey) { - return ( - this._dialogParams!.supervisor?.localize(`backup.${key}`) || - this._dialogParams!.localize!(`ui.panel.page-onboarding.restore.${key}`) - ); - } - protected render() { if (!this._dialogParams || !this._backup) { return nothing; @@ -102,7 +98,7 @@ class HassioBackupDialog @@ -161,7 +156,7 @@ class HassioBackupDialog .disabled=${this._restoringBackup || !!this._error} @click=${this._restoreClicked} > - ${this._localize("restore")} + ${this._dialogParams.supervisor?.localize("backup.restore")} @@ -196,18 +191,22 @@ class HassioBackupDialog } if ( !(await showConfirmationDialog(this, { - title: this._localize( - this._backup!.type === "full" - ? "confirm_restore_full_backup_title" - : "confirm_restore_partial_backup_title" + title: supervisor?.localize( + `backup.${ + this._backup!.type === "full" + ? "confirm_restore_full_backup_title" + : "confirm_restore_partial_backup_title" + }` ), - text: this._localize( - this._backup!.type === "full" - ? "confirm_restore_full_backup_text" - : "confirm_restore_partial_backup_text" + text: supervisor?.localize( + `backup.${ + this._backup!.type === "full" + ? "confirm_restore_full_backup_text" + : "confirm_restore_partial_backup_text" + }` ), - confirmText: this._localize("restore"), - dismissText: this._localize("cancel"), + confirmText: supervisor?.localize("backup.restore"), + dismissText: supervisor?.localize("backup.cancel"), })) ) { this._restoringBackup = false; @@ -227,7 +226,8 @@ class HassioBackupDialog this.closeDialog(); } catch (error: any) { this._error = - error?.body?.message || this._localize("restore_start_failed"); + error?.body?.message || + supervisor?.localize("backup.restore_start_failed"); } finally { this._restoringBackup = false; } @@ -286,7 +286,7 @@ class HassioBackupDialog title: supervisor.localize("backup.remote_download_title"), text: supervisor.localize("backup.remote_download_text"), confirmText: supervisor.localize("backup.download"), - dismissText: this._localize("cancel"), + dismissText: supervisor?.localize("backup.cancel"), }); if (!confirm) { return; @@ -302,7 +302,7 @@ class HassioBackupDialog private get _computeName() { return this._backup ? this._backup.name || this._backup.slug - : this._localize("unnamed_backup"); + : this._dialogParams!.supervisor?.localize("backup.unnamed_backup") || ""; } static get styles(): CSSResultGroup { diff --git a/hassio/src/dialogs/backup/show-dialog-hassio-backup.ts b/hassio/src/dialogs/backup/show-dialog-hassio-backup.ts index eef9a22f5e..57b354f087 100644 --- a/hassio/src/dialogs/backup/show-dialog-hassio-backup.ts +++ b/hassio/src/dialogs/backup/show-dialog-hassio-backup.ts @@ -1,5 +1,4 @@ import { fireEvent } from "../../../../src/common/dom/fire_event"; -import type { LocalizeFunc } from "../../../../src/common/translations/localize"; import type { Supervisor } from "../../../../src/data/supervisor/supervisor"; export interface HassioBackupDialogParams { @@ -8,7 +7,6 @@ export interface HassioBackupDialogParams { onRestoring?: () => void; onboarding?: boolean; supervisor?: Supervisor; - localize?: LocalizeFunc; } export const showHassioBackupDialog = ( diff --git a/hassio/src/util/translations.ts b/hassio/src/util/translations.ts deleted file mode 100644 index 1a7af29ef3..0000000000 --- a/hassio/src/util/translations.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { TranslationDict } from "../../../src/types"; - -export type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] & - keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"]; diff --git a/src/common/datetime/format_date_time.ts b/src/common/datetime/format_date_time.ts index 92c6e185ac..b0d6a5c68e 100644 --- a/src/common/datetime/format_date_time.ts +++ b/src/common/datetime/format_date_time.ts @@ -26,6 +26,20 @@ const formatDateTimeMem = memoizeOne( }) ); +export const formatDateTimeWithBrowserDefaults = (dateObj: Date) => + formatDateTimeWithBrowserDefaultsMem().format(dateObj); + +const formatDateTimeWithBrowserDefaultsMem = memoizeOne( + () => + new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) +); + // Aug 9, 2021, 8:23 AM export const formatShortDateTimeWithYear = ( dateObj: Date, diff --git a/src/components/ha-file-upload.ts b/src/components/ha-file-upload.ts index 245331a3b9..d665910525 100644 --- a/src/components/ha-file-upload.ts +++ b/src/components/ha-file-upload.ts @@ -11,6 +11,7 @@ import "./ha-icon-button"; import { blankBeforePercent } from "../common/translations/blank_before_percent"; import { ensureArray } from "../common/array/ensure-array"; import { bytesToString } from "../util/bytes-to-string"; +import type { LocalizeFunc } from "../common/translations/localize"; declare global { interface HASSDomEvents { @@ -23,6 +24,8 @@ declare global { export class HaFileUpload extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; + @property({ attribute: false }) public localize?: LocalizeFunc; + @property() public accept!: string; @property() public icon?: string; @@ -31,6 +34,10 @@ export class HaFileUpload extends LitElement { @property() public secondary?: string; + @property({ attribute: "uploading-label" }) public uploadingLabel?: string; + + @property({ attribute: "delete-label" }) public deleteLabel?: string; + @property() public supports?: string; @property({ type: Object }) public value?: File | File[] | FileList | string; @@ -73,23 +80,22 @@ export class HaFileUpload extends LitElement { } public render(): TemplateResult { + const localize = this.localize || this.hass!.localize; return html` ${this.uploading ? html`
${this.value - ? this.hass?.localize( - "ui.components.file-upload.uploading_name", - { name: this._name } - ) - : this.hass?.localize( - "ui.components.file-upload.uploading" - )}${this.uploadingLabel || this.value + ? localize("ui.components.file-upload.uploading_name", { + name: this._name, + }) + : localize("ui.components.file-upload.uploading")} ${this.progress ? html`
- ${this.progress}${blankBeforePercent(this.hass!.locale)}% + ${this.progress}${this.hass && + blankBeforePercent(this.hass!.locale)}%
` : nothing}
@@ -116,14 +122,11 @@ export class HaFileUpload extends LitElement { .path=${this.icon || mdiFileUpload} > - ${this.label || - this.hass?.localize("ui.components.file-upload.label")} + ${this.label || localize("ui.components.file-upload.label")} ${this.secondary || - this.hass?.localize( - "ui.components.file-upload.secondary" - )} ${this.supports}` : typeof this.value === "string" @@ -136,8 +139,7 @@ export class HaFileUpload extends LitElement {
` @@ -155,8 +157,8 @@ export class HaFileUpload extends LitElement { ` @@ -238,6 +240,10 @@ export class HaFileUpload extends LitElement { border-radius: var(--mdc-shape-small, 4px); height: 100%; } + .row { + display: flex; + align-items: center; + } label.container { border: dashed 1px var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42)); diff --git a/src/data/backup.ts b/src/data/backup.ts index 756ca4b47a..54424f0b21 100644 --- a/src/data/backup.ts +++ b/src/data/backup.ts @@ -2,7 +2,6 @@ import { memoize } from "@fullcalendar/core/internal"; import { setHours, setMinutes } from "date-fns"; import type { HassConfig } from "home-assistant-js-websocket"; import memoizeOne from "memoize-one"; -import checkValidDate from "../common/datetime/check_valid_date"; import { formatDateTime, formatDateTimeNumeric, @@ -13,6 +12,8 @@ import type { HomeAssistant } from "../types"; import { fileDownload } from "../util/file_download"; import { domainToName } from "./integration"; import type { FrontendLocaleData } from "./translation"; +import checkValidDate from "../common/datetime/check_valid_date"; +import { handleFetchPromise } from "../util/hass-call-api"; export const enum BackupScheduleRecurrence { NEVER = "never", @@ -231,27 +232,23 @@ export const restoreBackup = ( export const uploadBackup = async ( hass: HomeAssistant, file: File, - agent_ids: string[] -): Promise => { + agentIds: string[] +): Promise<{ backup_id: string }> => { const fd = new FormData(); fd.append("file", file); - const params = agent_ids.reduce((acc, agent_id) => { - acc.append("agent_id", agent_id); - return acc; - }, new URLSearchParams()); + const params = new URLSearchParams(); - const resp = await hass.fetchWithAuth( - `/api/backup/upload?${params.toString()}`, - { + agentIds.forEach((agentId) => { + params.append("agent_id", agentId); + }); + + return handleFetchPromise( + hass.fetchWithAuth(`/api/backup/upload?${params.toString()}`, { method: "POST", body: fd, - } + }) ); - - if (!resp.ok) { - throw new Error(`${resp.status} ${resp.statusText}`); - } }; export const getPreferredAgentForDownload = (agents: string[]) => { @@ -449,3 +446,13 @@ export const getFormattedBackupTime = memoizeOne( return `${formatTime(DEFAULT_OPTIMIZED_BACKUP_START_TIME, locale, config)} - ${formatTime(DEFAULT_OPTIMIZED_BACKUP_END_TIME, locale, config)}`; } ); + +export const SUPPORTED_UPLOAD_FORMAT = "application/x-tar"; + +export interface BackupUploadFileFormData { + file?: File; +} + +export const INITIAL_UPLOAD_FORM_DATA: BackupUploadFileFormData = { + file: undefined, +}; diff --git a/src/data/backup_onboarding.ts b/src/data/backup_onboarding.ts new file mode 100644 index 0000000000..bca3253679 --- /dev/null +++ b/src/data/backup_onboarding.ts @@ -0,0 +1,66 @@ +import { handleFetchPromise } from "../util/hass-call-api"; +import type { BackupContentExtended } from "./backup"; +import type { + BackupManagerState, + RestoreBackupStage, + RestoreBackupState, +} from "./backup_manager"; + +export interface BackupOnboardingInfo { + state: BackupManagerState; + last_non_idle_event?: { + manager_state: BackupManagerState; + stage: RestoreBackupStage | null; + state: RestoreBackupState; + reason: string | null; + } | null; +} + +export interface BackupOnboardingConfig extends BackupOnboardingInfo { + backups: BackupContentExtended[]; +} + +export const fetchBackupOnboardingInfo = async () => + handleFetchPromise( + fetch("/api/onboarding/backup/info") + ); + +export interface RestoreOnboardingBackupParams { + backup_id: string; + agent_id: string; + password?: string; + restore_addons?: string[]; + restore_database?: boolean; + restore_folders?: string[]; +} + +export const restoreOnboardingBackup = async ( + params: RestoreOnboardingBackupParams +) => + handleFetchPromise( + fetch("/api/onboarding/backup/restore", { + method: "POST", + body: JSON.stringify(params), + }) + ); + +export const uploadOnboardingBackup = async ( + file: File, + agentIds: string[] +): Promise<{ backup_id: string }> => { + const fd = new FormData(); + fd.append("file", file); + + const params = new URLSearchParams(); + + agentIds.forEach((agentId) => { + params.append("agent_id", agentId); + }); + + return handleFetchPromise( + fetch(`/api/onboarding/backup/upload?${params.toString()}`, { + method: "POST", + body: fd, + }) + ); +}; diff --git a/src/data/hassio/backup.ts b/src/data/hassio/backup.ts index d9937e1dce..0879ec28bd 100644 --- a/src/data/hassio/backup.ts +++ b/src/data/hassio/backup.ts @@ -1,6 +1,5 @@ import { atLeastVersion } from "../../common/config/version"; import type { HomeAssistant } from "../../types"; -import { handleFetchPromise } from "../../util/hass-call-api"; import type { HassioResponse } from "./common"; import { hassioApiResultExtractor } from "./common"; @@ -82,34 +81,24 @@ export const fetchHassioBackups = async ( }; export const fetchHassioBackupInfo = async ( - hass: HomeAssistant | undefined, + hass: HomeAssistant, backup: string ): Promise => { - if (hass) { - if (atLeastVersion(hass.config.version, 2021, 2, 4)) { - return hass.callWS({ - type: "supervisor/api", - endpoint: `/${ - atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots" - }/${backup}/info`, - method: "get", - }); - } - return hassioApiResultExtractor( - await hass.callApi>( - "GET", - `hassio/${ - atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots" - }/${backup}/info` - ) - ); + if (atLeastVersion(hass.config.version, 2021, 2, 4)) { + return hass.callWS({ + type: "supervisor/api", + endpoint: `/${ + atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots" + }/${backup}/info`, + method: "get", + }); } - // When called from onboarding we don't have hass return hassioApiResultExtractor( - await handleFetchPromise( - fetch(`/api/hassio/backups/${backup}/info`, { - method: "GET", - }) + await hass.callApi>( + "GET", + `hassio/${ + atLeastVersion(hass.config.version, 2021, 9) ? "backups" : "snapshots" + }/${backup}/info` ) ); }; @@ -240,24 +229,15 @@ export const uploadBackup = async ( }; export const restoreBackup = async ( - hass: HomeAssistant | undefined, + hass: HomeAssistant, type: HassioBackupDetail["type"], backupSlug: string, backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams, useSnapshotUrl: boolean ): Promise => { - if (hass) { - await hass.callApi>( - "POST", - `hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`, - backupDetails - ); - } else { - await handleFetchPromise( - fetch(`/api/hassio/backups/${backupSlug}/restore/${type}`, { - method: "POST", - body: JSON.stringify(backupDetails), - }) - ); - } + await hass.callApi>( + "POST", + `hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`, + backupDetails + ); }; diff --git a/src/mixins/lit-localize-lite-mixin.ts b/src/mixins/lit-localize-lite-mixin.ts index 8ba189bba7..2f2b4b9094 100644 --- a/src/mixins/lit-localize-lite-mixin.ts +++ b/src/mixins/lit-localize-lite-mixin.ts @@ -17,7 +17,7 @@ export const litLocalizeLiteMixin = >( @property({ attribute: false }) public localize: LocalizeFunc = empty; // Use browser language setup before login. - @property() public language?: string = getLocalLanguage(); + @property() public language: string = getLocalLanguage(); @property() public translationFragment?: string; diff --git a/src/onboarding/ha-onboarding.ts b/src/onboarding/ha-onboarding.ts index 25012234ba..6adc49e287 100644 --- a/src/onboarding/ha-onboarding.ts +++ b/src/onboarding/ha-onboarding.ts @@ -41,6 +41,7 @@ import "./onboarding-analytics"; import "./onboarding-create-user"; import "./onboarding-loading"; import "./onboarding-welcome"; +import "./onboarding-restore-backup"; import "./onboarding-welcome-links"; import { makeDialogManager } from "../dialogs/make-dialog-manager"; import { navigate } from "../common/navigate"; @@ -157,8 +158,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) { private _renderStep() { if (this._restoring) { return html` `; } @@ -166,8 +168,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) { if (this._init) { return html``; } @@ -236,7 +236,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) { } } if (changedProps.has("language")) { - document.querySelector("html")!.setAttribute("lang", this.language!); + document.querySelector("html")!.setAttribute("lang", this.language); } if (changedProps.has("hass")) { const oldHass = changedProps.get("hass") as HomeAssistant | undefined; @@ -272,10 +272,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) { "Home Assistant OS", "Home Assistant Supervised", ].includes(response.installation_type); - if (this._supervisor) { - // Only load if we have supervisor - import("./onboarding-restore-backup"); - } } catch (err: any) { // eslint-disable-next-line no-console console.error( @@ -454,7 +450,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) { subscribeOne(conn, subscribeUser), ]); this.initializeHass(auth, conn); - if (this.language && this.language !== this.hass!.language) { + if (this.language !== this.hass!.language) { this._updateHass({ locale: { ...this.hass!.locale, language: this.language }, language: this.language, diff --git a/src/onboarding/onboarding-restore-backup.ts b/src/onboarding/onboarding-restore-backup.ts index a02f51ff7c..7881cd7b33 100644 --- a/src/onboarding/onboarding-restore-backup.ts +++ b/src/onboarding/onboarding-restore-backup.ts @@ -1,136 +1,336 @@ -import type { CSSResultGroup, TemplateResult } from "lit"; +import type { TemplateResult } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { showHassioBackupDialog } from "../../hassio/src/dialogs/backup/show-dialog-hassio-backup"; -import "../../hassio/src/components/hassio-upload-backup"; +import "./restore-backup/onboarding-restore-backup-upload"; +import "./restore-backup/onboarding-restore-backup-details"; +import "./restore-backup/onboarding-restore-backup-restore"; +import "./restore-backup/onboarding-restore-backup-status"; import type { LocalizeFunc } from "../common/translations/localize"; -import "../components/ha-ansi-to-html"; import "../components/ha-card"; +import "../components/ha-icon-button-arrow-prev"; +import "../components/ha-circular-progress"; import "../components/ha-alert"; -import "../components/ha-button"; -import { fetchInstallationType } from "../data/onboarding"; -import type { HomeAssistant } from "../types"; import "./onboarding-loading"; -import { onBoardingStyles } from "./styles"; import { removeSearchParam } from "../common/url/search-params"; import { navigate } from "../common/navigate"; +import { onBoardingStyles } from "./styles"; +import { + fetchBackupOnboardingInfo, + type BackupOnboardingConfig, + type BackupOnboardingInfo, +} from "../data/backup_onboarding"; +import type { BackupContentExtended, BackupData } from "../data/backup"; +import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box"; +import { storage } from "../common/decorators/storage"; + +const STATUS_INTERVAL_IN_MS = 5000; @customElement("onboarding-restore-backup") class OnboardingRestoreBackup extends LitElement { - @property({ attribute: false }) public hass?: HomeAssistant; - @property({ attribute: false }) public localize!: LocalizeFunc; @property() public language!: string; - @state() private _restoring = false; + @property({ type: Boolean }) public supervisor = false; - @state() private _backupSlug?: string; + @state() private _view: + | "loading" + | "upload" + | "select_data" + | "confirm_restore" + | "status" = "loading"; + + @state() private _backup?: BackupContentExtended; + + @state() private _backupInfo?: BackupOnboardingInfo; + + @state() private _selectedData?: BackupData; + + @state() private _error?: string; + + @state() private _failed?: boolean; + + @storage({ + key: "onboarding-restore-backup-backup-id", + }) + private _backupId?: string; + + @storage({ + key: "onboarding-restore-running", + }) + private _restoreRunning?: boolean; protected render(): TemplateResult { return html` - ${this._restoring - ? html`

- ${this.localize("ui.panel.page-onboarding.restore.in_progress")} -

- - ${this.localize("ui.panel.page-onboarding.restore.in_progress")} - - ` - : html`

- ${this.localize("ui.panel.page-onboarding.restore.header")} -

- `} - + ${this._failed && this._view !== "status" + ? this.localize( + `ui.panel.page-onboarding.restore.${this._backupInfo?.last_non_idle_event?.reason === "password_incorrect" ? "failed_wrong_password_description" : "failed_description"}` + ) + : this._error} + ` + : nothing + } + ${ + this._view === "loading" + ? html`
+ +
` + : this._view === "upload" + ? html` + + ` + : this._view === "select_data" + ? html`` + : this._view === "confirm_restore" + ? html`` + : nothing + } + ${ + this._view === "status" && this._backupInfo + ? html`` + : nothing + } + ${ + ["select_data", "confirm_restore"].includes(this._view) && this._backup + ? html`
+ +
` + : nothing + } `; } - private _back(): void { - navigate(`${location.pathname}?${removeSearchParam("page")}`); - } - - private _backupUploaded(ev) { - const backup = ev.detail.backup; - this._backupSlug = backup.slug; - this._showBackupDialog(); - } - - private _backupCleared() { - this._backupSlug = undefined; - } - protected firstUpdated(changedProps) { super.firstUpdated(changedProps); + + this._loadBackupInfo(); } - private async _checkRestoreStatus(): Promise { - if (this._restoring) { - try { - await fetchInstallationType(); - } catch (err: any) { + private async _loadBackupInfo() { + let onboardingInfo: BackupOnboardingConfig; + try { + onboardingInfo = await fetchBackupOnboardingInfo(); + } catch (err: any) { + if (this._restoreRunning) { if ( - (err as Error).message === "unauthorized" || - (err as Error).message === "not_found" + err.error === "Request error" || + // core can restart but haven't loaded the backup integration yet + (err.status_code === 500 && err.body?.error === "backup_disabled") ) { + // core is down because of restore, keep trying + this._scheduleLoadBackupInfo(); + return; + } + + // core seems to be back up restored + if (err.status_code === 404) { + this._restoreRunning = undefined; + this._backupId = undefined; window.location.replace("/"); + return; } } + + this._error = err?.message || "Cannot get backup info"; + + // if we are in an unknown state, show upload + if (this._view === "loading") { + this._view = "upload"; + } + return; + } + + const { + last_non_idle_event: lastNonIdleEvent, + state: currentState, + backups, + } = onboardingInfo; + + this._backupInfo = { + state: currentState, + last_non_idle_event: lastNonIdleEvent, + }; + + if (this._backupId) { + this._backup = backups.find( + ({ backup_id }) => backup_id === this._backupId + ); + } + + const failedRestore = + lastNonIdleEvent?.manager_state === "restore_backup" && + lastNonIdleEvent?.state === "failed"; + + if (failedRestore) { + this._failed = true; + } + + if (this._restoreRunning) { + this._view = "status"; + if (failedRestore || currentState !== "restore_backup") { + this._failed = true; + this._restoreRunning = undefined; + } else { + this._scheduleLoadBackupInfo(); + } + return; + } + + if ( + this._backup && + // after backup was uploaded + (lastNonIdleEvent?.manager_state === "receive_backup" || + // when restore was confirmed but failed to start (for example, encryption key was wrong) + failedRestore) + ) { + if (!this.supervisor && this._backup.homeassistant_included) { + this._selectedData = { + homeassistant_included: true, + folders: [], + addons: [], + homeassistant_version: this._backup.homeassistant_version, + database_included: this._backup.database_included, + }; + // skip select data when supervisor is not available and backup includes HA + this._view = "confirm_restore"; + } else { + this._view = "select_data"; + } + return; + } + + // show upload as default + this._view = "upload"; + } + + private _scheduleLoadBackupInfo() { + setTimeout(() => this._loadBackupInfo(), STATUS_INTERVAL_IN_MS); + } + + private async _backupUploaded(ev: CustomEvent) { + this._backupId = ev.detail.backupId; + await this._loadBackupInfo(); + } + + private async _restoreStarted() { + if (this._backupInfo) { + this._backupInfo.state = "restore_backup"; + } + this._view = "status"; + this._restoreRunning = true; + await this._loadBackupInfo(); + } + + private async _back() { + if (this._view === "upload" || (this._view === "status" && this._failed)) { + navigate(`${location.pathname}?${removeSearchParam("page")}`); + } else { + const confirmed = await showConfirmationDialog(this, { + title: this.localize( + "ui.panel.page-onboarding.restore.cancel_restore.title" + ), + text: this.localize( + "ui.panel.page-onboarding.restore.cancel_restore.text" + ), + confirmText: this.localize( + "ui.panel.page-onboarding.restore.cancel_restore.yes" + ), + dismissText: this.localize( + "ui.panel.page-onboarding.restore.cancel_restore.no" + ), + }); + + if (!confirmed) { + return; + } + navigate(`${location.pathname}?${removeSearchParam("page")}`); } } - private _scheduleCheckRestoreStatus(): void { - setTimeout(() => this._checkRestoreStatus(), 1000); + private _restore(ev: CustomEvent) { + if (!this._backup || !ev.detail.selectedData) { + return; + } + this._selectedData = ev.detail.selectedData; + + this._view = "confirm_restore"; } - private _showBackupDialog(): void { - showHassioBackupDialog(this, { - slug: this._backupSlug!, - onboarding: true, - localize: this.localize, - onRestoring: () => { - this._restoring = true; - this._scheduleCheckRestoreStatus(); - }, - }); + private _reupload() { + this._backup = undefined; + this._backupId = undefined; + this._view = "upload"; } - static get styles(): CSSResultGroup { - return [ - onBoardingStyles, - css` - :host { - display: flex; - flex-direction: column; - align-items: center; - } - hassio-upload-backup { - width: 100%; - } - .footer { - display: flex; - justify-content: space-between; - width: 100%; - } - `, - ]; - } + static styles = [ + onBoardingStyles, + css` + :host { + display: flex; + flex-direction: column; + position: relative; + } + ha-icon-button-arrow-prev { + position: absolute; + top: 12px; + } + ha-card { + width: 100%; + } + .loading { + display: flex; + justify-content: center; + padding: 32px; + } + .backup-summary-wrapper { + margin-top: 24px; + padding: 0 20px; + } + `, + ]; } declare global { diff --git a/src/onboarding/onboarding-welcome.ts b/src/onboarding/onboarding-welcome.ts index a3f0dba051..56f30e28ab 100644 --- a/src/onboarding/onboarding-welcome.ts +++ b/src/onboarding/onboarding-welcome.ts @@ -1,5 +1,5 @@ import type { CSSResultGroup, TemplateResult } from "lit"; -import { LitElement, css, html, nothing } from "lit"; +import { LitElement, css, html } from "lit"; import { customElement, property } from "lit/decorators"; import type { LocalizeFunc } from "../common/translations/localize"; import type { HomeAssistant } from "../types"; @@ -13,8 +13,6 @@ class OnboardingWelcome extends LitElement { @property({ attribute: false }) public localize!: LocalizeFunc; - @property({ type: Boolean }) public supervisor = false; - protected render(): TemplateResult { return html`

${this.localize("ui.panel.page-onboarding.welcome.header")}

@@ -24,11 +22,9 @@ class OnboardingWelcome extends LitElement { ${this.localize("ui.panel.page-onboarding.welcome.start")} - ${this.supervisor - ? html` - ${this.localize("ui.panel.page-onboarding.welcome.restore_backup")} - ` - : nothing} + + ${this.localize("ui.panel.page-onboarding.welcome.restore_backup")} + `; } diff --git a/src/onboarding/restore-backup/onboarding-restore-backup-details.ts b/src/onboarding/restore-backup/onboarding-restore-backup-details.ts new file mode 100644 index 0000000000..5c5d976d4d --- /dev/null +++ b/src/onboarding/restore-backup/onboarding-restore-backup-details.ts @@ -0,0 +1,57 @@ +import { css, html, LitElement, type CSSResultGroup } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../components/ha-card"; +import "../../components/ha-circular-progress"; +import "../../components/ha-alert"; +import "../../components/ha-button"; +import "../../panels/config/backup/components/ha-backup-details-restore"; +import "../../panels/config/backup/components/ha-backup-details-summary"; +import { haStyle } from "../../resources/styles"; +import type { LocalizeFunc } from "../../common/translations/localize"; +import type { BackupContentExtended } from "../../data/backup"; + +@customElement("onboarding-restore-backup-details") +class OnboardingRestoreBackupDetails extends LitElement { + @property({ attribute: false }) public localize!: LocalizeFunc; + + @property({ attribute: false }) public backup!: BackupContentExtended; + + render() { + return html` + ${this.backup.homeassistant_included + ? html`` + : html` + + ${this.localize( + "ui.panel.page-onboarding.restore.details.home_assistant_missing" + )} + + `} + `; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + :host { + padding: 28px 20px 0; + } + ha-backup-details-restore { + display: block; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "onboarding-restore-backup-details": OnboardingRestoreBackupDetails; + } +} diff --git a/src/onboarding/restore-backup/onboarding-restore-backup-restore.ts b/src/onboarding/restore-backup/onboarding-restore-backup-restore.ts new file mode 100644 index 0000000000..bde1502f5b --- /dev/null +++ b/src/onboarding/restore-backup/onboarding-restore-backup-restore.ts @@ -0,0 +1,174 @@ +import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../components/ha-card"; +import "../../components/ha-alert"; +import "../../components/buttons/ha-progress-button"; +import "../../components/ha-password-field"; +import { haStyle } from "../../resources/styles"; +import type { LocalizeFunc } from "../../common/translations/localize"; +import { + CORE_LOCAL_AGENT, + HASSIO_LOCAL_AGENT, + type BackupContentExtended, + type BackupData, +} from "../../data/backup"; +import { restoreOnboardingBackup } from "../../data/backup_onboarding"; +import type { HaProgressButton } from "../../components/buttons/ha-progress-button"; +import { fireEvent } from "../../common/dom/fire_event"; + +@customElement("onboarding-restore-backup-restore") +class OnboardingRestoreBackupRestore extends LitElement { + @property({ attribute: false }) public localize!: LocalizeFunc; + + @property({ attribute: false }) public backup!: BackupContentExtended; + + @property({ attribute: false }) + public selectedData!: BackupData; + + @property({ type: Boolean }) public supervisor = false; + + @state() private _encryptionKey = ""; + + @state() private _encryptionKeyWrong = false; + + @state() private _error?: string; + + @state() private _loading = false; + + render() { + const agentId = this.supervisor ? HASSIO_LOCAL_AGENT : CORE_LOCAL_AGENT; + const backupProtected = this.backup.agents[agentId].protected; + + return html` + ${this.backup.homeassistant_included && + !this.supervisor && + (this.backup.addons.length > 0 || this.backup.folders.length > 0) + ? html` + ${this.localize( + "ui.panel.page-onboarding.restore.details.addons_unsupported" + )} + ` + : nothing} + +
+ ${this._error + ? html`${this._error} ` + : nothing} +

+ ${this.localize( + "ui.panel.page-onboarding.restore.confirm_restore_full_backup_text" + )} +

+ ${backupProtected + ? html`

+ ${this.localize( + "ui.panel.page-onboarding.restore.details.restore.encryption.title" + )} +

+ ${this._encryptionKeyWrong + ? html` + + ${this.localize( + "ui.panel.page-onboarding.restore.details.restore.encryption.incorrect_key" + )} + + ` + : nothing} + ` + : nothing} +
+
+ + ${this.localize( + "ui.panel.page-onboarding.restore.details.restore.action" + )} + +
+
+ `; + } + + private _encryptionKeyChanged(ev): void { + this._encryptionKey = ev.target.value; + } + + private async _startRestore(ev: CustomEvent): Promise { + const button = ev.currentTarget as HaProgressButton; + this._loading = true; + this._error = undefined; + this._encryptionKeyWrong = false; + + const backupAgent = this.supervisor ? HASSIO_LOCAL_AGENT : CORE_LOCAL_AGENT; + + try { + await restoreOnboardingBackup({ + agent_id: backupAgent, + backup_id: this.backup.backup_id, + password: this._encryptionKey || undefined, + restore_addons: this.selectedData.addons.map((addon) => addon.slug), + restore_database: this.selectedData.database_included, + restore_folders: this.selectedData.folders, + }); + button.actionSuccess(); + fireEvent(this, "restore-started"); + } catch (err: any) { + if (err.error === "Request error") { + // core can shutdown before we get a response + button.actionSuccess(); + fireEvent(this, "restore-started"); + return; + } + + button.actionError(); + if (err.body?.message === "incorrect_password") { + this._encryptionKeyWrong = true; + } else { + this._error = + err.body?.message || err.message || "Unknown error occurred"; + } + this._loading = false; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + :host { + padding: 28px 20px 0; + } + .card-actions { + display: flex; + justify-content: flex-end; + } + .supervisor-warning { + display: block; + margin-bottom: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "onboarding-restore-backup-restore": OnboardingRestoreBackupRestore; + } + interface HASSDomEvents { + "restore-started"; + } +} diff --git a/src/onboarding/restore-backup/onboarding-restore-backup-status.ts b/src/onboarding/restore-backup/onboarding-restore-backup-status.ts new file mode 100644 index 0000000000..6ce7ce01b8 --- /dev/null +++ b/src/onboarding/restore-backup/onboarding-restore-backup-status.ts @@ -0,0 +1,119 @@ +import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../components/ha-card"; +import "../../components/ha-circular-progress"; +import "../../components/ha-alert"; +import "../../components/ha-button"; +import { haStyle } from "../../resources/styles"; +import type { LocalizeFunc } from "../../common/translations/localize"; +import type { BackupOnboardingInfo } from "../../data/backup_onboarding"; +import { fireEvent } from "../../common/dom/fire_event"; +import { navigate } from "../../common/navigate"; +import { removeSearchParam } from "../../common/url/search-params"; + +@customElement("onboarding-restore-backup-status") +class OnboardingRestoreBackupStatus extends LitElement { + @property({ attribute: false }) public localize!: LocalizeFunc; + + @property({ attribute: false }) + public backupInfo!: BackupOnboardingInfo; + + render() { + return html` + +
+ ${this.backupInfo.state === "restore_backup" + ? html` +
+ +
+

+ ${this.localize( + "ui.panel.page-onboarding.restore.in_progress_description" + )} +

+ ` + : html` + + ${this.localize( + "ui.panel.page-onboarding.restore.failed_status_description" + )} + + ${this.backupInfo.last_non_idle_event?.reason + ? html` +
+

Error:

+ ${this.backupInfo.last_non_idle_event?.reason} +
+ ` + : nothing} + `} +
+ ${this.backupInfo.state !== "restore_backup" + ? html`
+ + ${this.localize( + `ui.panel.page-onboarding.restore.details.summary.upload_another` + )} + + + ${this.localize( + `ui.panel.page-onboarding.restore.details.summary.home` + )} + +
` + : nothing} +
+ `; + } + + private _uploadAnother() { + fireEvent(this, "show-backup-upload"); + } + + private _home() { + navigate(`${location.pathname}?${removeSearchParam("page")}`); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + :host { + padding: 28px 20px 0; + } + .card-actions { + display: flex; + justify-content: flex-end; + } + .loading { + display: flex; + justify-content: center; + padding: 32px; + } + p { + text-align: center; + padding: 0 16px; + font-size: 16px; + } + .failed { + padding: 16px 0; + font-size: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "onboarding-restore-backup-status": OnboardingRestoreBackupStatus; + } + interface HASSDomEvents { + "restore-started"; + } +} diff --git a/src/onboarding/restore-backup/onboarding-restore-backup-upload.ts b/src/onboarding/restore-backup/onboarding-restore-backup-upload.ts new file mode 100644 index 0000000000..2927fe86a2 --- /dev/null +++ b/src/onboarding/restore-backup/onboarding-restore-backup-upload.ts @@ -0,0 +1,126 @@ +import { mdiFolderUpload } from "@mdi/js"; +import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../components/ha-card"; +import "../../components/ha-file-upload"; +import "../../components/ha-alert"; +import { haStyle } from "../../resources/styles"; +import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event"; +import { showAlertDialog } from "../../dialogs/generic/show-dialog-box"; +import { + CORE_LOCAL_AGENT, + HASSIO_LOCAL_AGENT, + SUPPORTED_UPLOAD_FORMAT, +} from "../../data/backup"; +import type { LocalizeFunc } from "../../common/translations/localize"; +import { uploadOnboardingBackup } from "../../data/backup_onboarding"; + +declare global { + interface HASSDomEvents { + "backup-uploaded": { backupId: string }; + } +} +@customElement("onboarding-restore-backup-upload") +class OnboardingRestoreBackupUpload extends LitElement { + @property({ type: Boolean }) public supervisor = false; + + @property({ attribute: false }) public localize!: LocalizeFunc; + + @state() private _uploading = false; + + @state() private _error?: string; + + render() { + return html` + +
+ ${this._error + ? html`${this._error}` + : nothing} + +
+
+ `; + } + + private async _filePicked(ev: HASSDomEvent<{ files: File[] }>) { + this._error = undefined; + const file = ev.detail.files[0]; + + if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) { + showAlertDialog(this, { + title: this.localize( + "ui.panel.page-onboarding.restore.unsupported.title" + ), + text: this.localize( + "ui.panel.page-onboarding.restore.unsupported.text" + ), + confirmText: this.localize("ui.panel.page-onboarding.restore.ok"), + }); + return; + } + + const agentIds = this.supervisor + ? [HASSIO_LOCAL_AGENT] + : [CORE_LOCAL_AGENT]; + + this._uploading = true; + try { + const { backup_id } = await uploadOnboardingBackup(file, agentIds); + fireEvent(this, "backup-uploaded", { backupId: backup_id }); + } catch (err: any) { + this._error = + typeof err.body === "string" + ? err.body + : err.body?.message || err.message || "Unknown error occurred"; + } finally { + this._uploading = false; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + :host { + width: 100%; + } + .card-actions { + display: flex; + justify-content: flex-end; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "onboarding-restore-backup-upload": OnboardingRestoreBackupUpload; + } +} diff --git a/src/panels/config/backup/components/ha-backup-addons-picker.ts b/src/panels/config/backup/components/ha-backup-addons-picker.ts index e298a870f9..eb3a20e794 100644 --- a/src/panels/config/backup/components/ha-backup-addons-picker.ts +++ b/src/panels/config/backup/components/ha-backup-addons-picker.ts @@ -21,7 +21,7 @@ export interface BackupAddonItem { @customElement("ha-backup-addons-picker") export class HaBackupAddonsPicker extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public addons!: BackupAddonItem[]; @@ -32,7 +32,7 @@ export class HaBackupAddonsPicker extends LitElement { private _addons = memoizeOne((addons: BackupAddonItem[]) => addons.sort((a, b) => - stringCompare(a.name, b.name, this.hass.locale.language) + stringCompare(a.name, b.name, this.hass?.locale?.language) ) ); diff --git a/src/panels/config/backup/components/ha-backup-data-picker.ts b/src/panels/config/backup/components/ha-backup-data-picker.ts index 96a72a348a..b1276b89f5 100644 --- a/src/panels/config/backup/components/ha-backup-data-picker.ts +++ b/src/panels/config/backup/components/ha-backup-data-picker.ts @@ -47,23 +47,32 @@ interface SelectedItems { @customElement("ha-backup-data-picker") export class HaBackupDataPicker extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public data!: BackupData; @property({ attribute: false }) public value?: BackupData; + @property({ attribute: false }) public localize?: LocalizeFunc; + + @property({ type: Array, attribute: "required-items" }) + public requiredItems: string[] = []; + + @property({ attribute: "translation-key-panel" }) public translationKeyPanel: + | "page-onboarding.restore" + | "config.backup" = "config.backup"; + @state() public _addonIcons: Record = {}; protected firstUpdated(changedProps: PropertyValues): void { super.firstUpdated(changedProps); - if (isComponentLoaded(this.hass, "hassio")) { + if (this.hass && isComponentLoaded(this.hass, "hassio")) { this._fetchAddonInfo(); } } private async _fetchAddonInfo() { - const { addons } = await fetchHassioAddonsInfo(this.hass); + const { addons } = await fetchHassioAddonsInfo(this.hass!); this._addonIcons = addons.reduce>( (acc, addon) => ({ ...acc, @@ -74,16 +83,14 @@ export class HaBackupDataPicker extends LitElement { } private _homeAssistantItems = memoizeOne( - (data: BackupData, _localize: LocalizeFunc) => { + (data: BackupData, localize: LocalizeFunc) => { const items: CheckBoxItem[] = []; if (data.homeassistant_included) { items.push({ - label: data.database_included - ? this.hass.localize( - "ui.panel.config.backup.data_picker.settings_and_history" - ) - : this.hass.localize("ui.panel.config.backup.data_picker.settings"), + label: localize( + `ui.panel.${this.translationKeyPanel}.data_picker.${data.database_included ? "settings_and_history" : "settings"}` + ), id: "config", version: data.homeassistant_version, }); @@ -99,18 +106,22 @@ export class HaBackupDataPicker extends LitElement { ); private _localizeFolder(folder: string): string { + const localize = this.localize || this.hass!.localize; + switch (folder) { case "media": - return this.hass.localize("ui.panel.config.backup.data_picker.media"); + return localize( + `ui.panel.${this.translationKeyPanel}.data_picker.media` + ); case "share": - return this.hass.localize( - "ui.panel.config.backup.data_picker.share_folder" + return localize( + `ui.panel.${this.translationKeyPanel}.data_picker.share_folder` ); case "ssl": - return this.hass.localize("ui.panel.config.backup.data_picker.ssl"); + return localize(`ui.panel.${this.translationKeyPanel}.data_picker.ssl`); case "addons/local": - return this.hass.localize( - "ui.panel.config.backup.data_picker.local_addons" + return localize( + `ui.panel.${this.translationKeyPanel}.data_picker.local_addons` ); } return capitalizeFirstLetter(folder); @@ -215,14 +226,13 @@ export class HaBackupDataPicker extends LitElement { } protected render() { - const homeAssistantItems = this._homeAssistantItems( - this.data, - this.hass.localize - ); + const localize = this.localize || this.hass!.localize; + + const homeAssistantItems = this._homeAssistantItems(this.data, localize); const addonsItems = this._addonsItems( this.data, - this.hass.localize, + localize, this._addonIcons ); @@ -247,6 +257,7 @@ export class HaBackupDataPicker extends LitElement { selectedItems.homeassistant.length < homeAssistantItems.length} @change=${this._sectionChanged} + ?disabled=${this.requiredItems.length > 0} >
@@ -266,6 +277,7 @@ export class HaBackupDataPicker extends LitElement { item.id )} @change=${this._homeassistantChanged} + .disabled=${this.requiredItems.includes(item.id)} > ` @@ -280,8 +292,8 @@ export class HaBackupDataPicker extends LitElement { diff --git a/src/panels/config/backup/components/ha-backup-details-restore.ts b/src/panels/config/backup/components/ha-backup-details-restore.ts new file mode 100644 index 0000000000..170a39bf5b --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-details-restore.ts @@ -0,0 +1,148 @@ +import memoizeOne from "memoize-one"; +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../../components/ha-card"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-button"; +import "./ha-backup-data-picker"; +import type { HomeAssistant } from "../../../../types"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; +import type { + BackupContentExtended, + BackupData, +} from "../../../../data/backup"; +import { fireEvent } from "../../../../common/dom/fire_event"; + +@customElement("ha-backup-details-restore") +class HaBackupDetailsRestore extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public localize!: LocalizeFunc; + + @property({ type: Object }) public backup!: BackupContentExtended; + + @property({ type: Boolean, attribute: "ha-required" }) + public haRequired = false; + + @property({ attribute: "translation-key-panel" }) public translationKeyPanel: + | "page-onboarding.restore" + | "config.backup" = "config.backup"; + + @state() private _selectedData?: BackupData; + + protected willUpdate() { + if (!this.hasUpdated && this.haRequired) { + this._selectedData = { + homeassistant_included: true, + folders: [], + addons: [], + homeassistant_version: this.backup.homeassistant_version, + database_included: this.backup.database_included, + }; + } + } + + render() { + return html` + +
+ ${this.localize( + `ui.panel.${this.translationKeyPanel}.details.restore.title` + )} +
+
+ + +
+
+ + ${this.localize( + `ui.panel.${this.translationKeyPanel}.details.restore.action` + )} + +
+
+ `; + } + + private _restore() { + fireEvent(this, "backup-restore", { selectedData: this._selectedData }); + } + + private _selectedBackupChanged(ev: CustomEvent) { + ev.stopPropagation(); + this._selectedData = ev.detail.value; + } + + private _isHomeAssistantRequired = memoizeOne((required: boolean) => + required ? ["config"] : [] + ); + + private get _isRestoreDisabled(): boolean { + return ( + !this._selectedData || + (this.haRequired && !this._selectedData.homeassistant_included) || + !( + this._selectedData?.database_included || + this._selectedData?.homeassistant_included || + this._selectedData.addons.length || + this._selectedData.folders.length + ) + ); + } + + static styles = css` + :host { + max-width: 690px; + width: 100%; + margin: 0 auto; + gap: 24px; + display: grid; + } + .card-content { + padding: 0 20px; + } + .card-actions { + display: flex; + justify-content: flex-end; + } + ha-md-list { + background: none; + padding: 0; + } + ha-md-list-item { + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + --md-list-item-two-line-container-height: 64px; + } + ha-md-list-item [slot="supporting-text"] { + display: flex; + align-items: center; + flex-direction: row; + gap: 8px; + line-height: normal; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-details-restore": HaBackupDetailsRestore; + } + interface HASSDomEvents { + "backup-restore": { selectedData?: BackupData }; + } +} diff --git a/src/panels/config/backup/components/ha-backup-details-summary.ts b/src/panels/config/backup/components/ha-backup-details-summary.ts new file mode 100644 index 0000000000..e39ffbc552 --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-details-summary.ts @@ -0,0 +1,153 @@ +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../../../components/ha-card"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-button"; +import type { HomeAssistant } from "../../../../types"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; +import { + formatDateTime, + formatDateTimeWithBrowserDefaults, +} from "../../../../common/datetime/format_date_time"; +import { + computeBackupSize, + computeBackupType, + type BackupContentExtended, +} from "../../../../data/backup"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { bytesToString } from "../../../../util/bytes-to-string"; + +declare global { + interface HASSDomEvents { + "show-backup-upload": undefined; + } +} + +@customElement("ha-backup-details-summary") +class HaBackupDetailsSummary extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public localize!: LocalizeFunc; + + @property({ type: Object }) public backup!: BackupContentExtended; + + @property({ type: Boolean, attribute: "hassio" }) public isHassio = false; + + @property({ attribute: "translation-key-panel" }) public translationKeyPanel: + | "page-onboarding.restore" + | "config.backup" = "config.backup"; + + @property({ type: Boolean, attribute: "show-upload-another" }) + public showUploadAnother = false; + + render() { + const backupDate = new Date(this.backup.date); + const formattedDate = this.hass + ? formatDateTime(backupDate, this.hass.locale, this.hass.config) + : formatDateTimeWithBrowserDefaults(backupDate); + + return html` + +
+ ${this.localize( + `ui.panel.${this.translationKeyPanel}.details.summary.title` + )} +
+
+ + ${this.translationKeyPanel === "config.backup" + ? html` + + ${this.localize("ui.panel.config.backup.backup_type")} + + + ${this.localize( + `ui.panel.config.backup.type.${computeBackupType(this.backup, this.isHassio)}` + )} + + ` + : nothing} + + + ${this.localize( + `ui.panel.${this.translationKeyPanel}.details.summary.size` + )} + + + ${bytesToString(computeBackupSize(this.backup))} + + + + + ${this.localize( + `ui.panel.${this.translationKeyPanel}.details.summary.created` + )} + + ${formattedDate} + + +
+ ${this.showUploadAnother + ? html`
+ + ${this.localize( + `ui.panel.page-onboarding.restore.details.summary.upload_another` + )} + +
` + : nothing} +
+ `; + } + + private _uploadAnother() { + fireEvent(this, "show-backup-upload"); + } + + static styles = css` + :host { + max-width: 690px; + width: 100%; + margin: 0 auto; + gap: 24px; + display: grid; + } + .card-content { + padding: 0 20px; + } + .card-actions { + display: flex; + justify-content: flex-end; + } + ha-md-list { + background: none; + padding: 0; + } + ha-md-list-item { + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + --md-list-item-two-line-container-height: 64px; + } + ha-md-list.summary ha-md-list-item { + --md-list-item-supporting-text-size: 1rem; + --md-list-item-label-text-size: 0.875rem; + + --md-list-item-label-text-color: var(--secondary-text-color); + --md-list-item-supporting-text-color: var(--primary-text-color); + } + ha-md-list-item [slot="supporting-text"] { + display: flex; + align-items: center; + flex-direction: row; + gap: 8px; + line-height: normal; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-details-summary": HaBackupDetailsSummary; + } +} diff --git a/src/panels/config/backup/dialogs/dialog-upload-backup.ts b/src/panels/config/backup/dialogs/dialog-upload-backup.ts index 66f9dcfaaa..ab396fd4d1 100644 --- a/src/panels/config/backup/dialogs/dialog-upload-backup.ts +++ b/src/panels/config/backup/dialogs/dialog-upload-backup.ts @@ -3,7 +3,10 @@ import type { CSSResultGroup } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; -import { fireEvent } from "../../../../common/dom/fire_event"; +import { + fireEvent, + type HASSDomEvent, +} from "../../../../common/dom/fire_event"; import "../../../../components/ha-alert"; import "../../../../components/ha-dialog-header"; import "../../../../components/ha-expansion-panel"; @@ -14,7 +17,10 @@ import type { HaMdDialog } from "../../../../components/ha-md-dialog"; import { CORE_LOCAL_AGENT, HASSIO_LOCAL_AGENT, + SUPPORTED_UPLOAD_FORMAT, uploadBackup, + INITIAL_UPLOAD_FORM_DATA, + type BackupUploadFileFormData, } from "../../../../data/backup"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import { haStyle, haStyleDialog } from "../../../../resources/styles"; @@ -22,16 +28,6 @@ import type { HomeAssistant } from "../../../../types"; import { showAlertDialog } from "../../../lovelace/custom-card-helpers"; import type { UploadBackupDialogParams } from "./show-dialog-upload-backup"; -const SUPPORTED_FORMAT = "application/x-tar"; - -interface FormData { - file?: File; -} - -const INITIAL_DATA: FormData = { - file: undefined, -}; - @customElement("ha-dialog-upload-backup") export class DialogUploadBackup extends LitElement @@ -45,13 +41,13 @@ export class DialogUploadBackup @state() private _error?: string; - @state() private _formData?: FormData; + @state() private _formData?: BackupUploadFileFormData; @query("ha-md-dialog") private _dialog?: HaMdDialog; public async showDialog(params: UploadBackupDialogParams): Promise { this._params = params; - this._formData = INITIAL_DATA; + this._formData = INITIAL_UPLOAD_FORM_DATA; } private _dialogClosed() { @@ -78,13 +74,18 @@ export class DialogUploadBackup } return html` - + @@ -99,7 +100,8 @@ export class DialogUploadBackup .hass=${this.hass} .uploading=${this._uploading} .icon=${mdiFolderUpload} - accept=${SUPPORTED_FORMAT} + .accept=${SUPPORTED_UPLOAD_FORMAT} + .localize=${this.hass.localize} .label=${this.hass.localize( "ui.panel.config.backup.dialogs.upload.input_label" )} @@ -107,13 +109,17 @@ export class DialogUploadBackup "ui.panel.config.backup.dialogs.upload.supports_tar" )} @file-picked=${this._filePicked} + @files-cleared=${this._filesCleared} >
- ${this.hass.localize("ui.common.cancel")} - + ${this.hass.localize( "ui.panel.config.backup.dialogs.upload.action" )} @@ -123,7 +129,7 @@ export class DialogUploadBackup `; } - private async _filePicked(ev: CustomEvent<{ files: File[] }>): Promise { + private _filePicked(ev: HASSDomEvent<{ files: File[] }>) { this._error = undefined; const file = ev.detail.files[0]; @@ -133,9 +139,14 @@ export class DialogUploadBackup }; } + private _filesCleared() { + this._error = undefined; + this._formData = INITIAL_UPLOAD_FORM_DATA; + } + private async _upload() { const { file } = this._formData!; - if (!file || file.type !== SUPPORTED_FORMAT) { + if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) { showAlertDialog(this, { title: this.hass.localize( "ui.panel.config.backup.dialogs.upload.unsupported.title" @@ -154,7 +165,7 @@ export class DialogUploadBackup this._uploading = true; try { - await uploadBackup(this.hass!, file, agentIds); + await uploadBackup(this.hass, file, agentIds); this._params!.submit?.(); this.closeDialog(); } catch (err: any) { diff --git a/src/panels/config/backup/ha-config-backup-details.ts b/src/panels/config/backup/ha-config-backup-details.ts index c3bbd93552..42a553c098 100644 --- a/src/panels/config/backup/ha-config-backup-details.ts +++ b/src/panels/config/backup/ha-config-backup-details.ts @@ -8,7 +8,6 @@ import { } from "@mdi/js"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { formatDateTime } from "../../../common/datetime/format_date_time"; import { computeDomain } from "../../../common/entity/compute_domain"; import { navigate } from "../../../common/navigate"; import "../../../components/ha-alert"; @@ -25,24 +24,20 @@ import type { BackupConfig, BackupContentAgent, BackupContentExtended, - BackupData, } from "../../../data/backup"; +import "./components/ha-backup-details-summary"; +import "./components/ha-backup-details-restore"; import { compareAgents, computeBackupAgentName, - computeBackupSize, - computeBackupType, deleteBackup, fetchBackupDetails, isLocalAgent, isNetworkMountAgent, } from "../../../data/backup"; -import type { HassioAddonInfo } from "../../../data/hassio/addon"; import "../../../layouts/hass-subpage"; import type { HomeAssistant } from "../../../types"; import { brandsUrl } from "../../../util/brands-url"; -import { bytesToString } from "../../../util/bytes-to-string"; -import "./components/ha-backup-data-picker"; import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup"; import { fireEvent } from "../../../common/dom/fire_event"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; @@ -93,10 +88,6 @@ class HaConfigBackupDetails extends LitElement { @state() private _error?: string; - @state() private _selectedData?: BackupData; - - @state() private _addonsInfo?: HassioAddonInfo[]; - protected firstUpdated(changedProps) { super.firstUpdated(changedProps); @@ -157,81 +148,18 @@ class HaConfigBackupDetails extends LitElement { : !this._backup ? html`` : html` - -
- ${this.hass.localize( - "ui.panel.config.backup.details.summary.title" - )} -
-
- - - - ${this.hass.localize( - "ui.panel.config.backup.backup_type" - )} - - - ${this.hass.localize( - `ui.panel.config.backup.type.${computeBackupType(this._backup, isHassio)}` - )} - - - - - ${this.hass.localize( - "ui.panel.config.backup.details.summary.size" - )} - - - ${bytesToString(computeBackupSize(this._backup))} - - - - - ${this.hass.localize( - "ui.panel.config.backup.details.summary.created" - )} - - - ${formatDateTime( - new Date(this._backup.date), - this.hass.locale, - this.hass.config - )} - - - -
-
- -
- ${this.hass.localize( - "ui.panel.config.backup.details.restore.title" - )} -
-
- - -
-
- - ${this.hass.localize( - "ui.panel.config.backup.details.restore.action" - )} - -
-
+ +
${this.hass.localize( @@ -360,30 +288,13 @@ class HaConfigBackupDetails extends LitElement { `; } - private _selectedBackupChanged(ev: CustomEvent) { - ev.stopPropagation(); - this._selectedData = ev.detail.value; - } - - private _isRestoreDisabled() { - return ( - !this._selectedData || - !( - this._selectedData?.database_included || - this._selectedData?.homeassistant_included || - this._selectedData.addons.length || - this._selectedData.folders.length - ) - ); - } - - private _restore() { - if (!this._backup || !this._selectedData) { + private _restore(ev: CustomEvent) { + if (!this._backup || !ev.detail.selectedData) { return; } showRestoreBackupDialog(this, { backup: this._backup, - selectedData: this._selectedData, + selectedData: ev.detail.selectedData, }); } @@ -469,13 +380,6 @@ class HaConfigBackupDetails extends LitElement { --mdc-icon-size: 48px; color: var(--primary-text-color); } - ha-md-list.summary ha-md-list-item { - --md-list-item-supporting-text-size: 1rem; - --md-list-item-label-text-size: 0.875rem; - - --md-list-item-label-text-color: var(--secondary-text-color); - --md-list-item-supporting-text-color: var(--primary-text-color); - } .warning { color: var(--error-color); } @@ -485,9 +389,6 @@ class HaConfigBackupDetails extends LitElement { ha-button.danger { --mdc-theme-primary: var(--error-color); } - ha-backup-data-picker { - display: block; - } ha-md-list-item [slot="supporting-text"] { display: flex; align-items: center; diff --git a/src/translations/en.json b/src/translations/en.json index f2bac3e148..b1462e406b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -8193,9 +8193,53 @@ }, "restore": { "header": "Restore a backup", + "upload_backup": "[%key:ui::panel::config::backup::dialogs::upload::title%]", + "unsupported": { + "title": "[%key:ui::panel::config::backup::dialogs::upload::unsupported::title%]", + "text": "[%key:ui::panel::config::backup::dialogs::upload::unsupported::text%]" + }, + "ok": "[%key:ui::common::ok%]", + "upload_input_label": "[%key:ui::panel::config::backup::dialogs::upload::input_label%]", + "upload_supports_tar": "[%key:ui::panel::config::backup::dialogs::upload::supports_tar%]", + "upload_secondary": "[%key:ui::components::file-upload::secondary%]", + "delete": "[%key:ui::common::delete%]", + "uploading": "[%key:ui::components::file-upload::uploading%]", + "details": { + "home_assistant_missing": "This backup does not include your Home Assistant configuration, you cannot use it to restore your instance.", + "addons_unsupported": "This backup includes add-ons and folders, which are not supported in this installation method of Home Assistant. You can still restore Home Assistant, but the unsupported files will not be restored.", + "summary": { + "title": "[%key:ui::panel::config::backup::details::summary::title%]", + "size": "[%key:ui::panel::config::backup::details::summary::size%]", + "created": "[%key:ui::panel::config::backup::details::summary::created%]", + "upload_another": "Upload another", + "home": "Home" + }, + "restore": { + "title": "[%key:ui::panel::config::backup::details::restore::title%]", + "action": "[%key:ui::panel::config::backup::details::restore::action%]", + "encryption": { + "title": "[%key:ui::panel::config::backup::dialogs::restore::encryption::different_key%]", + "incorrect_key": "[%key:ui::panel::config::backup::dialogs::restore::encryption::incorrect_key%]", + "input_label": "[%key:ui::panel::config::backup::dialogs::restore::encryption::input_label%]" + } + } + }, + "data_picker": { + "settings": "[%key:ui::panel::config::backup::data_picker::settings%]", + "settings_and_history": "[%key:ui::panel::config::backup::data_picker::settings_and_history%]", + "media": "[%key:ui::panel::config::backup::data_picker::media%]", + "share_folder": "[%key:ui::panel::config::backup::data_picker::share_folder%]", + "local_addons": "[%key:ui::panel::config::backup::data_picker::local_addons%]", + "addons": "[%key:ui::panel::config::backup::data_picker::addons%]", + "ssl": "[%key:ui::panel::config::backup::data_picker::ssl%]" + }, + "restore_no_home_assistant": "[%key:supervisor::backup::restore_no_home_assistant%]", "in_progress": "Restore in progress", + "in_progress_description": "The restore process is running in the background. Home Assistant will automatically start again once the restore is complete. Please be patient, this can take a while. Do not close or refresh this page.", "failed": "Restore failed", - "upload_backup": "[%key:supervisor::backup::upload_backup%]", + "failed_status_description": "The backup could not be restored. Please try again.", + "failed_description": "The last restore attempt failed. Please try it again.", + "failed_wrong_password_description": "The last restore attempt failed, because the encryption key was incorrect. Please try it again.", "upload_supports": "Supports .TAR files", "upload_drop": "[%key:ui::components::file-upload::secondary%]", "show_log": "Show full log", @@ -8203,7 +8247,6 @@ "full_backup": "[%key:supervisor::backup::full_backup%]", "partial_backup": "[%key:supervisor::backup::partial_backup%]", "name": "[%key:supervisor::backup::name%]", - "type": "[%key:supervisor::backup::type%]", "select_type": "[%key:supervisor::backup::select_type%]", "folders": "[%key:supervisor::backup::folders%]", "addons": "[%key:supervisor::backup::addons%]", @@ -8218,10 +8261,16 @@ "close": "[%key:ui::common::close%]", "cancel": "[%key:ui::common::cancel%]", "retry": "Retry", + "back": "[%key:ui::common::back%]", "restore_start_failed": "[%key:supervisor::backup::restore_start_failed%]", "no_backup_found": "[%key:supervisor::backup::no_backup_found%]", - "restore_no_home_assistant": "[%key:supervisor::backup::restore_no_home_assistant%]", - "unnamed_backup": "[%key:supervisor::backup::unnamed_backup%]" + "unnamed_backup": "[%key:supervisor::backup::unnamed_backup%]", + "cancel_restore": { + "title": "Cancel restore process?", + "text": "Are you sure you want to cancel the restore process and return to the onboarding?", + "yes": "[%key:ui::common::yes%]", + "no": "[%key:ui::common::no%]" + } } }, "custom": { diff --git a/test/common/datetime/format_date_time.test.ts b/test/common/datetime/format_date_time.test.ts index 929536de18..a2afe53351 100644 --- a/test/common/datetime/format_date_time.test.ts +++ b/test/common/datetime/format_date_time.test.ts @@ -4,6 +4,7 @@ import { formatDateTime, formatDateTimeWithSeconds, formatDateTimeNumeric, + formatDateTimeWithBrowserDefaults, } from "../../../src/common/datetime/format_date_time"; import { NumberFormat, @@ -49,6 +50,19 @@ describe("formatDateTime", () => { "November 18, 2017 at 23:12" ); }); + + it("Formats date times without optional params", () => { + assert.strictEqual( + formatDateTimeWithBrowserDefaults(dateObj), + new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(dateObj) + ); + }); }); describe("formatDateTimeWithSeconds", () => {