Merge feature branch with backup changes to dev (#23239)

* 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 <mail@bramkragten.nl>

* 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 <mail@bramkragten.nl>

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* 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 <mail@bramkragten.nl>

* 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 <ludeeus@ludeeus.dev>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
This commit is contained in:
Paul Bottein 2024-12-11 21:52:37 +01:00 committed by GitHub
parent c8f58c7bc9
commit 86f1af6682
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 5885 additions and 250 deletions

View File

@ -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;

View File

@ -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"

View File

@ -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;
}
}

View File

@ -143,6 +143,10 @@ export class HaPasswordField extends LitElement {
></ha-icon-button>`;
}
public focus(): void {
this._textField.focus();
}
public checkValidity(): boolean {
return this._textField.checkValidity();
}

View File

@ -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<BackupData> =>
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<BackupInfo> =>
hass.callWS({
type: "backup/info",
});
export const removeBackup = (
export const fetchBackupDetails = (
hass: HomeAssistant,
slug: string
): Promise<void> =>
id: string
): Promise<BackupDetails> =>
hass.callWS({
type: "backup/remove",
slug,
type: "backup/details",
backup_id: id,
});
export const generateBackup = (hass: HomeAssistant): Promise<BackupContent> =>
export const fetchBackupAgentsInfo = (
hass: HomeAssistant
): Promise<BackupAgentsInfo> =>
hass.callWS({
type: "backup/agents/info",
});
export const deleteBackup = (hass: HomeAssistant, id: string): Promise<void> =>
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<void> =>
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<void> => {
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;
};

View File

@ -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<ManagerStateEvent>(callback, {
type: "backup/subscribe_events",
});
export const DEFAULT_MANAGER_STATE: ManagerStateEvent = {
manager_state: "idle",
};

View File

@ -468,6 +468,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
${!this.narrow
? html`
<div slot="header">
<slot name="top_header"></slot>
<slot name="header">
<div class="table-header">
${this.hasFilters && !this.showFilters

View File

@ -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`
<div class="items">
${this.addons.map(
(item) => html`
<ha-formfield>
<ha-backup-formfield-label
slot="label"
.label=${item.name}
.version=${item.version}
.iconPath=${item.iconPath || mdiPuzzle}
.imageUrl=${this.addons?.find((a) => a.slug === item.slug)?.icon
? `/api/hassio/addons/${item.slug}/icon`
: undefined}
>
</ha-backup-formfield-label>
<ha-checkbox
.id=${item.slug}
.checked=${this.value?.includes(item.slug) || false}
@change=${this._checkboxChanged}
></ha-checkbox>
</ha-formfield>
`
)}
</div>
`;
}
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;
}
}

View File

