mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-22 16:56:35 +00:00
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:
parent
c8f58c7bc9
commit
86f1af6682
@ -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;
|
||||
|
@ -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"
|
||||
|
31
src/components/ha-md-textfield.ts
Normal file
31
src/components/ha-md-textfield.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
77
src/data/backup_manager.ts
Normal file
77
src/data/backup_manager.ts
Normal 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",
|
||||
};
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
136
src/panels/config/backup/components/ha-backup-agents-picker.ts
Normal file
136
src/panels/config/backup/components/ha-backup-agents-picker.ts
Normal 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;
|
||||
}
|
||||
}
|
136
src/panels/config/backup/components/ha-backup-config-agents.ts
Normal file
136
src/panels/config/backup/components/ha-backup-config-agents.ts
Normal 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;
|
||||
}
|
||||
}
|
320
src/panels/config/backup/components/ha-backup-config-data.ts
Normal file
320
src/panels/config/backup/components/ha-backup-config-data.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
350
src/panels/config/backup/components/ha-backup-config-schedule.ts
Normal file
350
src/panels/config/backup/components/ha-backup-config-schedule.ts
Normal 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;
|
||||
}
|
||||
}
|
347
src/panels/config/backup/components/ha-backup-data-picker.ts
Normal file
347
src/panels/config/backup/components/ha-backup-data-picker.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
149
src/panels/config/backup/components/ha-backup-summary-card.ts
Normal file
149
src/panels/config/backup/components/ha-backup-summary-card.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
478
src/panels/config/backup/dialogs/dialog-backup-onboarding.ts
Normal file
478
src/panels/config/backup/dialogs/dialog-backup-onboarding.ts
Normal 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>
|
||||
It’s important that you don’t 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
It’s important that you don’t 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;
|
||||
}
|
||||
}
|
341
src/panels/config/backup/dialogs/dialog-generate-backup.ts
Normal file
341
src/panels/config/backup/dialogs/dialog-generate-backup.ts
Normal 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;
|
||||
}
|
||||
}
|
140
src/panels/config/backup/dialogs/dialog-new-backup.ts
Normal file
140
src/panels/config/backup/dialogs/dialog-new-backup.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
It’s important that you don’t 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;
|
||||
}
|
||||
}
|
259
src/panels/config/backup/dialogs/dialog-upload-backup.ts
Normal file
259
src/panels/config/backup/dialogs/dialog-upload-backup.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
40
src/panels/config/backup/dialogs/show-dialog-new-backup.ts
Normal file
40
src/panels/config/backup/dialogs/show-dialog-new-backup.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
634
src/panels/config/backup/ha-config-backup-dashboard.ts
Normal file
634
src/panels/config/backup/ha-config-backup-dashboard.ts
Normal 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;
|
||||
}
|
||||
}
|
363
src/panels/config/backup/ha-config-backup-details.ts
Normal file
363
src/panels/config/backup/ha-config-backup-details.ts
Normal 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;
|
||||
}
|
||||
}
|
138
src/panels/config/backup/ha-config-backup-locations.ts
Normal file
138
src/panels/config/backup/ha-config-backup-locations.ts
Normal 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;
|
||||
}
|
||||
}
|
250
src/panels/config/backup/ha-config-backup-strategy.ts
Normal file
250
src/panels/config/backup/ha-config-backup-strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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 =>
|
||||
|
Loading…
x
Reference in New Issue
Block a user