Add time option for backup schedule (#23757)

Co-authored-by: Wendelin <w@pe8.at>
This commit is contained in:
Bram Kragten 2025-01-21 11:58:07 +01:00 committed by GitHub
parent e994e3565d
commit 7535d66373
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 621 additions and 259 deletions

View File

@ -337,6 +337,7 @@ export class HaBaseTimeInput extends LitElement {
} }
.time-input-wrap { .time-input-wrap {
display: flex; display: flex;
flex: 1;
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0; border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@ -345,6 +346,7 @@ export class HaBaseTimeInput extends LitElement {
} }
ha-textfield { ha-textfield {
width: 55px; width: 55px;
flex-grow: 1;
text-align: center; text-align: center;
--mdc-shape-small: 0; --mdc-shape-small: 0;
--text-field-appearance: none; --text-field-appearance: none;

View File

@ -17,6 +17,7 @@ export class HaMdListItem extends MdListItem {
} }
md-item { md-item {
overflow: var(--md-item-overflow, hidden); overflow: var(--md-item-overflow, hidden);
align-items: var(--md-item-align-items, center);
} }
`, `,
]; ];

View File

@ -11,22 +11,33 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download"; import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration"; import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation"; import type { FrontendLocaleData } from "./translation";
import checkValidDate from "../common/datetime/check_valid_date";
export const enum BackupScheduleState { export const enum BackupScheduleRecurrence {
NEVER = "never", NEVER = "never",
DAILY = "daily", DAILY = "daily",
MONDAY = "mon", CUSTOM_DAYS = "custom_days",
TUESDAY = "tue",
WEDNESDAY = "wed",
THURSDAY = "thu",
FRIDAY = "fri",
SATURDAY = "sat",
SUNDAY = "sun",
} }
export type BackupDay = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
export const BACKUP_DAYS: BackupDay[] = [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
];
export const sortWeekdays = (weekdays) =>
weekdays.sort((a, b) => BACKUP_DAYS.indexOf(a) - BACKUP_DAYS.indexOf(b));
export interface BackupConfig { export interface BackupConfig {
last_attempted_automatic_backup: string | null; last_attempted_automatic_backup: string | null;
last_completed_automatic_backup: string | null; last_completed_automatic_backup: string | null;
next_automatic_backup: string | null;
create_backup: { create_backup: {
agent_ids: string[]; agent_ids: string[];
include_addons: string[] | null; include_addons: string[] | null;
@ -41,7 +52,9 @@ export interface BackupConfig {
days?: number | null; days?: number | null;
}; };
schedule: { schedule: {
state: BackupScheduleState; recurrence: BackupScheduleRecurrence;
time?: string | null;
days: BackupDay[];
}; };
} }
@ -59,7 +72,11 @@ export interface BackupMutableConfig {
copies?: number | null; copies?: number | null;
days?: number | null; days?: number | null;
}; };
schedule?: BackupScheduleState; schedule?: {
recurrence: BackupScheduleRecurrence;
time?: string | null;
days?: BackupDay[] | null;
};
} }
export interface BackupAgent { export interface BackupAgent {
@ -337,9 +354,34 @@ export const downloadEmergencyKit = (
geneateEmergencyKitFileName(hass, appendFileName) geneateEmergencyKitFileName(hass, appendFileName)
); );
export const DEFAULT_OPTIMIZED_BACKUP_START_TIME = setMinutes(
setHours(new Date(), 4),
45
);
export const DEFAULT_OPTIMIZED_BACKUP_END_TIME = setMinutes(
setHours(new Date(), 5),
45
);
export const getFormattedBackupTime = memoizeOne( export const getFormattedBackupTime = memoizeOne(
(locale: FrontendLocaleData, config: HassConfig) => { (
const date = setMinutes(setHours(new Date(), 4), 45); locale: FrontendLocaleData,
return formatTime(date, locale, config); config: HassConfig,
backupTime?: Date | string | null
) => {
if (checkValidDate(backupTime as Date)) {
return formatTime(backupTime as Date, locale, config);
}
if (typeof backupTime === "string" && backupTime) {
const splitted = backupTime.split(":");
const date = setMinutes(
setHours(new Date(), parseInt(splitted[0])),
parseInt(splitted[1])
);
return formatTime(date, locale, config);
}
return `${formatTime(DEFAULT_OPTIMIZED_BACKUP_START_TIME, locale, config)} - ${formatTime(DEFAULT_OPTIMIZED_BACKUP_END_TIME, locale, config)}`;
} }
); );

View File

@ -12,12 +12,22 @@ import type { HaMdSelect } from "../../../../../components/ha-md-select";
import "../../../../../components/ha-md-select-option"; import "../../../../../components/ha-md-select-option";
import "../../../../../components/ha-md-textfield"; import "../../../../../components/ha-md-textfield";
import "../../../../../components/ha-switch"; import "../../../../../components/ha-switch";
import type { BackupConfig } from "../../../../../data/backup"; import type { BackupConfig, BackupDay } from "../../../../../data/backup";
import { import {
BackupScheduleState, BACKUP_DAYS,
getFormattedBackupTime, BackupScheduleRecurrence,
DEFAULT_OPTIMIZED_BACKUP_END_TIME,
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
sortWeekdays,
} from "../../../../../data/backup"; } from "../../../../../data/backup";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
import "../../../../../components/ha-time-input";
import "../../../../../components/ha-tip";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-formfield";
import { formatTime } from "../../../../../common/datetime/format_time";
import { documentationUrl } from "../../../../../util/documentation-url";
export type BackupConfigSchedule = Pick<BackupConfig, "schedule" | "retention">; export type BackupConfigSchedule = Pick<BackupConfig, "schedule" | "retention">;
@ -30,6 +40,11 @@ enum RetentionPreset {
CUSTOM = "custom", CUSTOM = "custom",
} }
enum BackupScheduleTime {
DEFAULT = "default",
CUSTOM = "custom",
}
interface RetentionData { interface RetentionData {
type: "copies" | "days"; type: "copies" | "days";
value: number; value: number;
@ -44,15 +59,10 @@ const RETENTION_PRESETS: Record<
}; };
const SCHEDULE_OPTIONS = [ const SCHEDULE_OPTIONS = [
BackupScheduleState.DAILY, BackupScheduleRecurrence.NEVER,
BackupScheduleState.MONDAY, BackupScheduleRecurrence.DAILY,
BackupScheduleState.TUESDAY, BackupScheduleRecurrence.CUSTOM_DAYS,
BackupScheduleState.WEDNESDAY, ] as const satisfies BackupScheduleRecurrence[];
BackupScheduleState.THURSDAY,
BackupScheduleState.FRIDAY,
BackupScheduleState.SATURDAY,
BackupScheduleState.SUNDAY,
] as const satisfies BackupScheduleState[];
const RETENTION_PRESETS_OPTIONS = [ const RETENTION_PRESETS_OPTIONS = [
RetentionPreset.COPIES_3, RetentionPreset.COPIES_3,
@ -60,6 +70,11 @@ const RETENTION_PRESETS_OPTIONS = [
RetentionPreset.CUSTOM, RetentionPreset.CUSTOM,
] as const satisfies RetentionPreset[]; ] as const satisfies RetentionPreset[];
const SCHEDULE_TIME_OPTIONS = [
BackupScheduleTime.DEFAULT,
BackupScheduleTime.CUSTOM,
] as const satisfies BackupScheduleTime[];
const computeRetentionPreset = ( const computeRetentionPreset = (
data: RetentionData data: RetentionData
): RetentionPreset | undefined => { ): RetentionPreset | undefined => {
@ -72,8 +87,10 @@ const computeRetentionPreset = (
}; };
interface FormData { interface FormData {
enabled: boolean; recurrence: BackupScheduleRecurrence;
schedule: BackupScheduleState; time_option: BackupScheduleTime;
time?: string | null;
days: BackupDay[];
retention: { retention: {
type: "copies" | "days"; type: "copies" | "days";
value: number; value: number;
@ -81,8 +98,9 @@ interface FormData {
} }
const INITIAL_FORM_DATA: FormData = { const INITIAL_FORM_DATA: FormData = {
enabled: false, recurrence: BackupScheduleRecurrence.NEVER,
schedule: BackupScheduleState.NEVER, time_option: BackupScheduleTime.DEFAULT,
days: [],
retention: { retention: {
type: "copies", type: "copies",
value: 3, value: 3,
@ -114,8 +132,15 @@ class HaBackupConfigSchedule extends LitElement {
const config = value; const config = value;
return { return {
enabled: config.schedule.state !== BackupScheduleState.NEVER, recurrence: config.schedule.recurrence,
schedule: config.schedule.state, time_option: config.schedule.time
? BackupScheduleTime.CUSTOM
: BackupScheduleTime.DEFAULT,
time: config.schedule.time,
days:
config.schedule.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
? config.schedule.days
: [],
retention: { retention: {
type: config.retention.days != null ? "days" : "copies", type: config.retention.days != null ? "days" : "copies",
value: config.retention.days ?? config.retention.copies ?? 3, value: config.retention.days ?? config.retention.copies ?? 3,
@ -125,8 +150,14 @@ class HaBackupConfigSchedule extends LitElement {
private _setData(data: FormData) { private _setData(data: FormData) {
this.value = { this.value = {
...this.value,
schedule: { schedule: {
state: data.enabled ? data.schedule : BackupScheduleState.NEVER, recurrence: data.recurrence,
time: data.time_option === BackupScheduleTime.CUSTOM ? data.time : null,
days:
data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
? data.days
: [],
}, },
retention: retention:
data.retention.type === "days" data.retention.type === "days"
@ -140,49 +171,118 @@ class HaBackupConfigSchedule extends LitElement {
protected render() { protected render() {
const data = this._getData(this.value); const data = this._getData(this.value);
const time = getFormattedBackupTime(this.hass.locale, this.hass.config);
return html` return html`
<ha-md-list> <ha-md-list>
<ha-md-list-item> <ha-md-list-item>
<span slot="headline"> <span slot="headline">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.backup.schedule.use_automatic_backups" "ui.panel.config.backup.schedule.schedule"
)}</span
>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.schedule.schedule_description"
)} )}
</span> </span>
<ha-switch <ha-md-select
slot="end" slot="end"
@change=${this._enabledChanged} @change=${this._scheduleChanged}
.checked=${data.enabled} .value=${data.recurrence}
></ha-switch> >
${SCHEDULE_OPTIONS.map(
(option) => html`
<ha-md-select-option .value=${option}>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.schedule_options.${option}`
)}
</div>
</ha-md-select-option>
`
)}
</ha-md-select>
</ha-md-list-item> </ha-md-list-item>
${data.enabled ${data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
? html`<ha-expansion-panel
expanded
.header=${this.hass.localize(
"ui.panel.config.backup.schedule.custom_schedule"
)}
outlined
>
<ha-md-list-item class="days">
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.backup_every"
)}
</span>
<div slot="end">
${BACKUP_DAYS.map(
(day) => html`
<div>
<ha-formfield
.label=${this.hass.localize(`ui.panel.config.backup.overview.settings.weekdays.${day}`)}
>
<ha-checkbox
@change=${this._daysChanged}
.checked=${data.days.includes(day)}
.value=${day}
>
</ha-checkbox>
</span>
</ha-formfield>
</div>
`
)}
</div>
</ha-md-list-item>
</ha-expansion-panel>`
: nothing}
${data.recurrence === BackupScheduleRecurrence.DAILY ||
(data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS &&
data.days.length > 0)
? html` ? html`
<ha-md-list-item> <ha-md-list-item>
<span slot="headline"> <span slot="headline">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.backup.schedule.schedule" "ui.panel.config.backup.schedule.time"
)} )}</span
</span> >
<span slot="supporting-text"> <span slot="supporting-text">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.backup.schedule.schedule_description" "ui.panel.config.backup.schedule.schedule_time_description"
)} )}
${data.time_option === BackupScheduleTime.DEFAULT
? this.hass.localize(
"ui.panel.config.backup.schedule.schedule_time_optimal_description",
{
time_range_start: formatTime(
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
this.hass.locale,
this.hass.config
),
time_range_end: formatTime(
DEFAULT_OPTIMIZED_BACKUP_END_TIME,
this.hass.locale,
this.hass.config
),
}
)
: nothing}
</span> </span>
<ha-md-select <ha-md-select
slot="end" slot="end"
@change=${this._scheduleChanged} @change=${this._scheduleTimeChanged}
.value=${data.schedule} .value=${data.time_option}
> >
${SCHEDULE_OPTIONS.map( ${SCHEDULE_TIME_OPTIONS.map(
(option) => html` (option) => html`
<ha-md-select-option .value=${option}> <ha-md-select-option .value=${option}>
<div slot="headline"> <div slot="headline">
${this.hass.localize( ${this.hass.localize(
`ui.panel.config.backup.schedule.schedule_options.${option}`, `ui.panel.config.backup.schedule.time_options.${option}`
{ time }
)} )}
</div> </div>
</ha-md-select-option> </ha-md-select-option>
@ -190,100 +290,197 @@ class HaBackupConfigSchedule extends LitElement {
)} )}
</ha-md-select> </ha-md-select>
</ha-md-list-item> </ha-md-list-item>
<ha-md-list-item> ${data.time_option === BackupScheduleTime.CUSTOM
<span slot="headline"> ? html`<ha-expansion-panel
${this.hass.localize( expanded
`ui.panel.config.backup.schedule.retention` .header=${this.hass.localize(
)} "ui.panel.config.backup.schedule.custom_time"
</span> )}
<span slot="supporting-text"> outlined
${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-md-list-item> <ha-md-list-item>
<ha-md-textfield <span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_time_label"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_time_description",
{
time: formatTime(
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
this.hass.locale,
this.hass.config
),
}
)}
</span>
<ha-time-input
slot="end" slot="end"
@change=${this._retentionValueChanged} @value-changed=${this._timeChanged}
.value=${data.retention.value} .value=${data.time ?? undefined}
id="value" .locale=${this.hass.locale}
type="number"
.min=${MIN_VALUE}
.max=${MAX_VALUE}
step="1"
> >
</ha-md-textfield> </ha-time-input>
<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-md-list-item>
` </ha-expansion-panel>`
: nothing} : nothing}
` `
: nothing} : 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-tip .hass=${this.hass}
>${this.hass.localize("ui.panel.config.backup.schedule.tip", {
backup_create: html`<a
href=${documentationUrl(
this.hass,
"/integrations/backup#example-backing-up-every-night-at-300-am"
)}
target="_blank"
rel="noopener noreferrer"
>backup.create</a
>`,
})}</ha-tip
>
</ha-md-list> </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) { private _scheduleChanged(ev) {
ev.stopPropagation(); ev.stopPropagation();
const target = ev.currentTarget as HaMdSelect; const target = ev.currentTarget as HaMdSelect;
const data = this._getData(this.value); const data = this._getData(this.value);
let days = [...data.days];
if (
target.value === BackupScheduleRecurrence.CUSTOM_DAYS &&
data.days.length === 0
) {
days = [...BACKUP_DAYS];
}
this._setData({ this._setData({
...data, ...data,
schedule: target.value as BackupScheduleState, recurrence: target.value as BackupScheduleRecurrence,
days,
});
}
private _scheduleTimeChanged(ev) {
ev.stopPropagation();
const target = ev.currentTarget as HaMdSelect;
const data = this._getData(this.value);
this._setData({
...data,
time_option: target.value as BackupScheduleTime,
time: target.value === BackupScheduleTime.CUSTOM ? "04:45:00" : undefined,
});
}
private _timeChanged(ev) {
ev.stopPropagation();
const data = this._getData(this.value);
this._setData({
...data,
time: ev.detail.value,
});
}
private _daysChanged(ev) {
ev.stopPropagation();
const target = ev.currentTarget as HaCheckbox;
const value = target.value as BackupDay;
const data = this._getData(this.value);
const days = [...data.days];
if (target.checked && !data.days.includes(value)) {
days.push(value);
} else if (!target.checked && data.days.includes(value)) {
days.splice(days.indexOf(value), 1);
}
sortWeekdays(days);
this._setData({
...data,
days,
}); });
fireEvent(this, "value-changed", { value: this.value });
} }
private _retentionPresetChanged(ev) { private _retentionPresetChanged(ev) {
@ -304,8 +501,6 @@ class HaBackupConfigSchedule extends LitElement {
retention: RETENTION_PRESETS[value], retention: RETENTION_PRESETS[value],
}); });
} }
fireEvent(this, "value-changed", { value: this.value });
} }
private _retentionValueChanged(ev) { private _retentionValueChanged(ev) {
@ -321,8 +516,6 @@ class HaBackupConfigSchedule extends LitElement {
value: clamped, value: clamped,
}, },
}); });
fireEvent(this, "value-changed", { value: this.value });
} }
private _retentionTypeChanged(ev) { private _retentionTypeChanged(ev) {
@ -338,8 +531,6 @@ class HaBackupConfigSchedule extends LitElement {
type: value, type: value,
}, },
}); });
fireEvent(this, "value-changed", { value: this.value });
} }
static styles = css` static styles = css`
@ -351,11 +542,13 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-list-item { ha-md-list-item {
--md-item-overflow: visible; --md-item-overflow: visible;
} }
ha-md-select { ha-md-select,
ha-time-input {
min-width: 210px; min-width: 210px;
} }
@media all and (max-width: 450px) { @media all and (max-width: 450px) {
ha-md-select { ha-md-select,
ha-time-input {
min-width: 160px; min-width: 160px;
} }
} }
@ -365,6 +558,21 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select#type { ha-md-select#type {
min-width: 100px; min-width: 100px;
} }
ha-expansion-panel {
--expansion-panel-summary-padding: 0 16px;
--expansion-panel-content-padding: 0 16px;
margin-bottom: 16px;
}
ha-tip {
text-align: unset;
margin: 16px 0;
}
ha-md-list-item.days {
--md-item-align-items: flex-start;
}
a {
color: var(--primary-color);
}
`; `;
} }