@ -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`
<div class="agents">
${this._agentIds(this.agents).map((agent) => this._renderAgent(agent))}
</div>
`;
}
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`
<ha-formfield>
<span class="label" slot="label">
${isLocalAgent(agentId)
? html`
<ha-svg-icon .path=${mdiDatabase} slot="start"> </ha-svg-icon>
`
: html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`}
${name}
</span>
<ha-checkbox
.checked=${this.value.includes(agentId)}
.value=${agentId}
.disabled=${disabled}
@change=${this._checkboxChanged}
></ha-checkbox>
</ha-formfield>
`;
}
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;
}
}

View File

@ -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`
<ha-md-list>
${agentIds.map((agentId) => {
const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
agentIds
);
return html`
<ha-md-list-item>
${isLocalAgent(agentId)
? html`
<ha-svg-icon .path=${mdiDatabase} slot="start">
</ha-svg-icon>
`
: html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`}
<div slot="headline">${name}</div>
<ha-switch
slot="end"
id=${agentId}
.checked=${this._value.includes(agentId)}
@change=${this._agentToggled}
></ha-switch>
</ha-md-list-item>
`;
})}
</ha-md-list>
`
: html`<p>No sync agents configured</p>`}
`;
}
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;
}
}

View File

@ -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`
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
<span slot="headline">
${this.forceHomeAssistant
? "Home Assistant settings are always included"
: "Home Assistant settings"}
</span>
<span slot="supporting-text">
The bare minimum needed to restore your system.
</span>
${this.forceHomeAssistant
? html`<ha-button slot="end">Learn more</ha-button>`
: html`
<ha-switch
id="homeassistant"
slot="end"
@change=${this._switchChanged}
.checked=${data.homeassistant}
></ha-switch>
`}
</ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiChartBox}></ha-svg-icon>
<span slot="headline">History</span>
<span slot="supporting-text">
Historical data of your sensors, including your energy dashboard.
</span>
<ha-switch
id="database"
slot="end"
@change=${this._switchChanged}
.checked=${data.database}
></ha-switch>
</ha-md-list-item>
${isHassio
? html`
<ha-md-list-item>
<ha-svg-icon
slot="start"
.path=${mdiPlayBoxMultiple}
></ha-svg-icon>
<span slot="headline">Media</span>
<span slot="supporting-text">
For example, camera recordings.
</span>
<ha-switch
id="media"
slot="end"
@change=${this._switchChanged}
.checked=${data.media}
></ha-switch>
</ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiFolder}></ha-svg-icon>
<span slot="headline">Share folder</span>
<span slot="supporting-text">
Folder that is often used for advanced or older
configurations.
</span>
<ha-switch
id="share"
slot="end"
@change=${this._switchChanged}
.checked=${data.share}
></ha-switch>
</ha-md-list-item>
${this._addons.length
? html`
<ha-md-list-item>
<ha-svg-icon
slot="start"
.path=${mdiPuzzle}
></ha-svg-icon>
<span slot="headline">Add-ons</span>
<span slot="supporting-text">
Select what add-ons you want to include.
</span>
<ha-md-select
slot="end"
id="addons_mode"
@change=${this._selectChanged}
.value=${data.addons_mode}
>
<ha-md-select-option value="all">
<div slot="headline">All</div>
</ha-md-select-option>
<ha-md-select-option value="custom">
<div slot="headline">Custom</div>
</ha-md-select-option>
</ha-md-select>
</ha-md-list-item>
`
: nothing}
`
: nothing}
</ha-md-list>
${isHassio && data.addons_mode === "custom" && this._addons.length
? html`
<ha-expansion-panel .header=${"Add-ons"} outlined expanded>
<ha-backup-addons-picker
.hass=${this.hass}
.value=${data.addons}
@value-changed=${this._addonsChanged}
.addons=${this._addons}
></ha-backup-addons-picker>
</ha-expansion-panel>
`
: 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;
}
}

View File

