mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-08-27 19:36:29 +00:00
fix: --keep-last n param to mitigate loss of sub-hourly snapshots (#741)
This commit is contained in:
+18
-8
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user