Improve backup onboarding (#23282)

* Sort agents, enable cloud and local by default

* Enable cloud by default

* Improve wording

* Hide fab if onboarding is not complete

* Add recommended settings

* Use one step encryption key during onboarding

* Add description for cloud

* Update change encryption key dialog
This commit is contained in:
Paul Bottein 2024-12-16 14:08:51 +01:00 committed by GitHub
parent 875ab0cb97
commit d31f4a5f1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 291 additions and 172 deletions

View File

@ -212,8 +212,12 @@ export const getPreferredAgentForDownload = (agents: string[]) => {
return localAgents[0] || agents[0]; return localAgents[0] || agents[0];
}; };
export const CORE_LOCAL_AGENT = "backup.local";
export const HASSIO_LOCAL_AGENT = "backup.hassio";
export const CLOUD_AGENT = "cloud.cloud";
export const isLocalAgent = (agentId: string) => export const isLocalAgent = (agentId: string) =>
["backup.local", "hassio.local"].includes(agentId); [CORE_LOCAL_AGENT, HASSIO_LOCAL_AGENT].includes(agentId);
export const computeBackupAgentName = ( export const computeBackupAgentName = (
localize: LocalizeFunc, localize: LocalizeFunc,
@ -234,6 +238,12 @@ export const computeBackupAgentName = (
return showName ? `${domainName}: ${name}` : domainName; return showName ? `${domainName}: ${name}` : domainName;
}; };
export const compareAgents = (a: string, b: string) => {
const isLocalA = isLocalAgent(a);
const isLocalB = isLocalAgent(b);
return isLocalA === isLocalB ? a.localeCompare(b) : isLocalA ? -1 : 1;
};
export const generateEncryptionKey = () => { export const generateEncryptionKey = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const pattern = "xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx"; const pattern = "xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx";

View File

@ -8,6 +8,7 @@ import "../../../../components/ha-checkbox";
import "../../../../components/ha-formfield"; import "../../../../components/ha-formfield";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import { import {
compareAgents,
computeBackupAgentName, computeBackupAgentName,
isLocalAgent, isLocalAgent,
type BackupAgent, type BackupAgent,
@ -33,7 +34,7 @@ class HaBackupAgentsPicker extends LitElement {
public value!: string[]; public value!: string[];
private _agentIds = memoizeOne((agents: BackupAgent[]) => private _agentIds = memoizeOne((agents: BackupAgent[]) =>
agents.map((agent) => agent.agent_id) agents.map((agent) => agent.agent_id).sort(compareAgents)
); );
render() { render() {

View File

@ -1,19 +1,21 @@
import { mdiDatabase } from "@mdi/js"; import { mdiDatabase } from "@mdi/js";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../common/entity/compute_domain";
import "../../../../components/ha-md-list"; import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item"; import "../../../../components/ha-md-list-item";
import "../../../../components/ha-switch";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
import type { BackupAgent } from "../../../../data/backup"; import "../../../../components/ha-switch";
import { import {
CLOUD_AGENT,
compareAgents,
computeBackupAgentName, computeBackupAgentName,
fetchBackupAgentsInfo, fetchBackupAgentsInfo,
isLocalAgent, isLocalAgent,
} from "../../../../data/backup"; } from "../../../../data/backup";
import type { CloudStatus } from "../../../../data/cloud";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url"; import { brandsUrl } from "../../../../util/brands-url";
@ -23,7 +25,9 @@ const DEFAULT_AGENTS = [];
class HaBackupConfigAgents extends LitElement { class HaBackupConfigAgents extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _agents: BackupAgent[] = []; @property({ attribute: false }) public cloudStatus!: CloudStatus;
@state() private _agentIds: string[] = [];
@state() private value?: string[]; @state() private value?: string[];
@ -34,7 +38,10 @@ class HaBackupConfigAgents extends LitElement {
private async _fetchAgents() { private async _fetchAgents() {
const { agents } = await fetchBackupAgentsInfo(this.hass); const { agents } = await fetchBackupAgentsInfo(this.hass);
this._agents = agents; this._agentIds = agents
.map((agent) => agent.agent_id)
.filter((id) => id !== CLOUD_AGENT || this.cloudStatus.logged_in)
.sort(compareAgents);
} }
private get _value() { private get _value() {
@ -42,18 +49,16 @@ class HaBackupConfigAgents extends LitElement {
} }
protected render() { protected render() {
const agentIds = this._agents.map((agent) => agent.agent_id);
return html` return html`
${agentIds.length > 0 ${this._agentIds.length > 0
? html` ? html`
<ha-md-list> <ha-md-list>
${agentIds.map((agentId) => { ${this._agentIds.map((agentId) => {
const domain = computeDomain(agentId); const domain = computeDomain(agentId);
const name = computeBackupAgentName( const name = computeBackupAgentName(
this.hass.localize, this.hass.localize,
agentId, agentId,
agentIds this._agentIds
); );
return html` return html`
<ha-md-list-item> <ha-md-list-item>
@ -77,6 +82,14 @@ class HaBackupConfigAgents extends LitElement {
/> />
`} `}
<div slot="headline">${name}</div> <div slot="headline">${name}</div>
${agentId === CLOUD_AGENT
? html`
<div slot="supporting-text">
It stores one backup. The oldest backups are
deleted.
</div>
`
: nothing}
<ha-switch <ha-switch
slot="end" slot="end"
id=${agentId} id=${agentId}
@ -104,9 +117,9 @@ class HaBackupConfigAgents extends LitElement {
} }
// Ensure agents exist in the list // Ensure agents exist in the list
this.value = this.value.filter((agent) => this.value = this.value
this._agents.some((a) => a.agent_id === agent) .filter((agent) => this._agentIds.some((id) => id === agent))
); .filter((id) => id !== CLOUD_AGENT || this.cloudStatus);
fireEvent(this, "value-changed", { value: this.value }); fireEvent(this, "value-changed", { value: this.value });
} }

View File

@ -1,13 +1,15 @@
import { mdiClose, mdiDownload, mdiKey } from "@mdi/js"; import { mdiClose, mdiContentCopy, mdiDownload } from "@mdi/js";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup } from "lit";
import { LitElement, css, html, 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 } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button"; import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header"; import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button"; import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev"; import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-dialog"; import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog"; import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-list"; import "../../../../components/ha-md-list";
@ -20,7 +22,10 @@ import type {
} from "../../../../data/backup"; } from "../../../../data/backup";
import { import {
BackupScheduleState, BackupScheduleState,
CLOUD_AGENT,
CORE_LOCAL_AGENT,
generateEncryptionKey, generateEncryptionKey,
HASSIO_LOCAL_AGENT,
updateBackupConfig, updateBackupConfig,
} from "../../../../data/backup"; } from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
@ -33,12 +38,12 @@ import "../components/ha-backup-config-data";
import type { BackupConfigData } from "../components/ha-backup-config-data"; import type { BackupConfigData } from "../components/ha-backup-config-data";
import "../components/ha-backup-config-schedule"; import "../components/ha-backup-config-schedule";
import type { BackupConfigSchedule } from "../components/ha-backup-config-schedule"; import type { BackupConfigSchedule } from "../components/ha-backup-config-schedule";
import type { SetBackupEncryptionKeyDialogParams } from "./show-dialog-set-backup-encryption-key"; import type { BackupOnboardingDialogParams } from "./show-dialog-backup_onboarding";
const STEPS = [ const STEPS = [
"welcome", "welcome",
"new_key", "key",
"save_key", "setup",
"schedule", "schedule",
"data", "data",
"locations", "locations",
@ -46,7 +51,9 @@ const STEPS = [
type Step = (typeof STEPS)[number]; type Step = (typeof STEPS)[number];
const INITIAL_CONFIG: BackupConfig = { const FULL_DIALOG_STEPS = new Set<Step>(["setup"]);
const RECOMMENDED_CONFIG: BackupConfig = {
create_backup: { create_backup: {
agent_ids: [], agent_ids: [],
include_folders: [], include_folders: [],
@ -68,27 +75,37 @@ const INITIAL_CONFIG: BackupConfig = {
}; };
@customElement("ha-dialog-backup-onboarding") @customElement("ha-dialog-backup-onboarding")
class DialogSetBackupEncryptionKey extends LitElement implements HassDialog { class DialogBackupOnboarding extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false; @state() private _opened = false;
@state() private _step?: Step; @state() private _step?: Step;
@state() private _params?: SetBackupEncryptionKeyDialogParams; @state() private _params?: BackupOnboardingDialogParams;
@query("ha-md-dialog") private _dialog!: HaMdDialog; @query("ha-md-dialog") private _dialog!: HaMdDialog;
@state() private _config?: BackupConfig; @state() private _config?: BackupConfig;
private _suggestedEncryptionKey?: string; public showDialog(params: BackupOnboardingDialogParams): void {
public showDialog(params: SetBackupEncryptionKeyDialogParams): void {
this._params = params; this._params = params;
this._step = STEPS[0]; this._step = STEPS[0];
this._config = INITIAL_CONFIG; this._config = RECOMMENDED_CONFIG;
// Enable local location by default
if (isComponentLoaded(this.hass, "hassio")) {
this._config.create_backup.agent_ids.push(HASSIO_LOCAL_AGENT);
} else {
this._config.create_backup.agent_ids.push(CORE_LOCAL_AGENT);
}
// Enable cloud location if logged in
if (this._params.cloudStatus.logged_in) {
this._config.create_backup.agent_ids.push(CLOUD_AGENT);
}
this._config.create_backup.password = generateEncryptionKey();
this._opened = true; this._opened = true;
this._suggestedEncryptionKey = generateEncryptionKey();
} }
public closeDialog(): void { public closeDialog(): void {
@ -102,7 +119,6 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
this._step = undefined; this._step = undefined;
this._config = undefined; this._config = undefined;
this._params = undefined; this._params = undefined;
this._suggestedEncryptionKey = undefined;
} }
private async _done() { private async _done() {
@ -158,7 +174,7 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
} }
protected render() { protected render() {
if (!this._opened || !this._params) { if (!this._opened || !this._params || !this._step) {
return nothing; return nothing;
} }
@ -187,25 +203,29 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
<span slot="title">${this._stepTitle}</span> <span slot="title">${this._stepTitle}</span>
</ha-dialog-header> </ha-dialog-header>
<div slot="content">${this._renderStepContent()}</div> <div slot="content">${this._renderStepContent()}</div>
<div slot="actions"> ${!FULL_DIALOG_STEPS.has(this._step)
${isLastStep ? html`
? html` <div slot="actions">
<ha-button ${isLastStep
@click=${this._done} ? html`
.disabled=${!this._isStepValid()} <ha-button
> @click=${this._done}
Save .disabled=${!this._isStepValid()}
</ha-button> >
` Save
: html` </ha-button>
<ha-button `
@click=${this._nextStep} : html`
.disabled=${!this._isStepValid()} <ha-button
> @click=${this._nextStep}
Next .disabled=${!this._isStepValid()}
</ha-button> >
`} Next
</div> </ha-button>
`}
</div>
`
: nothing}
</ha-md-dialog> </ha-md-dialog>
`; `;
} }
@ -214,10 +234,10 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
switch (this._step) { switch (this._step) {
case "welcome": case "welcome":
return ""; return "";
case "new_key": case "key":
return "Encryption key"; return "Encryption key";
case "save_key": case "setup":
return "Save encryption key"; return "Set up your backup strategy";
case "schedule": case "schedule":
return "Automatic backups"; return "Automatic backups";
case "data": case "data":
@ -231,9 +251,9 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
private _isStepValid(): boolean { private _isStepValid(): boolean {
switch (this._step) { switch (this._step) {
case "new_key": case "key":
return !!this._config?.create_backup.password; return true;
case "save_key": case "setup":
return true; return true;
case "schedule": case "schedule":
return !!this._config?.schedule; return !!this._config?.schedule;
@ -269,37 +289,20 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
</p> </p>
</div> </div>
`; `;
case "new_key": case "key":
return html` return html`
<p> <p>
All your backups are encrypted to keep your data private and secure. All your backups are encrypted to keep your data private and secure.
You need this encryption key to restore any backup. We recommend to save this key somewhere secure. As you can only
</p> restore your data with the backup encryption key.
<ha-password-field
placeholder="New encryption key"
@input=${this._encryptionKeyChanged}
.value=${this._config.create_backup.password ?? ""}
></ha-password-field>
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiKey}></ha-svg-icon>
<span slot="headline">Use suggested encryption key</span>
<span slot="supporting-text">
${this._suggestedEncryptionKey}
</span>
<ha-button slot="end" @click=${this._useSuggestedEncryptionKey}>
Enter
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
case "save_key":
return html`
<p>
Its important that you dont lose this encryption key. We recommend
to save this key somewhere secure. As you can only restore your data
with the backup encryption key.
</p> </p>
<div class="encryption-key">
<p>${this._config.create_backup.password}</p>
<ha-icon-button
.path=${mdiContentCopy}
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list> <ha-md-list>
<ha-md-list-item> <ha-md-list-item>
<span slot="headline">Download emergency kit</span> <span slot="headline">Download emergency kit</span>
@ -313,6 +316,29 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
</ha-md-list-item> </ha-md-list-item>
</ha-md-list> </ha-md-list>
`; `;
case "setup":
return html`
<p>
It is recommended to create a daily backup and keep copies of the
last 3 days on two different locations. And one of them is off-site.
</p>
<ha-md-list class="full">
<ha-md-list-item type="button" @click=${this._done}>
<span slot="headline">Recommended settings</span>
<span slot="supporting-text">
Set the proven backup strategy of daily backup.
</span>
<ha-icon-next slot="end"> </ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="button" @click=${this._nextStep}>
<span slot="headline">Custom settings</span>
<span slot="supporting-text">
Select your own automation, data and locations
</span>
<ha-icon-next slot="end"> </ha-icon-next>
</ha-md-list-item>
</ha-md-list>
`;
case "schedule": case "schedule":
return html` return html`
<p> <p>
@ -347,6 +373,7 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
<ha-backup-config-agents <ha-backup-config-agents
.hass=${this.hass} .hass=${this.hass}
.value=${this._config.create_backup.agent_ids} .value=${this._config.create_backup.agent_ids}
.cloudStatus=${this._params!.cloudStatus}
@value-changed=${this._agentsConfigChanged} @value-changed=${this._agentsConfigChanged}
></ha-backup-config-agents> ></ha-backup-config-agents>
`; `;
@ -365,23 +392,11 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
); );
} }
private _encryptionKeyChanged(ev) { private _copyKeyToClipboard() {
const value = ev.target.value; copyToClipboard(this._config!.create_backup.password!);
this._setEncryptionKey(value); showToast(this, {
} message: this.hass.localize("ui.common.copied_clipboard"),
});
private _useSuggestedEncryptionKey() {
this._setEncryptionKey(this._suggestedEncryptionKey!);
}
private _setEncryptionKey(value: string) {
this._config = {
...this._config!,
create_backup: {
...this._config!.create_backup,
password: value,
},
};
} }
private _dataConfig(config: BackupConfig): BackupConfigData { private _dataConfig(config: BackupConfig): BackupConfigData {
@ -442,7 +457,7 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
css` css`
ha-md-dialog { ha-md-dialog {
width: 90vw; width: 90vw;
max-width: 500px; max-width: 560px;
} }
div[slot="content"] { div[slot="content"] {
margin-top: -16px; margin-top: -16px;
@ -452,6 +467,12 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
--md-list-item-leading-space: 0; --md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0; --md-list-item-trailing-space: 0;
} }
ha-md-list.full {
--md-list-item-leading-space: 24px;
--md-list-item-trailing-space: 24px;
margin-left: -24px;
margin-right: -24px;
}
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog { ha-md-dialog {
max-width: none; max-width: none;
@ -466,6 +487,30 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
.welcome { .welcome {
text-align: center; text-align: center;
} }
.encryption-key {
border: 1px solid var(--divider-color);
background-color: var(--primary-background-color);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
}
.encryption-key p {
margin: 0;
flex: 1;
font-family: "Roboto Mono", "Consolas", "Menlo", monospace;
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: 28px;
text-align: center;
}
.encryption-key ha-icon-button {
flex: none;
margin: -16px;
}
`, `,
]; ];
} }
@ -473,6 +518,6 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ha-dialog-backup-onboarding": DialogSetBackupEncryptionKey; "ha-dialog-backup-onboarding": DialogBackupOnboarding;
} }
} }

View File

@ -1,8 +1,9 @@
import { mdiClose, mdiDownload, mdiKey } from "@mdi/js"; import { mdiClose, mdiContentCopy, mdiDownload } from "@mdi/js";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button"; import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header"; import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button"; import "../../../../components/ha-icon-button";
@ -17,9 +18,12 @@ import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles"; import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { fileDownload } from "../../../../util/file_download"; import { fileDownload } from "../../../../util/file_download";
import { showToast } from "../../../../util/toast";
import type { ChangeBackupEncryptionKeyDialogParams } from "./show-dialog-change-backup-encryption-key"; import type { ChangeBackupEncryptionKeyDialogParams } from "./show-dialog-change-backup-encryption-key";
const STEPS = ["current", "new", "save"] as const; const STEPS = ["current", "new", "done"] as const;
type Step = (typeof STEPS)[number];
@customElement("ha-dialog-change-backup-encryption-key") @customElement("ha-dialog-change-backup-encryption-key")
class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog { class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
@ -27,7 +31,7 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
@state() private _opened = false; @state() private _opened = false;
@state() private _step?: "current" | "new" | "save"; @state() private _step?: Step;
@state() private _params?: ChangeBackupEncryptionKeyDialogParams; @state() private _params?: ChangeBackupEncryptionKeyDialogParams;
@ -35,13 +39,11 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
@state() private _newEncryptionKey?: string; @state() private _newEncryptionKey?: string;
private _suggestedEncryptionKey?: string;
public showDialog(params: ChangeBackupEncryptionKeyDialogParams): void { public showDialog(params: ChangeBackupEncryptionKeyDialogParams): void {
this._params = params; this._params = params;
this._step = STEPS[0]; this._step = STEPS[0];
this._opened = true; this._opened = true;
this._suggestedEncryptionKey = generateEncryptionKey(); this._newEncryptionKey = generateEncryptionKey();
} }
public closeDialog(): void { public closeDialog(): void {
@ -55,7 +57,6 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
this._step = undefined; this._step = undefined;
this._params = undefined; this._params = undefined;
this._newEncryptionKey = undefined; this._newEncryptionKey = undefined;
this._suggestedEncryptionKey = undefined;
} }
private _done() { private _done() {
@ -89,7 +90,7 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
? "Save current encryption key" ? "Save current encryption key"
: this._step === "new" : this._step === "new"
? "New encryption key" ? "New encryption key"
: "Save new encryption key"; : "";
return html` return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}> <ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
@ -120,13 +121,12 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
<ha-button <ha-button
@click=${this._submit} @click=${this._submit}
.disabled=${!this._newEncryptionKey} .disabled=${!this._newEncryptionKey}
class="danger"
> >
Change encryption key Change encryption key
</ha-button> </ha-button>
` `
: this._step === "save" : html`<ha-button @click=${this._done}>Done</ha-button>`}
? html`<ha-button @click=${this._done}>Done</ha-button>`
: nothing}
</div> </div>
</ha-md-dialog> </ha-md-dialog>
`; `;
@ -143,7 +143,7 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
</p> </p>
<ha-md-list> <ha-md-list>
<ha-md-list-item> <ha-md-list-item>
<span slot="headline">Download emergency kit</span> <span slot="headline">Download old emergency kit</span>
<span slot="supporting-text"> <span slot="supporting-text">
We recommend to save this encryption key somewhere secure. We recommend to save this encryption key somewhere secure.
</span> </span>
@ -155,36 +155,22 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
</ha-md-list> </ha-md-list>
`; `;
case "new": case "new":
return html`
<p>All next backups will use the new encryption key.</p>
<ha-password-field
placeholder="New encryption key"
@input=${this._encryptionKeyChanged}
.value=${this._newEncryptionKey || ""}
></ha-password-field>
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiKey}></ha-svg-icon>
<span slot="headline">Use suggested encryption key</span>
<span slot="supporting-text">
${this._suggestedEncryptionKey}
</span>
<ha-button slot="end" @click=${this._useSuggestedEncryptionKey}>
Enter
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
case "save":
return html` return html`
<p> <p>
Its important that you dont lose this encryption key. We recommend All next backups will use the new encryption key. We recommend to
to save this key somewhere secure. As you can only restore your data save this key somewhere secure. As you can only restore your data
with the backup encryption key. with the backup encryption key.
</p> </p>
<div class="encryption-key">
<p>${this._newEncryptionKey}</p>
<ha-icon-button
.path=${mdiContentCopy}
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list> <ha-md-list>
<ha-md-list-item> <ha-md-list-item>
<span slot="headline">Download emergency kit</span> <span slot="headline">Download new emergency kit</span>
<span slot="supporting-text"> <span slot="supporting-text">
We recommend to save this encryption key somewhere secure. We recommend to save this encryption key somewhere secure.
</span> </span>
@ -195,10 +181,27 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
</ha-md-list-item> </ha-md-list-item>
</ha-md-list> </ha-md-list>
`; `;
case "done":
return html`
<div class="done">
<img
src="/static/images/voice-assistant/hi.png"
alt="Casita Home Assistant logo"
/>
<p>Encryption key changed</p>
</div>
`;
} }
return nothing; return nothing;
} }
private _copyKeyToClipboard() {
copyToClipboard(this._newEncryptionKey);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
private _downloadOld() { private _downloadOld() {
if (!this._params?.currentKey) { if (!this._params?.currentKey) {
return; return;
@ -221,14 +224,6 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
); );
} }
private _encryptionKeyChanged(ev) {
this._newEncryptionKey = ev.target.value;
}
private _useSuggestedEncryptionKey() {
this._newEncryptionKey = this._suggestedEncryptionKey;
}
private async _submit() { private async _submit() {
if (!this._newEncryptionKey) { if (!this._newEncryptionKey) {
return; return;
@ -244,7 +239,7 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
css` css`
ha-md-dialog { ha-md-dialog {
width: 90vw; width: 90vw;
max-width: 500px; max-width: 560px;
} }
div[slot="content"] { div[slot="content"] {
margin-top: -16px; margin-top: -16px;
@ -254,6 +249,33 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
--md-list-item-leading-space: 0; --md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0; --md-list-item-trailing-space: 0;
} }
ha-button.danger {
--mdc-theme-primary: var(--error-color);
}
.encryption-key {
border: 1px solid var(--divider-color);
background-color: var(--primary-background-color);
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
}
.encryption-key p {
margin: 0;
flex: 1;
font-family: "Roboto Mono", "Consolas", "Menlo", monospace;
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: 28px;
text-align: center;
}
.encryption-key ha-icon-button {
flex: none;
margin: -16px;
}
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog { ha-md-dialog {
max-width: none; max-width: none;
@ -265,6 +287,13 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
p { p {
margin-top: 0; margin-top: 0;
} }
.done {
text-align: center;
font-size: 22px;
font-style: normal;
font-weight: 400;
line-height: 28px;
}
`, `,
]; ];
} }

View File

@ -1,8 +1,10 @@
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import type { CloudStatus } from "../../../../data/cloud";
export interface BackupOnboardingDialogParams { export interface BackupOnboardingDialogParams {
submit?: (value: boolean) => void; submit?: (value: boolean) => void;
cancel?: () => void; cancel?: () => void;
cloudStatus: CloudStatus;
} }
const loadDialog = () => import("./dialog-backup-onboarding"); const loadDialog = () => import("./dialog-backup-onboarding");

View File

@ -18,6 +18,7 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { capitalizeFirstLetter } from "../../../common/string/capitalize-first-letter";
import type { LocalizeFunc } from "../../../common/translations/localize"; import type { LocalizeFunc } from "../../../common/translations/localize";
import type { import type {
DataTableColumnContainer, DataTableColumnContainer,
@ -36,6 +37,7 @@ import "../../../components/ha-svg-icon";
import { getSignedPath } from "../../../data/auth"; import { getSignedPath } from "../../../data/auth";
import type { BackupConfig, BackupContent } from "../../../data/backup"; import type { BackupConfig, BackupContent } from "../../../data/backup";
import { import {
compareAgents,
computeBackupAgentName, computeBackupAgentName,
deleteBackup, deleteBackup,
fetchBackupConfig, fetchBackupConfig,
@ -51,6 +53,7 @@ import {
DEFAULT_MANAGER_STATE, DEFAULT_MANAGER_STATE,
subscribeBackupEvents, subscribeBackupEvents,
} from "../../../data/backup_manager"; } from "../../../data/backup_manager";
import type { CloudStatus } from "../../../data/cloud";
import { extractApiErrorMessage } from "../../../data/hassio/common"; import { extractApiErrorMessage } from "../../../data/hassio/common";
import { import {
showAlertDialog, showAlertDialog,
@ -72,7 +75,6 @@ import { showBackupOnboardingDialog } from "./dialogs/show-dialog-backup_onboard
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup"; import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup"; import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup"; import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup";
import { capitalizeFirstLetter } from "../../../common/string/capitalize-first-letter";
interface BackupRow extends BackupContent { interface BackupRow extends BackupContent {
formatted_type: string; formatted_type: string;
@ -86,6 +88,8 @@ const TYPE_ORDER: Array<BackupType> = ["strategy", "custom"];
class HaConfigBackupDashboard extends SubscribeMixin(LitElement) { class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus!: CloudStatus;
@property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route; @property({ attribute: false }) public route!: Route;
@ -331,7 +335,7 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
slot="action" slot="action"
@click=${this._setupBackupStrategy} @click=${this._setupBackupStrategy}
> >
Setup backup strategy Set up backup strategy
</ha-button> </ha-button>
</ha-backup-summary-card> </ha-backup-summary-card>
` `
@ -401,16 +405,21 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
</div> </div>
</div> ` </div> `
: nothing} : nothing}
${!this._needsOnboarding
<ha-fab ? html`
slot="fab" <ha-fab
?disabled=${backupInProgress} slot="fab"
.label=${this.hass.localize("ui.panel.config.backup.create_backup")} ?disabled=${backupInProgress}
extended .label=${this.hass.localize(
@click=${this._newBackup} "ui.panel.config.backup.create_backup"
> )}
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon> extended
</ha-fab> @click=${this._newBackup}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
`
: nothing}
</hass-tabs-subpage-data-table> </hass-tabs-subpage-data-table>
`; `;
} }
@ -480,7 +489,10 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
private async _fetchBackupInfo() { private async _fetchBackupInfo() {
const info = await fetchBackupInfo(this.hass); const info = await fetchBackupInfo(this.hass);
this._backups = info.backups; this._backups = info.backups.map((backup) => ({
...backup,
agent_ids: backup.agent_ids?.sort(compareAgents),
}));
} }
private async _fetchBackupConfig() { private async _fetchBackupConfig() {
@ -489,7 +501,7 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
} }
private get _needsOnboarding() { private get _needsOnboarding() {
return this._config && !this._config.create_backup.password; return !this._config?.create_backup.password;
} }
private async _uploadBackup(ev) { private async _uploadBackup(ev) {
@ -501,15 +513,6 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
} }
private async _newBackup(): Promise<void> { private async _newBackup(): Promise<void> {
if (this._needsOnboarding) {
const success = await showBackupOnboardingDialog(this, {});
if (!success) {
return;
}
}
await this._fetchBackupConfig();
const config = this._config!; const config = this._config!;
const type = await showNewBackupDialog(this, { config }); const type = await showNewBackupDialog(this, { config });
@ -603,12 +606,16 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
} }
private async _setupBackupStrategy() { private async _setupBackupStrategy() {
const success = await showBackupOnboardingDialog(this, {}); const success = await showBackupOnboardingDialog(this, {
cloudStatus: this.cloudStatus,
});
if (!success) { if (!success) {
return; return;
} }
await this._fetchBackupConfig(); this._fetchBackupConfig();
await generateBackupWithStrategySettings(this.hass);
await this._fetchBackupInfo();
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {

View File

@ -17,6 +17,7 @@ import "../../../components/ha-md-list-item";
import { getSignedPath } from "../../../data/auth"; import { getSignedPath } from "../../../data/auth";
import type { BackupContentExtended } from "../../../data/backup"; import type { BackupContentExtended } from "../../../data/backup";
import { import {
compareAgents,
computeBackupAgentName, computeBackupAgentName,
deleteBackup, deleteBackup,
fetchBackupDetails, fetchBackupDetails,
@ -265,7 +266,10 @@ class HaConfigBackupDetails extends LitElement {
private async _fetchBackup() { private async _fetchBackup() {
try { try {
const response = await fetchBackupDetails(this.hass, this.backupId); const response = await fetchBackupDetails(this.hass, this.backupId);
this._backup = response.backup; this._backup = {
...response.backup,
agent_ids: response.backup.agent_ids?.sort(compareAgents),
};
} catch (err: any) { } catch (err: any) {
this._error = err?.message || "Could not fetch backup details"; this._error = err?.message || "Could not fetch backup details";
} }

View File

@ -12,6 +12,7 @@ import {
fetchBackupConfig, fetchBackupConfig,
updateBackupConfig, updateBackupConfig,
} from "../../../data/backup"; } from "../../../data/backup";
import type { CloudStatus } from "../../../data/cloud";
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "./components/ha-backup-config-agents"; import "./components/ha-backup-config-agents";
@ -46,6 +47,8 @@ const INITIAL_BACKUP_CONFIG: BackupConfig = {
class HaConfigBackupStrategy extends LitElement { class HaConfigBackupStrategy extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus!: CloudStatus;
@property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public narrow = false;
@state() private _backupConfig: BackupConfig = INITIAL_BACKUP_CONFIG; @state() private _backupConfig: BackupConfig = INITIAL_BACKUP_CONFIG;
@ -111,6 +114,7 @@ class HaConfigBackupStrategy extends LitElement {
<ha-backup-config-agents <ha-backup-config-agents
.hass=${this.hass} .hass=${this.hass}
.value=${this._backupConfig.create_backup.agent_ids} .value=${this._backupConfig.create_backup.agent_ids}
.cloudStatus=${this.cloudStatus}
@value-changed=${this._agentsConfigChanged} @value-changed=${this._agentsConfigChanged}
></ha-backup-config-agents> ></ha-backup-config-agents>
</div> </div>

View File

@ -1,5 +1,6 @@
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import type { CloudStatus } from "../../../data/cloud";
import type { RouterOptions } from "../../../layouts/hass-router-page"; import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page"; import { HassRouterPage } from "../../../layouts/hass-router-page";
import "../../../layouts/hass-tabs-subpage-data-table"; import "../../../layouts/hass-tabs-subpage-data-table";
@ -10,6 +11,8 @@ import "./ha-config-backup-dashboard";
class HaConfigBackup extends HassRouterPage { class HaConfigBackup extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus!: CloudStatus;
@property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public narrow = false;
protected routerOptions: RouterOptions = { protected routerOptions: RouterOptions = {
@ -38,6 +41,7 @@ class HaConfigBackup extends HassRouterPage {
pageEl.hass = this.hass; pageEl.hass = this.hass;
pageEl.route = this.routeTail; pageEl.route = this.routeTail;
pageEl.narrow = this.narrow; pageEl.narrow = this.narrow;
pageEl.cloudStatus = this.cloudStatus;
if ( if (
(!changedProps || changedProps.has("route")) && (!changedProps || changedProps.has("route")) &&