@ -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`
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Download emergency kit</span>
<span slot="supporting-text">
We recommend to save this encryption key somewhere secure.
</span>
<ha-button slot="end" @click=${this._download}>
<ha-svg-icon .path=${mdiDownload} slot="icon"></ha-svg-icon>
Download
</ha-button>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">Change encryption key</span>
<span slot="supporting-text">
All next backups will use this encryption key.
</span>
<ha-button class="danger" slot="end" @click=${this._change}>
Change
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
}
return html`
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Set encryption key</span>
<span slot="supporting-text">
Set an encryption key for your backups.
</span>
<ha-button slot="end" @click=${this._set}> Set </ha-button>
</ha-md-list-item>
</ha-md-list>
`;
}
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;
}
}

View File

@ -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<BackupConfig, "schedule" | "retention">;
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<RetentionPreset, RetentionPreset.CUSTOM>,
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`
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Use automatic backups</span>
<span slot="supporting-text">
How often you want to create a backup.
</span>
<ha-switch
slot="end"
@change=${this._enabledChanged}
.checked=${data.enabled}
></ha-switch>
</ha-md-list-item>
${data.enabled
? html`
<ha-md-list-item>
<span slot="headline">Schedule</span>
<span slot="supporting-text">
How often you want to create a backup.
</span>
<ha-md-select
slot="end"
@change=${this._scheduleChanged}
.value=${data.schedule}
>
<ha-md-select-option .value=${BackupScheduleState.DAILY}>
<div slot="headline">Daily at 04:45</div>
</ha-md-select-option>
<ha-md-select-option .value=${BackupScheduleState.MONDAY}>
<div slot="headline">Monday at 04:45</div>
</ha-md-select-option>
<ha-md-select-option .value=${BackupScheduleState.TUESDAY}>
<div slot="headline">Tuesday at 04:45</div>
</ha-md-select-option>
<ha-md-select-option .value=${BackupScheduleState.WEDNESDAY}>
<div slot="headline">Wednesday at 04:45</div>
</ha-md-select-option>
<ha-md-select-option .value=${BackupScheduleState.THURSDAY}>
<div slot="headline">Thursday at 04:45</div>
</ha-md-select-option>
<ha-md-select-option .value=${BackupScheduleState.FRIDAY}>
<div slot="headline">Friday at 04:45</div>
</ha-md-select-option>
<ha-md-select-option .value=${BackupScheduleState.SATURDAY}>
<div slot="headline">Saturday at 04:45</div>
</ha-md-select-option>
<ha-md-select-option .value=${BackupScheduleState.SUNDAY}>
<div slot="headline">Sunday at 04:45</div>
</ha-md-select-option>
</ha-md-select>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">Maximum copies</span>
<span slot="supporting-text">
The number of backups that are saved
</span>
<ha-md-select
slot="end"
@change=${this._retentionPresetChanged}
.value=${this._retentionPreset}
>
<ha-md-select-option .value=${RetentionPreset.COPIES_3}>
<div slot="headline">Latest 3 copies</div>
</ha-md-select-option>
<ha-md-select-option .value=${RetentionPreset.DAYS_7}>
<div slot="headline">Keep 7 days</div>
</ha-md-select-option>
<ha-md-select-option .value=${RetentionPreset.FOREOVER}>
<div slot="headline">Keep forever</div>
</ha-md-select-option>
<ha-md-select-option .value=${RetentionPreset.CUSTOM}>
<div slot="headline">Custom</div>
</ha-md-select-option>
</ha-md-select>
</ha-md-list-item>
${this._retentionPreset === RetentionPreset.CUSTOM
? html`
<ha-md-list-item>
<ha-md-textfield
slot="end"
@change=${this._retentionValueChanged}
.value=${data.retention.value}
id="value"
type="number"
.min=${MIN_VALUE}
.max=${MAX_VALUE}
step="1"
>
</ha-md-textfield>
<ha-md-select
slot="end"
@change=${this._retentionTypeChanged}
.value=${data.retention.type}
id="type"
>
<ha-md-select-option .value=${"days"}>
<div slot="headline">days</div>
</ha-md-select-option>
<ha-md-select-option .value=${"copies"}>
<div slot="headline">copies</div>
</ha-md-select-option>
</ha-md-select>
</ha-md-list-item>
`
: nothing}
`
: nothing}
</ha-md-list>
`;
}
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;
}
}

View File

@ -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<string, boolean> = {};
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<Record<string, boolean>>(
(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<CheckBoxItem>((folder) => ({
label: capitalizeFirstLetter(folder),
id: folder,
}))
);
return items;
}
);
private _addonsItems = memoizeOne(
(
data: BackupData,
_localize: LocalizeFunc,
addonIcons: Record<string, boolean>
) => {
const items = data.addons.map<BackupAddonItem>((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`
<div class="section">
<ha-formfield>
<ha-backup-formfield-label
slot="label"
.label=${"Home Assistant"}
.iconPath=${mdiHomeAssistant}
>
</ha-backup-formfield-label>
<ha-checkbox
.id=${"homeassistant"}
.checked=${selectedItems.homeassistant.length ===
homeAssistantItems.length}
.indeterminate=${selectedItems.homeassistant.length > 0 &&
selectedItems.homeassistant.length <
homeAssistantItems.length}
@change=${this._sectionChanged}
></ha-checkbox>
</ha-formfield>
<div class="items">
${homeAssistantItems.map(
(item) => html`
<ha-formfield>
<ha-backup-formfield-label
slot="label"
.label=${item.label}
.version=${item.version}
.iconPath=${ITEM_ICONS[item.id] || mdiFolder}
>
</ha-backup-formfield-label>
<ha-checkbox
.id=${item.id}
.checked=${selectedItems.homeassistant.includes(
item.id
)}
.section=${"homeassistant"}
@change=${this._itemChanged}
></ha-checkbox>
</ha-formfield>
`
)}
</div>
</div>
`
: nothing}
${addonsItems.length
? html`
<div class="section">
<ha-formfield>
<ha-backup-formfield-label
slot="label"
.label=${"Add-ons"}
.iconPath=${mdiPuzzle}
>
</ha-backup-formfield-label>
<ha-checkbox
.id=${"addons"}
.checked=${selectedItems.addons.length === addonsItems.length}
.indeterminate=${selectedItems.addons.length > 0 &&
selectedItems.addons.length < addonsItems.length}
@change=${this._sectionChanged}
></ha-checkbox>
</ha-formfield>
<ha-backup-addons-picker
.hass=${this.hass}
.value=${selectedItems.addons}
@value-changed=${this._addonsChanged}
.addons=${addonsItems}
>
</ha-backup-addons-picker>
</div>
`
: 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;
}
}

View File

@ -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`<img loading="lazy" alt="" src=${this.imageUrl} class="icon" />`
: this.iconPath
? html`
<ha-svg-icon .path=${this.iconPath} class="icon"></ha-svg-icon>
`
: nothing}
<span class="label">
${this.label}
${this.version
? html`<span class="version">(${this.version})</span>`
: nothing}
</span>
`;
}
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;
}
}

