Backup retention by location (#25144)

This commit is contained in:
Wendelin 2025-04-28 09:24:34 +02:00 committed by GitHub
parent 672fbc6007
commit 1b79869c87
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 388 additions and 272 deletions

View File

@ -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<string, BackupAgentConfig>;
export interface BackupAgentConfig {
protected: boolean;
protected?: boolean;
retention?: Retention | null;
}
export interface BackupAgent {

View File

@ -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<BackupConfig, "schedule" | "retention">;
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<RetentionPreset, RetentionPreset.CUSTOM>,
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`
<ha-md-list-item>
<span slot="headline">
${this.headline ??
this.hass.localize(`ui.panel.config.backup.schedule.retention`)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention_description`
)}
</span>
<ha-md-select
slot="end"
@change=${this._retentionPresetChanged}
.value=${this._preset}
>
${this.presetOptions.map(
(option) => html`
<ha-md-select-option .value=${option}>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention_presets.${option}`
)}
</div>
</ha-md-select-option>
`
)}
</ha-md-select>
</ha-md-list-item>
${this._preset === RetentionPreset.CUSTOM
? html`<ha-expansion-panel
expanded
.header=${this.hass.localize(
"ui.panel.config.backup.schedule.custom_retention"
)}
outlined
>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_retention_label"
)}
</span>
<ha-md-textfield
slot="end"
@change=${this._retentionValueChanged}
.value=${this._value.toString()}
id="value"
type="number"
.min=${MIN_VALUE.toString()}
.max=${MAX_VALUE.toString()}
step="1"
>
</ha-md-textfield>
<ha-md-select
slot="end"
@change=${this._retentionTypeChanged}
.value=${this._type}
id="type"
>
<ha-md-select-option value="days">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.retention_units.days"
)}
</div>
</ha-md-select-option>
<ha-md-select-option value="copies">
${this.hass.localize(
"ui.panel.config.backup.schedule.retention_units.copies"
)}
</ha-md-select-option>
</ha-md-select>
</ha-md-list-item></ha-expansion-panel
> `
: 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;
}
}

View File

@ -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<BackupConfig, "schedule" | "retention">;
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<RetentionPreset, RetentionPreset.CUSTOM>,
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}
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(`ui.panel.config.backup.schedule.retention`)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention_description`
)}
</span>
<ha-md-select
slot="end"
@change=${this._retentionPresetChanged}
.value=${this._retentionPreset ?? ""}
>
${RETENTION_PRESETS_OPTIONS.map(
(option) => html`
<ha-md-select-option .value=${option}>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention_presets.${option}`
)}
</div>
</ha-md-select-option>
`
)}
</ha-md-select>
</ha-md-list-item>
${this._retentionPreset === RetentionPreset.CUSTOM
? html`<ha-expansion-panel
expanded
.header=${this.hass.localize(
"ui.panel.config.backup.schedule.custom_retention"
)}
outlined
>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_retention_label"
)}
</span>
<ha-md-textfield
slot="end"
@change=${this._retentionValueChanged}
.value=${data.retention.value.toString()}
id="value"
type="number"
.min=${MIN_VALUE.toString()}
.max=${MAX_VALUE.toString()}
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">
${this.hass.localize(
"ui.panel.config.backup.schedule.retention_units.days"
)}
</div>
</ha-md-select-option>
<ha-md-select-option value="copies">
${this.hass.localize(
"ui.panel.config.backup.schedule.retention_units.copies"
)}
</ha-md-select-option>
</ha-md-select>
</ha-md-list-item></ha-expansion-panel
> `
: nothing}
<ha-backup-config-retention
.hass=${this.hass}
.retention=${data.retention}
@value-changed=${this._retentionChanged}
></ha-backup-config-retention>
<ha-tip .hass=${this.hass}
>${this.hass.localize("ui.panel.config.backup.schedule.tip", {
backup_create: html`<a
@ -543,65 +405,18 @@ class HaBackupConfigSchedule extends LitElement {
});
}
private _retentionPresetChanged(ev) {
private _retentionChanged(ev: CustomEvent<{ value: Retention }>) {
ev.stopPropagation();
const target = ev.currentTarget as HaMdSelect;
let value = target.value as RetentionPreset;
const retention = ev.detail.value;
// 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({
const newData = {
...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 data = this._getData(this.value);
this._setData({
...data,
retention: {
...data.retention,
type: value,
},
});
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 {

View File

@ -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();
return html`
<hass-subpage
back-path="/config/backup/settings"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${(this._agent &&
const agentName =
(this._agent &&
computeBackupAgentName(
this.hass.localize,
this.agentId,
this.agents
)) ||
this.hass.localize("ui.panel.config.backup.location.header")}
this.hass.localize("ui.panel.config.backup.location.header");
return html`
<hass-subpage
back-path="/config/backup/settings"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${agentName}
>
<div class="content">
${this._error &&
@ -96,14 +102,14 @@ class HaConfigBackupDetails extends LitElement {
><ha-spinner></ha-spinner
></ha-fade-in>`
: html`
${CLOUD_AGENT === this.agentId
? html`
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.location.configuration.title"
)}
</div>
${CLOUD_AGENT === this.agentId
? html`
<div class="card-content">
<p>
${this.hass.localize(
@ -111,9 +117,21 @@ class HaConfigBackupDetails extends LitElement {
)}
</p>
</div>
</ha-card>
`
: this.config?.agents[this.agentId]
? html`<ha-backup-config-retention
location-specific
.headline=${this.hass.localize(
`ui.panel.config.backup.location.retention_for_${isLocalAgent(this.agentId) ? "this_system" : "location"}`,
{ location: agentName }
)}
.hass=${this.hass}
.retention=${this.config?.agents[this.agentId]
?.retention}
@value-changed=${this._retentionChanged}
></ha-backup-config-retention>`
: nothing}
</ha-card>
<ha-card>
<div class="card-header">
${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,
},
private async _updateAgentConfig(config: Partial<BackupAgentConfig>) {
try {
const agents = this.config?.agents || {};
agents[this.agentId] = {
...(agents[this.agentId] || {}),
...config,
};
await updateBackupConfig(this.hass, {
agents: agentsConfig,
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,
});
}
private _turnOnEncryption() {
@ -363,6 +400,10 @@ class HaConfigBackupDetails extends LitElement {
ha-spinner {
margin: 24px auto;
}
ha-backup-config-retention {
display: block;
padding: 16px;
}
`;
}

View File

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