mirror of
https://github.com/home-assistant/frontend.git
synced 2025-12-24 08:57:26 +00:00
Compare commits
22 Commits
fix_new_sc
...
fix-naviga
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a6e48a82b | ||
|
|
a8b8fb4c30 | ||
|
|
93947d76a2 | ||
|
|
18cce45b88 | ||
|
|
86f1af6682 | ||
|
|
c8f58c7bc9 | ||
|
|
e5e168803a | ||
|
|
3436a023f6 | ||
|
|
84322a21fe | ||
|
|
ec20f7e2c4 | ||
|
|
3579d82e8e | ||
|
|
70532ac3bf | ||
|
|
96b9d25bc5 | ||
|
|
91777d45b0 | ||
|
|
0582b8430d | ||
|
|
63d97398c1 | ||
|
|
3552417b39 | ||
|
|
a6cbbfe1a4 | ||
|
|
48f5d17060 | ||
|
|
c713106948 | ||
|
|
142e674020 | ||
|
|
e4fc21c991 |
@@ -13,28 +13,41 @@ const brotliOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
const compressDistBrotli = (rootDir, modernDir, compressServiceWorker = true) =>
|
||||
const compressModern = (rootDir, modernDir) =>
|
||||
gulp
|
||||
.src([`${modernDir}/**/${filesGlob}`, `${rootDir}/sw-modern.js`], {
|
||||
base: rootDir,
|
||||
allowEmpty: true,
|
||||
})
|
||||
.pipe(brotli(brotliOptions))
|
||||
.pipe(gulp.dest(rootDir));
|
||||
|
||||
const compressOther = (rootDir, modernDir) =>
|
||||
gulp
|
||||
.src(
|
||||
[
|
||||
`${modernDir}/**/${filesGlob}`,
|
||||
compressServiceWorker ? `${rootDir}/sw-modern.js` : undefined,
|
||||
].filter(Boolean),
|
||||
{
|
||||
base: rootDir,
|
||||
}
|
||||
`${rootDir}/**/${filesGlob}`,
|
||||
`!${modernDir}/**/${filesGlob}`,
|
||||
`!${rootDir}/{sw-modern,service_worker}.js`,
|
||||
`${rootDir}/{authorize,onboarding}.html`,
|
||||
],
|
||||
{ base: rootDir, allowEmpty: true }
|
||||
)
|
||||
.pipe(brotli(brotliOptions))
|
||||
.pipe(gulp.dest(rootDir));
|
||||
|
||||
const compressAppBrotli = () =>
|
||||
compressDistBrotli(paths.app_output_root, paths.app_output_latest);
|
||||
const compressHassioBrotli = () =>
|
||||
compressDistBrotli(
|
||||
paths.hassio_output_root,
|
||||
paths.hassio_output_latest,
|
||||
false
|
||||
);
|
||||
const compressAppModern = () =>
|
||||
compressModern(paths.app_output_root, paths.app_output_latest);
|
||||
const compressHassioModern = () =>
|
||||
compressModern(paths.hassio_output_root, paths.hassio_output_latest);
|
||||
|
||||
gulp.task("compress-app", compressAppBrotli);
|
||||
gulp.task("compress-hassio", compressHassioBrotli);
|
||||
const compressAppOther = () =>
|
||||
compressOther(paths.app_output_root, paths.app_output_latest);
|
||||
const compressHassioOther = () =>
|
||||
compressOther(paths.hassio_output_root, paths.hassio_output_latest);
|
||||
|
||||
gulp.task("compress-app", gulp.parallel(compressAppModern, compressAppOther));
|
||||
gulp.task(
|
||||
"compress-hassio",
|
||||
gulp.parallel(compressHassioModern, compressHassioOther)
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
|
||||
import { fireEvent } from "./dom/fire_event";
|
||||
import { mainWindow } from "./dom/get_main_window";
|
||||
|
||||
@@ -13,31 +14,44 @@ export interface NavigateOptions {
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export const navigate = (path: string, options?: NavigateOptions) => {
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
`${mainWindow.location.pathname}#${path}`
|
||||
);
|
||||
} else {
|
||||
mainWindow.location.hash = path;
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const { history } = mainWindow;
|
||||
if (history.state?.dialog) {
|
||||
const closed = await closeAllDialogs();
|
||||
if (!closed) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Navigation blocked, because dialog refused to close");
|
||||
return false;
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// need to wait for history state to be updated in case a dialog was closed
|
||||
setTimeout(async () => {
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__) {
|
||||
if (replace) {
|
||||
history.replaceState(
|
||||
history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
"",
|
||||
`${mainWindow.location.pathname}#${path}`
|
||||
);
|
||||
} else {
|
||||
mainWindow.location.hash = path;
|
||||
}
|
||||
} else if (replace) {
|
||||
history.replaceState(
|
||||
history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -164,7 +164,6 @@ export class HaRelatedItems extends LitElement {
|
||||
return html`
|
||||
<a
|
||||
href=${`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`}
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<img
|
||||
@@ -191,7 +190,6 @@ export class HaRelatedItems extends LitElement {
|
||||
(integration) =>
|
||||
html`<a
|
||||
href=${`/config/integrations/integration/${integration}`}
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<img
|
||||
@@ -223,10 +221,7 @@ export class HaRelatedItems extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<a
|
||||
href="/config/devices/device/${relatedDeviceId}"
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
<a href="/config/devices/device/${relatedDeviceId}">
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<ha-svg-icon
|
||||
.path=${mdiDevices}
|
||||
@@ -251,10 +246,7 @@ export class HaRelatedItems extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<a
|
||||
href="/config/areas/area/${relatedAreaId}"
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
<a href="/config/areas/area/${relatedAreaId}">
|
||||
<ha-list-item
|
||||
hasMeta
|
||||
.graphic=${area.picture ? "avatar" : "icon"}
|
||||
@@ -364,10 +356,7 @@ export class HaRelatedItems extends LitElement {
|
||||
const blueprintMeta = this._blueprints
|
||||
? this._blueprints.automation[path]
|
||||
: undefined;
|
||||
return html`<a
|
||||
href="/config/blueprint/dashboard"
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
return html`<a href="/config/blueprint/dashboard">
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<ha-svg-icon
|
||||
.path=${mdiPaletteSwatch}
|
||||
@@ -421,10 +410,7 @@ export class HaRelatedItems extends LitElement {
|
||||
const blueprintMeta = this._blueprints
|
||||
? this._blueprints.script[path]
|
||||
: undefined;
|
||||
return html`<a
|
||||
href="/config/blueprint/dashboard"
|
||||
@click=${this._navigateAwayClose}
|
||||
>
|
||||
return html`<a href="/config/blueprint/dashboard">
|
||||
<ha-list-item hasMeta graphic="icon">
|
||||
<ha-svg-icon
|
||||
.path=${mdiPaletteSwatch}
|
||||
@@ -468,14 +454,6 @@ export class HaRelatedItems extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _navigateAwayClose() {
|
||||
// allow new page to open before closing dialog
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
fireEvent(this, "close-dialog");
|
||||
}
|
||||
|
||||
private async _findRelated() {
|
||||
this._related = await findRelated(this.hass, this.itemType, this.itemId);
|
||||
if (this._related.config_entry) {
|
||||
|
||||
@@ -48,6 +48,7 @@ import "./ha-menu-button";
|
||||
import "./ha-sortable";
|
||||
import "./ha-svg-icon";
|
||||
import "./user/ha-user-badge";
|
||||
import { preventDefault } from "../common/dom/prevent_default";
|
||||
|
||||
const SHOW_AFTER_SPACER = ["config", "developer-tools"];
|
||||
|
||||
@@ -404,6 +405,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
@focusout=${this._listboxFocusOut}
|
||||
@scroll=${this._listboxScroll}
|
||||
@keydown=${this._listboxKeydown}
|
||||
@iron-activate=${preventDefault}
|
||||
>
|
||||
${this.editMode
|
||||
? this._renderPanelsEdit(beforeSpacer)
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -171,10 +171,23 @@ export const closeLastDialog = async () => {
|
||||
""
|
||||
);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const _handleClosed = async (ev: HASSDomEvent<DialogClosedParams>) => {
|
||||
export const closeAllDialogs = async () => {
|
||||
for (let i = OPEN_DIALOG_STACK.length - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const closed = await closeDialog(OPEN_DIALOG_STACK[i].dialogTag);
|
||||
if (!closed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const _handleClosed = (ev: HASSDomEvent<DialogClosedParams>) => {
|
||||
// If not closed by navigating back, remove the open state from history
|
||||
const dialogIndex = OPEN_DIALOG_STACK.findIndex(
|
||||
(state) => state.dialogTag === ev.detail.dialog
|
||||
@@ -189,7 +202,8 @@ const _handleClosed = async (ev: HASSDomEvent<DialogClosedParams>) => {
|
||||
{ dialog: OPEN_DIALOG_STACK[OPEN_DIALOG_STACK.length - 1].dialogTag },
|
||||
""
|
||||
);
|
||||
} else {
|
||||
} else if (dialogIndex !== -1) {
|
||||
// if the dialog is the last one and it was indeed open, go back
|
||||
mainWindow.history.back();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { PropertyValues } 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 { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { createSearchParam } from "../../common/url/search-params";
|
||||
import type { ChartResizeOptions } from "../../components/chart/ha-chart-base";
|
||||
@@ -77,7 +76,7 @@ export class MoreInfoHistory extends LitElement {
|
||||
</div>
|
||||
${__DEMO__
|
||||
? nothing
|
||||
: html`<a href=${this._showMoreHref} @click=${this._close}
|
||||
: html`<a href=${this._showMoreHref}
|
||||
>${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.show_more"
|
||||
)}</a
|
||||
@@ -244,10 +243,6 @@ export class MoreInfoHistory extends LitElement {
|
||||
this._setRedrawTimer();
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
setTimeout(() => fireEvent(this, "close-dialog"), 500);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { createSearchParam } from "../../common/url/search-params";
|
||||
import "../../panels/logbook/ha-logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -36,7 +35,7 @@ export class MoreInfoLogbook extends LitElement {
|
||||
<div class="title">
|
||||
${this.hass.localize("ui.dialogs.more_info_control.logbook")}
|
||||
</div>
|
||||
<a href=${this._showMoreHref} @click=${this._close}
|
||||
<a href=${this._showMoreHref}
|
||||
>${this.hass.localize("ui.dialogs.more_info_control.show_more")}</a
|
||||
>
|
||||
</div>
|
||||
@@ -67,10 +66,6 @@ export class MoreInfoLogbook extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
setTimeout(() => fireEvent(this, "close-dialog"), 500);
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
css`
|
||||
|
||||
@@ -142,7 +142,7 @@ export class HaVoiceCommandDialog extends LitElement {
|
||||
${this.hass.user?.is_admin
|
||||
? html`<li divider role="separator"></li>
|
||||
<a href="/config/voice-assistants/assistants"
|
||||
><ha-list-item @click=${this.closeDialog}
|
||||
><ha-list-item
|
||||
>${this.hass.localize(
|
||||
"ui.dialogs.voice_command.manage_assistants"
|
||||
)}</ha-list-item
|
||||
|
||||
@@ -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
|
||||
|
||||
57
src/mixins/prevent-unsaved-mixin.ts
Normal file
57
src/mixins/prevent-unsaved-mixin.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { LitElement, PropertyValues } from "lit";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import type { Constructor } from "../types";
|
||||
|
||||
export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
|
||||
superClass: T
|
||||
) =>
|
||||
class extends superClass {
|
||||
private _handleClick = async (e: MouseEvent) => {
|
||||
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
|
||||
const target = e.composedPath()[0];
|
||||
if (!isNavigationClick(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.promptDiscardChanges();
|
||||
if (result) {
|
||||
this._removeListeners();
|
||||
if (target) {
|
||||
const newEvent = new MouseEvent(e.type, e);
|
||||
target.dispatchEvent(newEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
|
||||
private _removeListeners() {
|
||||
window.removeEventListener("click", this._handleClick, true);
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
}
|
||||
|
||||
public willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
if (this.isDirty) {
|
||||
window.addEventListener("click", this._handleClick, true);
|
||||
window.addEventListener("beforeunload", this._handleUnload);
|
||||
} else {
|
||||
this._removeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this._removeListeners();
|
||||
}
|
||||
|
||||
protected get isDirty(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -64,6 +64,7 @@ import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-a
|
||||
import { showAutomationRenameDialog } from "./automation-rename-dialog/show-dialog-automation-rename";
|
||||
import "./blueprint-automation-editor";
|
||||
import "./manual-automation-editor";
|
||||
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -82,7 +83,9 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
|
||||
export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
KeyboardShortcutMixin(LitElement)
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public automationId: string | null = null;
|
||||
@@ -847,6 +850,14 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
|
||||
};
|
||||
}
|
||||
|
||||
protected get isDirty() {
|
||||
return this._dirty;
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges() {
|
||||
return this._confirmUnsavedChanged();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -237,7 +237,6 @@ class HaDomainIntegrations extends LitElement {
|
||||
["cloud", "google_assistant", "alexa"].includes(domain) &&
|
||||
isComponentLoaded(this.hass, "cloud")
|
||||
) {
|
||||
fireEvent(this, "close-dialog");
|
||||
navigate("/config/cloud");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { showAddUserDialog } from "../users/show-dialog-add-user";
|
||||
import { showAdminChangePasswordDialog } from "../users/show-dialog-admin-change-password";
|
||||
import type { PersonDetailDialogParams } from "./show-dialog-person-detail";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
|
||||
const includeDomains = ["device_tracker"];
|
||||
|
||||
@@ -101,6 +102,20 @@ class DialogPersonDetail extends LitElement {
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
// If we do not have a person ID yet (= person creation dialog was just cancelled), but
|
||||
// we already created a user ID for it, delete it now to not have it "free floating".
|
||||
if (!this._personExists && this._userId) {
|
||||
const callback = this._params?.refreshUsers;
|
||||
deleteUser(this.hass, this._userId).then(() => {
|
||||
callback?.();
|
||||
});
|
||||
this._userId = undefined;
|
||||
}
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
@@ -109,7 +124,7 @@ class DialogPersonDetail extends LitElement {
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@closed=${this._close}
|
||||
@closed=${this.closeDialog}
|
||||
scrimClickAction
|
||||
escapeKeyAction
|
||||
.heading=${createCloseHeading(
|
||||
@@ -163,7 +178,7 @@ class DialogPersonDetail extends LitElement {
|
||||
</ha-settings-row>
|
||||
|
||||
${this._renderUserFields()}
|
||||
${this._deviceTrackersAvailable(this.hass)
|
||||
${!this._deviceTrackersAvailable(this.hass)
|
||||
? html`
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
@@ -205,10 +220,7 @@ class DialogPersonDetail extends LitElement {
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
@click=${this._closeDialog}
|
||||
href="/config/integrations"
|
||||
>
|
||||
<a @click=${this.closeDialog} href="/config/integrations">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.person.detail.link_integrations_page"
|
||||
)}</a
|
||||
@@ -329,10 +341,6 @@ class DialogPersonDetail extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _closeDialog() {
|
||||
this._params = undefined;
|
||||
}
|
||||
|
||||
private _nameChanged(ev) {
|
||||
this._error = undefined;
|
||||
this._name = ev.target.value;
|
||||
@@ -496,7 +504,7 @@ class DialogPersonDetail extends LitElement {
|
||||
await this._params!.createEntry(values);
|
||||
this._personExists = true;
|
||||
}
|
||||
this._params = undefined;
|
||||
this.closeDialog();
|
||||
} catch (err: any) {
|
||||
this._error = err ? err.message : "Unknown error";
|
||||
} finally {
|
||||
@@ -511,25 +519,13 @@ class DialogPersonDetail extends LitElement {
|
||||
if (this._params!.entry!.user_id) {
|
||||
deleteUser(this.hass, this._params!.entry!.user_id);
|
||||
}
|
||||
this._params = undefined;
|
||||
this.closeDialog();
|
||||
}
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _close(): void {
|
||||
// If we do not have a person ID yet (= person creation dialog was just cancelled), but
|
||||
// we already created a user ID for it, delete it now to not have it "free floating".
|
||||
if (!this._personExists && this._userId) {
|
||||
deleteUser(this.hass, this._userId);
|
||||
this._params?.refreshUsers();
|
||||
this._userId = undefined;
|
||||
}
|
||||
|
||||
this._params = undefined;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
|
||||
@@ -77,6 +77,7 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import "../ha-config-section";
|
||||
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
|
||||
|
||||
interface DeviceEntities {
|
||||
id: string;
|
||||
@@ -89,8 +90,8 @@ interface DeviceEntitiesLookup {
|
||||
}
|
||||
|
||||
@customElement("ha-scene-editor")
|
||||
export class HaSceneEditor extends SubscribeMixin(
|
||||
KeyboardShortcutMixin(LitElement)
|
||||
export class HaSceneEditor extends PreventUnsavedMixin(
|
||||
SubscribeMixin(KeyboardShortcutMixin(LitElement))
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -1225,6 +1226,14 @@ export class HaSceneEditor extends SubscribeMixin(
|
||||
});
|
||||
}
|
||||
|
||||
protected get isDirty() {
|
||||
return this._dirty;
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges() {
|
||||
return this._confirmUnsavedChanged();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -57,8 +57,11 @@ import "./blueprint-script-editor";
|
||||
import "./manual-script-editor";
|
||||
import type { HaManualScriptEditor } from "./manual-script-editor";
|
||||
import { substituteBlueprint } from "../../../data/blueprint";
|
||||
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
|
||||
|
||||
export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
|
||||
export class HaScriptEditor extends PreventUnsavedMixin(
|
||||
KeyboardShortcutMixin(LitElement)
|
||||
) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public scriptId: string | null = null;
|
||||
@@ -813,6 +816,14 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
|
||||
};
|
||||
}
|
||||
|
||||
protected get isDirty() {
|
||||
return this._dirty;
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges() {
|
||||
return this._confirmUnsavedChanged();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -181,11 +181,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.assistants.pipeline.detail.no_cloud_message"
|
||||
)}
|
||||
<a
|
||||
href="/config/cloud"
|
||||
slot="action"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
<a href="/config/cloud" slot="action">
|
||||
<ha-button>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.assistants.pipeline.detail.no_cloud_action"
|
||||
|
||||
@@ -181,17 +181,21 @@ class HaPanelDevTemplate extends LitElement {
|
||||
>`
|
||||
: nothing}
|
||||
${this._templateResult
|
||||
? html`${this.hass.localize(
|
||||
"ui.panel.developer-tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
<!-- prettier-ignore -->
|
||||
<pre class="rendered ${classMap({
|
||||
[resultType]: resultType,
|
||||
})}"
|
||||
>${type === "object"
|
||||
? JSON.stringify(this._templateResult.result, null, 2)
|
||||
: this._templateResult.result}</pre>
|
||||
? html`<pre
|
||||
class="rendered ${classMap({
|
||||
[resultType]: resultType,
|
||||
})}"
|
||||
>
|
||||
${type === "object"
|
||||
? JSON.stringify(this._templateResult.result, null, 2)
|
||||
: this._templateResult.result}</pre
|
||||
>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.developer-tools.tabs.templates.result_type"
|
||||
)}:
|
||||
${resultType}
|
||||
</p>
|
||||
${this._templateResult.listeners.time
|
||||
? html`
|
||||
<p>
|
||||
@@ -281,6 +285,16 @@ class HaPanelDevTemplate extends LitElement {
|
||||
max(16px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.content.horizontal {
|
||||
--code-mirror-max-height: calc(
|
||||
100vh - var(--header-height) -
|
||||
(var(--paper-font-body1_-_line-height) * 3) - (1em * 2) -
|
||||
(max(16px, env(safe-area-inset-top)) * 2) -
|
||||
(max(16px, env(safe-area-inset-bottom)) * 2) -
|
||||
(var(--ha-card-border-width, 1px) * 2) - 179px
|
||||
);
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -293,8 +307,9 @@ class HaPanelDevTemplate extends LitElement {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.horizontal .edit-pane {
|
||||
max-width: 50%;
|
||||
.content.horizontal > * {
|
||||
width: 50%;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.render-spinner {
|
||||
@@ -316,15 +331,35 @@ class HaPanelDevTemplate extends LitElement {
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--secondary-background-color);
|
||||
padding: 8px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
p,
|
||||
ul {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane .card-content {
|
||||
overflow: auto;
|
||||
max-height: calc(
|
||||
var(--code-mirror-max-height) +
|
||||
47px - var(--ha-card-border-radius, 12px)
|
||||
);
|
||||
}
|
||||
|
||||
.content.horizontal .render-pane {
|
||||
overflow: hidden;
|
||||
padding-bottom: var(--ha-card-border-radius, 12px);
|
||||
}
|
||||
|
||||
.all_listeners {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
@media all and (max-width: 870px) {
|
||||
.render-pane {
|
||||
.content ha-card {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,12 +179,21 @@ export const moveCard = (
|
||||
export const addView = (
|
||||
hass: HomeAssistant,
|
||||
config: LovelaceConfig,
|
||||
viewConfig: LovelaceViewConfig
|
||||
viewConfig: LovelaceViewConfig,
|
||||
tolerantPath = false
|
||||
): LovelaceConfig => {
|
||||
if (viewConfig.path && config.views.some((v) => v.path === viewConfig.path)) {
|
||||
throw new Error(
|
||||
hass.localize("ui.panel.lovelace.editor.edit_view.error_same_url")
|
||||
);
|
||||
if (!tolerantPath) {
|
||||
throw new Error(
|
||||
hass.localize("ui.panel.lovelace.editor.edit_view.error_same_url")
|
||||
);
|
||||
} else {
|
||||
// add a suffix to the path
|
||||
viewConfig = {
|
||||
...viewConfig,
|
||||
path: `${viewConfig.path}-2`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
@@ -240,6 +249,20 @@ export const deleteView = (
|
||||
views: config.views.filter((_origView, index) => index !== viewIndex),
|
||||
});
|
||||
|
||||
export const moveViewToDashboard = (
|
||||
hass: HomeAssistant,
|
||||
fromConfig: LovelaceConfig,
|
||||
toConfig: LovelaceConfig,
|
||||
viewIndex: number
|
||||
): [LovelaceConfig, LovelaceConfig] => {
|
||||
const view = fromConfig.views[viewIndex];
|
||||
|
||||
return [
|
||||
deleteView(fromConfig, viewIndex),
|
||||
addView(hass, toConfig, view, true),
|
||||
];
|
||||
};
|
||||
|
||||
export const addSection = (
|
||||
config: LovelaceConfig,
|
||||
viewIndex: number,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-md-dialog";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-md-select";
|
||||
import "../../../../components/ha-md-select-option";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-circular-progress";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
|
||||
import { fetchDashboards } from "../../../../data/lovelace/dashboard";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { SelectDashboardDialogParams } from "./show-select-dashboard-dialog";
|
||||
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
|
||||
|
||||
@customElement("hui-dialog-select-dashboard")
|
||||
export class HuiDialogSelectDashboard extends LitElement {
|
||||
public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: SelectDashboardDialogParams;
|
||||
|
||||
@state() private _dashboards?: LovelaceDashboard[];
|
||||
|
||||
@state() private _fromUrlPath?: string | null;
|
||||
|
||||
@state() private _toUrlPath?: string | null;
|
||||
|
||||
@state() private _config?: LovelaceConfig;
|
||||
|
||||
@state() private _saving = false;
|
||||
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
|
||||
public showDialog(params: SelectDashboardDialogParams): void {
|
||||
this._config = params.lovelaceConfig;
|
||||
this._fromUrlPath = params.urlPath;
|
||||
this._params = params;
|
||||
this._getDashboards();
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._saving = false;
|
||||
this._dashboards = undefined;
|
||||
this._toUrlPath = undefined;
|
||||
this._dialog?.close();
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const dialogTitle =
|
||||
this._params.header ||
|
||||
this.hass.localize("ui.panel.lovelace.editor.select_dashboard.header");
|
||||
|
||||
return html`
|
||||
<ha-md-dialog
|
||||
open
|
||||
@closed=${this._dialogClosed}
|
||||
.ariaLabel=${dialogTitle}
|
||||
.disableCancelAction=${this._saving}
|
||||
>
|
||||
<ha-dialog-header slot="headline">
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
.label=${this.hass.localize("ui.dialogs.generic.close")}
|
||||
.path=${mdiClose}
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._saving}
|
||||
></ha-icon-button>
|
||||
<span slot="title" .title=${dialogTitle}>${dialogTitle}</span>
|
||||
</ha-dialog-header>
|
||||
<div slot="content">
|
||||
${this._dashboards && !this._saving
|
||||
? html`
|
||||
<ha-md-select
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.select_view.dashboard_label"
|
||||
)}
|
||||
@change=${this._dashboardChanged}
|
||||
.value=${this._toUrlPath || ""}
|
||||
>
|
||||
${this._dashboards.map(
|
||||
(dashboard) => html`
|
||||
<ha-md-select-option
|
||||
.disabled=${dashboard.mode !== "storage" ||
|
||||
dashboard.url_path === this._fromUrlPath ||
|
||||
(dashboard.url_path === "lovelace" &&
|
||||
this._fromUrlPath === null)}
|
||||
.value=${dashboard.url_path}
|
||||
>${dashboard.title}</ha-md-select-option
|
||||
>
|
||||
`
|
||||
)}
|
||||
</ha-md-select>
|
||||
`
|
||||
: html`<div class="loading">
|
||||
<ha-circular-progress
|
||||
indeterminate
|
||||
size="medium"
|
||||
></ha-circular-progress>
|
||||
</div>`}
|
||||
</div>
|
||||
<div slot="actions">
|
||||
<ha-button @click=${this.closeDialog} .disabled=${this._saving}>
|
||||
${this.hass!.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._selectDashboard}
|
||||
.disabled=${!this._config ||
|
||||
this._fromUrlPath === this._toUrlPath ||
|
||||
this._saving}
|
||||
>
|
||||
${this._params.actionLabel || this.hass!.localize("ui.common.move")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _getDashboards() {
|
||||
this._dashboards = [
|
||||
{
|
||||
id: "lovelace",
|
||||
url_path: "lovelace",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
title: this.hass.localize("ui.common.default"),
|
||||
mode: this.hass.panels.lovelace?.config?.mode,
|
||||
},
|
||||
...(this._params!.dashboards || (await fetchDashboards(this.hass))),
|
||||
];
|
||||
|
||||
const currentPath = this._fromUrlPath || this.hass.defaultPanel;
|
||||
for (const dashboard of this._dashboards!) {
|
||||
if (dashboard.url_path !== currentPath) {
|
||||
this._toUrlPath = dashboard.url_path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _dashboardChanged(ev) {
|
||||
const urlPath: string = ev.target.value;
|
||||
if (urlPath === this._toUrlPath) {
|
||||
return;
|
||||
}
|
||||
this._toUrlPath = urlPath;
|
||||
}
|
||||
|
||||
private async _selectDashboard() {
|
||||
this._saving = true;
|
||||
if (this._toUrlPath! === "lovelace") {
|
||||
this._toUrlPath = null;
|
||||
}
|
||||
this._params!.dashboardSelectedCallback(this._toUrlPath!);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-md-select {
|
||||
width: 100%;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-dialog-select-dashboard": HuiDialogSelectDashboard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
|
||||
|
||||
export interface SelectDashboardDialogParams {
|
||||
lovelaceConfig: LovelaceConfig;
|
||||
dashboards?: LovelaceDashboard[];
|
||||
urlPath?: string | null;
|
||||
header?: string;
|
||||
actionLabel?: string;
|
||||
dashboardSelectedCallback: (urlPath: string | null) => any;
|
||||
}
|
||||
|
||||
export const showSelectDashboardDialog = (
|
||||
element: HTMLElement,
|
||||
selectViewDialogParams: SelectDashboardDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "hui-dialog-select-dashboard",
|
||||
dialogImport: () => import("./hui-dialog-select-dashboard"),
|
||||
dialogParams: selectViewDialogParams,
|
||||
});
|
||||
};
|
||||
@@ -90,18 +90,15 @@ export class HuiDialogSelectView extends LitElement {
|
||||
>
|
||||
Default
|
||||
</mwc-list-item>
|
||||
${this._dashboards.map((dashboard) => {
|
||||
if (!this.hass.user!.is_admin && dashboard.require_admin) {
|
||||
return "";
|
||||
}
|
||||
return html`
|
||||
${this._dashboards.map(
|
||||
(dashboard) => html`
|
||||
<mwc-list-item
|
||||
.disabled=${dashboard.mode !== "storage"}
|
||||
.value=${dashboard.url_path}
|
||||
>${dashboard.title}</mwc-list-item
|
||||
>
|
||||
`;
|
||||
})}
|
||||
`
|
||||
)}
|
||||
</ha-select>`
|
||||
: ""}
|
||||
${!this._config || (this._config.views || []).length < 1
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import "@material/mwc-button";
|
||||
import type { ActionDetail } from "@material/mwc-list";
|
||||
import "@material/mwc-tab-bar/mwc-tab-bar";
|
||||
import "@material/mwc-tab/mwc-tab";
|
||||
import { mdiClose, mdiDotsVertical, mdiPlaylistEdit } from "@mdi/js";
|
||||
import {
|
||||
mdiClose,
|
||||
mdiDotsVertical,
|
||||
mdiFileMoveOutline,
|
||||
mdiPlaylistEdit,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -31,12 +35,25 @@ import "../../components/hui-entity-editor";
|
||||
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
|
||||
import { generateDefaultSection } from "../../views/default-section";
|
||||
import { getViewType } from "../../views/get-view-type";
|
||||
import { addView, deleteView, replaceView } from "../config-util";
|
||||
import {
|
||||
addView,
|
||||
deleteView,
|
||||
moveViewToDashboard,
|
||||
replaceView,
|
||||
} from "../config-util";
|
||||
import type { ViewEditEvent, ViewVisibilityChangeEvent } from "../types";
|
||||
import "./hui-view-background-editor";
|
||||
import "./hui-view-editor";
|
||||
import "./hui-view-visibility-editor";
|
||||
import type { EditViewDialogParams } from "./show-edit-view-dialog";
|
||||
import { showSelectDashboardDialog } from "../select-dashboard/show-select-dashboard-dialog";
|
||||
import {
|
||||
fetchConfig,
|
||||
isStrategyDashboard,
|
||||
saveConfig,
|
||||
type LovelaceConfig,
|
||||
} from "../../../../data/lovelace/config/types";
|
||||
import type { Lovelace } from "../../types";
|
||||
|
||||
const TABS = ["tab-settings", "tab-background", "tab-visibility"] as const;
|
||||
|
||||
@@ -46,6 +63,8 @@ export class HuiDialogEditView extends LitElement {
|
||||
|
||||
@state() private _params?: EditViewDialogParams;
|
||||
|
||||
@state() private _lovelace?: Lovelace;
|
||||
|
||||
@state() private _config?: LovelaceViewConfig;
|
||||
|
||||
@state() private _saving = false;
|
||||
@@ -83,7 +102,10 @@ export class HuiDialogEditView extends LitElement {
|
||||
this._dirty = false;
|
||||
return;
|
||||
}
|
||||
const view = this._params.lovelace!.config.views[this._params.viewIndex];
|
||||
|
||||
this._lovelace = this._params.lovelace;
|
||||
|
||||
const view = this._lovelace.config.views[this._params.viewIndex];
|
||||
// Todo : add better support for strategy views
|
||||
if (isStrategyView(view)) {
|
||||
const { strategy, ...viewConfig } = view;
|
||||
@@ -212,6 +234,15 @@ export class HuiDialogEditView extends LitElement {
|
||||
.path=${mdiPlaylistEdit}
|
||||
></ha-svg-icon>
|
||||
</ha-list-item>
|
||||
<ha-list-item graphic="icon">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.move_to_dashboard"
|
||||
)}
|
||||
<ha-svg-icon
|
||||
slot="graphic"
|
||||
.path=${mdiFileMoveOutline}
|
||||
></ha-svg-icon>
|
||||
</ha-list-item>
|
||||
</ha-button-menu>
|
||||
${convertToSection
|
||||
? html`
|
||||
@@ -300,9 +331,102 @@ export class HuiDialogEditView extends LitElement {
|
||||
case 0:
|
||||
this._yamlMode = !this._yamlMode;
|
||||
break;
|
||||
case 1:
|
||||
this._openSelectDashboard();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private _openSelectDashboard(): void {
|
||||
showSelectDashboardDialog(this, {
|
||||
lovelaceConfig: this._lovelace!.config,
|
||||
dashboardSelectedCallback: this._moveViewToDashboard,
|
||||
urlPath: this._lovelace!.urlPath,
|
||||
});
|
||||
}
|
||||
|
||||
private _moveViewToDashboard = async (urlPath: string | null) => {
|
||||
let errorMessage;
|
||||
let toConfig;
|
||||
let undoAction;
|
||||
|
||||
try {
|
||||
toConfig = (await fetchConfig(
|
||||
this.hass!.connection,
|
||||
urlPath,
|
||||
false
|
||||
)) as LovelaceConfig;
|
||||
} catch (err: any) {
|
||||
errorMessage = this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.select_dashboard.get_config_failed"
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
if (toConfig && isStrategyDashboard(toConfig)) {
|
||||
errorMessage = this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.select_dashboard.cannot_move_to_strategy"
|
||||
);
|
||||
}
|
||||
|
||||
if (!errorMessage) {
|
||||
const [newFromConfig, newToConfig] = moveViewToDashboard(
|
||||
this.hass!,
|
||||
this._lovelace!.config,
|
||||
toConfig,
|
||||
this._params!.viewIndex!
|
||||
);
|
||||
|
||||
const oldFromConfig = this._lovelace!.config;
|
||||
const oldToConfig = toConfig;
|
||||
|
||||
undoAction = async () => {
|
||||
await saveConfig(this.hass!, urlPath, oldToConfig);
|
||||
await this._lovelace!.saveConfig(oldFromConfig);
|
||||
};
|
||||
|
||||
try {
|
||||
await this._lovelace!.saveConfig(newFromConfig);
|
||||
await saveConfig(this.hass!, urlPath, newToConfig);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
try {
|
||||
await undoAction();
|
||||
errorMessage = this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.select_dashboard.move_failed"
|
||||
);
|
||||
} catch (revertError) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(revertError);
|
||||
errorMessage = this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.select_dashboard.revert_failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
showAlertDialog(this, {
|
||||
text: errorMessage,
|
||||
});
|
||||
} else {
|
||||
this._lovelace!.showToast({
|
||||
message: this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.select_dashboard.success"
|
||||
),
|
||||
duration: 4000,
|
||||
action: {
|
||||
action: undoAction,
|
||||
text: this.hass!.localize("ui.common.undo"),
|
||||
},
|
||||
});
|
||||
this.closeDialog();
|
||||
navigate(`/${window.location.pathname.split("/")[1]}`);
|
||||
}
|
||||
};
|
||||
|
||||
private async _convertToSection() {
|
||||
if (!this._params || !this._config) {
|
||||
return;
|
||||
|
||||
@@ -5929,7 +5929,8 @@
|
||||
"edit_ui": "Edit in visual editor",
|
||||
"edit_yaml": "Edit in YAML",
|
||||
"saving_failed": "Saving failed",
|
||||
"error_same_url": "You cannot save a view with the same URL as a different existing view."
|
||||
"error_same_url": "You cannot save a view with the same URL as a different existing view.",
|
||||
"move_to_dashboard": "Move to dashboard"
|
||||
},
|
||||
"edit_badges": {
|
||||
"view_no_badges": "Badges are not be supported by the current view type."
|
||||
@@ -6022,6 +6023,14 @@
|
||||
"no_views": "No views in this dashboard.",
|
||||
"strategy_type": "strategy"
|
||||
},
|
||||
"select_dashboard": {
|
||||
"header": "Choose a dasboard",
|
||||
"cannot_move_to_strategy": "The view cannot be moved because the selected dashboard is auto generated.",
|
||||
"get_config_failed": "Failed to load selected dashboard config.",
|
||||
"move_failed": "Failed to move the view to the new dashboard, please try again.",
|
||||
"revert_failed": "Failed to move the view to the new dashboard, your dashboards are in an unknown state. Please reload the page and check if everything is in place.",
|
||||
"success": "View moved successfully"
|
||||
},
|
||||
"section": {
|
||||
"add_badge": "Add badge",
|
||||
"add_card": "[%key:ui::panel::lovelace::editor::edit_card::add%]",
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user