View File

@ -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<SummaryStatus, string> = {
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`
<ha-card outlined>
<div class="summary">
${this.status === "loading"
? html`<ha-circular-progress indeterminate></ha-circular-progress>`
: html`
<div class="icon ${this.status}">
<ha-svg-icon .path=${ICONS[this.status]}></ha-svg-icon>
</div>
`}
<div class="content">
<p class="heading">${this.heading}</p>
<p class="description">${this.description}</p>
</div>
${this.hasAction
? html`
<div class="action">
<slot name="action"></slot>
</div>
`
: nothing}
</div>
</ha-card>
`;
}
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;
}
}

View File

@ -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`
<ha-backup-summary-card
.hass=${this.hass}
.heading=${this._heading}
.description=${this._description}
status="loading"
.hasAction=${this.hasAction}
>
<slot name="action" slot="action"></slot>
</ha-backup-summary-card>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-summary-progress": HaBackupSummaryProgress;
}
}

View File

@ -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`
<ha-backup-summary-card
heading="No backup available"
description="You have not created any backups yet."
.hasAction=${this.hasAction}
status="warning"
>
<slot name="action" slot="action"></slot>
</ha-backup-summary-card>
`;
}
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`
<ha-backup-summary-card
heading=${`No backup for ${numberOfDays} days`}
description=${description}
.hasAction=${this.hasAction}
status="warning"
>
<slot name="action" slot="action"></slot>
</ha-backup-summary-card>
`;
}
return html`
<ha-backup-summary-card
heading=${`Backed up`}
description=${description}
.hasAction=${this.hasAction}
status="success"
>
<slot name="action" slot="action"></slot>
</ha-backup-summary-card>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-summary-status": HaBackupSummaryProgress;
}
}

View File

@ -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`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
<ha-dialog-header slot="headline">
${isFirstStep
? html`
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
`
: html`
<ha-icon-button-prev
slot="navigationIcon"
@click=${this._previousStep}
></ha-icon-button-prev>
`}
<span slot="title">${this._stepTitle}</span>
</ha-dialog-header>
<div slot="content">${this._renderStepContent()}</div>
<div slot="actions">
${isLastStep
? html`
<ha-button
@click=${this._done}
.disabled=${!this._isStepValid()}
>
Save
</ha-button>
`
: html`
<ha-button
@click=${this._nextStep}
.disabled=${!this._isStepValid()}
>
Next
</ha-button>
`}
</div>
</ha-md-dialog>
`;
}
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`
<div class="welcome">
<img
src="/static/images/voice-assistant/hi.png"
alt="Casita Home Assistant logo"
/>
<h1>Set up your backup strategy</h1>
<p class="secondary">
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>
`;
case "new_key":
return html`
<p>
All your backups are encrypted to keep your data private and secure.
You need this encryption key to restore any backup.
</p>
<ha-password-field
placeholder="New encryption key"
@input=${this._encryptionKeyChanged}
.value=${this._config.create_backup.password ?? ""}
></ha-password-field>
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiKey}></ha-svg-icon>
<span slot="headline">Use suggested encryption key</span>
<span slot="supporting-text">
${this._suggestedEncryptionKey}
</span>
<ha-button slot="end" @click=${this._useSuggestedEncryptionKey}>
Enter
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
case "save_key":
return html`
<p>
Its important that you dont lose this encryption key. We recommend
to save this key somewhere secure. As you can only restore your data
with the backup encryption key.
</p>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Download emergency kit</span>
<span slot="supporting-text">
We recommend to save this encryption key somewhere secure.
</span>
<ha-button slot="end" @click=${this._downloadKey}>
<ha-svg-icon .path=${mdiDownload} slot="icon"></ha-svg-icon>
Download
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
case "schedule":
return html`
<p>
Let Home Assistant take care of your backups by creating a scheduled
backup that also removes older copies.
</p>
<ha-backup-config-schedule
.hass=${this.hass}
.value=${this._config}
@value-changed=${this._scheduleChanged}
></ha-backup-config-schedule>
`;
case "data":
return html`
<p>
Choose what data to include in your backups. You can always change
this later.
</p>
<ha-backup-config-data
.hass=${this.hass}
.value=${this._dataConfig(this._config)}
@value-changed=${this._dataChanged}
force-home-assistant
></ha-backup-config-data>
`;
case "locations":
return html`
<p>
Home Assistant will upload to these locations when this backup
strategy is used. You can use all locations for custom backups.
</p>
<ha-backup-config-agents
.hass=${this.hass}
.value=${this._config.create_backup.agent_ids}
@value-changed=${this._agentsConfigChanged}
></ha-backup-config-agents>
`;
}
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;
}
}

