Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/data/backup.ts
Original file line number Diff line number Diff line change
@@ -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<BackupData> =>
hass.callWS({
type: "backup/info",
});

export const removeBackup = (
hass: HomeAssistant,
slug: string
): Promise<void> =>
hass.callWS({
type: "backup/remove",
slug,
});

export const generateBackup = (hass: HomeAssistant): Promise<BackupContent> =>
hass.callWS({
type: "backup/generate",
});
224 changes: 224 additions & 0 deletions src/panels/config/backup/ha-config-backup.ts
Original file line number Diff line number Diff line change
@@ -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}
<div class="secondary">${backup.path}</div>`,
},
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`<ha-icon-overflow-menu
.hass=${this.hass}
.narrow=${this.narrow}
.items=${[
// Download Button
{
path: mdiDownload,
label: this.hass.localize(
"ui.panel.config.backup.download_backup"
),
action: () => 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)"
>
</ha-icon-overflow-menu>`,
},
})
);

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`<hass-loading-screen></hass-loading-screen>`;
}

return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.backup}
.columns=${this._columns(this.narrow, this.hass.language)}
.data=${this._getItems(this._backupData.backups)}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_bakcups")}
>
<ha-fab
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we disable the fab and change the text while a backup is being generated? Otherwise the user will not know a backup is in progress.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, a user should always be able to click and fail even if we know it will never work.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not disable plus change text that were making a backup ?

slot="fab"
?disabled=${this._backupData.backing_up}
.label=${this._backupData.backing_up
? this.hass.localize("ui.panel.config.backup.creating_backup")
: this.hass.localize("ui.panel.config.backup.create_backup")}
extended
@click=${this._generateBackup}
>
${this._backupData.backing_up
? html`<ha-circular-progress
slot="icon"
active
></ha-circular-progress>`
: html`<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>`}
</ha-fab>
</hass-tabs-subpage-data-table>
`;
}

protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._getBackups();
}

private async _getBackups(): Promise<void> {
this._backupData = await fetchBackupInfo(this.hass);
}

private async _downloadBackup(backup: BackupContent): Promise<void> {
const signedUrl = await getSignedPath(
this.hass,
getBackupDownloadUrl(backup.slug)
);
fileDownload(signedUrl.path);
}

private async _generateBackup(): Promise<void> {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.backup.create.title"),
text: this.hass.localize("ui.panel.config.backup.create.description"),
confirmText: this.hass.localize("ui.panel.config.backup.create.confirm"),
});
if (!confirm) {
return;
}

generateBackup(this.hass)
.then(() => this._getBackups())
.catch((err) => showAlertDialog(this, { text: (err as Error).message }));

await this._getBackups();
}

private async _removeBackup(backup: BackupContent): Promise<void> {
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;
}
}
21 changes: 21 additions & 0 deletions src/panels/config/ha-panel-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
mdiAccount,
mdiBackupRestore,
mdiBadgeAccountHorizontal,
mdiCellphoneCog,
mdiCog,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand Down
25 changes: 25 additions & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,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"
Expand Down Expand Up @@ -1158,6 +1162,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",
Expand Down