View File

@ -11,7 +11,7 @@ import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon"; import "../../../../../components/ha-svg-icon";
import type { BackupConfig } from "../../../../../data/backup"; import type { BackupConfig } from "../../../../../data/backup";
import { import {
BackupScheduleState, BackupScheduleRecurrence,
computeBackupAgentName, computeBackupAgentName,
getFormattedBackupTime, getFormattedBackupTime,
isLocalAgent, isLocalAgent,
@ -31,24 +31,87 @@ class HaBackupBackupsSummary extends LitElement {
private _scheduleDescription(config: BackupConfig): string { private _scheduleDescription(config: BackupConfig): string {
const { copies, days } = config.retention; const { copies, days } = config.retention;
const { state: schedule } = config.schedule; const { recurrence } = config.schedule;
if (schedule === BackupScheduleState.NEVER) { if (recurrence === BackupScheduleRecurrence.NEVER) {
return this.hass.localize( return this.hass.localize(
"ui.panel.config.backup.overview.settings.schedule_never" "ui.panel.config.backup.overview.settings.schedule_never"
); );
} }
const time = getFormattedBackupTime(this.hass.locale, this.hass.config); const time: string | undefined | null =
this.config.schedule.time &&
getFormattedBackupTime(
this.hass.locale,
this.hass.config,
this.config.schedule.time
);
const scheduleText = this.hass.localize( let scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${schedule}`, "ui.panel.config.backup.overview.settings.schedule_never"
{ time }
); );
const configDays = this.config.schedule.days;
if (
this.config.schedule.recurrence === BackupScheduleRecurrence.DAILY ||
(this.config.schedule.recurrence ===
BackupScheduleRecurrence.CUSTOM_DAYS &&
configDays.length === 7)
) {
scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}daily`,
{
time,
}
);
} else if (
this.config.schedule.recurrence ===
BackupScheduleRecurrence.CUSTOM_DAYS &&
configDays.length !== 0
) {
if (
configDays.length === 2 &&
configDays.includes("sat") &&
configDays.includes("sun")
) {
scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}weekend`,
{
time,
}
);
} else if (
configDays.length === 5 &&
!configDays.includes("sat") &&
!configDays.includes("sun")
) {
scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}weekdays`,
{
time,
}
);
} else {
scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}days`,
{
count: configDays.length,
days: configDays
.map((dayCode) =>
this.hass.localize(
`ui.panel.config.backup.overview.settings.${configDays.length > 2 ? "short_weekdays" : "weekdays"}.${dayCode}`
)
)
.join(", "),
time,
}
);
}
}
let copiesText = this.hass.localize( let copiesText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_copies_all`, `ui.panel.config.backup.overview.settings.schedule_copies_all`
{ time }
); );
if (copies) { if (copies) {
copiesText = this.hass.localize( copiesText = this.hass.localize(

View File

@ -1,7 +1,7 @@
import { mdiBackupRestore, mdiCalendar } from "@mdi/js"; import { mdiBackupRestore, mdiCalendar } from "@mdi/js";
import { addHours, differenceInDays } from "date-fns"; import { addHours, differenceInDays, isToday, isTomorrow } from "date-fns";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time"; import { relativeTime } from "../../../../../common/datetime/relative_time";
@ -12,12 +12,13 @@ import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon"; import "../../../../../components/ha-svg-icon";
import type { BackupConfig, BackupContent } from "../../../../../data/backup"; import type { BackupConfig, BackupContent } from "../../../../../data/backup";
import { import {
BackupScheduleState, BackupScheduleRecurrence,
getFormattedBackupTime, getFormattedBackupTime,
} from "../../../../../data/backup"; } from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles"; import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
import "../ha-backup-summary-card"; import "../ha-backup-summary-card";
import { formatDateWeekday } from "../../../../../common/datetime/format_date";
const OVERDUE_MARGIN_HOURS = 3; const OVERDUE_MARGIN_HOURS = 3;
@ -76,16 +77,6 @@ class HaBackupOverviewBackups extends LitElement {
const lastBackup = this._lastBackup(this.backups); const lastBackup = this._lastBackup(this.backups);
const backupTime = getFormattedBackupTime(
this.hass.locale,
this.hass.config
);
const nextBackupDescription = this.hass.localize(
`ui.panel.config.backup.overview.summary.next_backup_description.${this.config.schedule.state}`,
{ time: backupTime }
);
const lastAttemptDate = this.config.last_attempted_automatic_backup const lastAttemptDate = this.config.last_attempted_automatic_backup
? new Date(this.config.last_attempted_automatic_backup) ? new Date(this.config.last_attempted_automatic_backup)
: new Date(0); : new Date(0);
@ -94,6 +85,46 @@ class HaBackupOverviewBackups extends LitElement {
? new Date(this.config.last_completed_automatic_backup) ? new Date(this.config.last_completed_automatic_backup)
: new Date(0); : new Date(0);
const nextAutomaticDate = this.config.next_automatic_backup
? new Date(this.config.next_automatic_backup)
: undefined;
const backupTime = getFormattedBackupTime(
this.hass.locale,
this.hass.config,
nextAutomaticDate || this.config.schedule.time
);
const nextBackupDescription =
this.config.schedule.recurrence === BackupScheduleRecurrence.NEVER ||
(this.config.schedule.recurrence ===
BackupScheduleRecurrence.CUSTOM_DAYS &&
this.config.schedule.days.length === 0)
? this.hass.localize(
`ui.panel.config.backup.overview.summary.no_automatic_backup`
)
: nextAutomaticDate
? this.hass.localize(
`ui.panel.config.backup.overview.summary.next_automatic_backup`,
{
day: isTomorrow(nextAutomaticDate)
? this.hass.localize(
"ui.panel.config.backup.overview.summary.tomorrow"
)
: isToday(nextAutomaticDate)
? this.hass.localize(
"ui.panel.config.backup.overview.summary.today"
)
: formatDateWeekday(
nextAutomaticDate,
this.hass.locale,
this.hass.config
),
time: backupTime,
}
)
: "";
// If last attempt is after last completed backup, show error // If last attempt is after last completed backup, show error
if (lastAttemptDate > lastCompletedDate) { if (lastAttemptDate > lastCompletedDate) {
const lastUploadedBackup = this._lastUploadedBackup(this.backups); const lastUploadedBackup = this._lastUploadedBackup(this.backups);
@ -122,25 +153,32 @@ class HaBackupOverviewBackups extends LitElement {
)} )}
</span> </span>
</ha-md-list-item> </ha-md-list-item>
<ha-md-list-item> ${lastUploadedBackup || nextBackupDescription
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon> ? html`
<span slot="headline"> <ha-md-list-item>
${lastUploadedBackup <ha-svg-icon
? this.hass.localize( slot="start"
"ui.panel.config.backup.overview.summary.last_successful_backup_description", .path=${mdiCalendar}
{ ></ha-svg-icon>
relative_time: relativeTime( <span slot="headline">
new Date(lastUploadedBackup.date), ${lastUploadedBackup
this.hass.locale, ? this.hass.localize(
now, "ui.panel.config.backup.overview.summary.last_successful_backup_description",
true {
), relative_time: relativeTime(
count: lastUploadedBackup.agent_ids?.length ?? 0, new Date(lastUploadedBackup.date),
} this.hass.locale,
) now,
: nextBackupDescription} true
</span> ),
</ha-md-list-item> count: lastUploadedBackup.agent_ids?.length ?? 0,
}
)
: nextBackupDescription}
</span>
</ha-md-list-item>
`
: nothing}
</ha-md-list> </ha-md-list>
</ha-backup-summary-card> </ha-backup-summary-card>
`; `;
@ -164,10 +202,7 @@ class HaBackupOverviewBackups extends LitElement {
)} )}
</span> </span>
</ha-md-list-item> </ha-md-list-item>
<ha-md-list-item> ${this._renderNextBackupDescription(nextBackupDescription)}
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">${nextBackupDescription}</span>
</ha-md-list-item>
</ha-md-list> </ha-md-list>
</ha-backup-summary-card> </ha-backup-summary-card>
`; `;
@ -203,25 +238,28 @@ class HaBackupOverviewBackups extends LitElement {
)} )}
</span> </span>
</ha-md-list-item> </ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon> ${lastUploadedBackup || nextBackupDescription
<span slot="headline"> ? html` <ha-md-list-item>
${lastUploadedBackup <ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
? this.hass.localize( <span slot="headline">
"ui.panel.config.backup.overview.summary.last_successful_backup_description", ${lastUploadedBackup
{ ? this.hass.localize(
relative_time: relativeTime( "ui.panel.config.backup.overview.summary.last_successful_backup_description",
new Date(lastUploadedBackup.date), {
this.hass.locale, relative_time: relativeTime(
now, new Date(lastUploadedBackup.date),
true this.hass.locale,
), now,
count: lastUploadedBackup.agent_ids?.length ?? 0, true
} ),
) count: lastUploadedBackup.agent_ids?.length ?? 0,
: nextBackupDescription} }
</span> )
</ha-md-list-item> : nextBackupDescription}
</span>
</ha-md-list-item>`
: nothing}
</ha-md-list> </ha-md-list>
</ha-backup-summary-card> </ha-backup-summary-card>
`; `;
@ -248,53 +286,37 @@ class HaBackupOverviewBackups extends LitElement {
const isOverdue = const isOverdue =
(numberOfDays >= 1 && (numberOfDays >= 1 &&
this.config.schedule.state === BackupScheduleState.DAILY) || this.config.schedule.recurrence === BackupScheduleRecurrence.DAILY) ||
numberOfDays >= 7; numberOfDays >= 7;
if (isOverdue) {
return html`
<ha-backup-summary-card
.heading=${this.hass.localize(
"ui.panel.config.backup.overview.summary.backup_too_old_heading",
{ count: numberOfDays }
)}
status="warning"
>
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span slot="headline">${lastSuccessfulBackupDescription}</span>
</ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">${nextBackupDescription}</span>
</ha-md-list-item>
</ha-md-list>
</ha-backup-summary-card>
`;
}
return html` return html`
<ha-backup-summary-card <ha-backup-summary-card
.heading=${this.hass.localize( .heading=${this.hass.localize(
"ui.panel.config.backup.overview.summary.backup_success_heading" `ui.panel.config.backup.overview.summary.${isOverdue ? "backup_too_old_heading" : "backup_success_heading"}`,
{ count: numberOfDays }
)} )}
status="success" .status=${isOverdue ? "warning" : "success"}
> >
<ha-md-list> <ha-md-list>
<ha-md-list-item> <ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon> <ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span slot="headline">${lastSuccessfulBackupDescription}</span> <span slot="headline">${lastSuccessfulBackupDescription}</span>
</ha-md-list-item> </ha-md-list-item>
<ha-md-list-item> ${this._renderNextBackupDescription(nextBackupDescription)}
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">${nextBackupDescription}</span>
</ha-md-list-item>
</ha-md-list> </ha-md-list>
</ha-backup-summary-card> </ha-backup-summary-card>
`; `;
} }
private _renderNextBackupDescription(nextBackupDescription: string) {
return nextBackupDescription
? html` <ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">${nextBackupDescription}</span>
</ha-md-list-item>`
: nothing;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -21,7 +21,7 @@ import type {
BackupMutableConfig, BackupMutableConfig,
} from "../../../../data/backup"; } from "../../../../data/backup";
import { import {
BackupScheduleState, BackupScheduleRecurrence,
CLOUD_AGENT, CLOUD_AGENT,
CORE_LOCAL_AGENT, CORE_LOCAL_AGENT,
downloadEmergencyKit, downloadEmergencyKit,
@ -68,10 +68,13 @@ const RECOMMENDED_CONFIG: BackupConfig = {
days: null, days: null,
}, },
schedule: { schedule: {
state: BackupScheduleState.DAILY, recurrence: BackupScheduleRecurrence.DAILY,
time: null,
days: [],
}, },
last_attempted_automatic_backup: null, last_attempted_automatic_backup: null,
last_completed_automatic_backup: null, last_completed_automatic_backup: null,
next_automatic_backup: null,
}; };
@customElement("ha-dialog-backup-onboarding") @customElement("ha-dialog-backup-onboarding")
@ -145,7 +148,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
include_database: this._config.create_backup.include_database, include_database: this._config.create_backup.include_database,
agent_ids: this._config.create_backup.agent_ids, agent_ids: this._config.create_backup.agent_ids,
}, },
schedule: this._config.schedule.state, schedule: this._config.schedule,
retention: this._config.retention, retention: this._config.retention,
}; };