View File

@ -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`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
<ha-dialog-header slot="headline">
${this._step === "new"
? html`
<ha-icon-button-prev
slot="navigationIcon"
@click=${this._previousStep}
></ha-icon-button-prev>
`
: html`
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
`}
<span slot="title">${dialogTitle}</span>
</ha-dialog-header>
<div slot="content">${this._renderStepContent()}</div>
<div slot="actions">
${this._step === "current"
? html`<ha-button @click=${this._nextStep}>Next</ha-button>`
: this._step === "new"
? html`
<ha-button
@click=${this._submit}
.disabled=${!this._newEncryptionKey}
>
Change encryption key
</ha-button>
`
: this._step === "save"
? html`<ha-button @click=${this._done}>Done</ha-button>`
: nothing}
</div>
</ha-md-dialog>
`;
}
private _renderStepContent() {
switch (this._step) {
case "current":
return html`
<p>
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.
</p>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Download emergency kit</span>
<span slot="supporting-text">
We recommend to save this encryption key somewhere secure.
</span>
<ha-button slot="end" @click=${this._downloadOld}>
<ha-svg-icon .path=${mdiDownload} slot="icon"></ha-svg-icon>
Download
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
case "new":
return html`
<p>All next backups will use the new encryption key.</p>
<ha-password-field
placeholder="New encryption key"
@input=${this._encryptionKeyChanged}
.value=${this._newEncryptionKey || ""}
></ha-password-field>
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiKey}></ha-svg-icon>
<span slot="headline">Use suggested encryption key</span>
<span slot="supporting-text">
${this._suggestedEncryptionKey}
</span>
<ha-button slot="end" @click=${this._useSuggestedEncryptionKey}>
Enter
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
case "save":
return html`
<p>
Its important that you dont lose this encryption key. We recommend
to save this key somewhere secure. As you can only restore your data
with the backup encryption key.
</p>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Download emergency kit</span>
<span slot="supporting-text">
We recommend to save this encryption key somewhere secure.
</span>
<ha-button slot="end" @click=${this._downloadNew}>
<ha-svg-icon .path=${mdiDownload} slot="icon"></ha-svg-icon>
Download
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
}
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;
}
}

View File

@ -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`
<ha-md-dialog open disable-cancel-action @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
${isFirstStep
? html`
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
`
: html`
<ha-icon-button-prev
slot="navigationIcon"
@click=${this._previousStep}
></ha-icon-button-prev>
`}
<span slot="title" .title=${dialogTitle}> ${dialogTitle} </span>
</ha-dialog-header>
<div slot="content" class="content">
${this._step === "data" ? this._renderData() : this._renderSync()}
</div>
<div slot="actions">
${isFirstStep
? html`<ha-button @click=${this.closeDialog}>Cancel</ha-button>`
: nothing}
${isLastStep
? html`<ha-button @click=${this._submit}>Create backup</ha-button>`
: html`<ha-button @click=${this._nextStep}>Next</ha-button>`}
</div>
</ha-md-dialog>
`;
}
private _renderData() {
if (!this._formData) {
return nothing;
}
return html`
<ha-backup-config-data
.hass=${this.hass}
.value=${this._formData.data}
@value-changed=${this._dataConfigChanged}
></ha-backup-config-data>
`;
}
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`
<ha-textfield
name="name"
.label=${"Backup name"}
.value=${this._formData.name}
@change=${this._nameChanged}
>
</ha-textfield>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Backup locations</span>
<span slot="supporting-text">
What locations you want to automatically backup to.
</span>
<ha-md-select
slot="end"
id="agents_mode"
@change=${this._selectChanged}
.value=${this._formData.agents_mode}
>
<ha-md-select-option value="all">
<div slot="headline">All (${this._agents.length})</div>
</ha-md-select-option>
<ha-md-select-option value="custom">
<div slot="headline">Custom</div>
</ha-md-select-option>
</ha-md-select>
</ha-md-list-item>
</ha-md-list>
${this._formData.agents_mode === "custom"
? html`
<ha-expansion-panel .header=${"Locations"} outlined expanded>
<ha-backup-agents-picker
.hass=${this.hass}
.value=${this._formData.agent_ids}
@value-changed=${this._agentsChanged}
.agents=${this._agents}
></ha-backup-agents-picker>
</ha-expansion-panel>
`
: 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;
}
}

