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
This commit is contained in:
Joakim Sørensen 2024-11-13 16:15:14 +01:00 committed by GitHub
parent 2218a7121b
commit 0c2e62ec91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 626 additions and 226 deletions

View File

@ -1,11 +1,24 @@
import { HomeAssistant } from "../types";
export interface BackupContent {
interface BackupSyncAgent {
id: string;
}
interface BaseBackupContent {
slug: string;
date: string;
name: string;
size: number;
path: string;
agents?: string[];
}
export interface BackupContent extends BaseBackupContent {
path?: string;
}
export interface BackupSyncedContent extends BaseBackupContent {
id: string;
agent_id: string;
}
export interface BackupData {
@ -13,6 +26,11 @@ export interface BackupData {
backups: BackupContent[];
}
export interface BackupAgentsInfo {
agents: BackupSyncAgent[];
syncing: boolean;
}
export const getBackupDownloadUrl = (slug: string) =>
`/api/backup/download/${slug}`;
@ -21,6 +39,29 @@ export const fetchBackupInfo = (hass: HomeAssistant): Promise<BackupData> =>
type: "backup/info",
});
export const fetchBackupDetails = (
hass: HomeAssistant,
slug: string
): Promise<{ backup: BackupContent }> =>
hass.callWS({
type: "backup/details",
slug,
});
export const fetchBackupAgentsInfo = (
hass: HomeAssistant
): Promise<BackupAgentsInfo> =>
hass.callWS({
type: "backup/agents/info",
});
export const fetchBackupAgentsSynced = (
hass: HomeAssistant
): Promise<BackupSyncedContent[]> =>
hass.callWS({
type: "backup/agents/synced",
});
export const removeBackup = (
hass: HomeAssistant,
slug: string

View File

@ -0,0 +1,175 @@
import { css, html, LitElement, TemplateResult } from "lit";
import "@material/mwc-list/mwc-list";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-list-item";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { navigate } from "../../../common/navigate";
import { fetchBackupAgentsInfo } from "../../../data/backup";
@customElement("ha-config-backup-dashboard")
class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _agents: { id: string }[] = [];
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._fetchAgents();
}
protected render(): TemplateResult {
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.caption")}
>
<div class="content">
<div class="backup-status">
<ha-card outlined>
<div class="card-content">
<div class="backup-status-contents">
<div class="status-icon">
<ha-icon icon="mdi:check-circle"></ha-icon>
</div>
<span>
<div class="status-header">Backed up</div>
<div class="status-text">
Your configuration has been backed up.
</div>
</span>
</div>
<ha-button
@click=${this._showBackupList}
class="show-all-backups"
>
Show all backups
</ha-button>
</div>
</ha-card>
</div>
<div class="backup-agents">
<ha-card outlined>
<div class="card-content">
<div class="status-header">Locations</div>
<div class="status-text">
To keep your data safe it is recommended your backups is at
least on two different locations and one of them is off-site.
</div>
${this._agents.length > 0
? html`<mwc-list>
${this._agents.map((agent) => {
const [domain, name] = agent.id.split(".");
return html` <ha-list-item
graphic="medium"
hasMeta
.agent=${agent.id}
@click=${this._showAgentSyncs}
>
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt="cloud"
slot="graphic"
/>
<span>
${this.hass.localize(`component.${domain}.title`) ||
domain}:
${name}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`;
})}
</mwc-list>`
: html`<p>No sync agents configured</p>`}
</div>
</ha-card>
</div>
</div>
</hass-subpage>
`;
}
private async _fetchAgents() {
const resp = await fetchBackupAgentsInfo(this.hass);
this._agents = resp.agents;
}
private _showBackupList(): void {
navigate("/config/backup/list");
}
private _showAgentSyncs(event: Event): void {
const agent = (event.currentTarget as any).agent;
navigate(`/config/backup/list?agent=${agent}`);
}
static styles = css`
.content {
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: 24px;
display: grid;
}
.backup-status .card-content {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.backup-status-contents {
display: flex;
flex-direction: row;
}
.status-icon {
color: var(--success-color);
height: 100%;
align-content: center;
}
.status-icon ha-icon {
--mdc-icon-size: 40px;
padding: 8px 16px 8px 8px;
}
.status-header {
font-size: 22px;
line-height: 28px;
}
.status-text {
font-size: 14px;
line-height: 20px;
color: var(--secondary-text-color);
}
ha-button.show-all-backups {
align-items: center;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-backup-dashboard": HaConfigBackupDashboard;
}
}

View File

@ -0,0 +1,126 @@
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../layouts/hass-subpage";
import "../../../components/ha-relative-time";
import "../../../components/ha-alert";
import "../../../components/ha-card";
import "../../../components/ha-settings-row";
import type { HomeAssistant } from "../../../types";
import {
BackupAgentsInfo,
BackupContent,
fetchBackupDetails,
} from "../../../data/backup";
@customElement("ha-config-backup-details")
class HaConfigBackupDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public backupAgentsInfo?: BackupAgentsInfo;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "backup-slug" }) public backupSlug!: string;
@state() private _backup?: BackupContent | null;
@state() private _error?: string;
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (this.backupSlug) {
this._fetchBackup();
} else {
this._error = "Backup slug not defined";
}
}
protected render(): TemplateResult {
if (!this.hass) {
return html`:(`;
}
return html`
<hass-subpage
back-path="/config/backup/list"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this._backup?.name || "Backup"}
>
<div class="content">
${this._error &&
html`<ha-alert alert-type="error">${this._error}</ha-alert>`}
${this._backup === null
? html`<ha-alert alert-type="warning" title="Not found">
Backup matching ${this.backupSlug} not found
</ha-alert>`
: !this._backup
? html`<ha-circular-progress active></ha-circular-progress>`
: html`
<ha-card header="Backup">
<div class="card-content">
<ha-settings-row>
<span slot="heading">
${this._backup.type || "partial"}
</span>
<span slot="description">Type</span>
</ha-settings-row>
${this._backup.homeassistant?.version &&
html`<ha-settings-row>
<span slot="heading">
${this._backup.homeassistant.version}
</span>
<span slot="description">Home Assistant Version</span>
</ha-settings-row>`}
<ha-settings-row>
<span slot="heading">
${Math.ceil(this._backup.size * 10) / 10 + " MB"}
</span>
<span slot="description">Size</span>
</ha-settings-row>
<ha-settings-row>
<ha-relative-time
.hass=${this.hass}
.datetime=${this._backup.date}
slot="heading"
capitalize
>
</ha-relative-time>
<span slot="description">Created</span>
</ha-settings-row>
</div>
</ha-card>
`}
</div>
</hass-subpage>
`;
}
private async _fetchBackup() {
try {
const response = await fetchBackupDetails(this.hass, this.backupSlug);
this._backup = response.backup;
} catch (err: any) {
this._error = err?.message || "Could not fetch backup details";
}
}
static styles = css`
.content {
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: 24px;
display: grid;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-backup-details": HaConfigBackupDetails;
}
}

View File

@ -0,0 +1,247 @@
import { mdiPlus } from "@mdi/js";
import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoize from "memoize-one";
import { relativeTime } from "../../../common/datetime/relative_time";
import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize";
import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-circular-progress";
import "../../../components/ha-fab";
import "../../../components/ha-icon";
import "../../../components/ha-svg-icon";
import {
BackupContent,
BackupData,
fetchBackupAgentsSynced,
fetchBackupInfo,
generateBackup,
} 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 { brandsUrl } from "../../../util/brands-url";
const localAgent = "backup.local";
@customElement("ha-config-backup-list")
class HaConfigBackup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ 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<BackupContent> => ({
name: {
title: localize("ui.panel.config.backup.name"),
main: true,
sortable: true,
filterable: true,
flex: 2,
template: (backup) =>
narrow || !backup.path
? backup.name
: html`${backup.name}
<div class="secondary">${backup.path}</div>`,
},
path: {
title: localize("ui.panel.config.backup.path"),
hidden: !narrow,
template: (backup) => backup.path || "-",
},
size: {
title: localize("ui.panel.config.backup.size"),
filterable: true,
sortable: true,
template: (backup) => Math.ceil(backup.size * 10) / 10 + " MB",
},
date: {
title: localize("ui.panel.config.backup.created"),
direction: "desc",
filterable: true,
sortable: true,
template: (backup) =>
relativeTime(new Date(backup.date), this.hass.locale),
},
locations: {
title: "Locations",
template: (backup) =>
html`${[
...(backup.path ? [localAgent] : []),
...(backup.agents || []).sort(),
].map((agent) => {
const [domain, name] = agent.split(".");
return html`<img
title=${name}
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
height="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${name}
slot="graphic"
/>`;
})}`,
},
})
);
private _getItems = memoize((backupItems: BackupContent[]) =>
backupItems.map((backup) => ({
name: backup.name,
slug: backup.slug,
date: backup.date,
size: backup.size,
path: backup.path,
agents: backup.agents,
}))
);
protected render(): TemplateResult {
if (!this.hass || this._backupData === undefined) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
return html`
<hass-tabs-subpage-data-table
hasFab
.tabs=${[
{
translationKey: "ui.panel.config.backup.caption",
path: `/config/backup/list`,
},
]}
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/backup/dashboard"
clickable
id="slug"
.route=${this.route}
@row-click=${this._showBackupDetails}
.columns=${this._columns(
this.narrow,
this.hass.language,
this.hass.localize
)}
.data=${this._getItems(this._backupData.backups)}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
)}
>
<ha-fab
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"
indeterminate
></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> {
const backupData: Record<string, BackupContent> = {};
const [local, synced] = await Promise.all([
fetchBackupInfo(this.hass),
fetchBackupAgentsSynced(this.hass),
]);
for (const backup of local.backups) {
backupData[backup.slug] = backup;
}
for (const agent of synced) {
if (!(agent.slug in backupData)) {
backupData[agent.slug] = { ...agent, agents: [agent.agent_id] };
} else if (!("agents" in backupData[agent.slug])) {
backupData[agent.slug].agents = [agent.agent_id];
} else {
backupData[agent.slug].agents!.push(agent.agent_id);
}
}
this._backupData = {
backing_up: local.backing_up,
backups: Object.values(backupData),
};
}
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 _showBackupDetails(ev: CustomEvent): void {
const slug = (ev.detail as RowClickedEvent).id;
navigate(`/config/backup/details/${slug}`);
}
static get styles(): CSSResultGroup {
return [
css`
ha-fab[disabled] {
--mdc-theme-secondary: var(--disabled-text-color) !important;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-backup-list": HaConfigBackup;
}
}

View File

@ -1,242 +1,53 @@
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { mdiDelete, mdiDownload, mdiPlus } from "@mdi/js";
import { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import {
CSSResultGroup,
LitElement,
PropertyValues,
TemplateResult,
css,
html,
} 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 { LocalizeFunc } from "../../../common/translations/localize";
import { fileDownload } from "../../../util/file_download";
HassRouterPage,
RouterOptions,
} from "../../../layouts/hass-router-page";
import { 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({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public isWide = false;
@state() private _backupData?: BackupData;
@property({ type: Boolean }) public showAdvanced = false;
private _columns = memoize(
(
narrow,
_language,
localize: LocalizeFunc
): DataTableColumnContainer<BackupContent> => ({
name: {
title: localize("ui.panel.config.backup.name"),
main: true,
sortable: true,
filterable: true,
flex: 2,
template: narrow
? undefined
: (backup) =>
html`${backup.name}
<div class="secondary">${backup.path}</div>`,
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
dashboard: {
tag: "ha-config-backup-dashboard",
cache: true,
},
path: {
title: localize("ui.panel.config.backup.path"),
hidden: !narrow,
list: {
tag: "ha-config-backup-list",
load: () => import("./ha-config-backup-list"),
},
size: {
title: localize("ui.panel.config.backup.size"),
filterable: true,
sortable: true,
template: (backup) => Math.ceil(backup.size * 10) / 10 + " MB",
},
date: {
title: localize("ui.panel.config.backup.created"),
direction: "desc",
filterable: true,
sortable: true,
template: (backup) =>
relativeTime(new Date(backup.date), this.hass.locale),
details: {
tag: "ha-config-backup-details",
load: () => import("./ha-config-backup-details"),
},
},
};
actions: {
title: "",
type: "overflow-menu",
showNarrow: true,
hideable: false,
moveable: false,
template: (backup) =>
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>`,
},
})
);
protected updatePageEl(pageEl, changedProps: PropertyValues) {
pageEl.hass = this.hass;
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.route = this.routeTail;
pageEl.showAdvanced = this.showAdvanced;
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>`;
if (
(!changedProps || changedProps.has("route")) &&
this._currentPage === "details"
) {
pageEl.backupSlug = this.routeTail.path.substr(1);
}
return html`
<hass-tabs-subpage-data-table
hasFab
.tabs=${[
{
translationKey: "ui.panel.config.backup.caption",
path: `/config/backup`,
},
]}
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/system"
.route=${this.route}
.columns=${this._columns(
this.narrow,
this.hass.language,
this.hass.localize
)}
.data=${this._getItems(this._backupData.backups)}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
)}
>
<ha-fab
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"
indeterminate
></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;
}
`,
];
}
}