Create generate backup dialog (#22866)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Paul Bottein 2024-11-19 12:33:45 +01:00 committed by GitHub
parent be6ecefb9e
commit d9cd428bf4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 526 additions and 22 deletions

View File

@ -0,0 +1,104 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-checkbox";
import "../../../../components/ha-formfield";
import type { BackupAgent } from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
import { domainToName } from "../../../../data/integration";
@customElement("ha-backup-agents-select")
class HaBackupAgentsSelect extends LitElement {
@property({ attribute: false })
public hass!: HomeAssistant;
@property({ type: Boolean })
public disabled = false;
@property({ attribute: false })
public agents!: BackupAgent[];
@property({ attribute: false })
public disabledAgents?: string[];
@property({ attribute: false })
public value!: string[];
render() {
return html`
<div class="agents">
${this.agents.map((agent) => this._renderAgent(agent))}
</div>
`;
}
private _renderAgent(agent: BackupAgent) {
const [domain, name] = agent.agent_id.split(".");
const domainName = domainToName(this.hass.localize, domain);
return html`
<ha-formfield>
<span class="label" slot="label">
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
${domainName}: ${name}</span
>
<ha-checkbox
.checked=${this.value.includes(agent.agent_id)}
.value=${agent.agent_id}
.disabled=${this.disabled ||
this.disabledAgents?.includes(agent.agent_id)}
@change=${this._checkboxChanged}
></ha-checkbox>
</ha-formfield>
`;
}
private _checkboxChanged(ev: Event) {
const checkbox = ev.target as HTMLInputElement;
const value = checkbox.value;
const index = this.value.indexOf(value);
if (checkbox.checked && index === -1) {
this.value = [...this.value, value];
} else if (!checkbox.checked && index !== -1) {
this.value = [
...this.value.slice(0, index),
...this.value.slice(index + 1),
];
}
fireEvent(this, "value-changed", { value: this.value });
}
static styles = css`
img {
height: 24px;
width: 24px;
}
.agents {
display: flex;
flex-direction: column;
}
.label {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-agents-select": HaBackupAgentsSelect;
}
}

View File

@ -0,0 +1,372 @@
import {
mdiChartBox,
mdiClose,
mdiCog,
mdiFolder,
mdiPlayBoxMultiple,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-select";
import "../../../../components/ha-md-select-option";
import "../../../../components/ha-settings-row";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-switch";
import "../../../../components/ha-textfield";
import type { BackupAgent } from "../../../../data/backup";
import { fetchBackupAgentsInfo, generateBackup } from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../components/ha-backup-agents-select";
import type { GenerateBackupDialogParams } from "./show-dialog-generate-backup";
type FormData = {
name: string;
history: boolean;
media: boolean;
share: boolean;
addons_mode: "all" | "custom";
addons: string[];
agents_mode: "all" | "custom";
agents: string[];
};
const INITIAL_FORM_DATA: FormData = {
name: "",
history: true,
media: false,
share: false,
addons_mode: "all",
addons: [],
agents_mode: "all",
agents: [],
};
const STEPS = ["data", "sync"] as const;
@customElement("ha-dialog-generate-backup")
class DialogGenerateBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _formData?: FormData;
@state() private _step?: "data" | "sync";
@state() private _agents: BackupAgent[] = [];
@state() private _params?: GenerateBackupDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog(_params: GenerateBackupDialogParams): void {
this._step = STEPS[0];
this._formData = INITIAL_FORM_DATA;
this._params = _params;
this._fetchAgents();
}
private _dialogClosed() {
this._step = undefined;
this._formData = undefined;
this._agents = [];
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private async _fetchAgents() {
const { agents } = await fetchBackupAgentsInfo(this.hass);
this._agents = agents;
}
public closeDialog() {
this._dialog?.close();
}
private _previousStep() {
const index = STEPS.indexOf(this._step!);
if (index === 0) {
return;
}
this._step = STEPS[index - 1];
}
private _nextStep() {
const index = STEPS.indexOf(this._step!);
if (index === STEPS.length - 1) {
return;
}
this._step = STEPS[index + 1];
}
protected render() {
if (!this._step || !this._formData) {
return nothing;
}
const dialogTitle =
this._step === "sync" ? "Synchronization" : "Backup data";
const isFirstStep = this._step === STEPS[0];
const isLastStep = this._step === STEPS[STEPS.length - 1];
return html`
<ha-md-dialog open disable-cancel-action @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
${isFirstStep
? html`
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
`
: html`
<ha-icon-button-prev
slot="navigationIcon"
@click=${this._previousStep}
></ha-icon-button-prev>
`}
<span slot="title" .title=${dialogTitle}> ${dialogTitle} </span>
</ha-dialog-header>
<div slot="content" class="content">
${this._step === "data" ? this._renderData() : this._renderSync()}
</div>
<div slot="actions">
${isFirstStep
? html`<ha-button @click=${this.closeDialog}>Cancel</ha-button>`
: nothing}
${isLastStep
? html`<ha-button @click=${this._submit}>Create backup</ha-button>`
: html`<ha-button @click=${this._nextStep}>Next</ha-button>`}
</div>
</ha-md-dialog>
`;
}
private _renderData() {
if (!this._formData) {
return nothing;
}
return html`
<ha-settings-row>
<ha-svg-icon slot="prefix" .path=${mdiCog}></ha-svg-icon>
<span slot="heading">Home Assistant settings</span>
<span slot="description">
With these settings you are able to restore your system.
</span>
<ha-switch disabled checked></ha-switch>
</ha-settings-row>
<ha-settings-row>
<ha-svg-icon slot="prefix" .path=${mdiChartBox}></ha-svg-icon>
<span slot="heading">History</span>
<span slot="description">For example of your energy dashboard.</span>
<ha-switch
id="history"
name="history"
@change=${this._switchChanged}
.checked=${this._formData.history}
></ha-switch>
</ha-settings-row>
<ha-settings-row>
<ha-svg-icon slot="prefix" .path=${mdiPlayBoxMultiple}></ha-svg-icon>
<span slot="heading">Media</span>
<span slot="description">
Folder that is often used for advanced or older configurations.
</span>
<ha-switch
id="media"
name="media"
@change=${this._switchChanged}
.checked=${this._formData.media}
></ha-switch>
</ha-settings-row>
<ha-settings-row>
<ha-svg-icon slot="prefix" .path=${mdiFolder}></ha-svg-icon>
<span slot="heading">Share folder</span>
<span slot="description">
Folder that is often used for advanced or older configurations.
</span>
<ha-switch
id="share"
name="share"
@change=${this._switchChanged}
.checked=${this._formData.share}
></ha-switch>
</ha-settings-row>
`;
}
private _renderSync() {
if (!this._formData) {
return nothing;
}
return html`
<ha-textfield
name="name"
.label=${"Backup name"}
.value=${this._formData.name}
@change=${this._nameChanged}
>
</ha-textfield>
<ha-settings-row>
<span slot="heading">Locations</span>
<span slot="description">
What locations you want to automatically backup to.
</span>
<ha-md-select
@change=${this._agentModeChanged}
.value=${this._formData.agents_mode}
>
<ha-md-select-option value="all">
<div slot="headline">All (${this._agents.length})</div>
</ha-md-select-option>
<ha-md-select-option value="custom">
<div slot="headline">Custom</div>
</ha-md-select-option>
</ha-md-select>
</ha-settings-row>
${this._formData.agents_mode === "custom"
? html`
<ha-expansion-panel .header=${"Location"} outlined expanded>
<ha-backup-agents-select
.hass=${this.hass}
.value=${this._formData.agents}
@value-changed=${this._agentsChanged}
.agents=${this._agents}
></ha-backup-agents-select>
</ha-expansion-panel>
`
: nothing}
`;
}
private _agentModeChanged(ev) {
const select = ev.currentTarget;
this._formData = {
...this._formData!,
agents_mode: select.value,
};
}
private _agentsChanged(ev) {
this._formData = {
...this._formData!,
agents: ev.detail.value,
};
}
private _switchChanged(ev) {
const _switch = ev.currentTarget;
this._formData = {
...this._formData!,
[_switch.id]: _switch.checked,
};
}
private _nameChanged(ev) {
this._formData = {
...this._formData!,
name: ev.target.value,
};
}
private async _submit() {
if (!this._formData) {
return;
}
const {
addons,
addons_mode,
agents,
agents_mode,
history,
media,
name,
share,
} = this._formData;
const folders: string[] = [];
if (media) {
folders.push("media");
}
if (share) {
folders.push("share");
}
// TODO: Fetch all addons
const ALL_ADDONS = [];
const { slug } = await generateBackup(this.hass, {
name,
agent_ids:
agents_mode === "all"
? this._agents.map((agent) => agent.agent_id)
: agents,
database_included: history,
folders_included: folders,
addons_included: addons_mode === "all" ? ALL_ADDONS : addons,
});
this._params!.submit?.({ slug });
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
:host {
--dialog-content-overflow: visible;
}
ha-md-dialog {
--dialog-content-padding: 24px;
}
ha-settings-row {
--settings-row-prefix-display: flex;
padding: 0;
}
ha-settings-row > ha-svg-icon {
align-self: center;
margin-inline-end: 16px;
}
ha-settings-row > ha-md-select {
min-width: 150px;
}
ha-settings-row > ha-md-select > span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
ha-settings-row > ha-md-select-option {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
ha-textfield {
width: 100%;
}
.content {
padding-top: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-generate-backup": DialogGenerateBackup;
}
}

View File

@ -0,0 +1,37 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface GenerateBackupDialogParams {
submit?: (response: { slug: string }) => void;
cancel?: () => void;
}
export const loadGenerateBackupDialog = () =>
import("./dialog-generate-backup");
export const showGenerateBackupDialog = (
element: HTMLElement,
params: GenerateBackupDialogParams
) =>
new Promise<{ slug: string } | null>((resolve) => {
const origCancel = params.cancel;
const origSubmit = params.submit;
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-generate-backup",
dialogImport: loadGenerateBackupDialog,
dialogParams: {
...params,
cancel: () => {
resolve(null);
if (origCancel) {
origCancel();
}
},
submit: (response) => {
resolve(response);
if (origSubmit) {
origSubmit(response);
}
},
},
});
});

View File

@ -22,22 +22,22 @@ import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-svg-icon";
import {
fetchBackupInfo,
generateBackup,
removeBackup,
type BackupContent,
} from "../../../data/backup";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../lovelace/custom-card-helpers";
import "./components/ha-backup-summary-card";
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
@customElement("ha-config-backup-dashboard")
class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
@ -244,21 +244,10 @@ class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
}
private async _generateBackup(): Promise<void> {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.backup.create.title"),
text: this.hass.localize("ui.panel.config.backup.create.description"),
confirmText: this.hass.localize("ui.panel.config.backup.create.confirm"),
});
if (!confirm) {
return;
}
const response = await showGenerateBackupDialog(this, {});
try {
await generateBackup(this.hass, {
agent_ids: ["backup.local"],
});
} catch (err) {
showAlertDialog(this, { text: (err as Error).message });
if (!response) {
return;
}
await this._fetchBackupInfo();

View File

@ -10,6 +10,7 @@ import { fetchBackupAgentsInfo } from "../../../data/backup";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { domainToName } from "../../../data/integration";
@customElement("ha-config-backup-locations")
class HaConfigBackupLocations extends LitElement {
@ -47,9 +48,10 @@ class HaConfigBackupLocations extends LitElement {
<ha-md-list>
${this._agents.map((agent) => {
const [domain, name] = agent.agent_id.split(".");
const domainName =
this.hass.localize(`component.${domain}.title`) ||
domain;
const domainName = domainToName(
this.hass.localize,
domain
);
return html`
<ha-md-list-item
type="link"