Add overview summary for backups (#23343)

This commit is contained in:
Paul Bottein 2024-12-19 19:54:50 +01:00 committed by GitHub
parent b693fd1edc
commit 92b02e39c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 537 additions and 410 deletions

View File

@ -1,5 +1,5 @@
import {
mdiAlertCircleCheckOutline,
mdiAlertCircleOutline,
mdiAlertOutline,
mdiCheck,
mdiInformationOutline,
@ -16,7 +16,7 @@ type SummaryStatus = "success" | "error" | "info" | "warning" | "loading";
const ICONS: Record<SummaryStatus, string> = {
success: mdiCheck,
error: mdiAlertCircleCheckOutline,
error: mdiAlertCircleOutline,
warning: mdiAlertOutline,
info: mdiInformationOutline,
loading: mdiSync,
@ -60,6 +60,9 @@ class HaBackupSummaryCard extends LitElement {
`
: nothing}
</div>
<div class="content">
<slot></slot>
</div>
</ha-card>
`;
}
@ -71,7 +74,7 @@ class HaBackupSummaryCard extends LitElement {
column-gap: 16px;
row-gap: 8px;
align-items: center;
padding: 20px;
padding: 16px;
width: 100%;
box-sizing: border-box;
}

View File

@ -1,3 +1,4 @@
import { mdiCalendarSync, mdiGestureTap } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
@ -62,6 +63,7 @@ class HaBackupOverviewBackups extends LitElement {
<div class="card-content">
<ha-md-list>
<ha-md-list-item type="link" href="/config/backup/backups">
<ha-svg-icon slot="start" .path=${mdiCalendarSync}></ha-svg-icon>
<div slot="headline">
${automaticStats.count} automatic backups
</div>
@ -71,6 +73,7 @@ class HaBackupOverviewBackups extends LitElement {
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item type="link" href="/config/backup/backups">
<ha-svg-icon slot="start" .path=${mdiGestureTap}></ha-svg-icon>
<div slot="headline">${manualStats.count} manual backups</div>
<div slot="supporting-text">
${bytesToString(manualStats.size, 1)} in total

View File

@ -0,0 +1,104 @@
import { mdiInformationOutline } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-svg-icon";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
declare global {
// for fire event
interface HASSDomEvents {
"button-click": undefined;
}
}
@customElement("ha-backup-overview-onboarding")
class HaBackupOverviewBackups extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
private async _setup() {
fireEvent(this, "button-click");
}
render() {
return html`
<ha-card>
<div class="card-header">
<div class="icon">
<ha-svg-icon .path=${mdiInformationOutline}></ha-svg-icon>
</div>
Set up automatic backups
</div>
<div class="card-content">
<p>
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.
</p>
</div>
<div class="card-actions">
<ha-button @click=${this._setup}>
Set up automatic backups
</ha-button>
</div>
</ha-card>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.card-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
.icon {
position: relative;
border-radius: 20px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--primary-color);
opacity: 0.2;
}
.icon ha-svg-icon {
color: var(--primary-color);
width: 24px;
height: 24px;
}
p {
margin: 0;
}
.card-actions {
display: flex;
justify-content: flex-end;
border-top: none;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-overview-onboarding": HaBackupOverviewBackups;
}
}

View File

@ -1,18 +1,15 @@
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";
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 {
@customElement("ha-backup-overview-progress")
export class HaBackupOverviewProgress 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":
@ -93,9 +90,7 @@ export class HaBackupSummaryProgress extends LitElement {
.heading=${this._heading}
.description=${this._description}
status="loading"
.hasAction=${this.hasAction}
>
<slot name="action" slot="action"></slot>
</ha-backup-summary-card>
`;
}
@ -103,6 +98,6 @@ export class HaBackupSummaryProgress extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-backup-summary-progress": HaBackupSummaryProgress;
"ha-backup-overview-progress": HaBackupOverviewProgress;
}
}

View File

@ -0,0 +1,195 @@
import { mdiBackupRestore, mdiCalendar } from "@mdi/js";
import { differenceInDays, setHours, setMinutes } from "date-fns";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { formatTime } from "../../../../../common/datetime/format_time";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import "../../../../../components/ha-button";
import "../../../../../components/ha-card";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { BackupConfig, BackupContent } from "../../../../../data/backup";
import { BackupScheduleState } from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import "../ha-backup-summary-card";
@customElement("ha-backup-overview-summary")
class HaBackupOverviewBackups extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public backups: BackupContent[] = [];
@property({ attribute: false }) public config!: BackupConfig;
private _lastBackup = memoizeOne((backups: BackupContent[]) => {
const sortedBackups = backups
.filter((backup) => backup.with_automatic_settings)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return sortedBackups[0] as BackupContent | undefined;
});
private _nextBackupDescription(schedule: BackupScheduleState) {
const newDate = setMinutes(setHours(new Date(), 4), 45);
const time = formatTime(newDate, this.hass.locale, this.hass.config);
switch (schedule) {
case BackupScheduleState.DAILY:
return `Next automatic backup tomorrow at ${time}`;
case BackupScheduleState.MONDAY:
return `Next automatic backup next Monday at ${time}`;
case BackupScheduleState.TUESDAY:
return `Next automatic backup next Thuesday at ${time}`;
case BackupScheduleState.WEDNESDAY:
return `Next automatic backup next Wednesday at ${time}`;
case BackupScheduleState.THURSDAY:
return `Next automatic backup next Thursday at ${time}`;
case BackupScheduleState.FRIDAY:
return `Next automatic backup next Friday at ${time}`;
case BackupScheduleState.SATURDAY:
return `Next automatic backup next Saturday at ${time}`;
case BackupScheduleState.SUNDAY:
return `Next automatic backup next Sunday at ${time}`;
default:
return "No automatic backup scheduled";
}
}
protected render() {
const lastBackup = this._lastBackup(this.backups);
if (!lastBackup) {
return html`
<ha-backup-summary-card
heading="No automatic backup available"
description="You have no automatic backups yet."
status="warning"
>
</ha-backup-summary-card>
`;
}
const lastBackupDate = new Date(lastBackup.date);
const numberOfDays = differenceInDays(new Date(), lastBackupDate);
const now = new Date();
const lastBackupDescription = `Last successful backup ${relativeTime(lastBackupDate, this.hass.locale, now, true)} and synced to ${lastBackup.agent_ids?.length} locations.`;
const nextBackupDescription = this._nextBackupDescription(
this.config.schedule.state
);
const lastAttempt = this.config.last_attempted_automatic_backup
? new Date(this.config.last_attempted_automatic_backup)
: undefined;
if (lastAttempt && lastAttempt > lastBackupDate) {
const lastAttemptDescription = `The last automatic backup trigged ${relativeTime(lastAttempt, this.hass.locale, now, true)} wasn't successful.`;
return html`
<ha-backup-summary-card
heading=${`Last automatic backup failed`}
status="error"
>
<ul class="list">
<li class="item">
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span>${lastAttemptDescription}</span>
</li>
<li class="item">
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span>${lastBackupDescription}</span>
</li>
</ul>
</ha-backup-summary-card>
`;
}
if (numberOfDays > 0) {
return html`
<ha-backup-summary-card
heading=${`No backup for ${numberOfDays} days`}
status="warning"
>
<ul class="list">
<li class="item">
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span>${lastBackupDescription}</span>
</li>
<li class="item">
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span>${nextBackupDescription}</span>
</li>
</ul>
</ha-backup-summary-card>
`;
}
return html`
<ha-backup-summary-card heading=${`Backed up`} status="success">
<ul class="list">
<li class="item">
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span>${lastBackupDescription}</span>
</li>
<li class="item">
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span>${nextBackupDescription}</span>
</li>
</ul>
</ha-backup-summary-card>
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.card-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
p {
margin: 0;
}
.list {
display: flex;
flex-direction: column;
gap: 16px;
padding: 8px 24px 24px 24px;
margin: 0;
}
.item {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
color: var(--secondary-text-color);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.25px;
}
ha-svg-icon {
flex: none;
}
.card-actions {
display: flex;
justify-content: flex-end;
border-top: none;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-overview-summary": HaBackupOverviewBackups;
}
}

View File

@ -82,6 +82,8 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
@state() private _step?: Step;
@state() private _steps: Step[] = [];
@state() private _params?: BackupOnboardingDialogParams;
@query("ha-md-dialog") private _dialog!: HaMdDialog;
@ -90,7 +92,8 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
public showDialog(params: BackupOnboardingDialogParams): void {
this._params = params;
this._step = STEPS[0];
this._steps = params.showIntro ? STEPS.concat() : STEPS.slice(1);
this._step = this._steps[0];
this._config = RECOMMENDED_CONFIG;
// Enable local location by default
@ -117,6 +120,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
}
this._opened = false;
this._step = undefined;
this._steps = [];
this._config = undefined;
this._params = undefined;
}
@ -158,19 +162,19 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
}
private _previousStep() {
const index = STEPS.indexOf(this._step!);
const index = this._steps.indexOf(this._step!);
if (index === 0) {
return;
}
this._step = STEPS[index - 1];
this._step = this._steps[index - 1];
}
private _nextStep() {
const index = STEPS.indexOf(this._step!);
if (index === STEPS.length - 1) {
const index = this._steps.indexOf(this._step!);
if (index === this._steps.length - 1) {
return;
}
this._step = STEPS[index + 1];
this._step = this._steps[index + 1];
}
protected render() {
@ -178,8 +182,8 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
return nothing;
}
const isLastStep = this._step === STEPS[STEPS.length - 1];
const isFirstStep = this._step === STEPS[0];
const isLastStep = this._step === this._steps[this._steps.length - 1];
const isFirstStep = this._step === this._steps[0];
return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>

View File

@ -1,4 +1,4 @@
import { mdiClose, mdiCog, mdiPencil } from "@mdi/js";
import { mdiCalendarSync, mdiClose, mdiGestureTap } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@ -7,6 +7,7 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
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-svg-icon";
@ -14,7 +15,6 @@ 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";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
@customElement("ha-dialog-new-backup")
class DialogNewBackup extends LitElement implements HassDialog {
@ -75,7 +75,7 @@ class DialogNewBackup extends LitElement implements HassDialog {
type="button"
.disabled=${!this._params.config.create_backup.password}
>
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
<ha-svg-icon slot="start" .path=${mdiCalendarSync}></ha-svg-icon>
<span slot="headline">Automatic backup</span>
<span slot="supporting-text">
Create a backup with the data and locations you have configured.
@ -83,7 +83,7 @@ class DialogNewBackup extends LitElement implements HassDialog {
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item @click=${this._manual} type="button">
<ha-svg-icon slot="start" .path=${mdiPencil}></ha-svg-icon>
<ha-svg-icon slot="start" .path=${mdiGestureTap}></ha-svg-icon>
<span slot="headline">Manual backup</span>
<span slot="supporting-text">
Select data and locations for a manual backup.

View File

@ -4,6 +4,7 @@ import type { CloudStatus } from "../../../../data/cloud";
export interface BackupOnboardingDialogParams {
submit?: (value: boolean) => void;
cancel?: () => void;
showIntro?: boolean;
cloudStatus: CloudStatus;
}

View File

@ -6,14 +6,14 @@ import {
mdiPlus,
mdiUpload,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, 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 { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import { navigate } from "../../../common/navigate";
@ -36,11 +36,8 @@ import "../../../components/ha-svg-icon";
import { getSignedPath } from "../../../data/auth";
import type { BackupConfig, BackupContent } from "../../../data/backup";
import {
compareAgents,
computeBackupAgentName,
deleteBackup,
fetchBackupConfig,
fetchBackupInfo,
generateBackup,
generateBackupWithAutomaticSettings,
getBackupDownloadUrl,
@ -48,10 +45,6 @@ import {
isLocalAgent,
} from "../../../data/backup";
import type { ManagerStateEvent } from "../../../data/backup_manager";
import {
DEFAULT_MANAGER_STATE,
subscribeBackupEvents,
} from "../../../data/backup_manager";
import type { CloudStatus } from "../../../data/cloud";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
@ -66,7 +59,6 @@ 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 { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup";
@ -89,14 +81,14 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public route!: Route;
@state() private _manager: ManagerStateEvent = DEFAULT_MANAGER_STATE;
@property({ attribute: false }) public manager!: ManagerStateEvent;
@state() private _backups: BackupContent[] = [];
@property({ attribute: false }) public backups: BackupContent[] = [];
@property({ attribute: false }) public config?: BackupConfig;
@state() private _selected: string[] = [];
@state() private _config?: BackupConfig;
@storage({ key: "backups-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string = "formatted_type";
@ -107,8 +99,6 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
})
private _activeCollapsed: string[] = [];
private _subscribed?: Promise<() => void>;
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
@ -251,7 +241,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
protected render(): TemplateResult {
const backupInProgress =
"state" in this._manager && this._manager.state === "in_progress";
"state" in this.manager && this.manager.state === "in_progress";
return html`
<hass-tabs-subpage-data-table
@ -278,7 +268,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.route=${this.route}
@row-click=${this._showBackupDetails}
.columns=${this._columns(this.hass.localize)}
.data=${this._data(this._backups)}
.data=${this._data(this.backups)}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
@ -351,82 +341,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
`;
}
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._fetchBackupInfo();
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.map((backup) => ({
...backup,
agent_ids: backup.agent_ids?.sort(compareAgents),
failed_agent_ids: backup.failed_agent_ids?.sort(compareAgents),
}));
}
private async _fetchBackupConfig() {
const { config } = await fetchBackupConfig(this.hass);
this._config = config;
}
private get _needsOnboarding() {
return !this._config?.create_backup.password;
return !this.config?.create_backup.password;
}
private async _uploadBackup(ev) {
@ -438,7 +354,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
private async _newBackup(): Promise<void> {
const config = this._config!;
const config = this.config!;
const type = await showNewBackupDialog(this, { config });
@ -454,12 +370,12 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
await generateBackup(this.hass, params);
await this._fetchBackupInfo();
fireEvent(this, "ha-refresh-backup-info");
return;
}
if (type === "automatic") {
await generateBackupWithAutomaticSettings(this.hass);
await this._fetchBackupInfo();
fireEvent(this, "ha-refresh-backup-info");
}
}
@ -490,7 +406,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
await deleteBackup(this.hass, backup.backup_id);
this._fetchBackupInfo();
fireEvent(this, "ha-refresh-backup-info");
}
private async _deleteSelected() {
@ -516,7 +432,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
});
return;
}
await this._fetchBackupInfo();
fireEvent(this, "ha-refresh-backup-info");
this._dataTable.clearSelection();
}

View File

@ -1,138 +0,0 @@
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`
<hass-subpage
back-path="/config/backup"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.caption")}
>
<div class="content">
<div class="header">
<h2 class="title">Locations</h2>
<p class="description">
To keep your data safe it is recommended your backups is at least
on two different locations and one of them is off-site.
</p>
</div>
<ha-card class="agents">
<div class="card-content">
${this._agents.length > 0
? html`
<ha-md-list>
${this._agents.map((agent) => {
const [domain, name] = agent.agent_id.split(".");
const domainName = domainToName(
this.hass.localize,
domain
);
return html`
<ha-md-list-item
type="link"
href="/config/backup/locations/${agent.agent_id}"
>
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
<div slot="headline">${domainName}: ${name}</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
})}
</ha-md-list>
`
: html`<p>No sync agents configured</p>`}
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
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;
}
}

View File

@ -1,7 +1,8 @@
import { mdiDotsVertical, mdiPlus, mdiUpload } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-button";
import "../../../components/ha-button-menu";
@ -13,25 +14,24 @@ import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import {
fetchBackupConfig,
fetchBackupInfo,
generateBackup,
generateBackupWithAutomaticSettings,
type BackupConfig,
type BackupContent,
} from "../../../data/backup";
import type { ManagerStateEvent } from "../../../data/backup_manager";
import { DEFAULT_MANAGER_STATE } from "../../../data/backup_manager";
import type { CloudStatus } from "../../../data/cloud";
import "../../../layouts/hass-subpage";
import "../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import "./components/ha-backup-summary-card";
import "./components/ha-backup-summary-progress";
import "./components/ha-backup-summary-status";
import "./components/overview/ha-backup-overview-backups";
import "./components/overview/ha-backup-overview-onboarding";
import "./components/overview/ha-backup-overview-progress";
import "./components/overview/ha-backup-overview-settings";
import "./components/overview/ha-backup-overview-summary";
import { showBackupOnboardingDialog } from "./dialogs/show-dialog-backup_onboarding";
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
@ -47,27 +47,13 @@ class HaConfigBackupOverview extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _manager: ManagerStateEvent = DEFAULT_MANAGER_STATE;
@property({ attribute: false }) public manager!: ManagerStateEvent;
@state() private _backups: BackupContent[] = [];
@property({ attribute: false }) public backups: BackupContent[] = [];
@state() private _fetching = false;
@property({ attribute: false }) public fetching = false;
@state() private _config?: BackupConfig;
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._fetchBackupInfo();
this._fetchBackupConfig();
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._fetchBackupInfo();
this._fetchBackupConfig();
}
}
@property({ attribute: false }) public config?: BackupConfig;
private async _uploadBackup(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) {
@ -77,40 +63,36 @@ class HaConfigBackupOverview extends LitElement {
await showUploadBackupDialog(this, {});
}
private async _setupAutomaticBackup() {
private _handleOnboardingButtonClick(ev) {
ev.stopPropagation();
this._setupAutomaticBackup(false);
}
private async _setupAutomaticBackup(showIntro: boolean) {
const success = await showBackupOnboardingDialog(this, {
cloudStatus: this.cloudStatus,
showIntro: showIntro,
});
if (!success) {
return;
}
this._fetchBackupConfig();
fireEvent(this, "ha-refresh-backup-config");
await generateBackupWithAutomaticSettings(this.hass);
await this._fetchBackupInfo();
}
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;
fireEvent(this, "ha-refresh-backup-info");
}
private async _newBackup(): Promise<void> {
if (this._needsOnboarding) {
this._setupAutomaticBackup();
this._setupAutomaticBackup(true);
return;
}
if (!this._config) {
if (!this.config) {
return;
}
const config = this._config;
const config = this.config;
const type = await showNewBackupDialog(this, { config });
@ -126,22 +108,22 @@ class HaConfigBackupOverview extends LitElement {
}
await generateBackup(this.hass, params);
await this._fetchBackupInfo();
fireEvent(this, "ha-refresh-backup-info");
return;
}
if (type === "automatic") {
await generateBackupWithAutomaticSettings(this.hass);
await this._fetchBackupInfo();
fireEvent(this, "ha-refresh-backup-info");
}
}
private get _needsOnboarding() {
return !this._config?.create_backup.password;
return !this.config?.create_backup.password;
}
protected render(): TemplateResult {
const backupInProgress =
"state" in this._manager && this._manager.state === "in_progress";
"state" in this.manager && this.manager.state === "in_progress";
return html`
<hass-subpage
@ -167,57 +149,50 @@ class HaConfigBackupOverview extends LitElement {
</ha-button-menu>
</div>
<div class="content">
${this._fetching
${backupInProgress
? html`
<ha-backup-summary-card
heading="Loading backups"
description="Your backup information is being retrieved."
status="loading"
<ha-backup-overview-progress
.hass=${this.hass}
.manager=${this.manager}
>
</ha-backup-summary-card>
</ha-backup-overview-progress>
`
: backupInProgress
: this.fetching
? html`
<ha-backup-summary-progress
.hass=${this.hass}
.manager=${this._manager}
<ha-backup-summary-card
heading="Loading backups"
description="Your backup information is being retrieved."
status="loading"
>
</ha-backup-summary-progress>
</ha-backup-summary-card>
`
: this._needsOnboarding
? html`
<ha-backup-summary-card
heading="Configure automatic backups"
description="Have a one-click backup automation with selected data and locations."
has-action
status="info"
<ha-backup-overview-onboarding
.hass=${this.hass}
@button-click=${this._handleOnboardingButtonClick}
>
<ha-button
slot="action"
@click=${this._setupAutomaticBackup}
>
Set up automatic backups
</ha-button>
</ha-backup-summary-card>
</ha-backup-overview-onboarding>
`
: html`
<ha-backup-summary-status
<ha-backup-overview-summary
.hass=${this.hass}
.backups=${this._backups}
.backups=${this.backups}
.config=${this.config}
>
</ha-backup-summary-status>
</ha-backup-overview-summary>
`}
<ha-backup-overview-backups
.hass=${this.hass}
.backups=${this._backups}
.backups=${this.backups}
></ha-backup-overview-backups>
${!this._needsOnboarding
? html`
<ha-backup-overview-settings
.hass=${this.hass}
.config=${this._config!}
.config=${this.config!}
></ha-backup-overview-settings>
`
: nothing}
@ -258,6 +233,10 @@ class HaConfigBackupOverview extends LitElement {
padding-left: 0;
padding-right: 0;
}
ha-fab[disabled] {
--mdc-theme-secondary: var(--disabled-text-color) !important;
pointer-events: none;
}
`,
];
}

View File

@ -1,17 +1,14 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
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 { updateBackupConfig } from "../../../data/backup";
import type { CloudStatus } from "../../../data/cloud";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
@ -22,27 +19,6 @@ import "./components/config/ha-backup-config-encryption-key";
import "./components/config/ha-backup-config-schedule";
import type { BackupConfigSchedule } from "./components/config/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_automatic_backup: null,
last_completed_automatic_backup: null,
};
@customElement("ha-config-backup-settings")
class HaConfigBackupSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -51,22 +27,19 @@ class HaConfigBackupSettings extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _backupConfig: BackupConfig = INITIAL_BACKUP_CONFIG;
@property({ attribute: false }) public config?: BackupConfig;
protected willUpdate(changedProps) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._fetchData();
@state() private _config?: BackupConfig;
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (changedProperties.has("config") && !this._config) {
this._config = this.config;
}
}
private async _fetchData() {
const { config } = await fetchBackupConfig(this.hass);
this._backupConfig = config;
}
protected render() {
if (!this._backupConfig) {
if (!this._config) {
return nothing;
}
@ -87,7 +60,7 @@ class HaConfigBackupSettings extends LitElement {
</p>
<ha-backup-config-schedule
.hass=${this.hass}
.value=${this._backupConfig}
.value=${this._config}
@value-changed=${this._scheduleConfigChanged}
></ha-backup-config-schedule>
</div>
@ -113,7 +86,7 @@ class HaConfigBackupSettings extends LitElement {
</p>
<ha-backup-config-agents
.hass=${this.hass}
.value=${this._backupConfig.create_backup.agent_ids}
.value=${this._config.create_backup.agent_ids}
.cloudStatus=${this.cloudStatus}
@value-changed=${this._agentsConfigChanged}
></ha-backup-config-agents>
@ -130,7 +103,7 @@ class HaConfigBackupSettings extends LitElement {
</p>
<ha-backup-config-encryption-key
.hass=${this.hass}
.value=${this._backupConfig.create_backup.password}
.value=${this._config.create_backup.password}
@value-changed=${this._encryptionKeyChanged}
></ha-backup-config-encryption-key>
</div>
@ -142,8 +115,8 @@ class HaConfigBackupSettings extends LitElement {
private _scheduleConfigChanged(ev) {
const value = ev.detail.value as BackupConfigSchedule;
this._backupConfig = {
...this._backupConfig,
this._config = {
...this._config!,
schedule: value.schedule,
retention: value.retention,
};
@ -156,7 +129,7 @@ class HaConfigBackupSettings extends LitElement {
include_all_addons,
include_database,
include_folders,
} = this._backupConfig.create_backup;
} = this._config!.create_backup;
return {
include_homeassistant: true,
@ -169,10 +142,10 @@ class HaConfigBackupSettings extends LitElement {
private _dataConfigChanged(ev) {
const data = ev.detail.value as BackupConfigData;
this._backupConfig = {
...this._backupConfig,
this._config = {
...this._config!,
create_backup: {
...this._backupConfig.create_backup,
...this.config!.create_backup,
include_database: data.include_database,
include_folders: data.include_folders || null,
include_all_addons: data.include_all_addons,
@ -184,10 +157,10 @@ class HaConfigBackupSettings extends LitElement {
private _agentsConfigChanged(ev) {
const agents = ev.detail.value as string[];
this._backupConfig = {
...this._backupConfig,
this._config = {
...this._config!,
create_backup: {
...this._backupConfig.create_backup,
...this._config!.create_backup,
agent_ids: agents,
},
};
@ -196,10 +169,10 @@ class HaConfigBackupSettings extends LitElement {
private _encryptionKeyChanged(ev) {
const password = ev.detail.value as string;
this._backupConfig = {
...this._backupConfig,
this._config = {
...this._config!,
create_backup: {
...this._backupConfig.create_backup,
...this._config!.create_backup,
password: password,
},
};
@ -211,16 +184,17 @@ class HaConfigBackupSettings extends LitElement {
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,
agent_ids: this._config!.create_backup.agent_ids,
include_folders: this._config!.create_backup.include_folders ?? [],
include_database: this._config!.create_backup.include_database,
include_addons: this._config!.create_backup.include_addons ?? [],
include_all_addons: this._config!.create_backup.include_all_addons,
password: this._config!.create_backup.password,
},
retention: this._backupConfig.retention,
schedule: this._backupConfig.schedule.state,
retention: this._config!.retention,
schedule: this._config!.schedule.state,
});
fireEvent(this, "ha-refresh-backup-config");
}
static styles = css`
@ -233,14 +207,6 @@ class HaConfigBackupSettings extends LitElement {
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);
}

View File

@ -1,21 +1,89 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type { ManagerStateEvent } from "../../../data/backup_manager";
import {
DEFAULT_MANAGER_STATE,
subscribeBackupEvents,
} from "../../../data/backup_manager";
import type { CloudStatus } from "../../../data/cloud";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import "./ha-config-backup-overview";
import { showToast } from "../../../util/toast";
import "./ha-config-backup-backups";
import "./ha-config-backup-overview";
import type { BackupConfig, BackupContent } from "../../../data/backup";
import {
compareAgents,
fetchBackupConfig,
fetchBackupInfo,
} from "../../../data/backup";
declare global {
interface HASSDomEvents {
"ha-refresh-backup-info": undefined;
"ha-refresh-backup-config": undefined;
}
}
@customElement("ha-config-backup")
class HaConfigBackup extends HassRouterPage {
class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus!: CloudStatus;
@property({ type: Boolean }) public narrow = false;
@state() private _manager: ManagerStateEvent = DEFAULT_MANAGER_STATE;
@state() private _backups: BackupContent[] = [];
@state() private _fetching = false;
@state() private _config?: BackupConfig;
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._fetching = true;
Promise.all([this._fetchBackupInfo(), this._fetchBackupConfig()]).finally(
() => {
this._fetching = false;
}
);
this.addEventListener("ha-refresh-backup-info", () => {
this._fetchBackupInfo();
});
this.addEventListener("ha-refresh-backup-config", () => {
this._fetchBackupConfig();
});
}
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._fetchBackupInfo();
this._fetchBackupConfig();
}
}
private async _fetchBackupInfo() {
const info = await fetchBackupInfo(this.hass);
this._backups = info.backups.map((backup) => ({
...backup,
agent_ids: backup.agent_ids?.sort(compareAgents),
failed_agent_ids: backup.failed_agent_ids?.sort(compareAgents),
}));
}
private async _fetchBackupConfig() {
const { config } = await fetchBackupConfig(this.hass);
this._config = config;
}
protected routerOptions: RouterOptions = {
defaultPage: "overview",
routes: {
@ -31,10 +99,6 @@ class HaConfigBackup extends HassRouterPage {
tag: "ha-config-backup-details",
load: () => import("./ha-config-backup-details"),
},
locations: {
tag: "ha-config-backup-locations",
load: () => import("./ha-config-backup-locations"),
},
settings: {
tag: "ha-config-backup-settings",
load: () => import("./ha-config-backup-settings"),
@ -47,7 +111,12 @@ class HaConfigBackup extends HassRouterPage {
pageEl.route = this.routeTail;
pageEl.narrow = this.narrow;
pageEl.cloudStatus = this.cloudStatus;
pageEl.manager = this._manager;
pageEl.backups = this._backups;
pageEl.config = this._config;
pageEl.fetching = this._fetching;
pageEl.addEventListener("reload", () => {});
if (
(!changedProps || changedProps.has("route")) &&
this._currentPage === "details"
@ -55,6 +124,36 @@ class HaConfigBackup extends HassRouterPage {
pageEl.backupId = this.routeTail.path.substr(1);
}
}
public hassSubscribe(): Promise<UnsubscribeFunc>[] {
return [
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 });
}
}
}
}),
];
}
}
declare global {