View File

@ -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`
<ha-md-dialog open @closed=${this.closeDialog}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">Backup now</span>
</ha-dialog-header>
<div slot="content">
<ha-md-list
innerRole="listbox"
itemRoles="option"
innerAriaLabel="Backup options"
rootTabbable
dialogInitialFocus
>
<ha-md-list-item
@click=${this._default}
type="button"
.disabled=${!this._params.config.create_backup.password}
>
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
<span slot="headline">Use backup strategy</span>
<span slot="supporting-text">
Create a backup with the data and locations you have configured.
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item @click=${this._custom} type="button">
<ha-svg-icon slot="start" .path=${mdiPencil}></ha-svg-icon>
<span slot="headline">Make custom backup</span>
<span slot="supporting-text">
Select specific data and locations for a custom backup.
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-md-dialog>
`;
}
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;
}
}

View File

@ -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`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}>${dialogTitle}</span>
</ha-dialog-header>
<div slot="content" class="content">
<p>
${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."}
</p>
<ha-form
.schema=${schema}
.data=${this._formData}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
>
</ha-form>
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog}>Cancel</ha-button>
<ha-button @click=${this._submit} .disabled=${!this._getKey()}>
Restore
</ha-button>
</div>
</ha-md-dialog>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
this._formData = ev.detail.value;
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
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;
}
}

View File

@ -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`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title">${dialogTitle}</span>
</ha-dialog-header>
<div slot="content">${this._renderStepContent()}</div>
<div slot="actions">
${this._step === "new"
? html`
<ha-button
@click=${this._submit}
.disabled=${!this._newEncryptionKey}
>
Next
</ha-button>
`
: this._step === "save"
? html`<ha-button @click=${this._done}>Done</ha-button>`
: nothing}
</div>
</ha-md-dialog>
`;
}
private _renderStepContent() {
switch (this._step) {
case "new":
return html`
<p>
All your backups are encrypted to keep your data private and secure.
You need this encryption key to restore any backup.
</p>
<ha-password-field
placeholder="New encryption key"
@input=${this._encryptionKeyChanged}
.value=${this._newEncryptionKey || ""}
></ha-password-field>
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiKey}></ha-svg-icon>
<span slot="headline">Use suggested encryption key</span>
<span slot="supporting-text">
${this._suggestedEncryptionKey}
</span>
<ha-button slot="end" @click=${this._useSuggestedEncryptionKey}>
Enter
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
case "save":
return html`
<p>
Its important that you dont lose this encryption key. We recommend
to save this key somewhere secure. As you can only restore your data
with the backup encryption key.
</p>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Download emergency kit</span>
<span slot="supporting-text">
We recommend to save this encryption key somewhere secure.
</span>
<ha-button slot="end" @click=${this._downloadNew}>
<ha-svg-icon .path=${mdiDownload} slot="icon"></ha-svg-icon>
Download
</ha-button>
</ha-md-list-item>
</ha-md-list>
`;
}
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;
}
}

View File

@ -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<UploadBackupDialogParams>
{
@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<void> {
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`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title">Upload backup</span>
</ha-dialog-header>
<div slot="content">
<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept=${SUPPORTED_FORMAT}
label="Select backup file"
supports="Supports .tar files"
@file-picked=${this._filePicked}
></ha-file-upload>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">Locations</span>
<span slot="supporting-text">
What locations you want to upload this backup.
</span>
${keyed(
this._agents.length,
html`
<ha-md-select
slot="end"
id="agents_mode"
@change=${this._selectChanged}
.value=${this._formData!.agents_mode}
>
<ha-md-select-option value="all">
<div slot="headline">All (${this._agents.length})</div>
</ha-md-select-option>
<ha-md-select-option value="custom">
<div slot="headline">Custom</div>
</ha-md-select-option>
</ha-md-select>
`
)}
</ha-md-list-item>
</ha-md-list>
${this._formData.agents_mode === "custom"
? html`
<ha-expansion-panel .header=${"Locations"} outlined expanded>
<ha-backup-agents-picker
.hass=${this.hass}
.value=${this._formData.agent_ids}
@value-changed=${this._agentsChanged}
.agents=${this._agents}
></ha-backup-agents-picker>
</ha-expansion-panel>
`
: nothing}
${this._error
? html`<ha-alert alertType="error">${this._error}</ha-alert>`
: nothing}
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog}>Cancel</ha-button>
<ha-button @click=${this._upload} .disabled=${!this._formValid()}>
Upload backup
</ha-button>
</div>
</ha-md-dialog>
`;
}
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<void> {
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;
}
}

