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 <mail@bramkragten.nl>

* Show backup type not at onboarding

* Only show backup type in correct translationPanel

* Fix quotes

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Wendelin 2025-02-10 16:40:08 +01:00 committed by GitHub
parent 44cc75afbc
commit 03a415beff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1425 additions and 408 deletions

View File

@ -14,7 +14,7 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
"backup-uploaded": { backup: HassioBackup }; "hassio-backup-uploaded": { backup: HassioBackup };
"backup-cleared": undefined; "backup-cleared": undefined;
} }
} }
@ -70,7 +70,7 @@ export class HassioUploadBackup extends LitElement {
this._uploading = true; this._uploading = true;
try { try {
const backup = await uploadBackup(this.hass, file); 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) { } catch (err: any) {
showAlertDialog(this, { showAlertDialog(this, {
title: "Upload failed", title: "Upload failed",

View File

@ -5,7 +5,6 @@ import { customElement, property, query } from "lit/decorators";
import { atLeastVersion } from "../../../src/common/config/version"; import { atLeastVersion } from "../../../src/common/config/version";
import { formatDate } from "../../../src/common/datetime/format_date"; import { formatDate } from "../../../src/common/datetime/format_date";
import { formatDateTime } from "../../../src/common/datetime/format_date_time"; 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-checkbox";
import "../../../src/components/ha-formfield"; import "../../../src/components/ha-formfield";
import "../../../src/components/ha-textfield"; import "../../../src/components/ha-textfield";
@ -19,13 +18,10 @@ import type {
} from "../../../src/data/hassio/backup"; } from "../../../src/data/hassio/backup";
import type { Supervisor } from "../../../src/data/supervisor/supervisor"; import type { Supervisor } from "../../../src/data/supervisor/supervisor";
import { mdiHomeAssistant } from "../../../src/resources/home-assistant-logo-svg"; 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 "./supervisor-formfield-label";
import type { HaTextField } from "../../../src/components/ha-textfield"; import type { HaTextField } from "../../../src/components/ha-textfield";
type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] &
keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"];
interface CheckboxItem { interface CheckboxItem {
slug: string; slug: string;
checked: boolean; checked: boolean;
@ -67,8 +63,6 @@ const _computeAddons = (addons): AddonCheckboxItem[] =>
export class SupervisorBackupContent extends LitElement { export class SupervisorBackupContent extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ attribute: false }) public supervisor?: Supervisor; @property({ attribute: false }) public supervisor?: Supervisor;
@property({ attribute: false }) public backup?: HassioBackupDetail; @property({ attribute: false }) public backup?: HassioBackupDetail;
@ -115,10 +109,6 @@ export class SupervisorBackupContent extends LitElement {
this._focusTarget?.focus(); this._focusTarget?.focus();
} }
private _localize = (key: BackupOrRestoreKey) =>
this.supervisor?.localize(`backup.${key}`) ||
this.localize!(`ui.panel.page-onboarding.restore.${key}`);
protected render() { protected render() {
if (!this.onboarding && !this.supervisor) { if (!this.onboarding && !this.supervisor) {
return nothing; return nothing;
@ -132,8 +122,8 @@ export class SupervisorBackupContent extends LitElement {
${this.backup ${this.backup
? html`<div class="details"> ? html`<div class="details">
${this.backup.type === "full" ${this.backup.type === "full"
? this._localize("full_backup") ? this.supervisor?.localize("backup.full_backup")
: this._localize("partial_backup")} : this.supervisor?.localize("backup.partial_backup")}
(${Math.ceil(this.backup.size * 10) / 10 + " MB"})<br /> (${Math.ceil(this.backup.size * 10) / 10 + " MB"})<br />
${this.hass ${this.hass
? formatDateTime( ? formatDateTime(
@ -145,7 +135,7 @@ export class SupervisorBackupContent extends LitElement {
</div>` </div>`
: html`<ha-textfield : html`<ha-textfield
name="backupName" name="backupName"
.label=${this._localize("name")} .label=${this.supervisor?.localize("backup.name")}
.value=${this.backupName} .value=${this.backupName}
@change=${this._handleTextValueChanged} @change=${this._handleTextValueChanged}
> >
@ -153,11 +143,13 @@ export class SupervisorBackupContent extends LitElement {
${!this.backup || this.backup.type === "full" ${!this.backup || this.backup.type === "full"
? html`<div class="sub-header"> ? html`<div class="sub-header">
${!this.backup ${!this.backup
? this._localize("type") ? this.supervisor?.localize("backup.type")
: this._localize("select_type")} : this.supervisor?.localize("backup.select_type")}
</div> </div>
<div class="backup-types"> <div class="backup-types">
<ha-formfield .label=${this._localize("full_backup")}> <ha-formfield
.label=${this.supervisor?.localize("backup.full_backup")}
>
<ha-radio <ha-radio
@change=${this._handleRadioValueChanged} @change=${this._handleRadioValueChanged}
value="full" value="full"
@ -166,7 +158,9 @@ export class SupervisorBackupContent extends LitElement {
> >
</ha-radio> </ha-radio>
</ha-formfield> </ha-formfield>
<ha-formfield .label=${this._localize("partial_backup")}> <ha-formfield
.label=${this.supervisor?.localize("backup.partial_backup")}
>
<ha-radio <ha-radio
@change=${this._handleRadioValueChanged} @change=${this._handleRadioValueChanged}
value="partial" value="partial"
@ -202,7 +196,7 @@ export class SupervisorBackupContent extends LitElement {
? html` ? html`
<ha-formfield <ha-formfield
.label=${html`<supervisor-formfield-label .label=${html`<supervisor-formfield-label
.label=${this._localize("folders")} .label=${this.supervisor?.localize("backup.folders")}
.iconPath=${mdiFolder} .iconPath=${mdiFolder}
> >
</supervisor-formfield-label>`} </supervisor-formfield-label>`}
@ -222,7 +216,7 @@ export class SupervisorBackupContent extends LitElement {
? html` ? html`
<ha-formfield <ha-formfield
.label=${html`<supervisor-formfield-label .label=${html`<supervisor-formfield-label
.label=${this._localize("addons")} .label=${this.supervisor?.localize("backup.addons")}
.iconPath=${mdiPuzzle} .iconPath=${mdiPuzzle}
> >
</supervisor-formfield-label>`} </supervisor-formfield-label>`}
@ -247,7 +241,7 @@ export class SupervisorBackupContent extends LitElement {
${!this.backup ${!this.backup
? html`<ha-formfield ? html`<ha-formfield
class="password" class="password"
.label=${this._localize("password_protection")} .label=${this.supervisor?.localize("backup.password_protection")}
> >
<ha-checkbox <ha-checkbox
.checked=${this.backupHasPassword} .checked=${this.backupHasPassword}
@ -259,7 +253,7 @@ export class SupervisorBackupContent extends LitElement {
${this.backupHasPassword ${this.backupHasPassword
? html` ? html`
<ha-password-field <ha-password-field
.label=${this._localize("password")} .label=${this.supervisor?.localize("backup.password")}
name="backupPassword" name="backupPassword"
.value=${this.backupPassword} .value=${this.backupPassword}
@change=${this._handleTextValueChanged} @change=${this._handleTextValueChanged}
@ -267,7 +261,7 @@ export class SupervisorBackupContent extends LitElement {
</ha-password-field> </ha-password-field>
${!this.backup ${!this.backup
? html`<ha-password-field ? html`<ha-password-field
.label=${this._localize("confirm_password")} .label=${this.supervisor?.localize("backup.confirm_password")}
name="confirmBackupPassword" name="confirmBackupPassword"
.value=${this.confirmBackupPassword} .value=${this.confirmBackupPassword}
@change=${this._handleTextValueChanged} @change=${this._handleTextValueChanged}

View File

@ -72,7 +72,7 @@ export class DialogHassioBackupUpload
</ha-header-bar> </ha-header-bar>
</div> </div>
<hassio-upload-backup <hassio-upload-backup
@backup-uploaded=${this._backupUploaded} @hassio-backup-uploaded=${this._backupUploaded}
.hass=${this.hass} .hass=${this.hass}
></hassio-upload-backup> ></hassio-upload-backup>
</ha-dialog> </ha-dialog>

View File

@ -35,7 +35,6 @@ import { fileDownload } from "../../../../src/util/file_download";
import "../../components/supervisor-backup-content"; import "../../components/supervisor-backup-content";
import type { SupervisorBackupContent } from "../../components/supervisor-backup-content"; import type { SupervisorBackupContent } from "../../components/supervisor-backup-content";
import type { HassioBackupDialogParams } from "./show-dialog-hassio-backup"; import type { HassioBackupDialogParams } from "./show-dialog-hassio-backup";
import type { BackupOrRestoreKey } from "../../util/translations";
import type { HaMdDialog } from "../../../../src/components/ha-md-dialog"; import type { HaMdDialog } from "../../../../src/components/ha-md-dialog";
@customElement("dialog-hassio-backup") @customElement("dialog-hassio-backup")
@ -43,7 +42,7 @@ class HassioBackupDialog
extends LitElement extends LitElement
implements HassDialog<HassioBackupDialogParams> implements HassDialog<HassioBackupDialogParams>
{ {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: string; @state() private _error?: string;
@ -62,9 +61,13 @@ class HassioBackupDialog
this._dialogParams = dialogParams; this._dialogParams = dialogParams;
this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug); this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug);
if (!this._backup) { 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) { } 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; this._restoringBackup = false;
} }
@ -82,13 +85,6 @@ class HassioBackupDialog
return true; return true;
} }
private _localize(key: BackupOrRestoreKey) {
return (
this._dialogParams!.supervisor?.localize(`backup.${key}`) ||
this._dialogParams!.localize!(`ui.panel.page-onboarding.restore.${key}`)
);
}
protected render() { protected render() {
if (!this._dialogParams || !this._backup) { if (!this._dialogParams || !this._backup) {
return nothing; return nothing;
@ -102,7 +98,7 @@ class HassioBackupDialog
<ha-dialog-header slot="headline"> <ha-dialog-header slot="headline">
<ha-icon-button <ha-icon-button
slot="navigationIcon" slot="navigationIcon"
.label=${this._localize("close")} .label=${this._dialogParams.supervisor?.localize("backup.close")}
.path=${mdiClose} .path=${mdiClose}
@click=${this.closeDialog} @click=${this.closeDialog}
.disabled=${this._restoringBackup} .disabled=${this._restoringBackup}
@ -150,7 +146,6 @@ class HassioBackupDialog
.supervisor=${this._dialogParams.supervisor} .supervisor=${this._dialogParams.supervisor}
.backup=${this._backup} .backup=${this._backup}
.onboarding=${this._dialogParams.onboarding || false} .onboarding=${this._dialogParams.onboarding || false}
.localize=${this._dialogParams.localize}
dialogInitialFocus dialogInitialFocus
> >
</supervisor-backup-content> </supervisor-backup-content>
@ -161,7 +156,7 @@ class HassioBackupDialog
.disabled=${this._restoringBackup || !!this._error} .disabled=${this._restoringBackup || !!this._error}
@click=${this._restoreClicked} @click=${this._restoreClicked}
> >
${this._localize("restore")} ${this._dialogParams.supervisor?.localize("backup.restore")}
</ha-button> </ha-button>
</div> </div>
</ha-md-dialog> </ha-md-dialog>
@ -196,18 +191,22 @@ class HassioBackupDialog
} }
if ( if (
!(await showConfirmationDialog(this, { !(await showConfirmationDialog(this, {
title: this._localize( title: supervisor?.localize(
`backup.${
this._backup!.type === "full" this._backup!.type === "full"
? "confirm_restore_full_backup_title" ? "confirm_restore_full_backup_title"
: "confirm_restore_partial_backup_title" : "confirm_restore_partial_backup_title"
}`
), ),
text: this._localize( text: supervisor?.localize(
`backup.${
this._backup!.type === "full" this._backup!.type === "full"
? "confirm_restore_full_backup_text" ? "confirm_restore_full_backup_text"
: "confirm_restore_partial_backup_text" : "confirm_restore_partial_backup_text"
}`
), ),
confirmText: this._localize("restore"), confirmText: supervisor?.localize("backup.restore"),
dismissText: this._localize("cancel"), dismissText: supervisor?.localize("backup.cancel"),
})) }))
) { ) {
this._restoringBackup = false; this._restoringBackup = false;
@ -227,7 +226,8 @@ class HassioBackupDialog
this.closeDialog(); this.closeDialog();
} catch (error: any) { } catch (error: any) {
this._error = this._error =
error?.body?.message || this._localize("restore_start_failed"); error?.body?.message ||
supervisor?.localize("backup.restore_start_failed");
} finally { } finally {
this._restoringBackup = false; this._restoringBackup = false;
} }
@ -286,7 +286,7 @@ class HassioBackupDialog
title: supervisor.localize("backup.remote_download_title"), title: supervisor.localize("backup.remote_download_title"),
text: supervisor.localize("backup.remote_download_text"), text: supervisor.localize("backup.remote_download_text"),
confirmText: supervisor.localize("backup.download"), confirmText: supervisor.localize("backup.download"),
dismissText: this._localize("cancel"), dismissText: supervisor?.localize("backup.cancel"),
}); });
if (!confirm) { if (!confirm) {
return; return;
@ -302,7 +302,7 @@ class HassioBackupDialog
private get _computeName() { private get _computeName() {
return this._backup return this._backup
? this._backup.name || this._backup.slug ? this._backup.name || this._backup.slug
: this._localize("unnamed_backup"); : this._dialogParams!.supervisor?.localize("backup.unnamed_backup") || "";
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {

View File

@ -1,5 +1,4 @@
import { fireEvent } from "../../../../src/common/dom/fire_event"; import { fireEvent } from "../../../../src/common/dom/fire_event";
import type { LocalizeFunc } from "../../../../src/common/translations/localize";
import type { Supervisor } from "../../../../src/data/supervisor/supervisor"; import type { Supervisor } from "../../../../src/data/supervisor/supervisor";
export interface HassioBackupDialogParams { export interface HassioBackupDialogParams {
@ -8,7 +7,6 @@ export interface HassioBackupDialogParams {
onRestoring?: () => void; onRestoring?: () => void;
onboarding?: boolean; onboarding?: boolean;
supervisor?: Supervisor; supervisor?: Supervisor;
localize?: LocalizeFunc;
} }
export const showHassioBackupDialog = ( export const showHassioBackupDialog = (

View File

@ -1,4 +0,0 @@
import type { TranslationDict } from "../../../src/types";
export type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] &
keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"];

View File

@ -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 // Aug 9, 2021, 8:23 AM
export const formatShortDateTimeWithYear = ( export const formatShortDateTimeWithYear = (
dateObj: Date, dateObj: Date,

View File

@ -11,6 +11,7 @@ import "./ha-icon-button";
import { blankBeforePercent } from "../common/translations/blank_before_percent"; import { blankBeforePercent } from "../common/translations/blank_before_percent";
import { ensureArray } from "../common/array/ensure-array"; import { ensureArray } from "../common/array/ensure-array";
import { bytesToString } from "../util/bytes-to-string"; import { bytesToString } from "../util/bytes-to-string";
import type { LocalizeFunc } from "../common/translations/localize";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
@ -23,6 +24,8 @@ declare global {
export class HaFileUpload extends LitElement { export class HaFileUpload extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property() public accept!: string; @property() public accept!: string;
@property() public icon?: string; @property() public icon?: string;
@ -31,6 +34,10 @@ export class HaFileUpload extends LitElement {
@property() public secondary?: string; @property() public secondary?: string;
@property({ attribute: "uploading-label" }) public uploadingLabel?: string;
@property({ attribute: "delete-label" }) public deleteLabel?: string;
@property() public supports?: string; @property() public supports?: string;
@property({ type: Object }) public value?: File | File[] | FileList | string; @property({ type: Object }) public value?: File | File[] | FileList | string;
@ -73,23 +80,22 @@ export class HaFileUpload extends LitElement {
} }
public render(): TemplateResult { public render(): TemplateResult {
const localize = this.localize || this.hass!.localize;
return html` return html`
${this.uploading ${this.uploading
? html`<div class="container"> ? html`<div class="container">
<div class="uploading"> <div class="uploading">
<span class="header" <span class="header"
>${this.value >${this.uploadingLabel || this.value
? this.hass?.localize( ? localize("ui.components.file-upload.uploading_name", {
"ui.components.file-upload.uploading_name", name: this._name,
{ name: this._name } })
) : localize("ui.components.file-upload.uploading")}</span
: this.hass?.localize(
"ui.components.file-upload.uploading"
)}</span
> >
${this.progress ${this.progress
? html`<div class="progress"> ? html`<div class="progress">
${this.progress}${blankBeforePercent(this.hass!.locale)}% ${this.progress}${this.hass &&
blankBeforePercent(this.hass!.locale)}%
</div>` </div>`
: nothing} : nothing}
</div> </div>
@ -116,14 +122,11 @@ export class HaFileUpload extends LitElement {
.path=${this.icon || mdiFileUpload} .path=${this.icon || mdiFileUpload}
></ha-svg-icon> ></ha-svg-icon>
<ha-button unelevated @click=${this._openFilePicker}> <ha-button unelevated @click=${this._openFilePicker}>
${this.label || ${this.label || localize("ui.components.file-upload.label")}
this.hass?.localize("ui.components.file-upload.label")}
</ha-button> </ha-button>
<span class="secondary" <span class="secondary"
>${this.secondary || >${this.secondary ||
this.hass?.localize( localize("ui.components.file-upload.secondary")}</span
"ui.components.file-upload.secondary"
)}</span
> >
<span class="supports">${this.supports}</span>` <span class="supports">${this.supports}</span>`
: typeof this.value === "string" : typeof this.value === "string"
@ -136,8 +139,7 @@ export class HaFileUpload extends LitElement {
</div> </div>
<ha-icon-button <ha-icon-button
@click=${this._clearValue} @click=${this._clearValue}
.label=${this.hass?.localize("ui.common.delete") || .label=${this.deleteLabel || localize("ui.common.delete")}
"Delete"}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>
</div>` </div>`
@ -155,8 +157,8 @@ export class HaFileUpload extends LitElement {
</div> </div>
<ha-icon-button <ha-icon-button
@click=${this._clearValue} @click=${this._clearValue}
.label=${this.hass?.localize("ui.common.delete") || .label=${this.deleteLabel ||
"Delete"} localize("ui.common.delete")}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>
</div>` </div>`
@ -238,6 +240,10 @@ export class HaFileUpload extends LitElement {
border-radius: var(--mdc-shape-small, 4px); border-radius: var(--mdc-shape-small, 4px);
height: 100%; height: 100%;
} }
.row {
display: flex;
align-items: center;
}
label.container { label.container {
border: dashed 1px border: dashed 1px
var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42)); var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42));

View File

@ -2,7 +2,6 @@ import { memoize } from "@fullcalendar/core/internal";
import { setHours, setMinutes } from "date-fns"; import { setHours, setMinutes } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket"; import type { HassConfig } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import checkValidDate from "../common/datetime/check_valid_date";
import { import {
formatDateTime, formatDateTime,
formatDateTimeNumeric, formatDateTimeNumeric,
@ -13,6 +12,8 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download"; import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration"; import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation"; import type { FrontendLocaleData } from "./translation";
import checkValidDate from "../common/datetime/check_valid_date";
import { handleFetchPromise } from "../util/hass-call-api";
export const enum BackupScheduleRecurrence { export const enum BackupScheduleRecurrence {
NEVER = "never", NEVER = "never",
@ -231,27 +232,23 @@ export const restoreBackup = (
export const uploadBackup = async ( export const uploadBackup = async (
hass: HomeAssistant, hass: HomeAssistant,
file: File, file: File,
agent_ids: string[] agentIds: string[]
): Promise<void> => { ): Promise<{ backup_id: string }> => {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
const params = agent_ids.reduce((acc, agent_id) => { const params = new URLSearchParams();
acc.append("agent_id", agent_id);
return acc;
}, new URLSearchParams());
const resp = await hass.fetchWithAuth( agentIds.forEach((agentId) => {
`/api/backup/upload?${params.toString()}`, params.append("agent_id", agentId);
{ });
return handleFetchPromise(
hass.fetchWithAuth(`/api/backup/upload?${params.toString()}`, {
method: "POST", method: "POST",
body: fd, body: fd,
} })
); );
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}`);
}
}; };
export const getPreferredAgentForDownload = (agents: string[]) => { 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)}`; 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,
};

View File

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

View File

@ -1,6 +1,5 @@
import { atLeastVersion } from "../../common/config/version"; import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { handleFetchPromise } from "../../util/hass-call-api";
import type { HassioResponse } from "./common"; import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common"; import { hassioApiResultExtractor } from "./common";
@ -82,10 +81,9 @@ export const fetchHassioBackups = async (
}; };
export const fetchHassioBackupInfo = async ( export const fetchHassioBackupInfo = async (
hass: HomeAssistant | undefined, hass: HomeAssistant,
backup: string backup: string
): Promise<HassioBackupDetail> => { ): Promise<HassioBackupDetail> => {
if (hass) {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) { if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({ return hass.callWS({
type: "supervisor/api", type: "supervisor/api",
@ -103,15 +101,6 @@ export const fetchHassioBackupInfo = async (
}/${backup}/info` }/${backup}/info`
) )
); );
}
// When called from onboarding we don't have hass
return hassioApiResultExtractor(
await handleFetchPromise(
fetch(`/api/hassio/backups/${backup}/info`, {
method: "GET",
})
)
);
}; };
export const reloadHassioBackups = async (hass: HomeAssistant) => { export const reloadHassioBackups = async (hass: HomeAssistant) => {
@ -240,24 +229,15 @@ export const uploadBackup = async (
}; };
export const restoreBackup = async ( export const restoreBackup = async (
hass: HomeAssistant | undefined, hass: HomeAssistant,
type: HassioBackupDetail["type"], type: HassioBackupDetail["type"],
backupSlug: string, backupSlug: string,
backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams, backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams,
useSnapshotUrl: boolean useSnapshotUrl: boolean
): Promise<void> => { ): Promise<void> => {
if (hass) {
await hass.callApi<HassioResponse<{ job_id: string }>>( await hass.callApi<HassioResponse<{ job_id: string }>>(
"POST", "POST",
`hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`, `hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`,
backupDetails backupDetails
); );
} else {
await handleFetchPromise(
fetch(`/api/hassio/backups/${backupSlug}/restore/${type}`, {
method: "POST",
body: JSON.stringify(backupDetails),
})
);
}
}; };

View File

@ -17,7 +17,7 @@ export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
@property({ attribute: false }) public localize: LocalizeFunc = empty; @property({ attribute: false }) public localize: LocalizeFunc = empty;
// Use browser language setup before login. // Use browser language setup before login.
@property() public language?: string = getLocalLanguage(); @property() public language: string = getLocalLanguage();
@property() public translationFragment?: string; @property() public translationFragment?: string;

View File

@ -41,6 +41,7 @@ import "./onboarding-analytics";
import "./onboarding-create-user"; import "./onboarding-create-user";
import "./onboarding-loading"; import "./onboarding-loading";
import "./onboarding-welcome"; import "./onboarding-welcome";
import "./onboarding-restore-backup";
import "./onboarding-welcome-links"; import "./onboarding-welcome-links";
import { makeDialogManager } from "../dialogs/make-dialog-manager"; import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
@ -157,8 +158,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
private _renderStep() { private _renderStep() {
if (this._restoring) { if (this._restoring) {
return html`<onboarding-restore-backup return html`<onboarding-restore-backup
.hass=${this.hass}
.localize=${this.localize} .localize=${this.localize}
.supervisor=${this._supervisor ?? false}
.language=${this.language}
> >
</onboarding-restore-backup>`; </onboarding-restore-backup>`;
} }
@ -166,8 +168,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
if (this._init) { if (this._init) {
return html`<onboarding-welcome return html`<onboarding-welcome
.localize=${this.localize} .localize=${this.localize}
.language=${this.language}
.supervisor=${this._supervisor}
></onboarding-welcome>`; ></onboarding-welcome>`;
} }
@ -236,7 +236,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
} }
} }
if (changedProps.has("language")) { if (changedProps.has("language")) {
document.querySelector("html")!.setAttribute("lang", this.language!); document.querySelector("html")!.setAttribute("lang", this.language);
} }
if (changedProps.has("hass")) { if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
@ -272,10 +272,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
"Home Assistant OS", "Home Assistant OS",
"Home Assistant Supervised", "Home Assistant Supervised",
].includes(response.installation_type); ].includes(response.installation_type);
if (this._supervisor) {
// Only load if we have supervisor
import("./onboarding-restore-backup");
}
} catch (err: any) { } catch (err: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error( console.error(
@ -454,7 +450,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
subscribeOne(conn, subscribeUser), subscribeOne(conn, subscribeUser),
]); ]);
this.initializeHass(auth, conn); this.initializeHass(auth, conn);
if (this.language && this.language !== this.hass!.language) { if (this.language !== this.hass!.language) {
this._updateHass({ this._updateHass({
locale: { ...this.hass!.locale, language: this.language }, locale: { ...this.hass!.locale, language: this.language },
language: this.language, language: this.language,

View File

@ -1,137 +1,337 @@
import type { CSSResultGroup, TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { showHassioBackupDialog } from "../../hassio/src/dialogs/backup/show-dialog-hassio-backup"; import "./restore-backup/onboarding-restore-backup-upload";
import "../../hassio/src/components/hassio-upload-backup"; 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 type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-ansi-to-html";
import "../components/ha-card"; import "../components/ha-card";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-circular-progress";
import "../components/ha-alert"; import "../components/ha-alert";
import "../components/ha-button";
import { fetchInstallationType } from "../data/onboarding";
import type { HomeAssistant } from "../types";
import "./onboarding-loading"; import "./onboarding-loading";
import { onBoardingStyles } from "./styles";
import { removeSearchParam } from "../common/url/search-params"; import { removeSearchParam } from "../common/url/search-params";
import { navigate } from "../common/navigate"; 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") @customElement("onboarding-restore-backup")
class OnboardingRestoreBackup extends LitElement { class OnboardingRestoreBackup extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc; @property({ attribute: false }) public localize!: LocalizeFunc;
@property() public language!: string; @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 { protected render(): TemplateResult {
return html` return html`
${this._restoring ${
? html`<h1> this._view !== "status" || this._failed
${this.localize("ui.panel.page-onboarding.restore.in_progress")} ? html`<ha-icon-button-arrow-prev
</h1> .label=${this.localize("ui.panel.page-onboarding.restore.back")}
<ha-alert alert-type="info"> @click=${this._back}
${this.localize("ui.panel.page-onboarding.restore.in_progress")} ></ha-icon-button-arrow-prev>`
</ha-alert> : nothing
<onboarding-loading></onboarding-loading>` }
: html` <h1> </ha-icon-button>
${this.localize("ui.panel.page-onboarding.restore.header")} <h1>${this.localize("ui.panel.page-onboarding.restore.header")}</h1>
</h1> ${
<hassio-upload-backup this._error || (this._failed && this._view !== "status")
@backup-uploaded=${this._backupUploaded} ? html`<ha-alert
@backup-cleared=${this._backupCleared} alert-type="error"
.hass=${this.hass} .title=${this._failed && this._view !== "status"
.localize=${this.localize} ? this.localize("ui.panel.page-onboarding.restore.failed")
></hassio-upload-backup>`} : ""}
<div class="footer">
<ha-button @click=${this._back} .disabled=${this._restoring}>
${this.localize("ui.panel.page-onboarding.back")}
</ha-button>
${this._backupSlug
? html`<ha-button
@click=${this._showBackupDialog}
.disabled=${this._restoring}
> >
${this.localize("ui.panel.page-onboarding.restore.restore")} ${this._failed && this._view !== "status"
</ha-button>` ? this.localize(
: nothing} `ui.panel.page-onboarding.restore.${this._backupInfo?.last_non_idle_event?.reason === "password_incorrect" ? "failed_wrong_password_description" : "failed_description"}`
</div> )
: this._error}
</ha-alert>`
: nothing
}
${
this._view === "loading"
? html`<div class="loading">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>`
: this._view === "upload"
? html`
<onboarding-restore-backup-upload
.supervisor=${this.supervisor}
.localize=${this.localize}
@backup-uploaded=${this._backupUploaded}
></onboarding-restore-backup-upload>
`
: this._view === "select_data"
? html`<onboarding-restore-backup-details
.localize=${this.localize}
.backup=${this._backup!}
@backup-restore=${this._restore}
></onboarding-restore-backup-details>`
: this._view === "confirm_restore"
? html`<onboarding-restore-backup-restore
.localize=${this.localize}
.backup=${this._backup!}
.supervisor=${this.supervisor}
.selectedData=${this._selectedData!}
@restore-started=${this._restoreStarted}
></onboarding-restore-backup-restore>`
: nothing
}
${
this._view === "status" && this._backupInfo
? html`<onboarding-restore-backup-status
.localize=${this.localize}
.backupInfo=${this._backupInfo}
@show-backup-upload=${this._reupload}
></onboarding-restore-backup-status>`
: nothing
}
${
["select_data", "confirm_restore"].includes(this._view) && this._backup
? html`<div class="backup-summary-wrapper">
<ha-backup-details-summary
translation-key-panel="page-onboarding.restore"
show-upload-another
.backup=${this._backup}
.localize=${this.localize}
@show-backup-upload=${this._reupload}
.isHassio=${this.supervisor}
></ha-backup-details-summary>
</div>`
: 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) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
this._loadBackupInfo();
} }
private async _checkRestoreStatus(): Promise<void> { private async _loadBackupInfo() {
if (this._restoring) { let onboardingInfo: BackupOnboardingConfig;
try { try {
await fetchInstallationType(); onboardingInfo = await fetchBackupOnboardingInfo();
} catch (err: any) { } catch (err: any) {
if (this._restoreRunning) {
if ( if (
(err as Error).message === "unauthorized" || err.error === "Request error" ||
(err as Error).message === "not_found" // 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("/"); window.location.replace("/");
} return;
}
} }
} }
private _scheduleCheckRestoreStatus(): void { this._error = err?.message || "Cannot get backup info";
setTimeout(() => this._checkRestoreStatus(), 1000);
// if we are in an unknown state, show upload
if (this._view === "loading") {
this._view = "upload";
}
return;
} }
private _showBackupDialog(): void { const {
showHassioBackupDialog(this, { last_non_idle_event: lastNonIdleEvent,
slug: this._backupSlug!, state: currentState,
onboarding: true, backups,
localize: this.localize, } = onboardingInfo;
onRestoring: () => {
this._restoring = true; this._backupInfo = {
this._scheduleCheckRestoreStatus(); 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")}`);
}
} }
static get styles(): CSSResultGroup { private _restore(ev: CustomEvent) {
return [ if (!this._backup || !ev.detail.selectedData) {
return;
}
this._selectedData = ev.detail.selectedData;
this._view = "confirm_restore";
}
private _reupload() {
this._backup = undefined;
this._backupId = undefined;
this._view = "upload";
}
static styles = [
onBoardingStyles, onBoardingStyles,
css` css`
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; position: relative;
} }
hassio-upload-backup { ha-icon-button-arrow-prev {
position: absolute;
top: 12px;
}
ha-card {
width: 100%; width: 100%;
} }
.footer { .loading {
display: flex; display: flex;
justify-content: space-between; justify-content: center;
width: 100%; padding: 32px;
}
.backup-summary-wrapper {
margin-top: 24px;
padding: 0 20px;
} }
`, `,
]; ];
} }
}
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {

View File

@ -1,5 +1,5 @@
import type { CSSResultGroup, TemplateResult } from "lit"; 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 { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@ -13,8 +13,6 @@ class OnboardingWelcome extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc; @property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Boolean }) public supervisor = false;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<h1>${this.localize("ui.panel.page-onboarding.welcome.header")}</h1> <h1>${this.localize("ui.panel.page-onboarding.welcome.header")}</h1>
@ -24,11 +22,9 @@ class OnboardingWelcome extends LitElement {
${this.localize("ui.panel.page-onboarding.welcome.start")} ${this.localize("ui.panel.page-onboarding.welcome.start")}
</ha-button> </ha-button>
${this.supervisor <ha-button @click=${this._restoreBackup}>
? html`<ha-button @click=${this._restoreBackup}>
${this.localize("ui.panel.page-onboarding.welcome.restore_backup")} ${this.localize("ui.panel.page-onboarding.welcome.restore_backup")}
</ha-button>` </ha-button>
: nothing}
`; `;
} }

View File

@ -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`<ha-backup-details-restore
.backup=${this.backup}
.localize=${this.localize}
translation-key-panel="page-onboarding.restore"
ha-required
></ha-backup-details-restore>`
: html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.details.home_assistant_missing"
)}
</ha-alert>
`}
`;
}
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;
}
}

View File

@ -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`<ha-alert alert-type="warning" class="supervisor-warning">
${this.localize(
"ui.panel.page-onboarding.restore.details.addons_unsupported"
)}
</ha-alert>`
: nothing}
<ha-card
.header=${this.localize("ui.panel.page-onboarding.restore.restore")}
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert> `
: nothing}
<p>
${this.localize(
"ui.panel.page-onboarding.restore.confirm_restore_full_backup_text"
)}
</p>
${backupProtected
? html`<p>
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.title"
)}
</p>
${this._encryptionKeyWrong
? html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.incorrect_key"
)}
</ha-alert>
`
: nothing}
<ha-password-field
.disabled=${this._loading}
@input=${this._encryptionKeyChanged}
.label=${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.input_label"
)}
.value=${this._encryptionKey}
></ha-password-field>`
: nothing}
</div>
<div class="card-actions">
<ha-progress-button
.progress=${this._loading}
.disabled=${this._loading ||
(backupProtected && this._encryptionKey === "")}
@click=${this._startRestore}
>
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.action"
)}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _encryptionKeyChanged(ev): void {
this._encryptionKey = ev.target.value;
}
private async _startRestore(ev: CustomEvent): Promise<void> {
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";
}
}

View File

@ -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`
<ha-card
.header=${this.localize(
`ui.panel.page-onboarding.restore.${this.backupInfo.state === "restore_backup" ? "in_progress" : "failed"}`
)}
>
<div class="card-content">
${this.backupInfo.state === "restore_backup"
? html`
<div class="loading">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>
<p>
${this.localize(
"ui.panel.page-onboarding.restore.in_progress_description"
)}
</p>
`
: html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.failed_status_description"
)}
</ha-alert>
${this.backupInfo.last_non_idle_event?.reason
? html`
<div class="failed">
<h4>Error:</h4>
${this.backupInfo.last_non_idle_event?.reason}
</div>
`
: nothing}
`}
</div>
${this.backupInfo.state !== "restore_backup"
? html`<div class="card-actions">
<ha-button @click=${this._uploadAnother} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.upload_another`
)}
</ha-button>
<ha-button @click=${this._home} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.home`
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`;
}
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";
}
}

View File

@ -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`
<ha-card
.header=${this.localize(
"ui.panel.page-onboarding.restore.upload_backup"
)}
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.localize}
.label=${this.localize(
"ui.panel.page-onboarding.restore.upload_input_label"
)}
.secondary=${this.localize(
"ui.panel.page-onboarding.restore.upload_secondary"
)}
.supports=${this.localize(
"ui.panel.page-onboarding.restore.upload_supports_tar"
)}
.deleteLabel=${this.localize(
"ui.panel.page-onboarding.restore.delete"
)}
.uploadingLabel=${this.localize(
"ui.panel.page-onboarding.restore.uploading"
)}
@file-picked=${this._filePicked}
></ha-file-upload>
</div>
</ha-card>
`;
}
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;
}
}

View File

@ -21,7 +21,7 @@ export interface BackupAddonItem {
@customElement("ha-backup-addons-picker") @customElement("ha-backup-addons-picker")
export class HaBackupAddonsPicker extends LitElement { export class HaBackupAddonsPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public addons!: BackupAddonItem[]; @property({ attribute: false }) public addons!: BackupAddonItem[];
@ -32,7 +32,7 @@ export class HaBackupAddonsPicker extends LitElement {
private _addons = memoizeOne((addons: BackupAddonItem[]) => private _addons = memoizeOne((addons: BackupAddonItem[]) =>
addons.sort((a, b) => addons.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language) stringCompare(a.name, b.name, this.hass?.locale?.language)
) )
); );

View File

@ -47,23 +47,32 @@ interface SelectedItems {
@customElement("ha-backup-data-picker") @customElement("ha-backup-data-picker")
export class HaBackupDataPicker extends LitElement { 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 data!: BackupData;
@property({ attribute: false }) public value?: 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<string, boolean> = {}; @state() public _addonIcons: Record<string, boolean> = {};
protected firstUpdated(changedProps: PropertyValues): void { protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass, "hassio")) { if (this.hass && isComponentLoaded(this.hass, "hassio")) {
this._fetchAddonInfo(); this._fetchAddonInfo();
} }
} }
private async _fetchAddonInfo() { private async _fetchAddonInfo() {
const { addons } = await fetchHassioAddonsInfo(this.hass); const { addons } = await fetchHassioAddonsInfo(this.hass!);
this._addonIcons = addons.reduce<Record<string, boolean>>( this._addonIcons = addons.reduce<Record<string, boolean>>(
(acc, addon) => ({ (acc, addon) => ({
...acc, ...acc,
@ -74,16 +83,14 @@ export class HaBackupDataPicker extends LitElement {
} }
private _homeAssistantItems = memoizeOne( private _homeAssistantItems = memoizeOne(
(data: BackupData, _localize: LocalizeFunc) => { (data: BackupData, localize: LocalizeFunc) => {
const items: CheckBoxItem[] = []; const items: CheckBoxItem[] = [];
if (data.homeassistant_included) { if (data.homeassistant_included) {
items.push({ items.push({
label: data.database_included label: localize(
? this.hass.localize( `ui.panel.${this.translationKeyPanel}.data_picker.${data.database_included ? "settings_and_history" : "settings"}`
"ui.panel.config.backup.data_picker.settings_and_history" ),
)
: this.hass.localize("ui.panel.config.backup.data_picker.settings"),
id: "config", id: "config",
version: data.homeassistant_version, version: data.homeassistant_version,
}); });
@ -99,18 +106,22 @@ export class HaBackupDataPicker extends LitElement {
); );
private _localizeFolder(folder: string): string { private _localizeFolder(folder: string): string {
const localize = this.localize || this.hass!.localize;
switch (folder) { switch (folder) {
case "media": case "media":
return this.hass.localize("ui.panel.config.backup.data_picker.media"); return localize(
`ui.panel.${this.translationKeyPanel}.data_picker.media`
);
case "share": case "share":
return this.hass.localize( return localize(
"ui.panel.config.backup.data_picker.share_folder" `ui.panel.${this.translationKeyPanel}.data_picker.share_folder`
); );
case "ssl": 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": case "addons/local":
return this.hass.localize( return localize(
"ui.panel.config.backup.data_picker.local_addons" `ui.panel.${this.translationKeyPanel}.data_picker.local_addons`
); );
} }
return capitalizeFirstLetter(folder); return capitalizeFirstLetter(folder);
@ -215,14 +226,13 @@ export class HaBackupDataPicker extends LitElement {
} }
protected render() { protected render() {
const homeAssistantItems = this._homeAssistantItems( const localize = this.localize || this.hass!.localize;
this.data,
this.hass.localize const homeAssistantItems = this._homeAssistantItems(this.data, localize);
);
const addonsItems = this._addonsItems( const addonsItems = this._addonsItems(
this.data, this.data,
this.hass.localize, localize,
this._addonIcons this._addonIcons
); );
@ -247,6 +257,7 @@ export class HaBackupDataPicker extends LitElement {
selectedItems.homeassistant.length < selectedItems.homeassistant.length <
homeAssistantItems.length} homeAssistantItems.length}
@change=${this._sectionChanged} @change=${this._sectionChanged}
?disabled=${this.requiredItems.length > 0}
></ha-checkbox> ></ha-checkbox>
</ha-formfield> </ha-formfield>
<div class="items"> <div class="items">
@ -266,6 +277,7 @@ export class HaBackupDataPicker extends LitElement {
item.id item.id
)} )}
@change=${this._homeassistantChanged} @change=${this._homeassistantChanged}
.disabled=${this.requiredItems.includes(item.id)}
></ha-checkbox> ></ha-checkbox>
</ha-formfield> </ha-formfield>
` `
@ -280,8 +292,8 @@ export class HaBackupDataPicker extends LitElement {
<ha-formfield> <ha-formfield>
<ha-backup-formfield-label <ha-backup-formfield-label
slot="label" slot="label"
.label=${this.hass.localize( .label=${localize(
"ui.panel.config.backup.data_picker.addons" `ui.panel.${this.translationKeyPanel}.data_picker.addons`
)} )}
.iconPath=${mdiPuzzle} .iconPath=${mdiPuzzle}
> >

View File

@ -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`
<ha-card>
<div class="card-header">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.restore.title`
)}
</div>
<div class="card-content">
<ha-backup-data-picker
.translationKeyPanel=${this.translationKeyPanel}
.localize=${this.localize}
.hass=${this.hass}
.data=${this.backup}
.value=${this._selectedData}
@value-changed=${this._selectedBackupChanged}
.requiredItems=${this._isHomeAssistantRequired(this.haRequired)}
>
</ha-backup-data-picker>
</div>
<div class="card-actions">
<ha-button
@click=${this._restore}
.disabled=${this._isRestoreDisabled}
destructive
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.restore.action`
)}
</ha-button>
</div>
</ha-card>
`;
}
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 };
}
}

View File

@ -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`
<ha-card>
<div class="card-header">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.title`
)}
</div>
<div class="card-content">
<ha-md-list class="summary">
${this.translationKeyPanel === "config.backup"
? html`<ha-md-list-item>
<span slot="headline">
${this.localize("ui.panel.config.backup.backup_type")}
</span>
<span slot="supporting-text">
${this.localize(
`ui.panel.config.backup.type.${computeBackupType(this.backup, this.isHassio)}`
)}
</span>
</ha-md-list-item>`
: nothing}
<ha-md-list-item>
<span slot="headline">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.size`
)}
</span>
<span slot="supporting-text">
${bytesToString(computeBackupSize(this.backup))}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.created`
)}
</span>
<span slot="supporting-text"> ${formattedDate} </span>
</ha-md-list-item>
</ha-md-list>
</div>
${this.showUploadAnother
? html`<div class="card-actions">
<ha-button @click=${this._uploadAnother} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.upload_another`
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`;
}
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;
}
}

View File

@ -3,7 +3,10 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } 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,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert"; import "../../../../components/ha-alert";
import "../../../../components/ha-dialog-header"; import "../../../../components/ha-dialog-header";
import "../../../../components/ha-expansion-panel"; import "../../../../components/ha-expansion-panel";
@ -14,7 +17,10 @@ import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import { import {
CORE_LOCAL_AGENT, CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT, HASSIO_LOCAL_AGENT,
SUPPORTED_UPLOAD_FORMAT,
uploadBackup, uploadBackup,
INITIAL_UPLOAD_FORM_DATA,
type BackupUploadFileFormData,
} from "../../../../data/backup"; } from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles"; import { haStyle, haStyleDialog } from "../../../../resources/styles";
@ -22,16 +28,6 @@ import type { HomeAssistant } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers"; import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import type { UploadBackupDialogParams } from "./show-dialog-upload-backup"; 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") @customElement("ha-dialog-upload-backup")
export class DialogUploadBackup export class DialogUploadBackup
extends LitElement extends LitElement
@ -45,13 +41,13 @@ export class DialogUploadBackup
@state() private _error?: string; @state() private _error?: string;
@state() private _formData?: FormData; @state() private _formData?: BackupUploadFileFormData;
@query("ha-md-dialog") private _dialog?: HaMdDialog; @query("ha-md-dialog") private _dialog?: HaMdDialog;
public async showDialog(params: UploadBackupDialogParams): Promise<void> { public async showDialog(params: UploadBackupDialogParams): Promise<void> {
this._params = params; this._params = params;
this._formData = INITIAL_DATA; this._formData = INITIAL_UPLOAD_FORM_DATA;
} }
private _dialogClosed() { private _dialogClosed() {
@ -78,13 +74,18 @@ export class DialogUploadBackup
} }
return html` return html`
<ha-md-dialog open @closed=${this._dialogClosed}> <ha-md-dialog
open
@closed=${this._dialogClosed}
.disableCancelAction=${this._uploading}
>
<ha-dialog-header slot="headline"> <ha-dialog-header slot="headline">
<ha-icon-button <ha-icon-button
slot="navigationIcon" slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")} .label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose} .path=${mdiClose}
@click=${this.closeDialog} @click=${this.closeDialog}
.disabled=${this._uploading}
></ha-icon-button> ></ha-icon-button>
<span slot="title"> <span slot="title">
@ -99,7 +100,8 @@ export class DialogUploadBackup
.hass=${this.hass} .hass=${this.hass}
.uploading=${this._uploading} .uploading=${this._uploading}
.icon=${mdiFolderUpload} .icon=${mdiFolderUpload}
accept=${SUPPORTED_FORMAT} .accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.hass.localize}
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.input_label" "ui.panel.config.backup.dialogs.upload.input_label"
)} )}
@ -107,13 +109,17 @@ export class DialogUploadBackup
"ui.panel.config.backup.dialogs.upload.supports_tar" "ui.panel.config.backup.dialogs.upload.supports_tar"
)} )}
@file-picked=${this._filePicked} @file-picked=${this._filePicked}
@files-cleared=${this._filesCleared}
></ha-file-upload> ></ha-file-upload>
</div> </div>
<div slot="actions"> <div slot="actions">
<ha-button @click=${this.closeDialog} <ha-button @click=${this.closeDialog} .disabled=${this._uploading}
>${this.hass.localize("ui.common.cancel")}</ha-button >${this.hass.localize("ui.common.cancel")}</ha-button
> >
<ha-button @click=${this._upload} .disabled=${!this._formValid()}> <ha-button
@click=${this._upload}
.disabled=${!this._formValid() || this._uploading}
>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.action" "ui.panel.config.backup.dialogs.upload.action"
)} )}
@ -123,7 +129,7 @@ export class DialogUploadBackup
`; `;
} }
private async _filePicked(ev: CustomEvent<{ files: File[] }>): Promise<void> { private _filePicked(ev: HASSDomEvent<{ files: File[] }>) {
this._error = undefined; this._error = undefined;
const file = ev.detail.files[0]; 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() { private async _upload() {
const { file } = this._formData!; const { file } = this._formData!;
if (!file || file.type !== SUPPORTED_FORMAT) { if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
showAlertDialog(this, { showAlertDialog(this, {
title: this.hass.localize( title: this.hass.localize(
"ui.panel.config.backup.dialogs.upload.unsupported.title" "ui.panel.config.backup.dialogs.upload.unsupported.title"
@ -154,7 +165,7 @@ export class DialogUploadBackup
this._uploading = true; this._uploading = true;
try { try {
await uploadBackup(this.hass!, file, agentIds); await uploadBackup(this.hass, file, agentIds);
this._params!.submit?.(); this._params!.submit?.();
this.closeDialog(); this.closeDialog();
} catch (err: any) { } catch (err: any) {

View File

@ -8,7 +8,6 @@ import {
} from "@mdi/js"; } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
@ -25,24 +24,20 @@ import type {
BackupConfig, BackupConfig,
BackupContentAgent, BackupContentAgent,
BackupContentExtended, BackupContentExtended,
BackupData,
} from "../../../data/backup"; } from "../../../data/backup";
import "./components/ha-backup-details-summary";
import "./components/ha-backup-details-restore";
import { import {
compareAgents, compareAgents,
computeBackupAgentName, computeBackupAgentName,
computeBackupSize,
computeBackupType,
deleteBackup, deleteBackup,
fetchBackupDetails, fetchBackupDetails,
isLocalAgent, isLocalAgent,
isNetworkMountAgent, isNetworkMountAgent,
} from "../../../data/backup"; } from "../../../data/backup";
import type { HassioAddonInfo } from "../../../data/hassio/addon";
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url"; 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 { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
@ -93,10 +88,6 @@ class HaConfigBackupDetails extends LitElement {
@state() private _error?: string; @state() private _error?: string;
@state() private _selectedData?: BackupData;
@state() private _addonsInfo?: HassioAddonInfo[];
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
@ -157,81 +148,18 @@ class HaConfigBackupDetails extends LitElement {
: !this._backup : !this._backup
? html`<ha-circular-progress active></ha-circular-progress>` ? html`<ha-circular-progress active></ha-circular-progress>`
: html` : html`
<ha-card> <ha-backup-details-summary
<div class="card-header"> .backup=${this._backup}
${this.hass.localize(
"ui.panel.config.backup.details.summary.title"
)}
</div>
<div class="card-content">
<ha-md-list class="summary">
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.backup_type"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.type.${computeBackupType(this._backup, isHassio)}`
)}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.details.summary.size"
)}
</span>
<span slot="supporting-text">
${bytesToString(computeBackupSize(this._backup))}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.details.summary.created"
)}
</span>
<span slot="supporting-text">
${formatDateTime(
new Date(this._backup.date),
this.hass.locale,
this.hass.config
)}
</span>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.details.restore.title"
)}
</div>
<div class="card-content">
<ha-backup-data-picker
.hass=${this.hass} .hass=${this.hass}
.data=${this._backup} .localize=${this.hass.localize}
.value=${this._selectedData} .isHassio=${isHassio}
@value-changed=${this._selectedBackupChanged} ></ha-backup-details-summary>
.addonsInfo=${this._addonsInfo} <ha-backup-details-restore
> .backup=${this._backup}
</ha-backup-data-picker> @backup-restore=${this._restore}
</div> .hass=${this.hass}
<div class="card-actions"> .localize=${this.hass.localize}
<ha-button ></ha-backup-details-restore>
@click=${this._restore}
.disabled=${this._isRestoreDisabled()}
class="danger"
>
${this.hass.localize(
"ui.panel.config.backup.details.restore.action"
)}
</ha-button>
</div>
</ha-card>
<ha-card> <ha-card>
<div class="card-header"> <div class="card-header">
${this.hass.localize( ${this.hass.localize(
@ -360,30 +288,13 @@ class HaConfigBackupDetails extends LitElement {
`; `;
} }
private _selectedBackupChanged(ev: CustomEvent) { private _restore(ev: CustomEvent) {
ev.stopPropagation(); if (!this._backup || !ev.detail.selectedData) {
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) {
return; return;
} }
showRestoreBackupDialog(this, { showRestoreBackupDialog(this, {
backup: this._backup, backup: this._backup,
selectedData: this._selectedData, selectedData: ev.detail.selectedData,
}); });
} }
@ -469,13 +380,6 @@ class HaConfigBackupDetails extends LitElement {
--mdc-icon-size: 48px; --mdc-icon-size: 48px;
color: var(--primary-text-color); 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 { .warning {
color: var(--error-color); color: var(--error-color);
} }
@ -485,9 +389,6 @@ class HaConfigBackupDetails extends LitElement {
ha-button.danger { ha-button.danger {
--mdc-theme-primary: var(--error-color); --mdc-theme-primary: var(--error-color);
} }
ha-backup-data-picker {
display: block;
}
ha-md-list-item [slot="supporting-text"] { ha-md-list-item [slot="supporting-text"] {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -8193,9 +8193,53 @@
}, },
"restore": { "restore": {
"header": "Restore a backup", "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": "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", "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_supports": "Supports .TAR files",
"upload_drop": "[%key:ui::components::file-upload::secondary%]", "upload_drop": "[%key:ui::components::file-upload::secondary%]",
"show_log": "Show full log", "show_log": "Show full log",
@ -8203,7 +8247,6 @@
"full_backup": "[%key:supervisor::backup::full_backup%]", "full_backup": "[%key:supervisor::backup::full_backup%]",
"partial_backup": "[%key:supervisor::backup::partial_backup%]", "partial_backup": "[%key:supervisor::backup::partial_backup%]",
"name": "[%key:supervisor::backup::name%]", "name": "[%key:supervisor::backup::name%]",
"type": "[%key:supervisor::backup::type%]",
"select_type": "[%key:supervisor::backup::select_type%]", "select_type": "[%key:supervisor::backup::select_type%]",
"folders": "[%key:supervisor::backup::folders%]", "folders": "[%key:supervisor::backup::folders%]",
"addons": "[%key:supervisor::backup::addons%]", "addons": "[%key:supervisor::backup::addons%]",
@ -8218,10 +8261,16 @@
"close": "[%key:ui::common::close%]", "close": "[%key:ui::common::close%]",
"cancel": "[%key:ui::common::cancel%]", "cancel": "[%key:ui::common::cancel%]",
"retry": "Retry", "retry": "Retry",
"back": "[%key:ui::common::back%]",
"restore_start_failed": "[%key:supervisor::backup::restore_start_failed%]", "restore_start_failed": "[%key:supervisor::backup::restore_start_failed%]",
"no_backup_found": "[%key:supervisor::backup::no_backup_found%]", "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": { "custom": {

View File

@ -4,6 +4,7 @@ import {
formatDateTime, formatDateTime,
formatDateTimeWithSeconds, formatDateTimeWithSeconds,
formatDateTimeNumeric, formatDateTimeNumeric,
formatDateTimeWithBrowserDefaults,
} from "../../../src/common/datetime/format_date_time"; } from "../../../src/common/datetime/format_date_time";
import { import {
NumberFormat, NumberFormat,
@ -49,6 +50,19 @@ describe("formatDateTime", () => {
"November 18, 2017 at 23:12" "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", () => { describe("formatDateTimeWithSeconds", () => {