From 86f1af668238866d6c633d9ef799cdefb8f5f7bc Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 11 Dec 2024 21:52:37 +0100 Subject: [PATCH] Merge feature branch with backup changes to dev (#23239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add dialog to upload a backup file (#22405) * Add dialog to upload a backup file * Prosess feedback * Remoe unused definition * Early pushout of changes to the backup panel (#22321) * Eary pushout of changes to the backup panel * Add location icons * Path is optional * Set backupSlug from route * No need for subscription mixin * update * Reorder * init details * Fix import * Improve backup screen and navigation (#22827) * Add location page * Start dashboard * Move list to dashboard * Add mocked config page * Fix hardcoded boolean * Add summary card * Use new format for BackupAgent * Use new API * Rename to ha-backup-summary-card * Use new api * Fix backup agents * Rename backup slug to backup id (#22876) * Add delete backup action to datatable (#22867) * Create generate backup dialog (#22866) Co-authored-by: Bram Kragten * Add backup details page (#22884) * Add new backup dialog to choose between automatic and manual (#22895) Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> * Improve download backup (#22905) * Rename remove to delete in backup websocket type (#22902) * Use bytes for backup size (#22909) * Use default backup instead of automatic backup (#22915) * Update generate backup api (#22943) Use new backup api for generate backup * Improve details page for new backup (#22946) * Add content of backup in detail page * Add restore button * Add note * Use disabled * Fix backup generate * Use options to WS command backup/restore (#22950) * Add addons picker in generate backup dialog (#22951) * Add addons picker in generate backup dialog * Change condition * Fix label * Fix local addons * Review * Add local addon in addon mode is all * Fix local addon folder * Use addon picker inside data picker * Fetch addons info in detail page * Fetch addon inside component * Rename agents picker * Restrict generate backup content for core backup (#22958) * Fix addon mode all * Use event to check if a backup is in progress (#22960) * Use event to check if a backup is in progress * Update src/panels/config/backup/ha-config-backup-dashboard.ts Co-authored-by: Bram Kragten --------- Co-authored-by: Bram Kragten * Force enable home assistant settings when history is selected in backup (#22961) * Backup default config (#22954) * WIP default config * Add addons * save data * add icon * basics of change encryption key * Update dialog-change-backup-password.ts * use default config when manually triggering default backup * limit to hassio * enforce encryption key, manual use manual one * Update ha-config-backup-dashboard.ts * Add suggested password and copy buttons * Add download emergency kit button * review * fix * Update ha-config-backup-default-config.ts * Update ha-config-backup-default-config.ts * Update default backup settings (#23109) * Only display addons and folder for hassio (#23118) * Use new backup dashboard page for hassio backup (#23161) * Add support for copies and days for backup retention (#23128) * Add upload dialog for backup (#23139) * Improve generate backup dialog (#23167) * Propose to use encryption key if available when restoring a backup (#23164) Co-authored-by: Bram Kragten * Add encryption key onboarding (#23180) * Fix attributes broken by the warning fixes (#23182) * Don't allow any more eslint warnings (#23181) * Use dedicated endpoint to generate backup with default settings (#23224) * Add onboarding dialog for backups (#23225) * Add onboarding flow for backups * Add welcome screen * Add progress and status for backup dashboard (#23222) * Handle backup state * Add summary card * Use difference in days * Rename local backups and show icon (#23238) * Improve backup onboarding (#23241) * Do not navigate to config page after onboarding * Use casita image and center text * fix lint * Rename stored and default to strategy backup * Update * Fix icon and add type in datatable * Use strategy in more places * Fix list item overflow --------- Co-authored-by: Joakim Sørensen Co-authored-by: Bram Kragten Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> Co-authored-by: Petar Petrov --- src/components/ha-circular-progress.ts | 3 +- src/components/ha-file-upload.ts | 17 +- src/components/ha-md-textfield.ts | 31 + src/components/ha-password-field.ts | 4 + src/data/backup.ts | 237 ++++++- src/data/backup_manager.ts | 77 +++ src/layouts/hass-tabs-subpage-data-table.ts | 1 + .../components/ha-backup-addons-picker.ts | 84 +++ .../components/ha-backup-agents-picker.ts | 136 ++++ .../components/ha-backup-config-agents.ts | 136 ++++ .../components/ha-backup-config-data.ts | 320 +++++++++ .../ha-backup-config-encryption-key.ts | 106 +++ .../components/ha-backup-config-schedule.ts | 350 ++++++++++ .../components/ha-backup-data-picker.ts | 347 ++++++++++ .../components/ha-backup-formfield-label.ts | 67 ++ .../components/ha-backup-summary-card.ts | 149 ++++ .../components/ha-backup-summary-progress.ts | 108 +++ .../components/ha-backup-summary-status.ts | 84 +++ .../dialogs/dialog-backup-onboarding.ts | 478 +++++++++++++ .../dialog-change-backup-encryption-key.ts | 277 ++++++++ .../backup/dialogs/dialog-generate-backup.ts | 341 ++++++++++ .../backup/dialogs/dialog-new-backup.ts | 140 ++++ .../dialog-restore-backup-encryption-key.ts | 237 +++++++ .../dialog-set-backup-encryption-key.ts | 225 +++++++ .../backup/dialogs/dialog-upload-backup.ts | 259 +++++++ .../dialogs/show-dialog-backup_onboarding.ts | 36 + ...how-dialog-change-backup-encryption-key.ts | 38 ++ .../dialogs/show-dialog-generate-backup.ts | 38 ++ .../backup/dialogs/show-dialog-new-backup.ts | 40 ++ ...ow-dialog-restore-backup-encryption-key.ts | 37 + .../show-dialog-set-backup-encryption-key.ts | 37 + .../dialogs/show-dialog-upload-backup.ts | 36 + .../backup/ha-config-backup-dashboard.ts | 634 ++++++++++++++++++ .../config/backup/ha-config-backup-details.ts | 363 ++++++++++ .../backup/ha-config-backup-locations.ts | 138 ++++ .../backup/ha-config-backup-strategy.ts | 250 +++++++ src/panels/config/backup/ha-config-backup.ts | 250 +------ src/panels/config/ha-panel-config.ts | 8 - src/util/file_download.ts | 16 +- 39 files changed, 5885 insertions(+), 250 deletions(-) create mode 100644 src/components/ha-md-textfield.ts create mode 100644 src/data/backup_manager.ts create mode 100644 src/panels/config/backup/components/ha-backup-addons-picker.ts create mode 100644 src/panels/config/backup/components/ha-backup-agents-picker.ts create mode 100644 src/panels/config/backup/components/ha-backup-config-agents.ts create mode 100644 src/panels/config/backup/components/ha-backup-config-data.ts create mode 100644 src/panels/config/backup/components/ha-backup-config-encryption-key.ts create mode 100644 src/panels/config/backup/components/ha-backup-config-schedule.ts create mode 100644 src/panels/config/backup/components/ha-backup-data-picker.ts create mode 100644 src/panels/config/backup/components/ha-backup-formfield-label.ts create mode 100644 src/panels/config/backup/components/ha-backup-summary-card.ts create mode 100644 src/panels/config/backup/components/ha-backup-summary-progress.ts create mode 100644 src/panels/config/backup/components/ha-backup-summary-status.ts create mode 100644 src/panels/config/backup/dialogs/dialog-backup-onboarding.ts create mode 100644 src/panels/config/backup/dialogs/dialog-change-backup-encryption-key.ts create mode 100644 src/panels/config/backup/dialogs/dialog-generate-backup.ts create mode 100644 src/panels/config/backup/dialogs/dialog-new-backup.ts create mode 100644 src/panels/config/backup/dialogs/dialog-restore-backup-encryption-key.ts create mode 100644 src/panels/config/backup/dialogs/dialog-set-backup-encryption-key.ts create mode 100644 src/panels/config/backup/dialogs/dialog-upload-backup.ts create mode 100644 src/panels/config/backup/dialogs/show-dialog-backup_onboarding.ts create mode 100644 src/panels/config/backup/dialogs/show-dialog-change-backup-encryption-key.ts create mode 100644 src/panels/config/backup/dialogs/show-dialog-generate-backup.ts create mode 100644 src/panels/config/backup/dialogs/show-dialog-new-backup.ts create mode 100644 src/panels/config/backup/dialogs/show-dialog-restore-backup-encryption-key.ts create mode 100644 src/panels/config/backup/dialogs/show-dialog-set-backup-encryption-key.ts create mode 100644 src/panels/config/backup/dialogs/show-dialog-upload-backup.ts create mode 100644 src/panels/config/backup/ha-config-backup-dashboard.ts create mode 100644 src/panels/config/backup/ha-config-backup-details.ts create mode 100644 src/panels/config/backup/ha-config-backup-locations.ts create mode 100644 src/panels/config/backup/ha-config-backup-strategy.ts diff --git a/src/components/ha-circular-progress.ts b/src/components/ha-circular-progress.ts index 91fd45e9a1..bae2532d01 100644 --- a/src/components/ha-circular-progress.ts +++ b/src/components/ha-circular-progress.ts @@ -8,7 +8,7 @@ export class HaCircularProgress extends MdCircularProgress { @property({ attribute: "aria-label", type: String }) public ariaLabel = "Loading"; - @property() public size: "tiny" | "small" | "medium" | "large" = "medium"; + @property() public size?: "tiny" | "small" | "medium" | "large"; protected updated(changedProps: PropertyValues) { super.updated(changedProps); @@ -21,7 +21,6 @@ export class HaCircularProgress extends MdCircularProgress { case "small": this.style.setProperty("--md-circular-progress-size", "28px"); break; - // medium is default size case "medium": this.style.setProperty("--md-circular-progress-size", "48px"); break; diff --git a/src/components/ha-file-upload.ts b/src/components/ha-file-upload.ts index bd55538ef9..f68e52f673 100644 --- a/src/components/ha-file-upload.ts +++ b/src/components/ha-file-upload.ts @@ -56,6 +56,21 @@ export class HaFileUpload extends LitElement { } } + private get _name() { + if (this.value === undefined) { + return ""; + } + if (typeof this.value === "string") { + return this.value; + } + const files = + this.value instanceof FileList + ? Array.from(this.value) + : ensureArray(this.value); + + return files.map((file) => file.name).join(", "); + } + public render(): TemplateResult { return html` ${this.uploading @@ -65,7 +80,7 @@ export class HaFileUpload extends LitElement { >${this.value ? this.hass?.localize( "ui.components.file-upload.uploading_name", - { name: this.value.toString() } + { name: this._name } ) : this.hass?.localize( "ui.components.file-upload.uploading" diff --git a/src/components/ha-md-textfield.ts b/src/components/ha-md-textfield.ts new file mode 100644 index 0000000000..d0d0b808f7 --- /dev/null +++ b/src/components/ha-md-textfield.ts @@ -0,0 +1,31 @@ +import { MdFilledTextField } from "@material/web/textfield/filled-text-field"; +import { css } from "lit"; +import { customElement } from "lit/decorators"; + +@customElement("ha-md-textfield") +export class HaMdTextfield extends MdFilledTextField { + static override styles = [ + ...super.styles, + css` + :host { + --ha-icon-display: block; + --md-sys-color-primary: var(--primary-text-color); + --md-sys-color-secondary: var(--secondary-text-color); + --md-sys-color-surface: var(--card-background-color); + --md-sys-color-on-surface-variant: var(--secondary-text-color); + + --md-sys-color-surface-container-highest: var(--input-fill-color); + --md-sys-color-on-surface: var(--input-ink-color); + + --md-sys-color-surface-container: var(--input-fill-color); + --md-sys-color-secondary-container: var(--input-fill-color); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-md-textfield": HaMdTextfield; + } +} diff --git a/src/components/ha-password-field.ts b/src/components/ha-password-field.ts index 63b4e34be5..4c7c294706 100644 --- a/src/components/ha-password-field.ts +++ b/src/components/ha-password-field.ts @@ -143,6 +143,10 @@ export class HaPasswordField extends LitElement { >`; } + public focus(): void { + this._textField.focus(); + } + public checkValidity(): boolean { return this._textField.checkValidity(); } diff --git a/src/data/backup.ts b/src/data/backup.ts index 5f93a31f68..0d26743d97 100644 --- a/src/data/backup.ts +++ b/src/data/backup.ts @@ -1,36 +1,247 @@ +import type { LocalizeFunc } from "../common/translations/localize"; import type { HomeAssistant } from "../types"; +import { domainToName } from "./integration"; + +export const enum BackupScheduleState { + NEVER = "never", + DAILY = "daily", + MONDAY = "mon", + TUESDAY = "tue", + WEDNESDAY = "wed", + THURSDAY = "thu", + FRIDAY = "fri", + SATURDAY = "sat", + SUNDAY = "sun", +} + +export interface BackupConfig { + last_attempted_strategy_backup: string | null; + last_completed_strategy_backup: string | null; + create_backup: { + agent_ids: string[]; + include_addons: string[] | null; + include_all_addons: boolean; + include_database: boolean; + include_folders: string[] | null; + name: string | null; + password: string | null; + }; + retention: { + copies?: number | null; + days?: number | null; + }; + schedule: { + state: BackupScheduleState; + }; +} + +export interface BackupMutableConfig { + create_backup?: { + agent_ids?: string[]; + include_addons?: string[]; + include_all_addons?: boolean; + include_database?: boolean; + include_folders?: string[]; + name?: string | null; + password?: string | null; + }; + retention?: { + copies?: number | null; + days?: number | null; + }; + schedule?: BackupScheduleState; +} + +export interface BackupAgent { + agent_id: string; +} export interface BackupContent { - slug: string; + backup_id: string; date: string; name: string; + protected: boolean; size: number; - path: string; + agent_ids?: string[]; + with_strategy_settings: boolean; } export interface BackupData { - backing_up: boolean; - backups: BackupContent[]; + addons: BackupAddon[]; + database_included: boolean; + folders: string[]; + homeassistant_version: string; + homeassistant_included: boolean; } -export const getBackupDownloadUrl = (slug: string) => - `/api/backup/download/${slug}`; +export interface BackupAddon { + name: string; + slug: string; + version: string; +} -export const fetchBackupInfo = (hass: HomeAssistant): Promise => +export interface BackupContentExtended extends BackupContent, BackupData {} + +export interface BackupInfo { + backups: BackupContent[]; + backing_up: boolean; +} + +export interface BackupDetails { + backup: BackupContentExtended; +} + +export interface BackupAgentsInfo { + agents: BackupAgent[]; +} + +export type GenerateBackupParams = { + agent_ids: string[]; + include_addons?: string[]; + include_all_addons?: boolean; + include_database?: boolean; + include_folders?: string[]; + include_homeassistant?: boolean; + name?: string; + password?: string; +}; + +export type RestoreBackupParams = { + backup_id: string; + agent_id: string; + password?: string; + restore_addons?: string[]; + restore_database?: boolean; + restore_folders?: string[]; + restore_homeassistant?: boolean; +}; + +export const fetchBackupConfig = (hass: HomeAssistant) => + hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" }); + +export const updateBackupConfig = ( + hass: HomeAssistant, + config: BackupMutableConfig +) => hass.callWS({ type: "backup/config/update", ...config }); + +export const getBackupDownloadUrl = (id: string, agentId: string) => + `/api/backup/download/${id}?agent_id=${agentId}`; + +export const fetchBackupInfo = (hass: HomeAssistant): Promise => hass.callWS({ type: "backup/info", }); -export const removeBackup = ( +export const fetchBackupDetails = ( hass: HomeAssistant, - slug: string -): Promise => + id: string +): Promise => hass.callWS({ - type: "backup/remove", - slug, + type: "backup/details", + backup_id: id, }); -export const generateBackup = (hass: HomeAssistant): Promise => +export const fetchBackupAgentsInfo = ( + hass: HomeAssistant +): Promise => + hass.callWS({ + type: "backup/agents/info", + }); + +export const deleteBackup = (hass: HomeAssistant, id: string): Promise => + hass.callWS({ + type: "backup/delete", + backup_id: id, + }); + +export const generateBackup = ( + hass: HomeAssistant, + params: GenerateBackupParams +): Promise<{ backup_id: string }> => hass.callWS({ type: "backup/generate", + ...params, }); + +export const generateBackupWithStrategySettings = ( + hass: HomeAssistant +): Promise => + hass.callWS({ + type: "backup/generate_with_strategy_settings", + }); + +export const restoreBackup = ( + hass: HomeAssistant, + params: RestoreBackupParams +): Promise<{ backup_id: string }> => + hass.callWS({ + type: "backup/restore", + ...params, + }); + +export const uploadBackup = async ( + hass: HomeAssistant, + file: File, + agent_ids: string[] +): Promise => { + const fd = new FormData(); + fd.append("file", file); + + const params = agent_ids.reduce((acc, agent_id) => { + acc.append("agent_id", agent_id); + return acc; + }, new URLSearchParams()); + + const resp = await hass.fetchWithAuth( + `/api/backup/upload?${params.toString()}`, + { + method: "POST", + body: fd, + } + ); + + if (!resp.ok) { + throw new Error(`${resp.status} ${resp.statusText}`); + } +}; + +export const getPreferredAgentForDownload = (agents: string[]) => { + const localAgents = agents.filter( + (agent) => agent.split(".")[0] === "backup" + ); + return localAgents[0] || agents[0]; +}; + +export const isLocalAgent = (agentId: string) => + ["backup.local", "hassio.local"].includes(agentId); + +export const computeBackupAgentName = ( + localize: LocalizeFunc, + agentId: string, + agentIds?: string[] +) => { + if (isLocalAgent(agentId)) { + return "This system"; + } + const [domain, name] = agentId.split("."); + const domainName = domainToName(localize, domain); + + // If there are multiple agents for a domain, show the name + const showName = agentIds + ? agentIds.filter((a) => a.split(".")[0] === domain).length > 1 + : true; + + return showName ? `${domainName}: ${name}` : domainName; +}; + +export const generateEncryptionKey = () => { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const pattern = "xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx"; + let result = ""; + const randomArray = new Uint8Array(pattern.length); + crypto.getRandomValues(randomArray); + randomArray.forEach((number, index) => { + result += pattern[index] === "-" ? "-" : chars[number % chars.length]; + }); + return result; +}; diff --git a/src/data/backup_manager.ts b/src/data/backup_manager.ts new file mode 100644 index 0000000000..be6e26217a --- /dev/null +++ b/src/data/backup_manager.ts @@ -0,0 +1,77 @@ +import type { HomeAssistant } from "../types"; + +export type BackupManagerState = + | "idle" + | "create_backup" + | "receive_backup" + | "restore_backup"; + +export type CreateBackupStage = + | "addon_repositories" + | "addons" + | "await_addon_restarts" + | "docker_config" + | "finishing_file" + | "folders" + | "home_assistant" + | "upload_to_agents"; + +export type CreateBackupState = "completed" | "failed" | "in_progress"; + +export type ReceiveBackupStage = "receive_file" | "upload_to_agents"; + +export type ReceiveBackupState = "completed" | "failed" | "in_progress"; + +export type RestoreBackupStage = + | "addon_repositories" + | "addons" + | "await_addon_restarts" + | "await_home_assistant_restart" + | "check_home_assistant" + | "docker_config" + | "download_from_agent" + | "folders" + | "home_assistant" + | "remove_delta_addons"; + +export type RestoreBackupState = "completed" | "failed" | "in_progress"; + +type IdleEvent = { + manager_state: "idle"; +}; + +type CreateBackupEvent = { + manager_state: "create_backup"; + stage: CreateBackupStage | null; + state: CreateBackupState; +}; + +type ReceiveBackupEvent = { + manager_state: "receive_backup"; + stage: ReceiveBackupStage | null; + state: ReceiveBackupState; +}; + +type RestoreBackupEvent = { + manager_state: "restore_backup"; + stage: RestoreBackupStage | null; + state: RestoreBackupState; +}; + +export type ManagerStateEvent = + | IdleEvent + | CreateBackupEvent + | ReceiveBackupEvent + | RestoreBackupEvent; + +export const subscribeBackupEvents = ( + hass: HomeAssistant, + callback: (event: ManagerStateEvent) => void +) => + hass.connection.subscribeMessage(callback, { + type: "backup/subscribe_events", + }); + +export const DEFAULT_MANAGER_STATE: ManagerStateEvent = { + manager_state: "idle", +}; diff --git a/src/layouts/hass-tabs-subpage-data-table.ts b/src/layouts/hass-tabs-subpage-data-table.ts index 9effe92742..1858895621 100644 --- a/src/layouts/hass-tabs-subpage-data-table.ts +++ b/src/layouts/hass-tabs-subpage-data-table.ts @@ -468,6 +468,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) { ${!this.narrow ? html`
+
${this.hasFilters && !this.showFilters diff --git a/src/panels/config/backup/components/ha-backup-addons-picker.ts b/src/panels/config/backup/components/ha-backup-addons-picker.ts new file mode 100644 index 0000000000..1401f44172 --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-addons-picker.ts @@ -0,0 +1,84 @@ +import { mdiPuzzle } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-checkbox"; +import type { HaCheckbox } from "../../../../components/ha-checkbox"; +import "../../../../components/ha-formfield"; +import "../../../../components/ha-svg-icon"; +import type { HomeAssistant } from "../../../../types"; +import "./ha-backup-formfield-label"; + +export type BackupAddonItem = { + slug: string; + name: string; + version?: string; + icon?: boolean; + iconPath?: string; +}; + +@customElement("ha-backup-addons-picker") +export class HaBackupAddonsPicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public addons!: BackupAddonItem[]; + + @property({ attribute: false }) public value?: string[]; + + protected render() { + return html` +
+ ${this.addons.map( + (item) => html` + + a.slug === item.slug)?.icon + ? `/api/hassio/addons/${item.slug}/icon` + : undefined} + > + + + + ` + )} +
+ `; + } + + private _checkboxChanged(ev: Event) { + ev.stopPropagation(); + let value = this.value ?? []; + + const checkbox = ev.currentTarget as HaCheckbox; + if (checkbox.checked) { + value.push(checkbox.id); + } else { + value = value.filter((id) => id !== checkbox.id); + } + fireEvent(this, "value-changed", { value }); + } + + static get styles(): CSSResultGroup { + return css` + .items { + display: flex; + flex-direction: column; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-addons-picker": HaBackupAddonsPicker; + } +} diff --git a/src/panels/config/backup/components/ha-backup-agents-picker.ts b/src/panels/config/backup/components/ha-backup-agents-picker.ts new file mode 100644 index 0000000000..27b1a033ef --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-agents-picker.ts @@ -0,0 +1,136 @@ +import { mdiDatabase } from "@mdi/js"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import "../../../../components/ha-checkbox"; +import "../../../../components/ha-formfield"; +import "../../../../components/ha-svg-icon"; +import { + computeBackupAgentName, + isLocalAgent, + type BackupAgent, +} from "../../../../data/backup"; +import type { HomeAssistant } from "../../../../types"; +import { brandsUrl } from "../../../../util/brands-url"; + +@customElement("ha-backup-agents-picker") +class HaBackupAgentsPicker 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[]; + + private _agentIds = memoizeOne((agents: BackupAgent[]) => + agents.map((agent) => agent.agent_id) + ); + + render() { + return html` +
+ ${this._agentIds(this.agents).map((agent) => this._renderAgent(agent))} +
+ `; + } + + private _renderAgent(agentId: string) { + const domain = computeDomain(agentId); + const name = computeBackupAgentName( + this.hass.localize, + agentId, + this._agentIds(this.agents) + ); + + const disabled = + this.disabled || this.disabledAgents?.includes(agentId) || false; + + return html` + + + ${isLocalAgent(agentId) + ? html` + + ` + : html` + + `} + ${name} + + + + `; + } + + 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; + } + ha-svg-icon { + --mdc-icon-size: 24px; + color: var(--primary-text-color); + } + .agents { + display: flex; + flex-direction: column; + } + .label { + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.5px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-agents-picker": HaBackupAgentsPicker; + } +} diff --git a/src/panels/config/backup/components/ha-backup-config-agents.ts b/src/panels/config/backup/components/ha-backup-config-agents.ts new file mode 100644 index 0000000000..581b3cd252 --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-config-agents.ts @@ -0,0 +1,136 @@ +import { mdiDatabase } from "@mdi/js"; +import type { PropertyValues } from "lit"; +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { computeDomain } from "../../../../common/entity/compute_domain"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-switch"; +import "../../../../components/ha-svg-icon"; +import type { BackupAgent } from "../../../../data/backup"; +import { + computeBackupAgentName, + fetchBackupAgentsInfo, + isLocalAgent, +} from "../../../../data/backup"; +import type { HomeAssistant } from "../../../../types"; +import { brandsUrl } from "../../../../util/brands-url"; + +const DEFAULT_AGENTS = []; + +@customElement("ha-backup-config-agents") +class HaBackupConfigAgents extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _agents: BackupAgent[] = []; + + @state() private value?: string[]; + + protected firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + this._fetchAgents(); + } + + private async _fetchAgents() { + const { agents } = await fetchBackupAgentsInfo(this.hass); + this._agents = agents; + } + + private get _value() { + return this.value ?? DEFAULT_AGENTS; + } + + protected render() { + const agentIds = this._agents.map((agent) => agent.agent_id); + + return html` + ${agentIds.length > 0 + ? html` + + ${agentIds.map((agentId) => { + const domain = computeDomain(agentId); + const name = computeBackupAgentName( + this.hass.localize, + agentId, + agentIds + ); + return html` + + ${isLocalAgent(agentId) + ? html` + + + ` + : html` + + `} +
${name}
+ +
+ `; + })} +
+ ` + : html`

No sync agents configured

`} + `; + } + + private _agentToggled(ev) { + ev.stopPropagation(); + const value = ev.currentTarget.checked; + const agentId = ev.currentTarget.id; + + if (value) { + this.value = [...this._value, agentId]; + } else { + this.value = this._value.filter((agent) => agent !== agentId); + } + + // Ensure agents exist in the list + this.value = this.value.filter((agent) => + this._agents.some((a) => a.agent_id === agent) + ); + fireEvent(this, "value-changed", { value: this.value }); + } + + static styles = css` + ha-md-list { + background: none; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + ha-md-list-item { + --md-item-overflow: visible; + } + ha-md-list-item img { + width: 48px; + } + ha-md-list-item ha-svg-icon[slot="start"] { + --mdc-icon-size: 48px; + color: var(--primary-text-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-config-agents": HaBackupConfigAgents; + } +} diff --git a/src/panels/config/backup/components/ha-backup-config-data.ts b/src/panels/config/backup/components/ha-backup-config-data.ts new file mode 100644 index 0000000000..4d35627bda --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-config-data.ts @@ -0,0 +1,320 @@ +import { + mdiChartBox, + mdiCog, + mdiFolder, + mdiPlayBoxMultiple, + mdiPuzzle, +} from "@mdi/js"; +import type { PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-button"; +import "../../../../components/ha-expansion-panel"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-md-select"; +import type { HaMdSelect } from "../../../../components/ha-md-select"; +import "../../../../components/ha-md-select-option"; +import "../../../../components/ha-switch"; +import type { HaSwitch } from "../../../../components/ha-switch"; +import { fetchHassioAddonsInfo } from "../../../../data/hassio/addon"; +import type { HomeAssistant } from "../../../../types"; +import "./ha-backup-addons-picker"; +import type { BackupAddonItem } from "./ha-backup-addons-picker"; + +export type FormData = { + homeassistant: boolean; + database: boolean; + media: boolean; + share: boolean; + addons_mode: "all" | "custom"; + addons: string[]; +}; + +const INITIAL_FORM_DATA: FormData = { + homeassistant: false, + database: false, + media: false, + share: false, + addons_mode: "all", + addons: [], +}; + +export type BackupConfigData = { + include_homeassistant?: boolean; + include_database: boolean; + include_folders?: string[]; + include_all_addons: boolean; + include_addons?: string[]; +}; + +const SELF_CREATED_ADDONS_FOLDER = "addons/local"; +const SELF_CREATED_ADDONS_NAME = "___LOCAL_ADDONS___"; + +@customElement("ha-backup-config-data") +class HaBackupConfigData extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, attribute: "force-home-assistant" }) + public forceHomeAssistant = false; + + @state() private value?: BackupConfigData; + + @state() private _addons: BackupAddonItem[] = []; + + protected firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + if (isComponentLoaded(this.hass, "hassio")) { + this._fetchAddons(); + } + } + + private async _fetchAddons() { + const { addons } = await fetchHassioAddonsInfo(this.hass); + this._addons = [ + ...addons, + { + name: "Self created add-ons", + slug: SELF_CREATED_ADDONS_NAME, + iconPath: mdiFolder, + }, + ]; + } + + private _getData = memoizeOne((value?: BackupConfigData): FormData => { + if (!value) { + return INITIAL_FORM_DATA; + } + + const config = value; + + const hasLocalAddonFolder = config.include_folders?.includes( + SELF_CREATED_ADDONS_FOLDER + ); + + const addons = config.include_addons?.slice() ?? []; + + if (hasLocalAddonFolder && !value.include_all_addons) { + addons.push(SELF_CREATED_ADDONS_NAME); + } + + return { + homeassistant: config.include_homeassistant || this.forceHomeAssistant, + database: config.include_database, + media: config.include_folders?.includes("media") || false, + share: config.include_folders?.includes("share") || false, + addons_mode: config.include_all_addons ? "all" : "custom", + addons: addons, + }; + }); + + private _setData(data: FormData) { + const hasSelfCreatedAddons = data.addons.includes(SELF_CREATED_ADDONS_NAME); + + const include_folders = [ + ...(data.media ? ["media"] : []), + ...(data.share ? ["share"] : []), + ]; + + let include_addons = data.addons_mode === "custom" ? data.addons : []; + + if (hasSelfCreatedAddons || data.addons_mode === "all") { + include_folders.push(SELF_CREATED_ADDONS_FOLDER); + include_addons = include_addons.filter( + (addon) => addon !== SELF_CREATED_ADDONS_NAME + ); + } + + this.value = { + include_homeassistant: data.homeassistant || this.forceHomeAssistant, + include_addons: include_addons.length ? include_addons : undefined, + include_all_addons: data.addons_mode === "all", + include_database: data.database, + include_folders: include_folders.length ? include_folders : undefined, + }; + + fireEvent(this, "value-changed", { value: this.value }); + } + + protected render() { + const data = this._getData(this.value); + + const isHassio = isComponentLoaded(this.hass, "hassio"); + + return html` + + + + + ${this.forceHomeAssistant + ? "Home Assistant settings are always included" + : "Home Assistant settings"} + + + The bare minimum needed to restore your system. + + ${this.forceHomeAssistant + ? html`Learn more` + : html` + + `} + + + + + History + + Historical data of your sensors, including your energy dashboard. + + + + + ${isHassio + ? html` + + + Media + + For example, camera recordings. + + + + + + + Share folder + + Folder that is often used for advanced or older + configurations. + + + + + ${this._addons.length + ? html` + + + Add-ons + + Select what add-ons you want to include. + + + +
All
+
+ +
Custom
+
+
+
+ ` + : nothing} + ` + : nothing} +
+ ${isHassio && data.addons_mode === "custom" && this._addons.length + ? html` + + + + ` + : nothing} + `; + } + + private _switchChanged(ev: Event) { + const target = ev.currentTarget as HaSwitch; + const data = this._getData(this.value); + this._setData({ + ...data, + [target.id]: target.checked, + }); + fireEvent(this, "value-changed", { value: this.value }); + } + + private _selectChanged(ev: Event) { + const target = ev.currentTarget as HaMdSelect; + const data = this._getData(this.value); + this._setData({ + ...data, + [target.id]: target.value, + }); + fireEvent(this, "value-changed", { value: this.value }); + } + + private _addonsChanged(ev: CustomEvent) { + ev.stopPropagation(); + const addons = ev.detail.value; + const data = this._getData(this.value); + this._setData({ + ...data, + addons, + }); + fireEvent(this, "value-changed", { value: this.value }); + } + + static styles = css` + ha-md-list { + background: none; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + ha-md-select { + min-width: 210px; + } + ha-md-list-item { + --md-item-overflow: visible; + } + @media all and (max-width: 450px) { + ha-md-select { + min-width: 160px; + width: 160px; + } + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-config-data": HaBackupConfigData; + } +} diff --git a/src/panels/config/backup/components/ha-backup-config-encryption-key.ts b/src/panels/config/backup/components/ha-backup-config-encryption-key.ts new file mode 100644 index 0000000000..de775961d8 --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-config-encryption-key.ts @@ -0,0 +1,106 @@ +import { mdiDownload } from "@mdi/js"; +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import type { HomeAssistant } from "../../../../types"; +import { showChangeBackupEncryptionKeyDialog } from "../dialogs/show-dialog-change-backup-encryption-key"; +import { fileDownload } from "../../../../util/file_download"; +import { showSetBackupEncryptionKeyDialog } from "../dialogs/show-dialog-set-backup-encryption-key"; + +@customElement("ha-backup-config-encryption-key") +class HaBackupConfigEncryptionKey extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private value?: string; + + private get _value() { + return this.value ?? ""; + } + + protected render() { + if (this._value) { + return html` + + + Download emergency kit + + We recommend to save this encryption key somewhere secure. + + + + Download + + + + + Change encryption key + + All next backups will use this encryption key. + + + Change + + + + `; + } + + return html` + + + Set encryption key + + Set an encryption key for your backups. + + Set + + + `; + } + + private _download() { + if (!this._value) { + return; + } + fileDownload( + "data:text/plain;charset=utf-8," + encodeURIComponent(this._value), + "emergency_kit.txt" + ); + } + + private _change() { + showChangeBackupEncryptionKeyDialog(this, { + currentKey: this._value, + saveKey: (key) => { + fireEvent(this, "value-changed", { value: key }); + }, + }); + } + + private _set() { + showSetBackupEncryptionKeyDialog(this, { + saveKey: (key) => { + fireEvent(this, "value-changed", { value: key }); + }, + }); + } + + static styles = css` + ha-md-list { + background: none; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + .danger { + --mdc-theme-primary: var(--error-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-config-encryption-key": HaBackupConfigEncryptionKey; + } +} diff --git a/src/panels/config/backup/components/ha-backup-config-schedule.ts b/src/panels/config/backup/components/ha-backup-config-schedule.ts new file mode 100644 index 0000000000..13724c4551 --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-config-schedule.ts @@ -0,0 +1,350 @@ +import type { PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { HaCheckbox } from "../../../../components/ha-checkbox"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-md-select"; +import "../../../../components/ha-md-textfield"; +import type { HaMdSelect } from "../../../../components/ha-md-select"; +import "../../../../components/ha-md-select-option"; +import "../../../../components/ha-switch"; +import type { BackupConfig } from "../../../../data/backup"; +import { BackupScheduleState } from "../../../../data/backup"; +import type { HomeAssistant } from "../../../../types"; +import { clamp } from "../../../../common/number/clamp"; + +export type BackupConfigSchedule = Pick; + +const MIN_VALUE = 1; +const MAX_VALUE = 50; + +enum RetentionPreset { + COPIES_3 = "copies_3", + DAYS_7 = "days_7", + FOREOVER = "forever", + CUSTOM = "custom", +} + +type RetentionData = { + type: "copies" | "days"; + value: number; +}; + +const RETENTION_PRESETS: Record< + Exclude, + RetentionData +> = { + copies_3: { type: "copies", value: 3 }, + days_7: { type: "days", value: 7 }, + forever: { type: "days", value: 0 }, +}; + +const computeRetentionPreset = ( + data: RetentionData +): RetentionPreset | undefined => { + for (const [key, value] of Object.entries(RETENTION_PRESETS)) { + if (value.type === data.type && value.value === data.value) { + return key as RetentionPreset; + } + } + return RetentionPreset.CUSTOM; +}; + +type FormData = { + enabled: boolean; + schedule: BackupScheduleState; + retention: { + type: "copies" | "days"; + value: number; + }; +}; + +const INITIAL_FORM_DATA: FormData = { + enabled: false, + schedule: BackupScheduleState.NEVER, + retention: { + type: "copies", + value: 3, + }, +}; + +@customElement("ha-backup-config-schedule") +class HaBackupConfigSchedule extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: BackupConfigSchedule; + + @state() private _retentionPreset?: RetentionPreset; + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("value")) { + if (this._retentionPreset !== RetentionPreset.CUSTOM) { + const data = this._getData(this.value); + this._retentionPreset = computeRetentionPreset(data.retention); + } + } + } + + private _getData = memoizeOne((value?: BackupConfigSchedule): FormData => { + if (!value) { + return INITIAL_FORM_DATA; + } + + const config = value; + + return { + enabled: config.schedule.state !== BackupScheduleState.NEVER, + schedule: config.schedule.state, + retention: { + type: config.retention.days != null ? "days" : "copies", + value: config.retention.days ?? config.retention.copies ?? 3, + }, + }; + }); + + private _setData(data: FormData) { + this.value = { + schedule: { + state: data.enabled ? data.schedule : BackupScheduleState.NEVER, + }, + retention: + data.retention.type === "days" + ? { days: data.retention.value, copies: null } + : { copies: data.retention.value, days: null }, + }; + + fireEvent(this, "value-changed", { value: this.value }); + } + + protected render() { + const data = this._getData(this.value); + + return html` + + + Use automatic backups + + How often you want to create a backup. + + + + + ${data.enabled + ? html` + + Schedule + + How often you want to create a backup. + + + + +
Daily at 04:45
+
+ +
Monday at 04:45
+
+ +
Tuesday at 04:45
+
+ +
Wednesday at 04:45
+
+ +
Thursday at 04:45
+
+ +
Friday at 04:45
+
+ +
Saturday at 04:45
+
+ +
Sunday at 04:45
+
+
+
+ + Maximum copies + + The number of backups that are saved + + + +
Latest 3 copies
+
+ +
Keep 7 days
+
+ +
Keep forever
+
+ +
Custom
+
+
+
+ ${this._retentionPreset === RetentionPreset.CUSTOM + ? html` + + + + + +
days
+
+ +
copies
+
+
+
+ ` + : nothing} + ` + : nothing} +
+ `; + } + + private _enabledChanged(ev) { + ev.stopPropagation(); + const target = ev.currentTarget as HaCheckbox; + const data = this._getData(this.value); + this._setData({ + ...data, + enabled: target.checked, + schedule: target.checked + ? BackupScheduleState.DAILY + : BackupScheduleState.NEVER, + }); + fireEvent(this, "value-changed", { value: this.value }); + } + + private _scheduleChanged(ev) { + ev.stopPropagation(); + const target = ev.currentTarget as HaMdSelect; + const data = this._getData(this.value); + this._setData({ + ...data, + schedule: target.value as BackupScheduleState, + }); + fireEvent(this, "value-changed", { value: this.value }); + } + + private _retentionPresetChanged(ev) { + ev.stopPropagation(); + const target = ev.currentTarget as HaMdSelect; + const value = target.value as RetentionPreset; + + this._retentionPreset = value; + if (value !== RetentionPreset.CUSTOM) { + const data = this._getData(this.value); + const retention = RETENTION_PRESETS[value]; + // Ensure we have at least 1 in defaut value because user can't select 0 + retention.value = Math.max(retention.value, 1); + this._setData({ + ...data, + retention: RETENTION_PRESETS[value], + }); + } + + fireEvent(this, "value-changed", { value: this.value }); + } + + private _retentionValueChanged(ev) { + ev.stopPropagation(); + const target = ev.currentTarget as HaMdSelect; + const value = parseInt(target.value); + const clamped = clamp(value, MIN_VALUE, MAX_VALUE); + const data = this._getData(this.value); + this._setData({ + ...data, + retention: { + ...data.retention, + value: clamped, + }, + }); + + fireEvent(this, "value-changed", { value: this.value }); + } + + private _retentionTypeChanged(ev) { + ev.stopPropagation(); + const target = ev.currentTarget as HaMdSelect; + const value = target.value as "copies" | "days"; + + const data = this._getData(this.value); + this._setData({ + ...data, + retention: { + ...data.retention, + type: value, + }, + }); + + fireEvent(this, "value-changed", { value: this.value }); + } + + static styles = css` + ha-md-list { + background: none; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + ha-md-select { + min-width: 210px; + } + ha-md-list-item { + --md-item-overflow: visible; + } + @media all and (max-width: 450px) { + ha-md-select { + min-width: 160px; + width: 160px; + } + } + ha-md-textfield#value { + min-width: 70px; + width: 70px; + } + ha-md-select#type { + min-width: 100px; + width: 100px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-config-schedule": HaBackupConfigSchedule; + } +} diff --git a/src/panels/config/backup/components/ha-backup-data-picker.ts b/src/panels/config/backup/components/ha-backup-data-picker.ts new file mode 100644 index 0000000000..f2d27c7a81 --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-data-picker.ts @@ -0,0 +1,347 @@ +import { + mdiChartBox, + mdiCog, + mdiFolder, + mdiPlayBoxMultiple, + mdiPuzzle, +} from "@mdi/js"; +import type { CSSResultGroup, PropertyValues } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-checkbox"; +import type { HaCheckbox } from "../../../../components/ha-checkbox"; +import "../../../../components/ha-formfield"; +import "../../../../components/ha-svg-icon"; +import type { BackupData } from "../../../../data/backup"; +import { fetchHassioAddonsInfo } from "../../../../data/hassio/addon"; +import { mdiHomeAssistant } from "../../../../resources/home-assistant-logo-svg"; +import type { HomeAssistant } from "../../../../types"; +import "./ha-backup-addons-picker"; +import type { BackupAddonItem } from "./ha-backup-addons-picker"; +import "./ha-backup-formfield-label"; + +type CheckBoxItem = { + label: string; + id: string; + version?: string; +}; + +const SELF_CREATED_ADDONS_FOLDER = "addons/local"; +const SELF_CREATED_ADDONS_NAME = "___LOCAL_ADDONS___"; + +const ITEM_ICONS = { + config: mdiCog, + database: mdiChartBox, + media: mdiPlayBoxMultiple, + share: mdiFolder, +}; + +type SelectedItems = { + homeassistant: string[]; + addons: string[]; +}; + +@customElement("ha-backup-data-picker") +export class HaBackupDataPicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public data!: BackupData; + + @property({ attribute: false }) public value?: BackupData; + + @state() public _addonIcons: Record = {}; + + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + if (isComponentLoaded(this.hass, "hassio")) { + this._fetchAddonInfo(); + } + } + + private async _fetchAddonInfo() { + const { addons } = await fetchHassioAddonsInfo(this.hass); + this._addonIcons = addons.reduce>( + (acc, addon) => ({ + ...acc, + [addon.slug]: addon.icon, + }), + {} + ); + } + + private _homeAssistantItems = memoizeOne( + (data: BackupData, _localize: LocalizeFunc) => { + const items: CheckBoxItem[] = []; + + if (data.homeassistant_included) { + items.push({ + label: "Settings", + id: "config", + version: data.homeassistant_version, + }); + } + if (data.database_included) { + items.push({ + label: "History", + id: "database", + }); + } + // Filter out the local add-ons folder + const folders = data.folders.filter( + (folder) => folder !== SELF_CREATED_ADDONS_FOLDER + ); + items.push( + ...folders.map((folder) => ({ + label: capitalizeFirstLetter(folder), + id: folder, + })) + ); + return items; + } + ); + + private _addonsItems = memoizeOne( + ( + data: BackupData, + _localize: LocalizeFunc, + addonIcons: Record + ) => { + const items = data.addons.map((addon) => ({ + name: addon.name, + slug: addon.slug, + version: addon.version, + icon: addonIcons[addon.slug], + })); + + // Add local add-ons folder in addons items + if (data.folders.includes(SELF_CREATED_ADDONS_FOLDER)) { + items.push({ + name: "Self created add-ons", + slug: SELF_CREATED_ADDONS_NAME, + iconPath: mdiFolder, + }); + } + + return items; + } + ); + + private _parseValue = memoizeOne((value?: BackupData): SelectedItems => { + if (!value) { + return { + homeassistant: [], + addons: [], + }; + } + const homeassistant: string[] = []; + const addons: string[] = []; + + if (value.homeassistant_included) { + homeassistant.push("config"); + } + if (value.database_included) { + homeassistant.push("database"); + } + + let folders = [...value.folders]; + const addonsList = value.addons.map((addon) => addon.slug); + if (folders.includes(SELF_CREATED_ADDONS_FOLDER)) { + folders = folders.filter((f) => f !== SELF_CREATED_ADDONS_FOLDER); + addonsList.push(SELF_CREATED_ADDONS_NAME); + } + homeassistant.push(...folders); + addons.push(...addonsList); + + return { + homeassistant, + addons, + }; + }); + + private _formatValue = memoizeOne( + (selectedItems: SelectedItems, data: BackupData): BackupData => ({ + homeassistant_version: data.homeassistant_version, + homeassistant_included: selectedItems.homeassistant.includes("config"), + database_included: selectedItems.homeassistant.includes("database"), + addons: data.addons.filter((addon) => + selectedItems.addons.includes(addon.slug) + ), + folders: data.folders.filter( + (folder) => + selectedItems.homeassistant.includes(folder) || + (selectedItems.addons.includes(folder) && + folder === SELF_CREATED_ADDONS_FOLDER) + ), + }) + ); + + private _itemChanged(ev: Event) { + const itemValues = this._parseValue(this.value); + + const checkbox = ev.currentTarget as HaCheckbox; + const section = (checkbox as any).section; + if (checkbox.checked) { + itemValues[section].push(checkbox.id); + } else { + itemValues[section] = itemValues[section].filter( + (id) => id !== checkbox.id + ); + } + + const newValue = this._formatValue(itemValues, this.data); + fireEvent(this, "value-changed", { value: newValue }); + } + + private _addonsChanged(ev: CustomEvent) { + ev.stopPropagation(); + const itemValues = this._parseValue(this.value); + + const addons = ev.detail.value; + itemValues.addons = addons; + + const newValue = this._formatValue(itemValues, this.data); + fireEvent(this, "value-changed", { value: newValue }); + } + + private _sectionChanged(ev: Event) { + const itemValues = this._parseValue(this.value); + const allValues = this._parseValue(this.data); + + const checkbox = ev.currentTarget as HaCheckbox; + const id = checkbox.id; + if (checkbox.checked) { + itemValues[id] = allValues[id]; + } else { + itemValues[id] = []; + } + + const newValue = this._formatValue(itemValues, this.data); + fireEvent(this, "value-changed", { value: newValue }); + } + + protected render() { + const homeAssistantItems = this._homeAssistantItems( + this.data, + this.hass.localize + ); + + const addonsItems = this._addonsItems( + this.data, + this.hass.localize, + this._addonIcons + ); + + const selectedItems = this._parseValue(this.value); + + return html` + ${homeAssistantItems.length + ? html` +
+ + + + 0 && + selectedItems.homeassistant.length < + homeAssistantItems.length} + @change=${this._sectionChanged} + > + +
+ ${homeAssistantItems.map( + (item) => html` + + + + + + ` + )} +
+
+ ` + : nothing} + ${addonsItems.length + ? html` +
+ + + + 0 && + selectedItems.addons.length < addonsItems.length} + @change=${this._sectionChanged} + > + + + +
+ ` + : nothing} + `; + } + + static get styles(): CSSResultGroup { + return css` + .section { + margin-inline-start: -16px; + margin-inline-end: 0; + margin-left: -16px; + } + .items { + padding-inline-start: 40px; + padding-inline-end: 0; + padding-left: 40px; + display: flex; + flex-direction: column; + } + ha-backup-addons-picker { + display: block; + padding-inline-start: 40px; + padding-inline-end: 0; + padding-left: 40px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-data-picker": HaBackupDataPicker; + } +} diff --git a/src/panels/config/backup/components/ha-backup-formfield-label.ts b/src/panels/config/backup/components/ha-backup-formfield-label.ts new file mode 100644 index 0000000000..d2d0036f5b --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-formfield-label.ts @@ -0,0 +1,67 @@ +import type { CSSResultGroup, TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../../../components/ha-svg-icon"; + +@customElement("ha-backup-formfield-label") +class SupervisorFormfieldLabel extends LitElement { + @property({ type: String }) public label!: string; + + @property({ type: String, attribute: "image-url" }) public imageUrl?: string; + + @property({ type: String, attribute: "icon-path" }) public iconPath?: string; + + @property({ type: String }) public version?: string; + + protected render(): TemplateResult { + return html` + ${this.imageUrl + ? html`` + : this.iconPath + ? html` + + ` + : nothing} + + ${this.label} + ${this.version + ? html`(${this.version})` + : nothing} + + `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: flex; + flex-direction: row; + gap: 16px; + align-items: center; + } + .label { + margin-right: 4px; + margin-inline-end: 4px; + margin-inline-start: initial; + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.5px; + } + .version { + color: var(--secondary-text-color); + } + .icon { + --mdi-icon-size: 24px; + width: 24px; + height: 24px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-formfield-label": SupervisorFormfieldLabel; + } +} diff --git a/src/panels/config/backup/components/ha-backup-summary-card.ts b/src/panels/config/backup/components/ha-backup-summary-card.ts new file mode 100644 index 0000000000..0efef8c3b5 --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-summary-card.ts @@ -0,0 +1,149 @@ +import { + mdiAlertCircleCheckOutline, + mdiAlertOutline, + mdiCheck, + mdiInformationOutline, + mdiSync, +} from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../../../components/ha-button"; +import "../../../../components/ha-card"; +import "../../../../components/ha-circular-progress"; +import "../../../../components/ha-icon"; + +type SummaryStatus = "success" | "error" | "info" | "warning" | "loading"; + +const ICONS: Record = { + success: mdiCheck, + error: mdiAlertCircleCheckOutline, + warning: mdiAlertOutline, + info: mdiInformationOutline, + loading: mdiSync, +}; + +@customElement("ha-backup-summary-card") +class HaBackupSummaryCard extends LitElement { + @property() + public heading!: string; + + @property() + public description!: string; + + @property({ type: Boolean, attribute: "has-action" }) + public hasAction = false; + + @property() + public status: SummaryStatus = "info"; + + render() { + return html` + +
+ ${this.status === "loading" + ? html`` + : html` +
+ +
+ `} + +
+

${this.heading}

+

${this.description}

+
+ ${this.hasAction + ? html` +
+ +
+ ` + : nothing} +
+
+ `; + } + + static styles = css` + .summary { + display: flex; + flex-direction: row; + gap: 16px; + align-items: center; + padding: 20px; + width: 100%; + box-sizing: border-box; + } + .icon { + position: relative; + border-radius: 20px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + --icon-color: var(--primary-color); + } + .icon.success { + --icon-color: var(--success-color); + } + .icon.warning { + --icon-color: var(--warning-color); + } + .icon.error { + --icon-color: var(--error-color); + } + .icon::before { + display: block; + content: ""; + position: absolute; + inset: 0; + background-color: var(--icon-color, var(--primary-color)); + opacity: 0.2; + } + .icon ha-svg-icon { + color: var(--icon-color, var(--primary-color)); + width: 24px; + height: 24px; + } + ha-circular-progress { + --md-circular-progress-size: 40px; + } + .content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + } + .heading { + font-size: 22px; + font-style: normal; + font-weight: 400; + line-height: 28px; + color: var(--primary-text-color); + margin: 0; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + .description { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.25px; + color: var(--secondary-text-color); + margin: 0; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-summary-card": HaBackupSummaryCard; + } +} diff --git a/src/panels/config/backup/components/ha-backup-summary-progress.ts b/src/panels/config/backup/components/ha-backup-summary-progress.ts new file mode 100644 index 0000000000..63a0a9aa6b --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-summary-progress.ts @@ -0,0 +1,108 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import type { ManagerStateEvent } from "../../../../data/backup_manager"; +import type { HomeAssistant } from "../../../../types"; +import "./ha-backup-summary-card"; + +@customElement("ha-backup-summary-progress") +export class HaBackupSummaryProgress extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public manager!: ManagerStateEvent; + + @property({ type: Boolean, attribute: "has-action" }) + public hasAction = false; + + private get _heading() { + switch (this.manager.manager_state) { + case "create_backup": + return "Creating backup"; + case "restore_backup": + return "Restoring backup"; + case "receive_backup": + return "Receiving backup"; + default: + return ""; + } + } + + private get _description() { + switch (this.manager.manager_state) { + case "create_backup": + switch (this.manager.stage) { + case "addon_repositories": + case "addons": + return "Backing up add-ons"; + case "await_addon_restarts": + return "Waiting for add-ons to restart"; + case "docker_config": + return "Backing up Docker configuration"; + case "finishing_file": + return "Finishing backup file"; + case "folders": + return "Backing up folders"; + case "home_assistant": + return "Backing up Home Assistant"; + case "upload_to_agents": + return "Uploading to locations"; + default: + return ""; + } + case "restore_backup": + switch (this.manager.stage) { + case "addon_repositories": + case "addons": + return "Restoring add-ons"; + case "await_addon_restarts": + return "Waiting for add-ons to restart"; + case "await_home_assistant_restart": + return "Waiting for Home Assistant to restart"; + case "check_home_assistant": + return "Checking Home Assistant"; + case "docker_config": + return "Restoring Docker configuration"; + case "download_from_agent": + return "Downloading from location"; + case "folders": + return "Restoring folders"; + case "home_assistant": + return "Restoring Home Assistant"; + case "remove_delta_addons": + return "Removing delta add-ons"; + default: + return ""; + } + case "receive_backup": + switch (this.manager.stage) { + case "receive_file": + return "Receiving file"; + case "upload_to_agents": + return "Uploading to locations"; + default: + return ""; + } + default: + return ""; + } + } + + protected render() { + return html` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-summary-progress": HaBackupSummaryProgress; + } +} diff --git a/src/panels/config/backup/components/ha-backup-summary-status.ts b/src/panels/config/backup/components/ha-backup-summary-status.ts new file mode 100644 index 0000000000..e5ad3fea51 --- /dev/null +++ b/src/panels/config/backup/components/ha-backup-summary-status.ts @@ -0,0 +1,84 @@ +import { differenceInDays } from "date-fns"; +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { formatShortDateTime } from "../../../../common/datetime/format_date_time"; +import type { BackupContent } from "../../../../data/backup"; +import type { ManagerStateEvent } from "../../../../data/backup_manager"; +import type { HomeAssistant } from "../../../../types"; +import "./ha-backup-summary-card"; + +@customElement("ha-backup-summary-status") +export class HaBackupSummaryProgress extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public manager!: ManagerStateEvent; + + @property({ attribute: false }) public backups!: BackupContent[]; + + @property({ type: Boolean, attribute: "has-action" }) + public hasAction = false; + + private _lastBackup = memoizeOne((backups: BackupContent[]) => { + const sortedBackups = backups + // eslint-disable-next-line arrow-body-style + .filter((backup) => { + // TODO : only show backups with default flag + return backup.with_strategy_settings; + }) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + return sortedBackups[0] as BackupContent | undefined; + }); + + protected render() { + const lastBackup = this._lastBackup(this.backups); + + if (!lastBackup) { + return html` + + + + `; + } + + const lastBackupDate = new Date(lastBackup.date); + const numberOfDays = differenceInDays(new Date(), lastBackupDate); + + // TODO : Improve time format + const description = `Last successful backup ${formatShortDateTime(lastBackupDate, this.hass.locale, this.hass.config)} and synced to ${lastBackup.agent_ids?.length} locations`; + if (numberOfDays > 8) { + return html` + + + + `; + } + return html` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-summary-status": HaBackupSummaryProgress; + } +} diff --git a/src/panels/config/backup/dialogs/dialog-backup-onboarding.ts b/src/panels/config/backup/dialogs/dialog-backup-onboarding.ts new file mode 100644 index 0000000000..5ae379e176 --- /dev/null +++ b/src/panels/config/backup/dialogs/dialog-backup-onboarding.ts @@ -0,0 +1,478 @@ +import { mdiClose, mdiDownload, mdiKey } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-button"; +import "../../../../components/ha-dialog-header"; +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-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-password-field"; +import "../../../../components/ha-svg-icon"; +import type { + BackupConfig, + BackupMutableConfig, +} from "../../../../data/backup"; +import { + BackupScheduleState, + generateEncryptionKey, + updateBackupConfig, +} from "../../../../data/backup"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import { fileDownload } from "../../../../util/file_download"; +import { showToast } from "../../../../util/toast"; +import "../components/ha-backup-config-agents"; +import "../components/ha-backup-config-data"; +import type { BackupConfigData } from "../components/ha-backup-config-data"; +import "../components/ha-backup-config-schedule"; +import type { BackupConfigSchedule } from "../components/ha-backup-config-schedule"; +import type { SetBackupEncryptionKeyDialogParams } from "./show-dialog-set-backup-encryption-key"; + +const STEPS = [ + "welcome", + "new_key", + "save_key", + "schedule", + "data", + "locations", +] as const; + +type Step = (typeof STEPS)[number]; + +const INITIAL_CONFIG: BackupConfig = { + create_backup: { + agent_ids: [], + include_folders: [], + include_database: true, + include_addons: [], + include_all_addons: true, + password: null, + name: null, + }, + retention: { + copies: 3, + days: null, + }, + schedule: { + state: BackupScheduleState.DAILY, + }, + last_attempted_strategy_backup: null, + last_completed_strategy_backup: null, +}; + +@customElement("ha-dialog-backup-onboarding") +class DialogSetBackupEncryptionKey extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _opened = false; + + @state() private _step?: Step; + + @state() private _params?: SetBackupEncryptionKeyDialogParams; + + @query("ha-md-dialog") private _dialog!: HaMdDialog; + + @state() private _config?: BackupConfig; + + private _suggestedEncryptionKey?: string; + + public showDialog(params: SetBackupEncryptionKeyDialogParams): void { + this._params = params; + this._step = STEPS[0]; + this._config = INITIAL_CONFIG; + this._opened = true; + this._suggestedEncryptionKey = generateEncryptionKey(); + } + + public closeDialog(): void { + if (this._params!.cancel) { + this._params!.cancel(); + } + if (this._opened) { + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + this._opened = false; + this._step = undefined; + this._config = undefined; + this._params = undefined; + this._suggestedEncryptionKey = undefined; + } + + private async _done() { + if (!this._config) { + return; + } + + const params: BackupMutableConfig = { + create_backup: { + password: this._config.create_backup.password, + include_database: this._config.create_backup.include_database, + agent_ids: this._config.create_backup.agent_ids, + }, + schedule: this._config.schedule.state, + retention: this._config.retention, + }; + + if (isComponentLoaded(this.hass, "hassio")) { + params.create_backup!.include_folders = + this._config.create_backup.include_folders || []; + params.create_backup!.include_all_addons = + this._config.create_backup.include_all_addons; + params.create_backup!.include_addons = + this._config.create_backup.include_addons || []; + } + + try { + await updateBackupConfig(this.hass, params); + + this._params?.submit!(true); + this._dialog.close(); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + showToast(this, { message: "Failed to save backup configuration" }); + } + } + + 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._opened || !this._params) { + return nothing; + } + + const isLastStep = this._step === STEPS[STEPS.length - 1]; + const isFirstStep = this._step === STEPS[0]; + + return html` + + + ${isFirstStep + ? html` + + ` + : html` + + `} + + ${this._stepTitle} + +
${this._renderStepContent()}
+
+ ${isLastStep + ? html` + + Save + + ` + : html` + + Next + + `} +
+
+ `; + } + + private get _stepTitle(): string { + switch (this._step) { + case "welcome": + return ""; + case "new_key": + return "Encryption key"; + case "save_key": + return "Save encryption key"; + case "schedule": + return "Automatic backups"; + case "data": + return "Backup data"; + case "locations": + return "Locations"; + default: + return ""; + } + } + + private _isStepValid(): boolean { + switch (this._step) { + case "new_key": + return !!this._config?.create_backup.password; + case "save_key": + return true; + case "schedule": + return !!this._config?.schedule; + case "data": + return !!this._config?.schedule; + case "locations": + return !!this._config?.create_backup.agent_ids.length; + default: + return true; + } + } + + private _renderStepContent() { + if (!this._config) { + return nothing; + } + + switch (this._step) { + case "welcome": + return html` +
+ Casita Home Assistant logo +

Set up your backup strategy

+

+ Backups are essential to a reliable smart home. They protect your + setup against failures and allows you to quickly have a working + system again. 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. +

+
+ `; + case "new_key": + return html` +

+ All your backups are encrypted to keep your data private and secure. + You need this encryption key to restore any backup. +

+ + + + + Use suggested encryption key + + ${this._suggestedEncryptionKey} + + + Enter + + + + `; + case "save_key": + return html` +

+ It’s important that you don’t lose this encryption key. We recommend + to save this key somewhere secure. As you can only restore your data + with the backup encryption key. +

+ + + Download emergency kit + + We recommend to save this encryption key somewhere secure. + + + + Download + + + + `; + case "schedule": + return html` +

+ Let Home Assistant take care of your backups by creating a scheduled + backup that also removes older copies. +

+ + `; + case "data": + return html` +

+ Choose what data to include in your backups. You can always change + this later. +

+ + `; + case "locations": + return html` +

+ Home Assistant will upload to these locations when this backup + strategy is used. You can use all locations for custom backups. +

+ + `; + } + return nothing; + } + + private _downloadKey() { + const key = this._config?.create_backup.password; + if (!key) { + return; + } + fileDownload( + "data:text/plain;charset=utf-8," + encodeURIComponent(key), + "emergency_kit.txt" + ); + } + + private _encryptionKeyChanged(ev) { + const value = ev.target.value; + this._setEncryptionKey(value); + } + + 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 { + const { + include_addons, + include_all_addons, + include_database, + include_folders, + } = config.create_backup; + + return { + include_homeassistant: true, + include_database, + include_folders: include_folders || undefined, + include_all_addons, + include_addons: include_addons || undefined, + }; + } + + private _dataChanged(ev) { + const data = ev.detail.value as BackupConfigData; + this._config = { + ...this._config!, + create_backup: { + ...this._config!.create_backup, + include_database: data.include_database, + include_folders: data.include_folders || null, + include_all_addons: data.include_all_addons, + include_addons: data.include_addons || null, + }, + }; + } + + private _scheduleChanged(ev) { + const value = ev.detail.value as BackupConfigSchedule; + this._config = { + ...this._config!, + schedule: value.schedule, + retention: value.retention, + }; + } + + private _agentsConfigChanged(ev) { + const agents = ev.detail.value as string[]; + this._config = { + ...this._config!, + create_backup: { + ...this._config!.create_backup, + agent_ids: agents, + }, + }; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + width: 90vw; + max-width: 500px; + } + div[slot="content"] { + margin-top: -16px; + } + ha-md-list { + background: none; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + @media all and (max-width: 450px), all and (max-height: 500px) { + ha-md-dialog { + max-width: none; + } + div[slot="content"] { + margin-top: 0; + } + } + p { + margin-top: 0; + } + .welcome { + text-align: center; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-backup-onboarding": DialogSetBackupEncryptionKey; + } +} diff --git a/src/panels/config/backup/dialogs/dialog-change-backup-encryption-key.ts b/src/panels/config/backup/dialogs/dialog-change-backup-encryption-key.ts new file mode 100644 index 0000000000..a2e183e764 --- /dev/null +++ b/src/panels/config/backup/dialogs/dialog-change-backup-encryption-key.ts @@ -0,0 +1,277 @@ +import { mdiClose, mdiDownload, mdiKey } 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-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-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-password-field"; +import { generateEncryptionKey } from "../../../../data/backup"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import { fileDownload } from "../../../../util/file_download"; +import type { ChangeBackupEncryptionKeyDialogParams } from "./show-dialog-change-backup-encryption-key"; + +const STEPS = ["current", "new", "save"] as const; + +@customElement("ha-dialog-change-backup-encryption-key") +class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _opened = false; + + @state() private _step?: "current" | "new" | "save"; + + @state() private _params?: ChangeBackupEncryptionKeyDialogParams; + + @query("ha-md-dialog") private _dialog!: HaMdDialog; + + @state() private _newEncryptionKey?: string; + + private _suggestedEncryptionKey?: string; + + public showDialog(params: ChangeBackupEncryptionKeyDialogParams): void { + this._params = params; + this._step = STEPS[0]; + this._opened = true; + this._suggestedEncryptionKey = generateEncryptionKey(); + } + + public closeDialog(): void { + if (this._params!.cancel) { + this._params!.cancel(); + } + if (this._opened) { + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + this._opened = false; + this._step = undefined; + this._params = undefined; + this._newEncryptionKey = undefined; + this._suggestedEncryptionKey = undefined; + } + + private _done() { + this._params?.submit!(true); + 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._opened || !this._params) { + return nothing; + } + + const dialogTitle = + this._step === "current" + ? "Save current encryption key" + : this._step === "new" + ? "New encryption key" + : "Save new encryption key"; + + return html` + + + ${this._step === "new" + ? html` + + ` + : html` + + `} + ${dialogTitle} + +
${this._renderStepContent()}
+
+ ${this._step === "current" + ? html`Next` + : this._step === "new" + ? html` + + Change encryption key + + ` + : this._step === "save" + ? html`Done` + : nothing} +
+
+ `; + } + + private _renderStepContent() { + switch (this._step) { + case "current": + return html` +

+ Make sure you have saved the current encryption key to make sure you + have access to all your current backups. All next backups will use + the new encryption key. +

+ + + Download emergency kit + + We recommend to save this encryption key somewhere secure. + + + + Download + + + + `; + case "new": + return html` +

All next backups will use the new encryption key.

+ + + + + Use suggested encryption key + + ${this._suggestedEncryptionKey} + + + Enter + + + + `; + case "save": + return html` +

+ It’s important that you don’t lose this encryption key. We recommend + to save this key somewhere secure. As you can only restore your data + with the backup encryption key. +

+ + + Download emergency kit + + We recommend to save this encryption key somewhere secure. + + + + Download + + + + `; + } + return nothing; + } + + private _downloadOld() { + if (!this._params?.currentKey) { + return; + } + fileDownload( + "data:text/plain;charset=utf-8," + + encodeURIComponent(this._params.currentKey), + "emergency_kit_old.txt" + ); + } + + private _downloadNew() { + if (!this._newEncryptionKey) { + return; + } + fileDownload( + "data:text/plain;charset=utf-8," + + encodeURIComponent(this._newEncryptionKey), + "emergency_kit.txt" + ); + } + + private _encryptionKeyChanged(ev) { + this._newEncryptionKey = ev.target.value; + } + + private _useSuggestedEncryptionKey() { + this._newEncryptionKey = this._suggestedEncryptionKey; + } + + private async _submit() { + if (!this._newEncryptionKey) { + return; + } + this._params!.saveKey(this._newEncryptionKey); + this._nextStep(); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + width: 90vw; + max-width: 500px; + } + div[slot="content"] { + margin-top: -16px; + } + ha-md-list { + background: none; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + @media all and (max-width: 450px), all and (max-height: 500px) { + ha-md-dialog { + max-width: none; + } + div[slot="content"] { + margin-top: 0; + } + } + p { + margin-top: 0; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-change-backup-encryption-key": DialogChangeBackupEncryptionKey; + } +} diff --git a/src/panels/config/backup/dialogs/dialog-generate-backup.ts b/src/panels/config/backup/dialogs/dialog-generate-backup.ts new file mode 100644 index 0000000000..db05d4adff --- /dev/null +++ b/src/panels/config/backup/dialogs/dialog-generate-backup.ts @@ -0,0 +1,341 @@ +import { mdiClose } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement, 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-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-md-select"; +import "../../../../components/ha-md-select-option"; +import "../../../../components/ha-textfield"; +import type { + BackupAgent, + BackupConfig, + GenerateBackupParams, +} from "../../../../data/backup"; +import { + fetchBackupAgentsInfo, + fetchBackupConfig, +} 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-picker"; +import "../components/ha-backup-config-data"; +import type { BackupConfigData } from "../components/ha-backup-config-data"; +import type { GenerateBackupDialogParams } from "./show-dialog-generate-backup"; + +type FormData = { + name: string; + agents_mode: "all" | "custom"; + agent_ids: string[]; + data: BackupConfigData; +}; + +const INITIAL_DATA: FormData = { + data: { + include_homeassistant: true, + include_database: true, + include_folders: [], + include_all_addons: true, + }, + name: "", + agents_mode: "all", + agent_ids: [], +}; + +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 _step?: "data" | "sync"; + + @state() private _agents: BackupAgent[] = []; + + @state() private _backupConfig?: BackupConfig; + + @state() private _params?: GenerateBackupDialogParams; + + @state() private _formData?: FormData; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public showDialog(_params: GenerateBackupDialogParams): void { + this._step = STEPS[0]; + this._formData = INITIAL_DATA; + this._params = _params; + this._fetchAgents(); + this._fetchBackupConfig(); + } + + private _dialogClosed() { + if (this._params!.cancel) { + this._params!.cancel(); + } + this._step = undefined; + this._formData = undefined; + this._agents = []; + this._backupConfig = undefined; + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private async _fetchAgents() { + const { agents } = await fetchBackupAgentsInfo(this.hass); + this._agents = agents; + } + + private async _fetchBackupConfig() { + const { config } = await fetchBackupConfig(this.hass); + this._backupConfig = config; + } + + 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` + + + ${isFirstStep + ? html` + + ` + : html` + + `} + ${dialogTitle} + +
+ ${this._step === "data" ? this._renderData() : this._renderSync()} +
+
+ ${isFirstStep + ? html`Cancel` + : nothing} + ${isLastStep + ? html`Create backup` + : html`Next`} +
+
+ `; + } + + private _renderData() { + if (!this._formData) { + return nothing; + } + + return html` + + `; + } + + private _dataConfigChanged(ev) { + ev.stopPropagation(); + const data = ev.detail.value as BackupConfigData; + this._formData = { + ...this._formData!, + data, + }; + } + + private _renderSync() { + if (!this._formData) { + return nothing; + } + + return html` + + + + + Backup locations + + What locations you want to automatically backup to. + + + +
All (${this._agents.length})
+
+ +
Custom
+
+
+
+
+ ${this._formData.agents_mode === "custom" + ? html` + + + + ` + : nothing} + `; + } + + private _selectChanged(ev) { + const select = ev.currentTarget; + this._formData = { + ...this._formData!, + [select.id]: select.value, + }; + } + + private _agentsChanged(ev) { + this._formData = { + ...this._formData!, + agent_ids: ev.detail.value, + }; + } + + private _nameChanged(ev) { + this._formData = { + ...this._formData!, + name: ev.target.value, + }; + } + + private async _submit() { + if (!this._formData) { + return; + } + + const { agent_ids, agents_mode, name, data } = this._formData; + + const password = this._backupConfig?.create_backup.password || undefined; + + const ALL_AGENT_IDS = this._agents.map((agent) => agent.agent_id); + + const params: GenerateBackupParams = { + name, + password, + agent_ids: agents_mode === "all" ? ALL_AGENT_IDS : agent_ids, + // We always include homeassistant if we include database + include_homeassistant: + data.include_homeassistant || data.include_database, + include_database: data.include_database, + include_addons: data.include_addons, + include_folders: data.include_folders, + include_all_addons: data.include_all_addons, + }; + + this._params!.submit?.(params); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + --dialog-content-padding: 24px; + max-height: calc(100vh - 48px); + } + ha-md-list { + background: none; + padding: 0; + } + ha-md-list-item { + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + ha-md-list-item ha-md-select { + min-width: 210px; + } + @media all and (max-width: 450px) { + ha-md-list-item ha-md-select { + min-width: 160px; + width: 160px; + } + } + ha-md-list-item ha-md-select > span { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + ha-md-list-item 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; + } +} diff --git a/src/panels/config/backup/dialogs/dialog-new-backup.ts b/src/panels/config/backup/dialogs/dialog-new-backup.ts new file mode 100644 index 0000000000..cce434126c --- /dev/null +++ b/src/panels/config/backup/dialogs/dialog-new-backup.ts @@ -0,0 +1,140 @@ +import { mdiClose, mdiCog, mdiPencil } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-icon-next"; +import "../../../../components/ha-md-dialog"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-svg-icon"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import type { NewBackupDialogParams } from "./show-dialog-new-backup"; + +@customElement("ha-dialog-new-backup") +class DialogNewBackup extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _opened = false; + + @state() private _params?: NewBackupDialogParams; + + public showDialog(params: NewBackupDialogParams): void { + this._opened = true; + this._params = params; + } + + public closeDialog(): void { + if (this._params!.cancel) { + this._params!.cancel(); + } + if (this._opened) { + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + this._opened = false; + this._params = undefined; + } + + protected render() { + if (!this._opened || !this._params) { + return nothing; + } + + return html` + + + + Backup now + +
+ + + + Use backup strategy + + Create a backup with the data and locations you have configured. + + + + + + Make custom backup + + Select specific data and locations for a custom backup. + + + + +
+
+ `; + } + + private async _custom() { + this._params!.submit?.("custom"); + this.closeDialog(); + } + + private async _default() { + this._params!.submit?.("strategy"); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + --dialog-content-padding: 0; + max-width: 500px; + } + div[slot="content"] { + margin-top: -16px; + } + @media all and (max-width: 450px), all and (max-height: 500px) { + ha-md-dialog { + max-width: none; + } + div[slot="content"] { + margin-top: 0; + } + } + + ha-md-list { + background: none; + } + ha-md-list-item { + } + ha-icon-next { + width: 24px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-new-backup": DialogNewBackup; + } +} diff --git a/src/panels/config/backup/dialogs/dialog-restore-backup-encryption-key.ts b/src/panels/config/backup/dialogs/dialog-restore-backup-encryption-key.ts new file mode 100644 index 0000000000..54e8f01cd7 --- /dev/null +++ b/src/panels/config/backup/dialogs/dialog-restore-backup-encryption-key.ts @@ -0,0 +1,237 @@ +import { mdiClose } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-button"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-form/ha-form"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-md-dialog"; +import type { HaMdDialog } from "../../../../components/ha-md-dialog"; +import "../../../../components/ha-svg-icon"; +import { fetchBackupConfig } from "../../../../data/backup"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import type { RestoreBackupEncryptionKeyDialogParams } from "./show-dialog-restore-backup-encryption-key"; + +type FormData = { + encryption_key_type: "config" | "custom"; + custom_encryption_key: string; +}; + +const INITIAL_DATA: FormData = { + encryption_key_type: "config", + custom_encryption_key: "", +}; + +@customElement("ha-dialog-restore-backup-encryption-key") +class DialogRestoreBackupEncryptionKey + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: RestoreBackupEncryptionKeyDialogParams; + + @state() private _formData?: FormData; + + @state() private _backupEncryptionKey?: string; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public showDialog(_params: RestoreBackupEncryptionKeyDialogParams): void { + this._params = _params; + this._formData = INITIAL_DATA; + this._fetchEncryptionKey(); + } + + private _dialogClosed() { + if (this._params!.cancel) { + this._params!.cancel(); + } + this._formData = undefined; + this._params = undefined; + this._backupEncryptionKey = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private async _fetchEncryptionKey() { + try { + const { config } = await fetchBackupConfig(this.hass); + this._backupEncryptionKey = config.create_backup.password || undefined; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + } + } + + public closeDialog() { + this._dialog?.close(); + } + + private _schema = memoizeOne( + (hasEncryptionKey: boolean, type: "config" | "custom") => + [ + ...(hasEncryptionKey + ? [ + { + name: "encryption_key_type", + selector: { + select: { + options: [ + { + value: "config", + label: "Use backup encryption key", + }, + { + value: "custom", + label: "Enter encryption key", + }, + ], + }, + }, + context: { + filter_entity: "entity", + }, + }, + ] + : []), + ...(!hasEncryptionKey || type === "custom" + ? ([ + { + name: "custom_encryption_key", + selector: { + text: {}, + }, + }, + ] as const satisfies readonly HaFormSchema[]) + : []), + ] as const satisfies readonly HaFormSchema[] + ); + + protected render() { + if (!this._params || !this._formData) { + return nothing; + } + + const dialogTitle = "Restore backup"; + + const hasEncryptionKey = this._backupEncryptionKey != null; + + const schema = this._schema( + hasEncryptionKey, + this._formData.encryption_key_type + ); + + return html` + + + + ${dialogTitle} + +
+

+ ${hasEncryptionKey + ? "The backup is encrypted. Which encryption key would you like to use to decrypt the backup?" + : "The backup is encrypted. Provide the encryption key to decrypt the backup."} +

+ + +
+
+ Cancel + + Restore + +
+
+ `; + } + + private _valueChanged(ev: CustomEvent): void { + ev.stopPropagation(); + this._formData = ev.detail.value; + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "encryption_key_type": + return ""; + case "custom_encryption_key": + return "Encryption key"; + default: + return ""; + } + }; + + private _getKey() { + if (!this._formData) { + return undefined; + } + const hasEncryptionKey = this._backupEncryptionKey != null; + + if (hasEncryptionKey) { + return this._formData.encryption_key_type === "config" + ? this._backupEncryptionKey + : this._formData.custom_encryption_key; + } + + return this._formData.custom_encryption_key; + } + + private async _submit() { + if (!this._formData) { + return; + } + + const key = this._getKey(); + + if (!key) { + return; + } + + this._params!.submit?.(key!); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + max-width: 500px; + width: 100%; + } + .content p { + margin: 0 0 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-restore-backup-encryption-key": DialogRestoreBackupEncryptionKey; + } +} diff --git a/src/panels/config/backup/dialogs/dialog-set-backup-encryption-key.ts b/src/panels/config/backup/dialogs/dialog-set-backup-encryption-key.ts new file mode 100644 index 0000000000..2d6fe6b439 --- /dev/null +++ b/src/panels/config/backup/dialogs/dialog-set-backup-encryption-key.ts @@ -0,0 +1,225 @@ +import { mdiClose, mdiDownload, mdiKey } 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-icon-button"; +import "../../../../components/ha-md-dialog"; +import type { HaMdDialog } from "../../../../components/ha-md-dialog"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-password-field"; +import { generateEncryptionKey } from "../../../../data/backup"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import { fileDownload } from "../../../../util/file_download"; +import type { SetBackupEncryptionKeyDialogParams } from "./show-dialog-set-backup-encryption-key"; + +const STEPS = ["new", "save"] as const; + +@customElement("ha-dialog-set-backup-encryption-key") +class DialogSetBackupEncryptionKey extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _opened = false; + + @state() private _step?: "new" | "save"; + + @state() private _params?: SetBackupEncryptionKeyDialogParams; + + @query("ha-md-dialog") private _dialog!: HaMdDialog; + + @state() private _newEncryptionKey?: string; + + private _suggestedEncryptionKey?: string; + + public showDialog(params: SetBackupEncryptionKeyDialogParams): void { + this._params = params; + this._step = STEPS[0]; + this._opened = true; + this._suggestedEncryptionKey = generateEncryptionKey(); + } + + public closeDialog(): void { + if (this._params!.cancel) { + this._params!.cancel(); + } + if (this._opened) { + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + this._opened = false; + this._step = undefined; + this._params = undefined; + this._newEncryptionKey = undefined; + this._suggestedEncryptionKey = undefined; + } + + private _done() { + this._params?.submit!(true); + this._dialog.close(); + } + + private _nextStep() { + const index = STEPS.indexOf(this._step!); + if (index === STEPS.length - 1) { + return; + } + this._step = STEPS[index + 1]; + } + + protected render() { + if (!this._opened || !this._params) { + return nothing; + } + + const dialogTitle = + this._step === "new" ? "Encryption key" : "Save new encryption key"; + + return html` + + + + ${dialogTitle} + +
${this._renderStepContent()}
+
+ ${this._step === "new" + ? html` + + Next + + ` + : this._step === "save" + ? html`Done` + : nothing} +
+
+ `; + } + + private _renderStepContent() { + switch (this._step) { + case "new": + return html` +

+ All your backups are encrypted to keep your data private and secure. + You need this encryption key to restore any backup. +

+ + + + + Use suggested encryption key + + ${this._suggestedEncryptionKey} + + + Enter + + + + `; + case "save": + return html` +

+ It’s important that you don’t lose this encryption key. We recommend + to save this key somewhere secure. As you can only restore your data + with the backup encryption key. +

+ + + Download emergency kit + + We recommend to save this encryption key somewhere secure. + + + + Download + + + + `; + } + return nothing; + } + + private _downloadNew() { + if (!this._newEncryptionKey) { + return; + } + fileDownload( + "data:text/plain;charset=utf-8," + + encodeURIComponent(this._newEncryptionKey), + "emergency_kit.txt" + ); + } + + private _encryptionKeyChanged(ev) { + this._newEncryptionKey = ev.target.value; + } + + private _useSuggestedEncryptionKey() { + this._newEncryptionKey = this._suggestedEncryptionKey; + } + + private async _submit() { + if (!this._newEncryptionKey) { + return; + } + this._params!.saveKey(this._newEncryptionKey); + this._nextStep(); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + width: 90vw; + max-width: 500px; + } + div[slot="content"] { + margin-top: -16px; + } + ha-md-list { + background: none; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + @media all and (max-width: 450px), all and (max-height: 500px) { + ha-md-dialog { + max-width: none; + } + div[slot="content"] { + margin-top: 0; + } + } + p { + margin-top: 0; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-set-backup-encryption-key": DialogSetBackupEncryptionKey; + } +} diff --git a/src/panels/config/backup/dialogs/dialog-upload-backup.ts b/src/panels/config/backup/dialogs/dialog-upload-backup.ts new file mode 100644 index 0000000000..53809752e5 --- /dev/null +++ b/src/panels/config/backup/dialogs/dialog-upload-backup.ts @@ -0,0 +1,259 @@ +import { mdiClose, mdiFolderUpload } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { keyed } from "lit/directives/keyed"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-alert"; +import "../../../../components/ha-dialog-header"; +import "../../../../components/ha-expansion-panel"; +import "../../../../components/ha-file-upload"; +import "../../../../components/ha-icon-button"; +import "../../../../components/ha-md-dialog"; +import type { HaMdDialog } from "../../../../components/ha-md-dialog"; +import "../../../../components/ha-md-list"; +import "../../../../components/ha-md-list-item"; +import "../../../../components/ha-md-select"; +import "../../../../components/ha-md-select-option"; +import type { BackupAgent } from "../../../../data/backup"; +import { fetchBackupAgentsInfo, uploadBackup } from "../../../../data/backup"; +import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; +import { showAlertDialog } from "../../../lovelace/custom-card-helpers"; +import "../components/ha-backup-agents-picker"; +import type { UploadBackupDialogParams } from "./show-dialog-upload-backup"; + +const SUPPORTED_FORMAT = "application/x-tar"; + +type FormData = { + agents_mode: "all" | "custom"; + agent_ids: string[]; + file?: File; +}; + +const INITIAL_DATA: FormData = { + agents_mode: "all", + agent_ids: [], + file: undefined, +}; + +@customElement("ha-dialog-upload-backup") +export class DialogUploadBackup + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: UploadBackupDialogParams; + + @state() private _uploading = false; + + @state() private _error?: string; + + @state() private _agents: BackupAgent[] = []; + + @state() private _formData?: FormData; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public async showDialog(params: UploadBackupDialogParams): Promise { + this._params = params; + this._formData = INITIAL_DATA; + this._fetchAgents(); + } + + private _dialogClosed() { + if (this._params!.cancel) { + this._params!.cancel(); + } + this._formData = undefined; + this._agents = []; + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + public closeDialog() { + this._dialog?.close(); + } + + private async _fetchAgents() { + const { agents } = await fetchBackupAgentsInfo(this.hass); + this._agents = agents; + } + + private _formValid() { + return ( + this._formData?.file !== undefined && + (this._formData.agents_mode === "all" || + this._formData.agent_ids.length > 0) + ); + } + + protected render() { + if (!this._params || !this._formData) { + return nothing; + } + + return html` + + + + + Upload backup + +
+ + + + Locations + + What locations you want to upload this backup. + + ${keyed( + this._agents.length, + html` + + +
All (${this._agents.length})
+
+ +
Custom
+
+
+ ` + )} +
+
+ ${this._formData.agents_mode === "custom" + ? html` + + + + ` + : nothing} + ${this._error + ? html`${this._error}` + : nothing} +
+
+ Cancel + + Upload backup + +
+
+ `; + } + + private _selectChanged(ev) { + const select = ev.currentTarget; + this._formData = { + ...this._formData!, + [select.id]: select.value, + }; + } + + private _agentsChanged(ev) { + this._formData = { + ...this._formData!, + agent_ids: ev.detail.value, + }; + } + + private async _filePicked(ev: CustomEvent<{ files: File[] }>): Promise { + this._error = undefined; + const file = ev.detail.files[0]; + + this._formData = { + ...this._formData!, + file, + }; + } + + private async _upload() { + const { file, agent_ids, agents_mode } = this._formData!; + if (!file || file.type !== SUPPORTED_FORMAT) { + showAlertDialog(this, { + title: "Unsupported file format", + text: "Please choose a Home Assistant backup file (.tar)", + confirmText: "ok", + }); + return; + } + + const agents = + agents_mode === "all" + ? this._agents.map((agent) => agent.agent_id) + : agent_ids; + + this._uploading = true; + try { + await uploadBackup(this.hass!, file, agents); + this._params!.submit?.(); + this.closeDialog(); + } catch (err: any) { + this._error = err.message; + } finally { + this._uploading = false; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + max-width: 500px; + width: 100%; + max-width: 500px; + max-height: 100%; + } + ha-md-list { + background: none; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + ha-md-select { + min-width: 210px; + } + @media all and (max-width: 450px) { + ha-md-select { + min-width: 160px; + width: 160px; + } + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-dialog-upload-backup": DialogUploadBackup; + } +} diff --git a/src/panels/config/backup/dialogs/show-dialog-backup_onboarding.ts b/src/panels/config/backup/dialogs/show-dialog-backup_onboarding.ts new file mode 100644 index 0000000000..e8d88a4f4a --- /dev/null +++ b/src/panels/config/backup/dialogs/show-dialog-backup_onboarding.ts @@ -0,0 +1,36 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; + +export interface BackupOnboardingDialogParams { + submit?: (value: boolean) => void; + cancel?: () => void; +} + +const loadDialog = () => import("./dialog-backup-onboarding"); + +export const showBackupOnboardingDialog = ( + element: HTMLElement, + params?: BackupOnboardingDialogParams +) => + new Promise((resolve) => { + const origCancel = params?.cancel; + const origSubmit = params?.submit; + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-backup-onboarding", + dialogImport: loadDialog, + dialogParams: { + ...params, + cancel: () => { + resolve(false); + if (origCancel) { + origCancel(); + } + }, + submit: (value) => { + resolve(value); + if (origSubmit) { + origSubmit(value); + } + }, + }, + }); + }); diff --git a/src/panels/config/backup/dialogs/show-dialog-change-backup-encryption-key.ts b/src/panels/config/backup/dialogs/show-dialog-change-backup-encryption-key.ts new file mode 100644 index 0000000000..f69d674c99 --- /dev/null +++ b/src/panels/config/backup/dialogs/show-dialog-change-backup-encryption-key.ts @@ -0,0 +1,38 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; + +export interface ChangeBackupEncryptionKeyDialogParams { + currentKey: string; + submit?: (success: boolean) => void; + cancel?: () => void; + saveKey: (key: string) => any; +} + +const loadDialog = () => import("./dialog-change-backup-encryption-key"); + +export const showChangeBackupEncryptionKeyDialog = ( + element: HTMLElement, + params?: ChangeBackupEncryptionKeyDialogParams +) => + new Promise((resolve) => { + const origCancel = params?.cancel; + const origSubmit = params?.submit; + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-change-backup-encryption-key", + dialogImport: loadDialog, + dialogParams: { + ...params, + cancel: () => { + resolve(false); + if (origCancel) { + origCancel(); + } + }, + submit: (value) => { + resolve(value); + if (origSubmit) { + origSubmit(value); + } + }, + }, + }); + }); diff --git a/src/panels/config/backup/dialogs/show-dialog-generate-backup.ts b/src/panels/config/backup/dialogs/show-dialog-generate-backup.ts new file mode 100644 index 0000000000..c764290fa4 --- /dev/null +++ b/src/panels/config/backup/dialogs/show-dialog-generate-backup.ts @@ -0,0 +1,38 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { GenerateBackupParams } from "../../../../data/backup"; + +export interface GenerateBackupDialogParams { + submit?: (response: GenerateBackupParams) => void; + cancel?: () => void; +} + +export const loadGenerateBackupDialog = () => + import("./dialog-generate-backup"); + +export const showGenerateBackupDialog = ( + element: HTMLElement, + params: GenerateBackupDialogParams +) => + new Promise((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); + } + }, + }, + }); + }); diff --git a/src/panels/config/backup/dialogs/show-dialog-new-backup.ts b/src/panels/config/backup/dialogs/show-dialog-new-backup.ts new file mode 100644 index 0000000000..36a021c5c2 --- /dev/null +++ b/src/panels/config/backup/dialogs/show-dialog-new-backup.ts @@ -0,0 +1,40 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import type { BackupConfig } from "../../../../data/backup"; + +export type NewBackupType = "strategy" | "custom"; + +export interface NewBackupDialogParams { + config: BackupConfig; + submit?: (type: NewBackupType) => void; + cancel?: () => void; +} + +export const loadNewBackupDialog = () => import("./dialog-new-backup"); + +export const showNewBackupDialog = ( + element: HTMLElement, + params: NewBackupDialogParams +) => + new Promise((resolve) => { + const origCancel = params.cancel; + const origSubmit = params.submit; + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-new-backup", + dialogImport: loadNewBackupDialog, + dialogParams: { + ...params, + cancel: () => { + resolve(null); + if (origCancel) { + origCancel(); + } + }, + submit: (response) => { + resolve(response); + if (origSubmit) { + origSubmit(response); + } + }, + }, + }); + }); diff --git a/src/panels/config/backup/dialogs/show-dialog-restore-backup-encryption-key.ts b/src/panels/config/backup/dialogs/show-dialog-restore-backup-encryption-key.ts new file mode 100644 index 0000000000..bf824e0cb6 --- /dev/null +++ b/src/panels/config/backup/dialogs/show-dialog-restore-backup-encryption-key.ts @@ -0,0 +1,37 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; + +export interface RestoreBackupEncryptionKeyDialogParams { + submit?: (value: string) => void; + cancel?: () => void; +} + +export const loadRestoreBackupEncryptionKeyDialog = () => + import("./dialog-restore-backup-encryption-key"); + +export const showRestoreBackupEncryptionKeyDialog = ( + element: HTMLElement, + params: RestoreBackupEncryptionKeyDialogParams +) => + new Promise((resolve) => { + const origCancel = params.cancel; + const origSubmit = params.submit; + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-restore-backup-encryption-key", + dialogImport: loadRestoreBackupEncryptionKeyDialog, + dialogParams: { + ...params, + cancel: () => { + resolve(null); + if (origCancel) { + origCancel(); + } + }, + submit: (response) => { + resolve(response); + if (origSubmit) { + origSubmit(response); + } + }, + }, + }); + }); diff --git a/src/panels/config/backup/dialogs/show-dialog-set-backup-encryption-key.ts b/src/panels/config/backup/dialogs/show-dialog-set-backup-encryption-key.ts new file mode 100644 index 0000000000..9c8cead640 --- /dev/null +++ b/src/panels/config/backup/dialogs/show-dialog-set-backup-encryption-key.ts @@ -0,0 +1,37 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; + +export interface SetBackupEncryptionKeyDialogParams { + submit?: (key: boolean) => void; + cancel?: () => void; + saveKey: (key: string) => any; +} + +const loadDialog = () => import("./dialog-set-backup-encryption-key"); + +export const showSetBackupEncryptionKeyDialog = ( + element: HTMLElement, + params?: SetBackupEncryptionKeyDialogParams +) => + new Promise((resolve) => { + const origCancel = params?.cancel; + const origSubmit = params?.submit; + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-set-backup-encryption-key", + dialogImport: loadDialog, + dialogParams: { + ...params, + cancel: () => { + resolve(false); + if (origCancel) { + origCancel(); + } + }, + submit: (value) => { + resolve(value); + if (origSubmit) { + origSubmit(value); + } + }, + }, + }); + }); diff --git a/src/panels/config/backup/dialogs/show-dialog-upload-backup.ts b/src/panels/config/backup/dialogs/show-dialog-upload-backup.ts new file mode 100644 index 0000000000..6482af7803 --- /dev/null +++ b/src/panels/config/backup/dialogs/show-dialog-upload-backup.ts @@ -0,0 +1,36 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; + +export interface UploadBackupDialogParams { + submit?: () => void; + cancel?: () => void; +} + +export const loadUploadBackupDialog = () => import("./dialog-upload-backup"); + +export const showUploadBackupDialog = ( + element: HTMLElement, + params: UploadBackupDialogParams +) => + new Promise((resolve) => { + const origCancel = params.cancel; + const origSubmit = params.submit; + fireEvent(element, "show-dialog", { + dialogTag: "ha-dialog-upload-backup", + dialogImport: loadUploadBackupDialog, + dialogParams: { + ...params, + cancel: () => { + resolve(null); + if (origCancel) { + origCancel(); + } + }, + submit: () => { + resolve(); + if (origSubmit) { + origSubmit(); + } + }, + }, + }); + }); diff --git a/src/panels/config/backup/ha-config-backup-dashboard.ts b/src/panels/config/backup/ha-config-backup-dashboard.ts new file mode 100644 index 0000000000..6eeb53f63b --- /dev/null +++ b/src/panels/config/backup/ha-config-backup-dashboard.ts @@ -0,0 +1,634 @@ +import { + mdiDatabase, + mdiDelete, + mdiDotsVertical, + mdiDownload, + mdiPlus, + mdiUpload, +} from "@mdi/js"; +import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { relativeTime } from "../../../common/datetime/relative_time"; +import type { HASSDomEvent } from "../../../common/dom/fire_event"; +import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; +import { navigate } from "../../../common/navigate"; +import type { LocalizeFunc } from "../../../common/translations/localize"; +import type { + DataTableColumnContainer, + DataTableRowData, + RowClickedEvent, + SelectionChangedEvent, +} from "../../../components/data-table/ha-data-table"; +import "../../../components/ha-button"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-card"; +import "../../../components/ha-fab"; +import "../../../components/ha-icon"; +import "../../../components/ha-icon-next"; +import "../../../components/ha-icon-overflow-menu"; +import "../../../components/ha-list-item"; +import "../../../components/ha-svg-icon"; +import { getSignedPath } from "../../../data/auth"; +import type { BackupConfig, BackupContent } from "../../../data/backup"; +import { + computeBackupAgentName, + deleteBackup, + fetchBackupConfig, + fetchBackupInfo, + generateBackup, + generateBackupWithStrategySettings, + getBackupDownloadUrl, + getPreferredAgentForDownload, + isLocalAgent, +} from "../../../data/backup"; +import type { ManagerStateEvent } from "../../../data/backup_manager"; +import { + DEFAULT_MANAGER_STATE, + subscribeBackupEvents, +} from "../../../data/backup_manager"; +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 { bytesToString } from "../../../util/bytes-to-string"; +import { fileDownload } from "../../../util/file_download"; +import { showToast } from "../../../util/toast"; +import "./components/ha-backup-summary-card"; +import "./components/ha-backup-summary-progress"; +import "./components/ha-backup-summary-status"; +import { showBackupOnboardingDialog } from "./dialogs/show-dialog-backup_onboarding"; +import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup"; +import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup"; +import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup"; +import { computeDomain } from "../../../common/entity/compute_domain"; + +@customElement("ha-config-backup-dashboard") +class HaConfigBackupDashboard extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: false }) public route!: Route; + + @state() private _manager: ManagerStateEvent = DEFAULT_MANAGER_STATE; + + @state() private _backups: BackupContent[] = []; + + @state() private _fetching = false; + + @state() private _selected: string[] = []; + + @state() private _config?: BackupConfig; + + private _subscribed?: Promise<() => void>; + + @query("hass-tabs-subpage-data-table", true) + private _dataTable!: HaTabsSubpageDataTable; + + private _columns = memoizeOne( + (localize: LocalizeFunc): DataTableColumnContainer => ({ + name: { + title: localize("ui.panel.config.backup.name"), + main: true, + sortable: true, + filterable: true, + flex: 2, + template: (backup) => backup.name, + }, + size: { + title: localize("ui.panel.config.backup.size"), + filterable: true, + sortable: true, + template: (backup) => bytesToString(backup.size), + }, + date: { + title: localize("ui.panel.config.backup.created"), + direction: "desc", + filterable: true, + sortable: true, + template: (backup) => + relativeTime(new Date(backup.date), this.hass.locale), + }, + with_strategy_settings: { + title: "Type", + filterable: true, + sortable: true, + template: (backup) => + backup.with_strategy_settings ? "Strategy" : "Custom", + }, + locations: { + title: "Locations", + template: (backup) => html` +
+ ${(backup.agent_ids || []).map((agentId) => { + const name = computeBackupAgentName( + this.hass.localize, + agentId, + backup.agent_ids + ); + if (isLocalAgent(agentId)) { + return html` + + `; + } + const domain = computeDomain(agentId); + return html` + ${name} + `; + })} +
+ `, + }, + actions: { + title: "", + label: localize("ui.panel.config.generic.headers.actions"), + showNarrow: true, + moveable: false, + hideable: false, + type: "overflow-menu", + template: (backup) => html` + this._downloadBackup(backup), + }, + { + label: this.hass.localize("ui.common.delete"), + path: mdiDelete, + action: () => this._deleteBackup(backup), + warning: true, + }, + ]} + > + + `, + }, + }) + ); + + private _handleSelectionChanged( + ev: HASSDomEvent + ): void { + this._selected = ev.detail.value; + } + + protected render(): TemplateResult { + const backupInProgress = + "state" in this._manager && this._manager.state === "in_progress"; + + const data: DataTableRowData[] = this._backups; + + return html` + +
+ ${this._fetching + ? html` + + + Configure + + + ` + : backupInProgress + ? html` + + + Configure + + + ` + : this._needsOnboarding + ? html` + + + Setup backup strategy + + + ` + : html` + + + Configure + + + `} +
+ +
+ + + + + Upload backup + + +
+ + ${this._selected.length + ? html`
+

+ ${this._selected.length} backups selected +

+
+ ${!this.narrow + ? html` + + Delete selected + + ` + : html` + + + Delete selected + + `} +
+
` + : nothing} + + + + +
+ `; + } + + private _unsubscribeEvents() { + if (this._subscribed) { + this._subscribed.then((unsub) => unsub()); + this._subscribed = undefined; + } + } + + private async _subscribeEvents() { + this._unsubscribeEvents(); + if (!this.isConnected) { + return; + } + + this._subscribed = subscribeBackupEvents(this.hass!, (event) => { + this._manager = event; + if ("state" in event) { + if (event.state === "completed" || event.state === "failed") { + this._fetchBackupInfo(); + } + if (event.state === "failed") { + let message = ""; + switch (this._manager.manager_state) { + case "create_backup": + message = "Failed to create backup"; + break; + case "restore_backup": + message = "Failed to restore backup"; + break; + case "receive_backup": + message = "Failed to upload backup"; + break; + } + if (message) { + showToast(this, { message }); + } + } + } + }); + } + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this._fetching = true; + this._fetchBackupInfo().then(() => { + this._fetching = false; + }); + this._subscribeEvents(); + this._fetchBackupConfig(); + } + + public connectedCallback() { + super.connectedCallback(); + if (this.hasUpdated) { + this._fetchBackupInfo(); + this._subscribeEvents(); + } + } + + public disconnectedCallback(): void { + super.disconnectedCallback(); + this._unsubscribeEvents(); + } + + private async _fetchBackupInfo() { + const info = await fetchBackupInfo(this.hass); + this._backups = info.backups; + } + + private async _fetchBackupConfig() { + const { config } = await fetchBackupConfig(this.hass); + this._config = config; + } + + private get _needsOnboarding() { + return this._config && !this._config.create_backup.password; + } + + private async _uploadBackup(ev) { + if (!shouldHandleRequestSelectedEvent(ev)) { + return; + } + + await showUploadBackupDialog(this, {}); + } + + private async _newBackup(): Promise { + if (this._needsOnboarding) { + const success = await showBackupOnboardingDialog(this, {}); + if (!success) { + return; + } + } + + await this._fetchBackupConfig(); + + const config = this._config!; + + const type = await showNewBackupDialog(this, { config }); + + if (!type) { + return; + } + + if (type === "custom") { + const params = await showGenerateBackupDialog(this, {}); + + if (!params) { + return; + } + + if (!isComponentLoaded(this.hass, "hassio")) { + delete params.include_folders; + delete params.include_all_addons; + delete params.include_addons; + } + + await generateBackup(this.hass, params); + await this._fetchBackupInfo(); + return; + } + if (type === "strategy") { + await generateBackupWithStrategySettings(this.hass); + await this._fetchBackupInfo(); + } + } + + private _showBackupDetails(ev: CustomEvent): void { + const id = (ev.detail as RowClickedEvent).id; + navigate(`/config/backup/details/${id}`); + } + + private async _downloadBackup(backup: BackupContent): Promise { + const preferedAgent = getPreferredAgentForDownload(backup!.agent_ids!); + const signedUrl = await getSignedPath( + this.hass, + getBackupDownloadUrl(backup.backup_id, preferedAgent) + ); + fileDownload(signedUrl.path); + } + + private async _deleteBackup(backup: BackupContent): Promise { + const confirm = await showConfirmationDialog(this, { + title: "Delete backup", + text: "This backup will be permanently deleted.", + confirmText: this.hass.localize("ui.common.delete"), + destructive: true, + }); + + if (!confirm) { + return; + } + + await deleteBackup(this.hass, backup.backup_id); + this._fetchBackupInfo(); + } + + private async _deleteSelected() { + const confirm = await showConfirmationDialog(this, { + title: "Delete selected backups", + text: "These backups will be permanently deleted.", + confirmText: this.hass.localize("ui.common.delete"), + destructive: true, + }); + + if (!confirm) { + return; + } + + try { + await Promise.all( + this._selected.map((slug) => deleteBackup(this.hass, slug)) + ); + } catch (err: any) { + showAlertDialog(this, { + title: "Failed to delete backups", + text: extractApiErrorMessage(err), + }); + return; + } + await this._fetchBackupInfo(); + this._dataTable.clearSelection(); + } + + private _configureBackupStrategy() { + navigate("/config/backup/strategy"); + } + + private async _setupBackupStrategy() { + const success = await showBackupOnboardingDialog(this, {}); + if (!success) { + return; + } + + await this._fetchBackupConfig(); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .header { + padding: 16px 16px 0 16px; + display: flex; + flex-direction: row; + gap: 16px; + background-color: var(--primary-background-color); + } + @media (max-width: 1000px) { + .header { + flex-direction: column; + } + } + .header > * { + flex: 1; + min-width: 0; + } + + ha-fab[disabled] { + --mdc-theme-secondary: var(--disabled-text-color) !important; + } + + .table-header { + display: flex; + justify-content: space-between; + align-items: center; + height: var(--header-height); + box-sizing: border-box; + } + .header-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--secondary-text-color); + position: relative; + top: -4px; + } + .selected-txt { + font-weight: bold; + padding-left: 16px; + padding-inline-start: 16px; + padding-inline-end: initial; + color: var(--primary-text-color); + } + .table-header .selected-txt { + margin-top: 20px; + } + .header-toolbar .selected-txt { + font-size: 16px; + } + .header-toolbar .header-btns { + margin-right: -12px; + margin-inline-end: -12px; + margin-inline-start: initial; + } + .header-btns > ha-button, + .header-btns > ha-icon-button { + margin: 8px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-backup-dashboard": HaConfigBackupDashboard; + } +} diff --git a/src/panels/config/backup/ha-config-backup-details.ts b/src/panels/config/backup/ha-config-backup-details.ts new file mode 100644 index 0000000000..6c65925bb3 --- /dev/null +++ b/src/panels/config/backup/ha-config-backup-details.ts @@ -0,0 +1,363 @@ +import type { ActionDetail } from "@material/mwc-list"; +import { mdiDatabase, mdiDelete, mdiDotsVertical, mdiDownload } from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { formatDateTime } from "../../../common/datetime/format_date_time"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { navigate } from "../../../common/navigate"; +import "../../../components/ha-alert"; +import "../../../components/ha-button"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-card"; +import "../../../components/ha-circular-progress"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-list-item"; +import "../../../components/ha-md-list"; +import "../../../components/ha-md-list-item"; +import { getSignedPath } from "../../../data/auth"; +import type { BackupContentExtended } from "../../../data/backup"; +import { + computeBackupAgentName, + deleteBackup, + fetchBackupDetails, + getBackupDownloadUrl, + getPreferredAgentForDownload, + isLocalAgent, + restoreBackup, +} from "../../../data/backup"; +import type { HassioAddonInfo } from "../../../data/hassio/addon"; +import "../../../layouts/hass-subpage"; +import type { HomeAssistant } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; +import { bytesToString } from "../../../util/bytes-to-string"; +import { fileDownload } from "../../../util/file_download"; +import { showConfirmationDialog } from "../../lovelace/custom-card-helpers"; +import "./components/ha-backup-data-picker"; +import { showRestoreBackupEncryptionKeyDialog } from "./dialogs/show-dialog-restore-backup-encryption-key"; + +@customElement("ha-config-backup-details") +class HaConfigBackupDetails extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: "backup-id" }) public backupId!: string; + + @state() private _backup?: BackupContentExtended | null; + + @state() private _error?: string; + + @state() private _selectedBackup?: BackupContentExtended; + + @state() private _addonsInfo?: HassioAddonInfo[]; + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + + if (this.backupId) { + this._fetchBackup(); + } else { + this._error = "Backup id not defined"; + } + } + + protected render() { + if (!this.hass) { + return nothing; + } + + return html` + + + + + + ${this.hass.localize("ui.common.download")} + + + + ${this.hass.localize("ui.common.delete")} + + +
+ ${this._error && + html`${this._error}`} + ${this._backup === null + ? html` + Backup matching ${this.backupId} not found + ` + : !this._backup + ? html`` + : html` + +
+ + +
+
+ + Restore + +
+
+ +
+ + + + ${bytesToString(this._backup.size)} + + Size + + + ${formatDateTime( + new Date(this._backup.date), + this.hass.locale, + this.hass.config + )} + Created + + +
+
+ +
+ + ${this._backup.agent_ids?.map((agentId) => { + const domain = computeDomain(agentId); + const name = computeBackupAgentName( + this.hass.localize, + agentId, + this._backup!.agent_ids! + ); + + return html` + + ${isLocalAgent(agentId) + ? html` + + + ` + : html` + + `} +
${name}
+ + + + + Download from this location + + +
+ `; + })} +
+
+
+ `} +
+
+ `; + } + + private _selectedBackupChanged(ev: CustomEvent) { + ev.stopPropagation(); + this._selectedBackup = ev.detail.value; + } + + private _isRestoreDisabled() { + return ( + !this._selectedBackup || + !( + this._selectedBackup?.database_included || + this._selectedBackup?.homeassistant_included || + this._selectedBackup.addons.length || + this._selectedBackup.folders.length + ) + ); + } + + private async _restore() { + let password: string | undefined; + if (this._backup?.protected) { + const response = await showRestoreBackupEncryptionKeyDialog(this, {}); + if (!response) { + return; + } + password = response; + } else { + const response = await showConfirmationDialog(this, { + title: "Restore backup", + text: "The backup will be restored to your instance.", + confirmText: "Restore", + dismissText: "Cancel", + destructive: true, + }); + if (!response) { + return; + } + } + + const preferedAgent = getPreferredAgentForDownload( + this._backup!.agent_ids! + ); + + const { addons, database_included, homeassistant_included, folders } = + this._selectedBackup!; + + await restoreBackup(this.hass, { + backup_id: this._backup!.backup_id, + agent_id: preferedAgent, + password: password, + restore_addons: addons.map((addon) => addon.slug), + restore_database: database_included, + restore_folders: folders, + restore_homeassistant: homeassistant_included, + }); + } + + private async _fetchBackup() { + try { + const response = await fetchBackupDetails(this.hass, this.backupId); + this._backup = response.backup; + } catch (err: any) { + this._error = err?.message || "Could not fetch backup details"; + } + } + + private _handleAction(ev: CustomEvent) { + switch (ev.detail.index) { + case 0: + this._downloadBackup(); + break; + case 1: + this._deleteBackup(); + break; + } + } + + private _handleAgentAction(ev: CustomEvent) { + const button = ev.currentTarget; + const agentId = (button as any).agent; + this._downloadBackup(agentId); + } + + private async _downloadBackup(agentId?: string): Promise { + const preferedAgent = + agentId ?? getPreferredAgentForDownload(this._backup!.agent_ids!); + const signedUrl = await getSignedPath( + this.hass, + getBackupDownloadUrl(this._backup!.backup_id, preferedAgent) + ); + fileDownload(signedUrl.path); + } + + private async _deleteBackup(): Promise { + const confirm = await showConfirmationDialog(this, { + title: "Delete backup", + text: "This backup will be permanently deleted.", + confirmText: this.hass.localize("ui.common.delete"), + destructive: true, + }); + + if (!confirm) { + return; + } + + await deleteBackup(this.hass, this._backup!.backup_id); + navigate("/config/backup"); + } + + static styles = css` + .content { + padding: 28px 20px 0; + max-width: 690px; + margin: 0 auto; + gap: 24px; + display: grid; + } + .card-content { + padding: 0 20px 8px 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; + } + ha-md-list-item img { + width: 48px; + } + ha-md-list-item ha-svg-icon[slot="start"] { + --mdc-icon-size: 48px; + color: var(--primary-text-color); + } + .warning { + color: var(--error-color); + } + .warning ha-svg-icon { + color: var(--error-color); + } + ha-backup-data-picker { + display: block; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-backup-details": HaConfigBackupDetails; + } +} diff --git a/src/panels/config/backup/ha-config-backup-locations.ts b/src/panels/config/backup/ha-config-backup-locations.ts new file mode 100644 index 0000000000..80312f30c2 --- /dev/null +++ b/src/panels/config/backup/ha-config-backup-locations.ts @@ -0,0 +1,138 @@ +import type { TemplateResult } from "lit"; +import { css, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import "../../../components/ha-card"; +import "../../../components/ha-icon-next"; +import "../../../components/ha-md-list"; +import "../../../components/ha-md-list-item"; +import type { BackupAgent } from "../../../data/backup"; +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 { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @state() private _agents: BackupAgent[] = []; + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + this._fetchAgents(); + } + + protected render(): TemplateResult { + return html` + +
+
+

Locations

+

+ To keep your data safe it is recommended your backups is at least + on two different locations and one of them is off-site. +

+
+ +
+ ${this._agents.length > 0 + ? html` + + ${this._agents.map((agent) => { + const [domain, name] = agent.agent_id.split("."); + const domainName = domainToName( + this.hass.localize, + domain + ); + return html` + + +
${domainName}: ${name}
+ +
+ `; + })} +
+ ` + : html`

No sync agents configured

`} +
+
+
+
+ `; + } + + private async _fetchAgents() { + const data = await fetchBackupAgentsInfo(this.hass); + this._agents = data.agents; + } + + static styles = css` + .content { + padding: 28px 20px 0; + max-width: 690px; + margin: 0 auto; + gap: 24px; + display: flex; + flex-direction: column; + } + + .header .title { + font-size: 22px; + font-style: normal; + font-weight: 400; + line-height: 28px; + color: var(--primary-text-color); + margin: 0; + margin-bottom: 8px; + } + + .header .description { + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.25px; + color: var(--secondary-text-color); + margin: 0; + } + + ha-md-list { + background: none; + } + ha-md-list-item img { + width: 48px; + } + .card-content { + padding: 0; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-backup-locations": HaConfigBackupLocations; + } +} diff --git a/src/panels/config/backup/ha-config-backup-strategy.ts b/src/panels/config/backup/ha-config-backup-strategy.ts new file mode 100644 index 0000000000..459d7798a1 --- /dev/null +++ b/src/panels/config/backup/ha-config-backup-strategy.ts @@ -0,0 +1,250 @@ +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { debounce } from "../../../common/util/debounce"; +import "../../../components/ha-button"; +import "../../../components/ha-card"; +import "../../../components/ha-icon-next"; +import "../../../components/ha-password-field"; +import "../../../components/ha-settings-row"; +import type { BackupConfig } from "../../../data/backup"; +import { + BackupScheduleState, + fetchBackupConfig, + updateBackupConfig, +} from "../../../data/backup"; +import "../../../layouts/hass-subpage"; +import type { HomeAssistant } from "../../../types"; +import "./components/ha-backup-config-agents"; +import "./components/ha-backup-config-data"; +import type { BackupConfigData } from "./components/ha-backup-config-data"; +import "./components/ha-backup-config-encryption-key"; +import "./components/ha-backup-config-schedule"; +import type { BackupConfigSchedule } from "./components/ha-backup-config-schedule"; + +const INITIAL_BACKUP_CONFIG: BackupConfig = { + create_backup: { + agent_ids: [], + include_folders: [], + include_database: true, + include_addons: [], + include_all_addons: true, + password: null, + name: null, + }, + retention: { + copies: 3, + days: null, + }, + schedule: { + state: BackupScheduleState.DAILY, + }, + last_attempted_strategy_backup: null, + last_completed_strategy_backup: null, +}; + +@customElement("ha-config-backup-strategy") +class HaConfigBackupStrategy extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @state() private _backupConfig: BackupConfig = INITIAL_BACKUP_CONFIG; + + protected willUpdate(changedProps) { + super.willUpdate(changedProps); + if (!this.hasUpdated) { + this._fetchData(); + } + } + + private async _fetchData() { + const { config } = await fetchBackupConfig(this.hass); + this._backupConfig = config; + } + + protected render() { + if (!this._backupConfig) { + return nothing; + } + + return html` + +
+ +
Automatic backups
+
+

+ Let Home Assistant take care of your backup strategy by creating + a scheduled backup that also removes older copies. +

+ +
+
+ +
Backup data
+
+ +
+
+ + +
Locations
+
+

+ Your backup will be stored on these locations when this default + backup is created. You can use all locations for custom backups. +

+ +
+
+ +
Encryption key
+
+

+ All your backups are encrypted to keep your data private and + secure. You need this key to restore a backup. It's important + that you don't lose this key, as no one else can restore your + data. +

+ +
+
+
+
+ `; + } + + private _scheduleConfigChanged(ev) { + const value = ev.detail.value as BackupConfigSchedule; + this._backupConfig = { + ...this._backupConfig, + schedule: value.schedule, + retention: value.retention, + }; + this._debounceSave(); + } + + private get _dataConfig(): BackupConfigData { + const { + include_addons, + include_all_addons, + include_database, + include_folders, + } = this._backupConfig.create_backup; + + return { + include_homeassistant: true, + include_database, + include_folders: include_folders || undefined, + include_all_addons, + include_addons: include_addons || undefined, + }; + } + + private _dataConfigChanged(ev) { + const data = ev.detail.value as BackupConfigData; + this._backupConfig = { + ...this._backupConfig, + create_backup: { + ...this._backupConfig.create_backup, + include_database: data.include_database, + include_folders: data.include_folders || null, + include_all_addons: data.include_all_addons, + include_addons: data.include_addons || null, + }, + }; + this._debounceSave(); + } + + private _agentsConfigChanged(ev) { + const agents = ev.detail.value as string[]; + this._backupConfig = { + ...this._backupConfig, + create_backup: { + ...this._backupConfig.create_backup, + agent_ids: agents, + }, + }; + this._debounceSave(); + } + + private _encryptionKeyChanged(ev) { + const password = ev.detail.value as string; + this._backupConfig = { + ...this._backupConfig, + create_backup: { + ...this._backupConfig.create_backup, + password: password, + }, + }; + this._debounceSave(); + } + + private _debounceSave = debounce(() => this._save(), 500); + + private async _save() { + await updateBackupConfig(this.hass, { + create_backup: { + agent_ids: this._backupConfig.create_backup.agent_ids, + include_folders: this._backupConfig.create_backup.include_folders ?? [], + include_database: this._backupConfig.create_backup.include_database, + include_addons: this._backupConfig.create_backup.include_addons ?? [], + include_all_addons: this._backupConfig.create_backup.include_all_addons, + password: this._backupConfig.create_backup.password, + }, + retention: this._backupConfig.retention, + schedule: this._backupConfig.schedule.state, + }); + } + + static styles = css` + .content { + padding: 28px 20px 0; + max-width: 690px; + margin: 0 auto; + gap: 24px; + display: flex; + flex-direction: column; + margin-bottom: 24px; + } + ha-settings-row { + --settings-row-prefix-display: flex; + padding: 0; + } + ha-settings-row > ha-svg-icon { + align-self: center; + margin-inline-end: 16px; + } + .alert { + --mdc-theme-primary: var(--error-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-backup-strategy": HaConfigBackupStrategy; + } +} diff --git a/src/panels/config/backup/ha-config-backup.ts b/src/panels/config/backup/ha-config-backup.ts index 0c3b4bf041..d86fd841fa 100644 --- a/src/panels/config/backup/ha-config-backup.ts +++ b/src/panels/config/backup/ha-config-backup.ts @@ -1,235 +1,49 @@ -import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; -import { mdiDelete, mdiDownload, mdiPlus } from "@mdi/js"; -import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; -import { LitElement, css, html } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import memoize from "memoize-one"; -import { relativeTime } from "../../../common/datetime/relative_time"; -import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table"; -import "../../../components/ha-circular-progress"; -import "../../../components/ha-fab"; -import "../../../components/ha-icon"; -import "../../../components/ha-icon-overflow-menu"; -import "../../../components/ha-svg-icon"; -import { getSignedPath } from "../../../data/auth"; -import type { BackupContent, BackupData } from "../../../data/backup"; -import { - fetchBackupInfo, - generateBackup, - getBackupDownloadUrl, - removeBackup, -} from "../../../data/backup"; -import { - showAlertDialog, - showConfirmationDialog, -} from "../../../dialogs/generic/show-dialog-box"; -import "../../../layouts/hass-loading-screen"; +import type { PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators"; +import type { RouterOptions } from "../../../layouts/hass-router-page"; +import { HassRouterPage } from "../../../layouts/hass-router-page"; import "../../../layouts/hass-tabs-subpage-data-table"; -import type { HomeAssistant, Route } from "../../../types"; -import type { LocalizeFunc } from "../../../common/translations/localize"; -import { fileDownload } from "../../../util/file_download"; +import type { HomeAssistant } from "../../../types"; +import "./ha-config-backup-dashboard"; @customElement("ha-config-backup") -class HaConfigBackup extends LitElement { +class HaConfigBackup extends HassRouterPage { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: "is-wide", type: Boolean }) public isWide = false; - @property({ type: Boolean }) public narrow = false; - @property({ attribute: false }) public route!: Route; - - @state() private _backupData?: BackupData; - - private _columns = memoize( - ( - narrow, - _language, - localize: LocalizeFunc - ): DataTableColumnContainer => ({ - name: { - title: localize("ui.panel.config.backup.name"), - main: true, - sortable: true, - filterable: true, - flex: 2, - template: narrow - ? undefined - : (backup) => - html`${backup.name} -
${backup.path}
`, + protected routerOptions: RouterOptions = { + defaultPage: "dashboard", + routes: { + dashboard: { + tag: "ha-config-backup-dashboard", + cache: true, }, - path: { - title: localize("ui.panel.config.backup.path"), - hidden: !narrow, + details: { + tag: "ha-config-backup-details", + load: () => import("./ha-config-backup-details"), }, - size: { - title: localize("ui.panel.config.backup.size"), - filterable: true, - sortable: true, - template: (backup) => Math.ceil(backup.size * 10) / 10 + " MB", + locations: { + tag: "ha-config-backup-locations", + load: () => import("./ha-config-backup-locations"), }, - date: { - title: localize("ui.panel.config.backup.created"), - direction: "desc", - filterable: true, - sortable: true, - template: (backup) => - relativeTime(new Date(backup.date), this.hass.locale), + strategy: { + tag: "ha-config-backup-strategy", + load: () => import("./ha-config-backup-strategy"), }, + }, + }; - actions: { - title: "", - type: "overflow-menu", - showNarrow: true, - hideable: false, - moveable: false, - template: (backup) => - html` this._downloadBackup(backup), - }, - // Delete button - { - path: mdiDelete, - label: this.hass.localize( - "ui.panel.config.backup.remove_backup" - ), - action: () => this._removeBackup(backup), - }, - ]} - style="color: var(--secondary-text-color)" - > - `, - }, - }) - ); + protected updatePageEl(pageEl, changedProps: PropertyValues) { + pageEl.hass = this.hass; + pageEl.route = this.routeTail; - private _getItems = memoize((backupItems: BackupContent[]) => - backupItems.map((backup) => ({ - name: backup.name, - slug: backup.slug, - date: backup.date, - size: backup.size, - path: backup.path, - })) - ); - - protected render(): TemplateResult { - if (!this.hass || this._backupData === undefined) { - return html``; + if ( + (!changedProps || changedProps.has("route")) && + this._currentPage === "details" + ) { + pageEl.backupId = this.routeTail.path.substr(1); } - - return html` - - - ${this._backupData.backing_up - ? html`` - : html``} - - - `; - } - - protected firstUpdated(changedProps: PropertyValues) { - super.firstUpdated(changedProps); - this._getBackups(); - } - - private async _getBackups(): Promise { - this._backupData = await fetchBackupInfo(this.hass); - } - - private async _downloadBackup(backup: BackupContent): Promise { - const signedUrl = await getSignedPath( - this.hass, - getBackupDownloadUrl(backup.slug) - ); - fileDownload(signedUrl.path); - } - - private async _generateBackup(): Promise { - 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; - } - - generateBackup(this.hass) - .then(() => this._getBackups()) - .catch((err) => showAlertDialog(this, { text: (err as Error).message })); - - await this._getBackups(); - } - - private async _removeBackup(backup: BackupContent): Promise { - const confirm = await showConfirmationDialog(this, { - title: this.hass.localize("ui.panel.config.backup.remove.title"), - text: this.hass.localize("ui.panel.config.backup.remove.description", { - name: backup.name, - }), - confirmText: this.hass.localize("ui.panel.config.backup.remove.confirm"), - }); - if (!confirm) { - return; - } - - await removeBackup(this.hass, backup.slug); - await this._getBackups(); - } - - static get styles(): CSSResultGroup { - return [ - css` - ha-fab[disabled] { - --mdc-theme-secondary: var(--disabled-text-color) !important; - } - `, - ]; } } diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index 391f894184..d88ef1b16a 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -321,14 +321,6 @@ export const configSections: { [name: string]: PageNavigation[] } = { iconPath: mdiBackupRestore, iconColor: "#0D47A1", component: "backup", - not_component: "hassio", - }, - { - path: "/hassio/backups", - translationKey: "backup", - iconPath: mdiBackupRestore, - iconColor: "#0D47A1", - component: "hassio", }, { path: "/config/analytics", diff --git a/src/util/file_download.ts b/src/util/file_download.ts index dc9ba6b54a..71d9a896df 100644 --- a/src/util/file_download.ts +++ b/src/util/file_download.ts @@ -2,14 +2,14 @@ import type { HomeAssistant } from "../types"; import { isIosApp } from "./is_ios"; export const fileDownload = (href: string, filename = ""): void => { - const a = document.createElement("a"); - a.target = "_blank"; - a.href = href; - a.download = filename; - - document.body.appendChild(a); - a.dispatchEvent(new MouseEvent("click")); - document.body.removeChild(a); + const element = document.createElement("a"); + element.target = "_blank"; + element.href = href; + element.download = filename; + element.style.display = "none"; + document.body.appendChild(element); + element.dispatchEvent(new MouseEvent("click")); + document.body.removeChild(element); }; export const downloadFileSupported = (hass: HomeAssistant): boolean =>