View File

@ -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<boolean>((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);
}
},
},
});
});

View File

@ -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<boolean>((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);
}
},
},
});
});

View File

@ -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<GenerateBackupParams | null>((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);
}
},
},
});
});

View File

@ -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<NewBackupType | null>((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);
}
},
},
});
});

View File

@ -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<string | null>((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);
}
},
},
});
});

View File

@ -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<boolean>((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);
}
},
},
});
});

View File

@ -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<void | null>((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();
}
},
},
});
});

View File

@ -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<BackupContent> => ({
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`
<div style="display: flex; gap: 4px;">
${(backup.agent_ids || []).map((agentId) => {
const name = computeBackupAgentName(
this.hass.localize,
agentId,
backup.agent_ids
);
if (isLocalAgent(agentId)) {
return html`
<ha-svg-icon
.path=${mdiDatabase}
title=${name}
slot="graphic"
></ha-svg-icon>
`;
}
const domain = computeDomain(agentId);
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"
/>
`;
})}
</div>
`,
},
actions: {
title: "",
label: localize("ui.panel.config.generic.headers.actions"),
showNarrow: true,
moveable: false,
hideable: false,
type: "overflow-menu",
template: (backup) => html`
<ha-icon-overflow-menu
.hass=${this.hass}
narrow
.items=${[
{
label: this.hass.localize("ui.common.download"),
path: mdiDownload,
action: () => this._downloadBackup(backup),
},
{
label: this.hass.localize("ui.common.delete"),
path: mdiDelete,
action: () => this._deleteBackup(backup),
warning: true,
},
]}
>
</ha-icon-overflow-menu>
`,
},
})
);
private _handleSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
): 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`
<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/system"
clickable
id="backup_id"
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
.route=${this.route}
@row-click=${this._showBackupDetails}
.columns=${this._columns(this.hass.localize)}
.data=${data}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
)}
>
<div slot="top_header" class="header">
${this._fetching
? html`
<ha-backup-summary-card
heading="Loading backups"
description="Your backup information is being retrieved."
has-action
status="loading"
>
<ha-button
slot="action"
@click=${this._configureBackupStrategy}
>
Configure
</ha-button>
</ha-backup-summary-card>
`
: backupInProgress
? html`
<ha-backup-summary-progress
.hass=${this.hass}
.manager=${this._manager}
has-action
>
<ha-button
slot="action"
@click=${this._configureBackupStrategy}
>
Configure
</ha-button>
</ha-backup-summary-progress>
`
: this._needsOnboarding
? html`
<ha-backup-summary-card
heading="Configure backup strategy"
description="Have a one-click backup automation with selected data and locations."
has-action
status="info"
>
<ha-button
slot="action"
@click=${this._setupBackupStrategy}
>
Setup backup strategy
</ha-button>
</ha-backup-summary-card>
`
: html`
<ha-backup-summary-status
.hass=${this.hass}
.backups=${this._backups}
has-action
>
<ha-button
slot="action"
@click=${this._configureBackupStrategy}
>
Configure
</ha-button>
</ha-backup-summary-status>
`}
</div>
<div slot="toolbar-icon">
<ha-button-menu>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item
graphic="icon"
@request-selected=${this._uploadBackup}
>
<ha-svg-icon slot="graphic" .path=${mdiUpload}></ha-svg-icon>
Upload backup
</ha-list-item>
</ha-button-menu>
</div>
${this._selected.length
? html`<div
class=${classMap({
"header-toolbar": this.narrow,
"table-header": !this.narrow,
})}
slot="header"
>
<p class="selected-txt">
${this._selected.length} backups selected
</p>
<div class="header-btns">
${!this.narrow
? html`
<ha-button @click=${this._deleteSelected} class="warning">
Delete selected
</ha-button>
`
: html`
<ha-icon-button
.label=${"Delete selected"}
.path=${mdiDelete}
id="delete-btn"
class="warning"
@click=${this._deleteSelected}
></ha-icon-button>
<simple-tooltip animation-delay="0" for="delete-btn">
Delete selected
</simple-tooltip>
`}
</div>
</div> `
: nothing}
<ha-fab
slot="fab"
?disabled=${backupInProgress}
.label=${this.hass.localize("ui.panel.config.backup.create_backup")}
extended
@click=${this._newBackup}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
`;
}
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<void> {
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<void> {
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<void> {
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;
}
}

View File

@ -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`
<hass-subpage
back-path="/config/backup"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this._backup?.name || "Backup"}
>
<ha-button-menu slot="toolbar-icon" @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize("ui.common.download")}
</ha-list-item>
<ha-list-item graphic="icon" class="warning">
<ha-svg-icon slot="graphic" .path=${mdiDelete}></ha-svg-icon>
${this.hass.localize("ui.common.delete")}
</ha-list-item>
</ha-button-menu>
<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.backupId} not found
</ha-alert>`
: !this._backup
? html`<ha-circular-progress active></ha-circular-progress>`
: html`
<ha-card header="Select what to restore">
<div class="card-content">
<ha-backup-data-picker
.hass=${this.hass}
.data=${this._backup}
.value=${this._selectedBackup}
@value-changed=${this._selectedBackupChanged}
.addonsInfo=${this._addonsInfo}
>
</ha-backup-data-picker>
</div>
<div class="card-actions">
<ha-button
@click=${this._restore}
.disabled=${this._isRestoreDisabled()}
>
Restore
</ha-button>
</div>
</ha-card>
<ha-card header="Backup">
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${bytesToString(this._backup.size)}
</span>
<span slot="supporting-text">Size</span>
</ha-md-list-item>
<ha-md-list-item>
${formatDateTime(
new Date(this._backup.date),
this.hass.locale,
this.hass.config
)}
<span slot="supporting-text">Created</span>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
<ha-card header="Locations">
<div class="card-content">
<ha-md-list>
${this._backup.agent_ids?.map((agentId) => {
const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this._backup!.agent_ids!
);
return html`
<ha-md-list-item>
${isLocalAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiDatabase}
slot="start"
>
</ha-svg-icon>
`
: html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized:
this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`}
<div slot="headline">${name}</div>
<ha-button-menu
slot="end"
@action=${this._handleAgentAction}
.agent=${agentId}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
<ha-svg-icon
slot="graphic"
.path=${mdiDownload}
></ha-svg-icon>
Download from this location
</ha-list-item>
</ha-button-menu>
</ha-md-list-item>
`;
})}
</ha-md-list>
</div>
</ha-card>
`}
</div>
</hass-subpage>
`;
}
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<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._downloadBackup();
break;
case 1:
this._deleteBackup();
break;
}
}
private _handleAgentAction(ev: CustomEvent<ActionDetail>) {
const button = ev.currentTarget;
const agentId = (button as any).agent;
this._downloadBackup(agentId);
}
private async _downloadBackup(agentId?: string): Promise<void> {
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<void> {
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;
}
}

