fix: --keep-last n param to mitigate loss of sub-hourly snapshots (#741)

This commit is contained in:
Ian Paterson
2025-05-04 23:41:54 -07:00
committed by GitHub
parent d15f0e758b
commit 62f3ca8eea
9 changed files with 420 additions and 85 deletions
+18 -8
View File
@@ -1656,11 +1656,12 @@ type RetentionPolicy_TimeBucketedCounts struct {
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Hourly int32 `protobuf:"varint,1,opt,name=hourly,proto3" json:"hourly,omitempty"` // keep the last n hourly snapshots.
Daily int32 `protobuf:"varint,2,opt,name=daily,proto3" json:"daily,omitempty"` // keep the last n daily snapshots.
Weekly int32 `protobuf:"varint,3,opt,name=weekly,proto3" json:"weekly,omitempty"` // keep the last n weekly snapshots.
Monthly int32 `protobuf:"varint,4,opt,name=monthly,proto3" json:"monthly,omitempty"` // keep the last n monthly snapshots.
Yearly int32 `protobuf:"varint,5,opt,name=yearly,proto3" json:"yearly,omitempty"` // keep the last n yearly snapshots.
Hourly int32 `protobuf:"varint,1,opt,name=hourly,proto3" json:"hourly,omitempty"` // keep the last n hourly snapshots.
Daily int32 `protobuf:"varint,2,opt,name=daily,proto3" json:"daily,omitempty"` // keep the last n daily snapshots.
Weekly int32 `protobuf:"varint,3,opt,name=weekly,proto3" json:"weekly,omitempty"` // keep the last n weekly snapshots.
Monthly int32 `protobuf:"varint,4,opt,name=monthly,proto3" json:"monthly,omitempty"` // keep the last n monthly snapshots.
Yearly int32 `protobuf:"varint,5,opt,name=yearly,proto3" json:"yearly,omitempty"` // keep the last n yearly snapshots.
KeepLastN int32 `protobuf:"varint,6,opt,name=keep_last_n,json=keepLastN,proto3" json:"keep_last_n,omitempty"` // keep the last n snapshots regardless of age.
}
func (x *RetentionPolicy_TimeBucketedCounts) Reset() {
@@ -1728,6 +1729,13 @@ func (x *RetentionPolicy_TimeBucketedCounts) GetYearly() int32 {
return 0
}
func (x *RetentionPolicy_TimeBucketedCounts) GetKeepLastN() int32 {
if x != nil {
return x.KeepLastN
}
return 0
}
type Hook_Command struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -2238,7 +2246,7 @@ var file_v1_config_proto_rawDesc = []byte{
0x0a, 0x0c, 0x43, 0x50, 0x55, 0x4e, 0x69, 0x63, 0x65, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x0f,
0x0a, 0x0b, 0x43, 0x50, 0x55, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12,
0x0c, 0x0a, 0x08, 0x43, 0x50, 0x55, 0x5f, 0x48, 0x49, 0x47, 0x48, 0x10, 0x01, 0x12, 0x0b, 0x0a,
0x07, 0x43, 0x50, 0x55, 0x5f, 0x4c, 0x4f, 0x57, 0x10, 0x02, 0x22, 0xdf, 0x02, 0x0a, 0x0f, 0x52,
0x07, 0x43, 0x50, 0x55, 0x5f, 0x4c, 0x4f, 0x57, 0x10, 0x02, 0x22, 0xff, 0x02, 0x0a, 0x0f, 0x52,
0x65, 0x74, 0x65, 0x6e, 0x74, 0x69, 0x6f, 0x6e, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x12, 0x2d,
0x0a, 0x12, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x6b, 0x65, 0x65, 0x70, 0x5f, 0x6c, 0x61,
0x73, 0x74, 0x5f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x0f, 0x70, 0x6f,
@@ -2251,7 +2259,7 @@ var file_v1_config_proto_rawDesc = []byte{
0x65, 0x42, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x0f, 0x70, 0x6f, 0x6c,
0x69, 0x63, 0x79, 0x5f, 0x6b, 0x65, 0x65, 0x70, 0x5f, 0x61, 0x6c, 0x6c, 0x18, 0x0c, 0x20, 0x01,
0x28, 0x08, 0x48, 0x00, 0x52, 0x0d, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4b, 0x65, 0x65, 0x70,
0x41, 0x6c, 0x6c, 0x1a, 0x8c, 0x01, 0x0a, 0x12, 0x54, 0x69, 0x6d, 0x65, 0x42, 0x75, 0x63, 0x6b,
0x41, 0x6c, 0x6c, 0x1a, 0xac, 0x01, 0x0a, 0x12, 0x54, 0x69, 0x6d, 0x65, 0x42, 0x75, 0x63, 0x6b,
0x65, 0x74, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x6f,
0x75, 0x72, 0x6c, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x68, 0x6f, 0x75, 0x72,
0x6c, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x61, 0x69, 0x6c, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28,
@@ -2260,7 +2268,9 @@ var file_v1_config_proto_rawDesc = []byte{
0x12, 0x18, 0x0a, 0x07, 0x6d, 0x6f, 0x6e, 0x74, 0x68, 0x6c, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28,
0x05, 0x52, 0x07, 0x6d, 0x6f, 0x6e, 0x74, 0x68, 0x6c, 0x79, 0x12, 0x16, 0x0a, 0x06, 0x79, 0x65,
0x61, 0x72, 0x6c, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x79, 0x65, 0x61, 0x72,
0x6c, 0x79, 0x42, 0x08, 0x0a, 0x06, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x22, 0x8f, 0x01, 0x0a,
0x6c, 0x79, 0x12, 0x1e, 0x0a, 0x0b, 0x6b, 0x65, 0x65, 0x70, 0x5f, 0x6c, 0x61, 0x73, 0x74, 0x5f,
0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6b, 0x65, 0x65, 0x70, 0x4c, 0x61, 0x73,
0x74, 0x4e, 0x42, 0x08, 0x0a, 0x06, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x22, 0x8f, 0x01, 0x0a,
0x0b, 0x50, 0x72, 0x75, 0x6e, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x12, 0x28, 0x0a, 0x08,
0x73, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0c,
0x2e, 0x76, 0x31, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x64, 0x75, 0x6c, 0x65, 0x52, 0x08, 0x73, 0x63,
+1
View File
@@ -108,6 +108,7 @@ message RetentionPolicy {
int32 weekly = 3 [json_name="weekly"]; // keep the last n weekly snapshots.
int32 monthly = 4 [json_name="monthly"]; // keep the last n monthly snapshots.
int32 yearly = 5 [json_name="yearly"]; // keep the last n yearly snapshots.
int32 keep_last_n = 6 [json_name="keepLastN"]; // keep the last n snapshots regardless of age.
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -32,7 +32,7 @@
"process": "^0.11.10",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-js-cron": "^5.0.1",
"react-js-cron": "^5.2.0",
"react-router": "^6.28.0",
"react-router-dom": "^6.28.0",
"react-virtualized": "^9.22.5",
+5 -5
View File
@@ -66,8 +66,8 @@ importers:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
react-js-cron:
specifier: ^5.0.1
version: 5.0.1(antd@5.22.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
specifier: ^5.2.0
version: 5.2.0(antd@5.22.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-router:
specifier: ^6.28.0
version: 6.28.0(react@18.3.1)
@@ -1584,8 +1584,8 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-js-cron@5.0.1:
resolution: {integrity: sha512-qHUb/qWeMvXklGW6/hLtH9CjboRU7pu2hHxGGErUDTjHDOLn/b6CZHvVsx/e3ak+UObwf4Y0BHSQJzpn1JpvvA==}
react-js-cron@5.2.0:
resolution: {integrity: sha512-+Mxm3cS7qmIoAIz7NVY27jvsJKNZ4tx+H/nNtMUJW4DcKR7jUIL1GP0jOD79K5j86Dq8jvShKJMh30+c8bmVtA==}
peerDependencies:
antd: '>=5.8.0'
react: '>=17.0.0'
@@ -3646,7 +3646,7 @@ snapshots:
react-is@18.3.1: {}
react-js-cron@5.0.1(antd@5.22.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
react-js-cron@5.2.0(antd@5.22.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
antd: 5.22.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
+3
View File
@@ -605,6 +605,9 @@ const ForgetOperationDetails = ({
if (val.yearly) {
policyDesc.push(`Keep yearly for ${val.yearly} years`);
}
if (val.keepLastN) {
policyDesc.push(`Keep latest ${val.keepLastN} snapshots regardless of age`);
}
}
}
+219
View File
@@ -0,0 +1,219 @@
import { converter } from "react-js-cron";
interface FindOptimalStartIndexConstraints {
/** The number of forward values to consider when optimizing the start index */
windowSize?: number;
/** Whether the values at the end of the list can wrap around to the beginning */
wrap?: boolean;
}
/**
* Find the index of the value that is nearest to other values within the specified constraints.
* @param sortedValues A list of numbers in ascending order
* @param maxValue The maximum value that could appear in the list, e.g. 59 for a list of minutes
* @returns The index of the value that is closest to the optimal start index
*/
const findMinimalDurationStartIndex = (
sortedValues: readonly number[],
maxValue: number,
{ windowSize = 0, wrap = false }: FindOptimalStartIndexConstraints = {},
) => {
let minDuration = Infinity;
let minDurationIndex = 0;
if (windowSize > maxValue) {
windowSize = 0;
}
// If no constraints are specified, return the first value since it minimizes the number of
// iterations at the next higher level.
if (!wrap && !windowSize) {
return 0;
}
// From each value, calculate the forward duration to all other values within the constraints
for (const [valueIndex, value] of sortedValues.entries()) {
// If there are not enough values after this one, stop
if (!wrap && valueIndex + windowSize >= sortedValues.length) {
break;
}
// Calculate the duration to all values after this one
let duration = 0;
const limit = windowSize || (sortedValues.length - 1);
for (let otherIndex = 0; otherIndex < limit; otherIndex++) {
const other = sortedValues[(valueIndex + 1 + otherIndex) % sortedValues.length];
// If the other value is after the value, calculate the duration
if (other > value) {
duration += other - value;
} else if (wrap) {
// The other value is before the value; calculate the duration after wrapping around
duration += maxValue - value + 1 + other;
} else {
break;
}
}
if (duration < minDuration) {
minDuration = duration;
minDurationIndex = valueIndex;
}
}
return minDurationIndex;
};
const monthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/**
* Generates days that are enabled by the cron expression.
*
* Yields increasing numbers representing any day enabled by either `days` or `weekdays`. The
* numbers represent an absolute count of days since the start of the schedule. Follows calendar
* months beginning in February.
*/
function* enabledCronDays(days: readonly number[], weekdays: readonly number[]) {
let absoluteDay = 1;
let monthDay = 1;
let month = 1; // February
const enabledDays = new Set(days.length === 31 && weekdays.length < 7 ? [] : days);
const enabledWeekdays = new Set(weekdays.length === 7 ? [] : weekdays);
while (true) {
if (monthDay > monthDays[month]) {
// Jump to the next month
monthDay = 1;
month = (month + 1) % 12;
}
if (enabledWeekdays.has(absoluteDay % 7) || enabledDays.has(monthDay)) {
yield absoluteDay;
}
absoluteDay++;
monthDay++;
}
}
const hasConsecutiveValues = (sortedValues: readonly number[]) => sortedValues.some(
(v, i) => i > 0 && v === sortedValues[i - 1] + 1,
);
// min/max range for each cron unit in the order returned by `converter.parseCronString`
const rangeByUnit = [
[0, 59],
[0, 23],
[1, 31],
[1, 12],
[0, 6],
];
/** Parses a cron expression and returns the enabled values for each unit. */
const parseCronExpression = (cronExpr: string) => {
const [ minutes, hours, days, months, weekdays ] = converter.parseCronString(cronExpr).map(
(value, unitIndex) => (value.length > 0
? value
// `parseCronString` returns `*` as an empty array; swap with the full range of values
: Array.from(
{ length: rangeByUnit[unitIndex][1] - rangeByUnit[unitIndex][0] + 1 },
(_, i) => rangeByUnit[unitIndex][0] + i
)
)
);
return { minutes, hours, days, months, weekdays };
};
/**
* Calculates a rough minimum duration for the cron expression to run the specified number of times.
*
* Months are ignored since backrest does not support scheduling by month.
* @param cronExpr The cron expression to evaluate
* @param targetInvocations The number of times the cron expression must run
* @returns Minimal duration in milliseconds
*/
export const getMinimumCronDuration = (cronExpr: string, targetInvocations = 1) => {
// Get arrays of minutes, hours, days, and weekdays that are enabled by the cron expression
const { minutes, hours, days, weekdays } = parseCronExpression(cronExpr);
// The total duration in minutes
let elapsedMinutes = 0;
let remainingInvocations = targetInvocations;
const daysIterator = enabledCronDays(days, weekdays);
// Start with just enough days to test whether there are consecutive days
const daysAndWeekdays = Array.from(
{
length: weekdays.length < 7
// Days and weekdays align sufficiently after about 100 invocations to test consecutive
// days; no need to generate the full 7 month repeating pattern
? 100
// If no weekdays specified we just need the days pattern with enough days to test wrapping
: days.length * 2
},
() => +daysIterator.next().value,
);
let dayIndex = -1;
do {
// Calculate more days as needed
if (dayIndex >= daysAndWeekdays.length) {
daysAndWeekdays.push(+daysIterator.next().value);
}
// Add time elapsed between days
if (dayIndex >= 0) {
const skippedDays = (daysAndWeekdays[dayIndex] - (daysAndWeekdays[dayIndex - 1] ?? 0)) - 1;
elapsedMinutes += skippedDays * 24 * 60;
}
let hourIndex = 0;
do {
const isFirstHour = elapsedMinutes === 0;
if (!isFirstHour) {
const skippedHours = hours[hourIndex] - (hours[hourIndex - 1] ?? 0) - 1;
elapsedMinutes += skippedHours * 60;
}
let minuteStartIndex = 0;
let minuteStartValue = 0;
// Find the optimal start index for minutes on the first invocation
if (isFirstHour) {
const hasConsecutiveHours = hasConsecutiveValues(hours);
minuteStartIndex = findMinimalDurationStartIndex(minutes, 59, {
wrap: hasConsecutiveHours,
windowSize: remainingInvocations,
});
minuteStartValue = minutes[minuteStartIndex];
}
// Need more invocations, so add all of the minute schedules
if (remainingInvocations > minutes.length - minuteStartIndex) {
elapsedMinutes += 60 - minuteStartValue;
remainingInvocations -= minutes.length - minuteStartIndex;
} else {
// This iteration satisfies the remaining invocations, so add what's left
elapsedMinutes += minutes[minuteStartIndex + remainingInvocations - 1] - minuteStartValue;
// Return the duration in milliseconds
return elapsedMinutes * 60_000;
}
// Need to keep iterating, so let's figure out which hour to start at
if (isFirstHour) {
const hasConsecutiveDays = hasConsecutiveValues(daysAndWeekdays);
hourIndex = findMinimalDurationStartIndex(hours, 23, {
wrap: hasConsecutiveDays,
windowSize: Math.ceil(remainingInvocations / minutes.length),
});
}
hourIndex++;
} while (hourIndex < hours.length);
// Add time elapsed through the end of the day
const addHours = 24 - hours[hours.length - 1];
elapsedMinutes += addHours * 60;
if (dayIndex < 0) {
// No need to calculate the start index until we know that more than one day is
// needed
dayIndex = findMinimalDurationStartIndex(
daysAndWeekdays,
daysAndWeekdays[daysAndWeekdays.length - 1],
{
// We're not actually moving through months, daysAndWeekdays is just an increasing
// sequence of day numbers
wrap: false,
windowSize: Math.ceil(remainingInvocations / hours.length / minutes.length),
},
);
}
dayIndex++;
} while (true);
};
+39 -6
View File
@@ -60,16 +60,49 @@ export const formatDate = (time: number | string | Date) => {
return isoStr.substring(0, 10);
};
export const formatDuration = (ms: number) => {
const durationUnits = ["seconds", "minutes", "hours", "days"] as const;
type DurationUnit = typeof durationUnits[number];
export interface FormatDurationOptions {
minUnit?: DurationUnit;
maxUnit?: DurationUnit;
}
export const formatDuration = (ms: number, options?: FormatDurationOptions) => {
const minUnitIndex = durationUnits.indexOf(options?.minUnit || "seconds");
const maxUnitIndex = durationUnits.indexOf(options?.maxUnit || "hours");
const seconds = Math.ceil(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours === 0 && minutes === 0) {
return `${seconds % 60}s`;
} else if (hours === 0) {
return `${minutes}m${seconds % 60}s`;
const days = Math.floor(hours / 24);
let parts: string[] = [];
if (maxUnitIndex >= 3 && days > 0) {
parts.push(`${days}d`);
}
return `${hours}h${minutes % 60}m${Math.floor(seconds % 60)}s`;
if (maxUnitIndex >= 2 && minUnitIndex <= 2) {
const h = maxUnitIndex === 2 ? hours : (hours % 24);
if (h > 0) {
parts.push(`${h}h`);
}
}
if (maxUnitIndex >= 1 && minUnitIndex <= 1) {
const m = maxUnitIndex === 1 ? minutes : (minutes % 60);
if (m > 0) {
parts.push(`${m}m`);
}
}
if (maxUnitIndex >= 0) {
const s = maxUnitIndex === 0 ? seconds : (seconds % 60);
if (s > 0 || parts.length === 0) {
parts.push(`${s}s`);
}
}
return parts.join("");
};
export const normalizeSnapshotId = (id: string) => {
+126 -64
View File
@@ -13,7 +13,7 @@ import {
Collapse,
Checkbox,
} from "antd";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useShowModal } from "../components/ModalManager";
import {
ConfigSchema,
@@ -21,9 +21,8 @@ import {
RetentionPolicySchema,
Schedule_Clock,
type Plan,
type RetentionPolicy,
} from "../../gen/ts/v1/config_pb";
import { MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { CalculatorOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { URIAutocomplete } from "../components/URIAutocomplete";
import { formatErrorAlert, useAlertApi } from "../components/Alerts";
import { namePattern, validateForm } from "../lib/formutil";
@@ -39,6 +38,8 @@ import {
ScheduleFormItem,
} from "../components/ScheduleFormItem";
import { clone, create, equals, fromJson, toJson } from "@bufbuild/protobuf";
import { formatDuration } from "../lib/formatting";
import { getMinimumCronDuration } from "../lib/cronutil";
const planDefaults = create(PlanSchema, {
schedule: {
@@ -546,7 +547,32 @@ export const AddPlanModal = ({ template }: { template: Plan | null }) => {
const RetentionPolicyView = () => {
const form = Form.useFormInstance();
const schedule = Form.useWatch("schedule", { form }) as any;
const retention = Form.useWatch("retention", { form, preserve: true }) as any;
// If the first value in the cron expression (minutes) is not just a plain number (e.g. 30), the
// cron will hit more than once per hour (e.g. "*/15" "1,30" and "*").
const cronIsSubHourly = useMemo(() => schedule?.cron && !/^\d+ /.test(schedule.cron), [schedule?.cron]);
// Translates the number of snapshots retained to a retention duration for cron schedules.
const minRetention = useMemo(() => {
const keepLastN = retention?.policyTimeBucketed?.keepLastN;
if (!keepLastN) {
return null;
}
const msPerHour = 60 * 60 * 1000;
const msPerDay = 24 * msPerHour;
let duration = 0;
// Simple calculations for non-cron schedules
if (schedule?.maxFrequencyHours) {
duration = schedule.maxFrequencyHours * (keepLastN - 1) * msPerHour;
} else if (schedule?.maxFrequencyDays) {
duration = schedule.maxFrequencyDays * (keepLastN - 1) * msPerDay;
} else if (schedule?.cron && retention.policyTimeBucketed?.keepLastN) {
duration = getMinimumCronDuration(schedule.cron, retention.policyTimeBucketed?.keepLastN);
}
return duration
? formatDuration(duration, { maxUnit: "days", minUnit: "minutes" })
: null;
}, [schedule, retention?.policyTimeBucketed?.keepLastN]);
const determineMode = () => {
if (!retention) {
@@ -602,67 +628,103 @@ const RetentionPolicyView = () => {
);
} else if (mode === "policyTimeBucketed") {
elem = (
<Row>
<Col span={11}>
<Form.Item
name={["retention", "policyTimeBucketed", "yearly"]}
validateTrigger={["onChange", "onBlur"]}
initialValue={0}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Yearly</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "policyTimeBucketed", "monthly"]}
initialValue={0}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Monthly</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "policyTimeBucketed", "weekly"]}
initialValue={0}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Weekly</div>}
type="number"
/>
</Form.Item>
</Col>
<Col span={11} offset={1}>
<Form.Item
name={["retention", "policyTimeBucketed", "daily"]}
validateTrigger={["onChange", "onBlur"]}
initialValue={0}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Daily</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "policyTimeBucketed", "hourly"]}
validateTrigger={["onChange", "onBlur"]}
initialValue={0}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Hourly</div>}
type="number"
/>
</Form.Item>
</Col>
</Row>
<>
<Row>
<Col span={11}>
<Form.Item
name={["retention", "policyTimeBucketed", "yearly"]}
validateTrigger={["onChange", "onBlur"]}
initialValue={0}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Yearly</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "policyTimeBucketed", "monthly"]}
initialValue={0}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Monthly</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "policyTimeBucketed", "weekly"]}
initialValue={0}
validateTrigger={["onChange", "onBlur"]}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Weekly</div>}
type="number"
/>
</Form.Item>
</Col>
<Col span={11} offset={1}>
<Form.Item
name={["retention", "policyTimeBucketed", "daily"]}
validateTrigger={["onChange", "onBlur"]}
initialValue={0}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Daily</div>}
type="number"
/>
</Form.Item>
<Form.Item
name={["retention", "policyTimeBucketed", "hourly"]}
validateTrigger={["onChange", "onBlur"]}
initialValue={0}
required={false}
>
<InputNumber
addonBefore={<div style={{ width: "5em" }}>Hourly</div>}
type="number"
/>
</Form.Item>
</Col>
</Row>
<Form.Item
name={["retention", "policyTimeBucketed", "keepLastN"]}
label="Latest snapshots to keep regardless of age"
validateTrigger={["onChange", "onBlur"]}
initialValue={0}
required={cronIsSubHourly}
rules={[
{
validator: async (_, value) => {
if (cronIsSubHourly && !(value > 1)) {
throw new Error("Specify a number greater than 1");
}
},
message: "Your schedule runs more than once per hour; choose how many snapshots to keep before handing off to the retention policy.",
},
]}
>
<InputNumber
type="number"
min={0}
addonAfter={
<Tooltip title={minRetention
? `${retention?.policyTimeBucketed?.keepLastN} snapshots represents an expected retention duration of at least
${minRetention}, but this may vary with manual backups or if intermittently online.`
: "Choose how many snapshots to retain, then use the calculator to see the expected duration they would cover."
}>
<CalculatorOutlined style={{
padding: ".5em",
margin: "0 -.5em"
}} />
</Tooltip>
}
/>
</Form.Item>
</>
);
}