View File

@ -308,7 +308,7 @@ class HaConfigBackupSettings extends LitElement {
password: this._config!.create_backup.password, password: this._config!.create_backup.password,
}, },
retention: this._config!.retention, retention: this._config!.retention,
schedule: this._config!.schedule.state, schedule: this._config!.schedule,
}); });
fireEvent(this, "ha-refresh-backup-config"); fireEvent(this, "ha-refresh-backup-config");
} }

View File

@ -2411,20 +2411,29 @@
"addons": "Add-ons" "addons": "Add-ons"
}, },
"schedule": { "schedule": {
"use_automatic_backups": "Use automatic backups",
"schedule": "Schedule", "schedule": "Schedule",
"backup_every": "Backup every",
"custom_schedule": "Custom schedule",
"time": "Time",
"custom_time": "Custom time",
"custom_time_label": "Backup at",
"custom_time_description": "The optimal time is after Home Assistant finished cleaning up the database. This is done at {time}.",
"schedule_description": "How often you want to create a backup.", "schedule_description": "How often you want to create a backup.",
"schedule_time_description": "When the backup creation starts.",
"schedule_time_optimal_description": "By default Home Assistant picks the optimal time between {time_range_start} and {time_range_end}.",
"tip": "You can create your own custom backup automation with the {backup_create} action",
"schedule_options": { "schedule_options": {
"daily": "Daily at {time}", "never": "Never",
"mon": "Monday at {time}", "daily": "Daily",
"tue": "Tuesday at {time}", "custom_days": "Custom days"
"wed": "Wednesday at {time}", },
"thu": "Thursday at {time}", "time_options": {
"fri": "Friday at {time}", "default": "System optimal",
"sat": "Saturday at {time}", "custom": "Custom"
"sun": "Sunday at {time}"
}, },
"retention": "Retention", "retention": "Retention",
"custom_retention": "Custom retention",
"custom_retention_label": "Clean up every",
"retention_description": "Based on the maximum number of backups or how many days they should be kept.", "retention_description": "Based on the maximum number of backups or how many days they should be kept.",
"retention_presets": { "retention_presets": {
"copies_3": "3 backups", "copies_3": "3 backups",
@ -2509,17 +2518,10 @@
} }
}, },
"summary": { "summary": {
"next_backup_description": { "no_automatic_backup": "No automatic backups scheduled",
"daily": "Next automatic backup tomorrow at {time}", "next_automatic_backup": "Next automatic backup {day} at {time}",
"mon": "Next automatic backup next Monday at {time}", "today": "today",
"tue": "Next automatic backup next Tuesday at {time}", "tomorrow": "tomorrow",
"wed": "Next automatic backup next Wednesday at {time}",
"thu": "Next automatic backup next Thursday at {time}",
"fri": "Next automatic backup next Friday at {time}",
"sat": "Next automatic backup next Saturday at {time}",
"sun": "Next automatic backup next Sunday at {time}",
"never": "No automatic backups scheduled"
},
"loading": "Loading backups...", "loading": "Loading backups...",
"last_backup_failed_heading": "Last automatic backup failed", "last_backup_failed_heading": "Last automatic backup failed",
"last_backup_failed_description": "The last automatic backup triggered {relative_time} wasn't successful.", "last_backup_failed_description": "The last automatic backup triggered {relative_time} wasn't successful.",
@ -2545,13 +2547,13 @@
"schedule_copies_backups": "and keep {count} {count, plural,\n one {backup}\n other {backups}\n}", "schedule_copies_backups": "and keep {count} {count, plural,\n one {backup}\n other {backups}\n}",
"schedule_copies_days": "and keep {count} {count, plural,\n one {day}\n other {days}\n}", "schedule_copies_days": "and keep {count} {count, plural,\n one {day}\n other {days}\n}",
"schedule_daily": "Daily at {time}", "schedule_daily": "Daily at {time}",
"schedule_mon": "Weekly on Mondays at {time}", "schedule_days": "Every {days} at {time}",
"schedule_tue": "Weekly on Tuesdays at {time}", "schedule_weekdays": "Every weekday at {time}",
"schedule_wed": "Weekly on Wednesdays at {time}", "schedule_optimized_weekdays": "Every weekday",
"schedule_thu": "Weekly on Thursdays at {time}", "schedule_weekend": "Every weekends at {time}",
"schedule_fri": "Weekly on Fridays at {time}", "schedule_optimized_weekend": "Every weekends",
"schedule_sat": "Weekly on Saturdays at {time}", "schedule_optimized_daily": "Daily",
"schedule_sun": "Weekly on Sundays at {time}", "schedule_optimized_days": "Every {days}",
"schedule_never": "Automatic backups are not scheduled", "schedule_never": "Automatic backups are not scheduled",
"data": "Home Assistant data that is included", "data": "Home Assistant data that is included",
"data_settings_history": "Settings and history", "data_settings_history": "Settings and history",
@ -2564,7 +2566,26 @@
"locations_one": "Store in {name}", "locations_one": "Store in {name}",
"locations_many": "Store in {count} off-site {count, plural,\n one {location}\n other {locations}\n}", "locations_many": "Store in {count} off-site {count, plural,\n one {location}\n other {locations}\n}",
"locations_local_only": "Local backup only", "locations_local_only": "Local backup only",
"locations_none": "No locations configured" "locations_none": "No locations configured",
"system_optimal_time": "system optimal time",
"weekdays": {
"mon": "[%key:ui::weekdays::monday%]",
"tue": "[%key:ui::weekdays::tuesday%]",
"wed": "[%key:ui::weekdays::wednesday%]",
"thu": "[%key:ui::weekdays::thursday%]",
"fri": "[%key:ui::weekdays::friday%]",
"sat": "[%key:ui::weekdays::saturday%]",
"sun": "[%key:ui::weekdays::sunday%]"
},
"short_weekdays": {
"mon": "Mo",
"tue": "Tu",
"wed": "We",
"thu": "Th",
"fri": "Fr",
"sat": "Sa",
"sun": "So"
}
} }
}, },
"backups": { "backups": {