View File

@ -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`
<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

@ -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`
<hass-subpage
back-path="/config/backup"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${"Backup strategy"}
>
<div class="content">
<ha-card>
<div class="card-header">Automatic backups</div>
<div class="card-content">
<p>
Let Home Assistant take care of your backup strategy by creating
a scheduled backup that also removes older copies.
</p>
<ha-backup-config-schedule
.hass=${this.hass}
.value=${this._backupConfig}
@value-changed=${this._scheduleConfigChanged}
></ha-backup-config-schedule>
</div>
</ha-card>
<ha-card>
<div class="card-header">Backup data</div>
<div class="card-content">
<ha-backup-config-data
.hass=${this.hass}
.value=${this._dataConfig}
@value-changed=${this._dataConfigChanged}
force-home-assistant
></ha-backup-config-data>
</div>
</ha-card>
<ha-card class="agents">
<div class="card-header">Locations</div>
<div class="card-content">
<p>
Your backup will be stored on these locations when this default
backup is created. You can use all locations for custom backups.
</p>
<ha-backup-config-agents
.hass=${this.hass}
.value=${this._backupConfig.create_backup.agent_ids}
@value-changed=${this._agentsConfigChanged}
></ha-backup-config-agents>
</div>
</ha-card>
<ha-card>
<div class="card-header">Encryption key</div>
<div class="card-content">
<p>
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.
</p>
<ha-backup-config-encryption-key
.hass=${this.hass}
.value=${this._backupConfig.create_backup.password}
@value-changed=${this._encryptionKeyChanged}
></ha-backup-config-encryption-key>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
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;
}
}

View File

@ -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<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,
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`<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.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`<hass-loading-screen></hass-loading-screen>`;
if (
(!changedProps || changedProps.has("route")) &&
this._currentPage === "details"
) {
pageEl.backupId = this.routeTail.path.substr(1);
}
return html`
<hass-tabs-subpage-data-table
has-fab
.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;
}
`,
];
}
}

View File

@ -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",

View File

@ -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 =>