diff --git a/src/data/backup.ts b/src/data/backup.ts new file mode 100644 index 0000000000..129b76726d --- /dev/null +++ b/src/data/backup.ts @@ -0,0 +1,36 @@ +import { HomeAssistant } from "../types"; + +export interface BackupContent { + slug: string; + date: string; + name: string; + size: number; + path: string; +} + +export interface BackupData { + backing_up: boolean; + backups: BackupContent[]; +} + +export const getBackupDownloadUrl = (slug: string) => + `/api/backup/download/${slug}`; + +export const fetchBackupInfo = (hass: HomeAssistant): Promise => + hass.callWS({ + type: "backup/info", + }); + +export const removeBackup = ( + hass: HomeAssistant, + slug: string +): Promise => + hass.callWS({ + type: "backup/remove", + slug, + }); + +export const generateBackup = (hass: HomeAssistant): Promise => + hass.callWS({ + type: "backup/generate", + }); diff --git a/src/panels/config/backup/ha-config-backup.ts b/src/panels/config/backup/ha-config-backup.ts new file mode 100644 index 0000000000..514f9f23e3 --- /dev/null +++ b/src/panels/config/backup/ha-config-backup.ts @@ -0,0 +1,224 @@ +import { mdiDelete, mdiDownload, mdiPlus } from "@mdi/js"; +import "@polymer/paper-tooltip/paper-tooltip"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoize from "memoize-one"; +import { relativeTime } from "../../../common/datetime/relative_time"; +import { 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 { + BackupContent, + BackupData, + fetchBackupInfo, + generateBackup, + getBackupDownloadUrl, + removeBackup, +} from "../../../data/backup"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-loading-screen"; +import "../../../layouts/hass-tabs-subpage-data-table"; +import { HomeAssistant, Route } from "../../../types"; +import { fileDownload } from "../../../util/file_download"; +import { configSections } from "../ha-panel-config"; + +@customElement("ha-config-backup") +class HaConfigBackup extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public isWide!: boolean; + + @property({ type: Boolean }) public narrow!: boolean; + + @property({ attribute: false }) public route!: Route; + + @state() private _backupData?: BackupData; + + private _columns = memoize( + (narrow, _language): DataTableColumnContainer => ({ + name: { + title: this.hass.localize("ui.panel.config.backup.name"), + sortable: true, + filterable: true, + grows: true, + template: (entry: string, backup: BackupContent) => + html`${entry} +
${backup.path}
`, + }, + size: { + title: this.hass.localize("ui.panel.config.backup.size"), + width: "15%", + hidden: narrow, + filterable: true, + sortable: true, + template: (entry: number) => Math.ceil(entry * 10) / 10 + " MB", + }, + date: { + title: this.hass.localize("ui.panel.config.backup.created"), + width: "15%", + direction: "desc", + hidden: narrow, + filterable: true, + sortable: true, + template: (entry: string) => + relativeTime(new Date(entry), this.hass.locale), + }, + + actions: { + title: "", + width: "15%", + template: (_: string, backup: BackupContent) => + 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)" + > + `, + }, + }) + ); + + 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``; + } + + 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; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-backup": HaConfigBackup; + } +} diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index 0ca0544fe7..7483960acd 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -1,5 +1,6 @@ import { mdiAccount, + mdiBackupRestore, mdiBadgeAccountHorizontal, mdiCellphoneCog, mdiCog, @@ -63,6 +64,13 @@ export const configSections: { [name: string]: PageNavigation[] } = { iconColor: "#64B5F6", component: "blueprint", }, + { + path: "/config/backup", + translationKey: "backup", + iconPath: mdiBackupRestore, + iconColor: "#4084CD", + component: "backup", + }, { path: "/hassio", translationKey: "supervisor", @@ -105,6 +113,15 @@ export const configSections: { [name: string]: PageNavigation[] } = { core: true, }, ], + backup: [ + { + path: "/config/backup", + translationKey: "ui.panel.config.backup.caption", + iconPath: mdiBackupRestore, + iconColor: "#4084CD", + component: "backup", + }, + ], devices: [ { component: "integrations", @@ -287,6 +304,10 @@ class HaPanelConfig extends HassRouterPage { tag: "ha-config-automation", load: () => import("./automation/ha-config-automation"), }, + backup: { + tag: "ha-config-backup", + load: () => import("./backup/ha-config-backup"), + }, blueprint: { tag: "ha-config-blueprint", load: () => import("./blueprint/ha-config-blueprint"), diff --git a/src/translations/en.json b/src/translations/en.json index 051fa237c0..2a3869adbd 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1070,6 +1070,10 @@ "title": "Automations & Scenes", "description": "Manage automations, scenes, scripts and helpers" }, + "backup": { + "title": "Backup", + "description": "Generate backups of your Home Assistant configuration" + }, "blueprints": { "title": "Blueprints", "description": "Pre-made automations and scripts by the community" @@ -1160,6 +1164,27 @@ "confirmation_text": "All devices in this area will become unassigned." } }, + "backup": { + "caption": "[%key:ui::panel::config::dashboard::backup::title%]", + "create_backup": "[%key:supervisor::backup::create_backup%]", + "creating_backup": "Backup is currently being created", + "download_backup": "[%key:supervisor::backup::download_backup%]", + "remove_backup": "[%key:supervisor::backup::delete_backup_title%]", + "name": "[%key:supervisor::backup::name%]", + "size": "[%key:supervisor::backup::size%]", + "created": "[%key:supervisor::backup::created%]", + "no_backups": "[%key:supervisor::backup::no_backups%]", + "create": { + "title": "Create backup", + "description": "Create a backup of your current configuration directory, this will take some time.", + "confirm": "create" + }, + "remove": { + "title": "Remove backup", + "description": "Are you sure you want to remove the backup with the name {name}?", + "confirm": "[%key:ui::common::remove%]" + } + }, "tag": { "caption": "Tags", "description": "Trigger automations when an NFC tag, QR code, etc. is scanned",