diff --git a/src/data/backup.ts b/src/data/backup.ts index ecdc679728..d37c50f431 100644 --- a/src/data/backup.ts +++ b/src/data/backup.ts @@ -2,6 +2,7 @@ import { memoize } from "@fullcalendar/core/internal"; import { setHours, setMinutes } from "date-fns"; import type { HassConfig } from "home-assistant-js-websocket"; import memoizeOne from "memoize-one"; +import checkValidDate from "../common/datetime/check_valid_date"; import { formatDateTime, formatDateTimeNumeric, @@ -10,11 +11,10 @@ import { formatTime } from "../common/datetime/format_time"; import type { LocalizeFunc } from "../common/translations/localize"; import type { HomeAssistant } from "../types"; import { fileDownload } from "../util/file_download"; +import { handleFetchPromise } from "../util/hass-call-api"; +import type { BackupManagerState, ManagerStateEvent } from "./backup_manager"; import { domainToName } from "./integration"; import type { FrontendLocaleData } from "./translation"; -import type { BackupManagerState, ManagerStateEvent } from "./backup_manager"; -import checkValidDate from "../common/datetime/check_valid_date"; -import { handleFetchPromise } from "../util/hass-call-api"; export const enum BackupScheduleRecurrence { NEVER = "never", @@ -37,6 +37,11 @@ export const BACKUP_DAYS: BackupDay[] = [ export const sortWeekdays = (weekdays) => weekdays.sort((a, b) => BACKUP_DAYS.indexOf(a) - BACKUP_DAYS.indexOf(b)); +export interface Retention { + copies?: number | null; + days?: number | null; +} + export interface BackupConfig { automatic_backups_configured: boolean; last_attempted_automatic_backup: string | null; @@ -52,10 +57,7 @@ export interface BackupConfig { name: string | null; password: string | null; }; - retention: { - copies?: number | null; - days?: number | null; - }; + retention: Retention; schedule: { recurrence: BackupScheduleRecurrence; time?: string | null; @@ -75,10 +77,7 @@ export interface BackupMutableConfig { name?: string | null; password?: string | null; }; - retention?: { - copies?: number | null; - days?: number | null; - }; + retention?: Retention; schedule?: { recurrence: BackupScheduleRecurrence; time?: string | null; @@ -90,7 +89,8 @@ export interface BackupMutableConfig { export type BackupAgentsConfig = Record; export interface BackupAgentConfig { - protected: boolean; + protected?: boolean; + retention?: Retention | null; } export interface BackupAgent { diff --git a/src/panels/config/backup/components/config/ha-backup-config-retention.ts b/src/panels/config/backup/components/config/ha-backup-config-retention.ts new file mode 100644 index 0000000000..245f700600 --- /dev/null +++ b/src/panels/config/backup/components/config/ha-backup-config-retention.ts @@ -0,0 +1,274 @@ +import { css, html, LitElement, nothing, type PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { clamp } from "../../../../../common/number/clamp"; +import "../../../../../components/ha-expansion-panel"; +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-md-textfield"; +import type { BackupConfig, Retention } from "../../../../../data/backup"; +import type { HomeAssistant } from "../../../../../types"; + +export type BackupConfigSchedule = Pick; + +const MIN_VALUE = 1; +const MAX_VALUE = 9999; // because of input width + +export enum RetentionPreset { + GLOBAL = "global", + COPIES_3 = "copies_3", + FOREVER = "forever", + CUSTOM = "custom", +} + +const PRESET_MAP: Record< + Exclude, + Retention | null +> = { + copies_3: { copies: 3, days: null }, + forever: { copies: null, days: null }, + global: null, +}; + +export interface RetentionData { + type: "copies" | "days" | "forever"; + value: number; +} + +@customElement("ha-backup-config-retention") +class HaBackupConfigRetention extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public retention?: Retention | null; + + @property() public headline?: string; + + @property({ type: Boolean, attribute: "location-specific" }) + public locationSpecific = false; + + @state() private _preset: RetentionPreset = RetentionPreset.COPIES_3; + + @state() private _type: "copies" | "days" = "copies"; + + @state() private _value = 3; + + private presetOptions = [ + RetentionPreset.COPIES_3, + RetentionPreset.FOREVER, + RetentionPreset.CUSTOM, + ]; + + public willUpdate(properties: PropertyValues) { + super.willUpdate(properties); + + if (!this.hasUpdated) { + if (!this.retention) { + this._preset = RetentionPreset.GLOBAL; + } else if ( + this.retention?.days === null && + this.retention?.copies === null + ) { + this._preset = RetentionPreset.FOREVER; + } else { + this._value = this.retention.copies || this.retention.days || 3; + if ( + this.retention.days || + this.locationSpecific || + this.retention.copies !== 3 + ) { + this._preset = RetentionPreset.CUSTOM; + this._type = this.retention?.copies ? "copies" : "days"; + } + } + + if (this.locationSpecific) { + this.presetOptions = [ + RetentionPreset.GLOBAL, + RetentionPreset.FOREVER, + RetentionPreset.CUSTOM, + ]; + } + } + } + + protected render() { + return html` + + + ${this.headline ?? + this.hass.localize(`ui.panel.config.backup.schedule.retention`)} + + + ${this.hass.localize( + `ui.panel.config.backup.schedule.retention_description` + )} + + + ${this.presetOptions.map( + (option) => html` + +
+ ${this.hass.localize( + `ui.panel.config.backup.schedule.retention_presets.${option}` + )} +
+
+ ` + )} +
+
+ + ${this._preset === RetentionPreset.CUSTOM + ? html` + + + ${this.hass.localize( + "ui.panel.config.backup.schedule.custom_retention_label" + )} + + + + + +
+ ${this.hass.localize( + "ui.panel.config.backup.schedule.retention_units.days" + )} +
+
+ + ${this.hass.localize( + "ui.panel.config.backup.schedule.retention_units.copies" + )} + +
+
` + : nothing} + `; + } + + private _retentionPresetChanged(ev) { + ev.stopPropagation(); + const target = ev.currentTarget as HaMdSelect; + let value = target.value as RetentionPreset; + + if ( + value === RetentionPreset.CUSTOM && + (this.locationSpecific || this._preset === RetentionPreset.FOREVER) + ) { + this._preset = value; + // custom needs to have a type of days or copies, set it to default copies 3 + value = RetentionPreset.COPIES_3; + } else { + this._preset = value; + } + + if (this.locationSpecific || value !== RetentionPreset.CUSTOM) { + const retention = PRESET_MAP[value]; + + fireEvent(this, "value-changed", { + value: retention, + }); + } + } + + private _retentionValueChanged(ev) { + ev.stopPropagation(); + const target = ev.currentTarget as HaMdSelect; + const value = parseInt(target.value); + const clamped = clamp(value, MIN_VALUE, MAX_VALUE); + target.value = clamped.toString(); + + fireEvent(this, "value-changed", { + value: { + copies: this._type === "copies" ? clamped : null, + days: this._type === "days" ? clamped : null, + }, + }); + } + + private _retentionTypeChanged(ev) { + ev.stopPropagation(); + const target = ev.currentTarget as HaMdSelect; + const type = target.value as "copies" | "days"; + + fireEvent(this, "value-changed", { + value: { + copies: type === "copies" ? this._value : null, + days: type === "days" ? this._value : null, + }, + }); + } + + static styles = css` + ha-md-list-item { + --md-item-overflow: visible; + --md-list-item-leading-space: 0; + --md-list-item-trailing-space: 0; + } + @media all and (max-width: 450px) { + ha-md-select { + min-width: 160px; + width: 160px; + --md-filled-field-content-space: 0; + } + } + ha-md-textfield#value { + min-width: 70px; + } + ha-md-select#type { + min-width: 100px; + } + @media all and (max-width: 450px) { + ha-md-textfield#value { + min-width: 60px; + margin: 0 -8px; + } + ha-md-select#type { + min-width: 120px; + width: 120px; + } + } + ha-expansion-panel { + --expansion-panel-summary-padding: 0 16px; + --expansion-panel-content-padding: 0 16px; + margin-bottom: 16px; + } + ha-md-list-item.days { + --md-item-align-items: flex-start; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-backup-config-retention": HaBackupConfigRetention; + } +} diff --git a/src/panels/config/backup/components/config/ha-backup-config-schedule.ts b/src/panels/config/backup/components/config/ha-backup-config-schedule.ts index a0f7e9db2a..e572b7a442 100644 --- a/src/panels/config/backup/components/config/ha-backup-config-schedule.ts +++ b/src/panels/config/backup/components/config/ha-backup-config-schedule.ts @@ -1,10 +1,8 @@ -import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { customElement, property } from "lit/decorators"; import memoizeOne from "memoize-one"; import { formatTime } from "../../../../../common/datetime/format_time"; import { fireEvent } from "../../../../../common/dom/fire_event"; -import { clamp } from "../../../../../common/number/clamp"; import "../../../../../components/ha-checkbox"; import type { HaCheckbox } from "../../../../../components/ha-checkbox"; import "../../../../../components/ha-expansion-panel"; @@ -15,10 +13,13 @@ import "../../../../../components/ha-md-select"; import type { HaMdSelect } from "../../../../../components/ha-md-select"; import "../../../../../components/ha-md-select-option"; import "../../../../../components/ha-md-textfield"; -import "../../../../../components/ha-switch"; import "../../../../../components/ha-time-input"; import "../../../../../components/ha-tip"; -import type { BackupConfig, BackupDay } from "../../../../../data/backup"; +import type { + BackupConfig, + BackupDay, + Retention, +} from "../../../../../data/backup"; import { BACKUP_DAYS, BackupScheduleRecurrence, @@ -29,76 +30,32 @@ import { import type { SupervisorUpdateConfig } from "../../../../../data/supervisor/update"; import type { HomeAssistant } from "../../../../../types"; import { documentationUrl } from "../../../../../util/documentation-url"; +import "./ha-backup-config-retention"; export type BackupConfigSchedule = Pick; -const MIN_VALUE = 1; -const MAX_VALUE = 50; - -enum RetentionPreset { - COPIES_3 = "copies_3", - FOREVER = "forever", - CUSTOM = "custom", -} - enum BackupScheduleTime { DEFAULT = "default", CUSTOM = "custom", } -interface RetentionData { - type: "copies" | "days" | "forever"; - value: number; -} - -const RETENTION_PRESETS: Record< - Exclude, - RetentionData -> = { - copies_3: { type: "copies", value: 3 }, - forever: { type: "forever", value: 0 }, -}; - const SCHEDULE_OPTIONS = [ BackupScheduleRecurrence.NEVER, BackupScheduleRecurrence.DAILY, BackupScheduleRecurrence.CUSTOM_DAYS, ] as const satisfies BackupScheduleRecurrence[]; -const RETENTION_PRESETS_OPTIONS = [ - RetentionPreset.COPIES_3, - RetentionPreset.FOREVER, - RetentionPreset.CUSTOM, -] as const satisfies RetentionPreset[]; - const SCHEDULE_TIME_OPTIONS = [ BackupScheduleTime.DEFAULT, BackupScheduleTime.CUSTOM, ] as const satisfies BackupScheduleTime[]; -const computeRetentionPreset = ( - data: RetentionData -): RetentionPreset | undefined => { - for (const [key, value] of Object.entries(RETENTION_PRESETS)) { - if ( - value.type === data.type && - (value.type === RetentionPreset.FOREVER || value.value === data.value) - ) { - return key as RetentionPreset; - } - } - return RetentionPreset.CUSTOM; -}; - interface FormData { recurrence: BackupScheduleRecurrence; time_option: BackupScheduleTime; time?: string | null; days: BackupDay[]; - retention: { - type: "copies" | "days" | "forever"; - value: number; - }; + retention: Retention; } const INITIAL_FORM_DATA: FormData = { @@ -106,8 +63,7 @@ const INITIAL_FORM_DATA: FormData = { time_option: BackupScheduleTime.DEFAULT, days: [], retention: { - type: "copies", - value: 3, + copies: 3, }, }; @@ -122,17 +78,6 @@ class HaBackupConfigSchedule extends LitElement { @property({ attribute: false }) public supervisorUpdateConfig?: SupervisorUpdateConfig; - @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; @@ -150,15 +95,7 @@ class HaBackupConfigSchedule extends LitElement { config.schedule.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS ? config.schedule.days : [], - retention: { - type: - config.retention.days === null && config.retention.copies === null - ? "forever" - : config.retention.days != null - ? "days" - : "copies", - value: config.retention.days ?? config.retention.copies ?? 3, - }, + retention: config.retention, }; }); @@ -173,12 +110,7 @@ class HaBackupConfigSchedule extends LitElement { ? data.days : [], }, - retention: - data.retention.type === "forever" - ? { days: null, copies: null } - : data.retention.type === "days" - ? { days: data.retention.value, copies: null } - : { copies: data.retention.value, days: null }, + retention: data.retention, }; fireEvent(this, "value-changed", { value: this.value }); @@ -377,81 +309,11 @@ class HaBackupConfigSchedule extends LitElement { ` : nothing} - - - ${this.hass.localize(`ui.panel.config.backup.schedule.retention`)} - - - ${this.hass.localize( - `ui.panel.config.backup.schedule.retention_description` - )} - - - ${RETENTION_PRESETS_OPTIONS.map( - (option) => html` - -
- ${this.hass.localize( - `ui.panel.config.backup.schedule.retention_presets.${option}` - )} -
-
- ` - )} -
-
- - ${this._retentionPreset === RetentionPreset.CUSTOM - ? html` - - - ${this.hass.localize( - "ui.panel.config.backup.schedule.custom_retention_label" - )} - - - - - -
- ${this.hass.localize( - "ui.panel.config.backup.schedule.retention_units.days" - )} -
-
- - ${this.hass.localize( - "ui.panel.config.backup.schedule.retention_units.copies" - )} - -
-
` - : nothing} + ${this.hass.localize("ui.panel.config.backup.schedule.tip", { backup_create: html`) { ev.stopPropagation(); - const target = ev.currentTarget as HaMdSelect; - let value = target.value as RetentionPreset; - - // custom needs to have a type of days or copies, set it to default copies 3 - if ( - value === RetentionPreset.CUSTOM && - this._retentionPreset === RetentionPreset.FOREVER - ) { - this._retentionPreset = value; - value = RetentionPreset.COPIES_3; - } else { - 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 default value because user can't select 0 - if (value !== RetentionPreset.FOREVER) { - retention.value = Math.max(retention.value, 1); - } - this._setData({ - ...data, - retention, - }); - } - } - - 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); - target.value = clamped.toString(); - this._setData({ - ...data, - retention: { - ...data.retention, - value: clamped, - }, - }); - } - - private _retentionTypeChanged(ev) { - ev.stopPropagation(); - const target = ev.currentTarget as HaMdSelect; - const value = target.value as "copies" | "days"; + const retention = ev.detail.value; const data = this._getData(this.value); - this._setData({ + + const newData = { ...data, - retention: { - ...data.retention, - type: value, - }, - }); + retention, + }; + + this._setData(newData); } static styles = css` @@ -631,25 +446,7 @@ class HaBackupConfigSchedule extends LitElement { width: 145px; } } - ha-md-textfield#value { - min-width: 70px; - } - ha-md-select#type { - min-width: 100px; - } - @media all and (max-width: 450px) { - ha-md-textfield#value { - min-width: 60px; - margin: 0 -8px; - } - ha-md-select#type { - min-width: 120px; - width: 120px; - } - } ha-expansion-panel { - --expansion-panel-summary-padding: 0 16px; - --expansion-panel-content-padding: 0 16px; margin-bottom: 16px; } ha-tip { diff --git a/src/panels/config/backup/ha-config-backup-location.ts b/src/panels/config/backup/ha-config-backup-location.ts index 35d5aff149..6e5d5b585f 100644 --- a/src/panels/config/backup/ha-config-backup-location.ts +++ b/src/panels/config/backup/ha-config-backup-location.ts @@ -1,32 +1,35 @@ import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-alert"; import "../../../components/ha-button"; -import "../../../components/ha-switch"; import "../../../components/ha-button-menu"; import "../../../components/ha-card"; import "../../../components/ha-fade-in"; -import "../../../components/ha-spinner"; import "../../../components/ha-icon-button"; import "../../../components/ha-md-list"; import "../../../components/ha-md-list-item"; +import "../../../components/ha-spinner"; +import "../../../components/ha-switch"; import type { BackupAgent, BackupAgentConfig, BackupConfig, + Retention, } from "../../../data/backup"; import { CLOUD_AGENT, computeBackupAgentName, fetchBackupAgentsInfo, + isLocalAgent, updateBackupConfig, } from "../../../data/backup"; import "../../../layouts/hass-subpage"; import type { HomeAssistant } from "../../../types"; -import "./components/ha-backup-data-picker"; import { showConfirmationDialog } from "../../lovelace/custom-card-helpers"; -import { fireEvent } from "../../../common/dom/fire_event"; +import "./components/config/ha-backup-config-retention"; +import "./components/ha-backup-data-picker"; @customElement("ha-config-backup-location") class HaConfigBackupDetails extends LitElement { @@ -61,18 +64,21 @@ class HaConfigBackupDetails extends LitElement { const encrypted = this._isEncryptionTurnedOn(); + const agentName = + (this._agent && + computeBackupAgentName( + this.hass.localize, + this.agentId, + this.agents + )) || + this.hass.localize("ui.panel.config.backup.location.header"); + return html`
${this._error && @@ -96,14 +102,14 @@ class HaConfigBackupDetails extends LitElement { >` : html` - ${CLOUD_AGENT === this.agentId - ? html` - -
- ${this.hass.localize( - "ui.panel.config.backup.location.configuration.title" - )} -
+ +
+ ${this.hass.localize( + "ui.panel.config.backup.location.configuration.title" + )} +
+ ${CLOUD_AGENT === this.agentId + ? html`

${this.hass.localize( @@ -111,9 +117,21 @@ class HaConfigBackupDetails extends LitElement { )}

-
- ` - : nothing} + ` + : this.config?.agents[this.agentId] + ? html`` + : nothing} +
${this.hass.localize( @@ -247,18 +265,37 @@ class HaConfigBackupDetails extends LitElement { } } - private async _updateAgentEncryption(value: boolean) { - const agentsConfig = { - ...this.config?.agents, - [this.agentId]: { - ...this.config?.agents[this.agentId], - protected: value, - }, - }; - await updateBackupConfig(this.hass, { - agents: agentsConfig, + private async _updateAgentConfig(config: Partial) { + try { + const agents = this.config?.agents || {}; + agents[this.agentId] = { + ...(agents[this.agentId] || {}), + ...config, + }; + + await updateBackupConfig(this.hass, { + agents, + }); + fireEvent(this, "ha-refresh-backup-config"); + } catch (err: any) { + this._error = this.hass.localize( + "ui.panel.config.backup.location.save_error", + { error: err.message } + ); + } + } + + private _retentionChanged(ev: CustomEvent<{ value: Retention }>) { + const retention = ev.detail.value; + this._updateAgentConfig({ + retention, + }); + } + + private async _updateAgentEncryption(value: boolean) { + this._updateAgentConfig({ + protected: value, }); - fireEvent(this, "ha-refresh-backup-config"); } private _turnOnEncryption() { @@ -363,6 +400,10 @@ class HaConfigBackupDetails extends LitElement { ha-spinner { margin: 24px auto; } + ha-backup-config-retention { + display: block; + padding: 16px; + } `; } diff --git a/src/translations/en.json b/src/translations/en.json index d75c32b3cc..5e8e0cf5ca 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2533,6 +2533,7 @@ "custom_retention_label": "Keep only", "retention_description": "Based on the maximum number of backups or how many days they should be kept.", "retention_presets": { + "global": "Use global settings", "copies_3": "3 backups", "forever": "Forever", "custom": "Custom" @@ -2762,6 +2763,9 @@ }, "location": { "header": "Location", + "save_error": "Error saving configuration: {error}", + "retention_for_this_system": "Retention for this system", + "retention_for_location": "Retention for {location}", "not_found": "Not found", "not_found_description": "Location matching ''{backupId}'' not found", "error": "Could not fetch location details",