diff --git a/gen/go/v1/config.pb.go b/gen/go/v1/config.pb.go
index 8e0e66b6..08fe57ce 100644
--- a/gen/go/v1/config.pb.go
+++ b/gen/go/v1/config.pb.go
@@ -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,
diff --git a/proto/v1/config.proto b/proto/v1/config.proto
index 11199ae4..8e8785d0 100644
--- a/proto/v1/config.proto
+++ b/proto/v1/config.proto
@@ -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.
}
}
diff --git a/webui/gen/ts/v1/config_pb.ts b/webui/gen/ts/v1/config_pb.ts
index 627ab0b4..6244e869 100644
--- a/webui/gen/ts/v1/config_pb.ts
+++ b/webui/gen/ts/v1/config_pb.ts
@@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file v1/config.proto.
*/
export const file_v1_config: GenFile = /*@__PURE__*/
- fileDesc("Cg92MS9jb25maWcucHJvdG8SAnYxImYKCUh1YkNvbmZpZxItCglpbnN0YW5jZXMYASADKAsyGi52MS5IdWJDb25maWcuSW5zdGFuY2VJbmZvGioKDEluc3RhbmNlSW5mbxIKCgJpZBgBIAEoCRIOCgZzZWNyZXQYAiABKAkirAEKBkNvbmZpZxINCgVtb2RubxgBIAEoBRIPCgd2ZXJzaW9uGAYgASgFEhAKCGluc3RhbmNlGAIgASgJEhcKBXJlcG9zGAMgAygLMggudjEuUmVwbxIXCgVwbGFucxgEIAMoCzIILnYxLlBsYW4SFgoEYXV0aBgFIAEoCzIILnYxLkF1dGgSJgoJbXVsdGlob3N0GAcgASgLMg0udjEuTXVsdGlob3N0UgRzeW5jItcBCglNdWx0aWhvc3QSJwoLa25vd25faG9zdHMYASADKAsyEi52MS5NdWx0aWhvc3QuUGVlchIuChJhdXRob3JpemVkX2NsaWVudHMYAiADKAsyEi52MS5NdWx0aWhvc3QuUGVlchpxCgRQZWVyEhMKC2luc3RhbmNlX2lkGAEgASgJEiEKCnB1YmxpY19rZXkYAyABKAsyDS52MS5QdWJsaWNLZXkSGwoTcHVibGljX2tleV92ZXJpZmllZBgEIAEoCBIUCgxpbnN0YW5jZV91cmwYAiABKAkizAIKBFJlcG8SCgoCaWQYASABKAkSCwoDdXJpGAIgASgJEgwKBGd1aWQYCyABKAkSEAoIcGFzc3dvcmQYAyABKAkSCwoDZW52GAQgAygJEg0KBWZsYWdzGAUgAygJEiUKDHBydW5lX3BvbGljeRgGIAEoCzIPLnYxLlBydW5lUG9saWN5EiUKDGNoZWNrX3BvbGljeRgJIAEoCzIPLnYxLkNoZWNrUG9saWN5EhcKBWhvb2tzGAcgAygLMggudjEuSG9vaxITCgthdXRvX3VubG9jaxgIIAEoCBIXCg9hdXRvX2luaXRpYWxpemUYDCABKAgSKQoOY29tbWFuZF9wcmVmaXgYCiABKAsyES52MS5Db21tYW5kUHJlZml4Ei8KGWFsbG93ZWRfcGVlcl9pbnN0YW5jZV9pZHMYZCADKAlSDGFsbG93ZWRQZWVycyKGAgoEUGxhbhIKCgJpZBgBIAEoCRIMCgRyZXBvGAIgASgJEg0KBXBhdGhzGAQgAygJEhAKCGV4Y2x1ZGVzGAUgAygJEhEKCWlleGNsdWRlcxgJIAMoCRIeCghzY2hlZHVsZRgMIAEoCzIMLnYxLlNjaGVkdWxlEiYKCXJldGVudGlvbhgHIAEoCzITLnYxLlJldGVudGlvblBvbGljeRIXCgVob29rcxgIIAMoCzIILnYxLkhvb2sSIgoMYmFja3VwX2ZsYWdzGAogAygJUgxiYWNrdXBfZmxhZ3MSGQoRc2tpcF9pZl91bmNoYW5nZWQYDSABKAhKBAgDEARKBAgGEAdKBAgLEAwiigIKDUNvbW1hbmRQcmVmaXgSLgoHaW9fbmljZRgBIAEoDjIdLnYxLkNvbW1hbmRQcmVmaXguSU9OaWNlTGV2ZWwSMAoIY3B1X25pY2UYAiABKA4yHi52MS5Db21tYW5kUHJlZml4LkNQVU5pY2VMZXZlbCJbCgtJT05pY2VMZXZlbBIOCgpJT19ERUZBVUxUEAASFgoSSU9fQkVTVF9FRkZPUlRfTE9XEAESFwoTSU9fQkVTVF9FRkZPUlRfSElHSBACEgsKB0lPX0lETEUQAyI6CgxDUFVOaWNlTGV2ZWwSDwoLQ1BVX0RFRkFVTFQQABIMCghDUFVfSElHSBABEgsKB0NQVV9MT1cQAiKCAgoPUmV0ZW50aW9uUG9saWN5EhwKEnBvbGljeV9rZWVwX2xhc3RfbhgKIAEoBUgAEkYKFHBvbGljeV90aW1lX2J1Y2tldGVkGAsgASgLMiYudjEuUmV0ZW50aW9uUG9saWN5LlRpbWVCdWNrZXRlZENvdW50c0gAEhkKD3BvbGljeV9rZWVwX2FsbBgMIAEoCEgAGmQKElRpbWVCdWNrZXRlZENvdW50cxIOCgZob3VybHkYASABKAUSDQoFZGFpbHkYAiABKAUSDgoGd2Vla2x5GAMgASgFEg8KB21vbnRobHkYBCABKAUSDgoGeWVhcmx5GAUgASgFQggKBnBvbGljeSJjCgtQcnVuZVBvbGljeRIeCghzY2hlZHVsZRgCIAEoCzIMLnYxLlNjaGVkdWxlEhgKEG1heF91bnVzZWRfYnl0ZXMYAyABKAMSGgoSbWF4X3VudXNlZF9wZXJjZW50GAQgASgBInMKC0NoZWNrUG9saWN5Eh4KCHNjaGVkdWxlGAEgASgLMgwudjEuU2NoZWR1bGUSGAoOc3RydWN0dXJlX29ubHkYZCABKAhIABIiChhyZWFkX2RhdGFfc3Vic2V0X3BlcmNlbnQYZSABKAFIAEIGCgRtb2RlIusBCghTY2hlZHVsZRISCghkaXNhYmxlZBgBIAEoCEgAEg4KBGNyb24YAiABKAlIABIaChBtYXhGcmVxdWVuY3lEYXlzGAMgASgFSAASGwoRbWF4RnJlcXVlbmN5SG91cnMYBCABKAVIABIhCgVjbG9jaxgFIAEoDjISLnYxLlNjaGVkdWxlLkNsb2NrIlMKBUNsb2NrEhEKDUNMT0NLX0RFRkFVTFQQABIPCgtDTE9DS19MT0NBTBABEg0KCUNMT0NLX1VUQxACEhcKE0NMT0NLX0xBU1RfUlVOX1RJTUUQA0IKCghzY2hlZHVsZSKQDAoESG9vaxImCgpjb25kaXRpb25zGAEgAygOMhIudjEuSG9vay5Db25kaXRpb24SIgoIb25fZXJyb3IYAiABKA4yEC52MS5Ib29rLk9uRXJyb3ISKgoOYWN0aW9uX2NvbW1hbmQYZCABKAsyEC52MS5Ib29rLkNvbW1hbmRIABIqCg5hY3Rpb25fd2ViaG9vaxhlIAEoCzIQLnYxLkhvb2suV2ViaG9va0gAEioKDmFjdGlvbl9kaXNjb3JkGGYgASgLMhAudjEuSG9vay5EaXNjb3JkSAASKAoNYWN0aW9uX2dvdGlmeRhnIAEoCzIPLnYxLkhvb2suR290aWZ5SAASJgoMYWN0aW9uX3NsYWNrGGggASgLMg4udjEuSG9vay5TbGFja0gAEiwKD2FjdGlvbl9zaG91dHJychhpIAEoCzIRLnYxLkhvb2suU2hvdXRycnJIABI0ChNhY3Rpb25faGVhbHRoY2hlY2tzGGogASgLMhUudjEuSG9vay5IZWFsdGhjaGVja3NIABoaCgdDb21tYW5kEg8KB2NvbW1hbmQYASABKAkagwEKB1dlYmhvb2sSEwoLd2ViaG9va191cmwYASABKAkSJwoGbWV0aG9kGAIgASgOMhcudjEuSG9vay5XZWJob29rLk1ldGhvZBIQCgh0ZW1wbGF0ZRhkIAEoCSIoCgZNZXRob2QSCwoHVU5LTk9XThAAEgcKA0dFVBABEggKBFBPU1QQAhowCgdEaXNjb3JkEhMKC3dlYmhvb2tfdXJsGAEgASgJEhAKCHRlbXBsYXRlGAIgASgJGmUKBkdvdGlmeRIQCghiYXNlX3VybBgBIAEoCRINCgV0b2tlbhgDIAEoCRIQCgh0ZW1wbGF0ZRhkIAEoCRIWCg50aXRsZV90ZW1wbGF0ZRhlIAEoCRIQCghwcmlvcml0eRhmIAEoBRouCgVTbGFjaxITCgt3ZWJob29rX3VybBgBIAEoCRIQCgh0ZW1wbGF0ZRgCIAEoCRoyCghTaG91dHJychIUCgxzaG91dHJycl91cmwYASABKAkSEAoIdGVtcGxhdGUYAiABKAkaNQoMSGVhbHRoY2hlY2tzEhMKC3dlYmhvb2tfdXJsGAEgASgJEhAKCHRlbXBsYXRlGAIgASgJIvUDCglDb25kaXRpb24SFQoRQ09ORElUSU9OX1VOS05PV04QABIXChNDT05ESVRJT05fQU5ZX0VSUk9SEAESHAoYQ09ORElUSU9OX1NOQVBTSE9UX1NUQVJUEAISGgoWQ09ORElUSU9OX1NOQVBTSE9UX0VORBADEhwKGENPTkRJVElPTl9TTkFQU0hPVF9FUlJPUhAEEh4KGkNPTkRJVElPTl9TTkFQU0hPVF9XQVJOSU5HEAUSHgoaQ09ORElUSU9OX1NOQVBTSE9UX1NVQ0NFU1MQBhIeChpDT05ESVRJT05fU05BUFNIT1RfU0tJUFBFRBAHEhkKFUNPTkRJVElPTl9QUlVORV9TVEFSVBBkEhkKFUNPTkRJVElPTl9QUlVORV9FUlJPUhBlEhsKF0NPTkRJVElPTl9QUlVORV9TVUNDRVNTEGYSGgoVQ09ORElUSU9OX0NIRUNLX1NUQVJUEMgBEhoKFUNPTkRJVElPTl9DSEVDS19FUlJPUhDJARIcChdDT05ESVRJT05fQ0hFQ0tfU1VDQ0VTUxDKARIbChZDT05ESVRJT05fRk9SR0VUX1NUQVJUEKwCEhsKFkNPTkRJVElPTl9GT1JHRVRfRVJST1IQrQISHQoYQ09ORElUSU9OX0ZPUkdFVF9TVUNDRVNTEK4CIqkBCgdPbkVycm9yEhMKD09OX0VSUk9SX0lHTk9SRRAAEhMKD09OX0VSUk9SX0NBTkNFTBABEhIKDk9OX0VSUk9SX0ZBVEFMEAISGgoWT05fRVJST1JfUkVUUllfMU1JTlVURRBkEhwKGE9OX0VSUk9SX1JFVFJZXzEwTUlOVVRFUxBlEiYKIk9OX0VSUk9SX1JFVFJZX0VYUE9ORU5USUFMX0JBQ0tPRkYQZ0IICgZhY3Rpb24iMQoEQXV0aBIQCghkaXNhYmxlZBgBIAEoCBIXCgV1c2VycxgCIAMoCzIILnYxLlVzZXIiOwoEVXNlchIMCgRuYW1lGAEgASgJEhkKD3Bhc3N3b3JkX2JjcnlwdBgCIAEoCUgAQgoKCHBhc3N3b3JkQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z", [file_google_protobuf_empty, file_v1_crypto]);
+ fileDesc("Cg92MS9jb25maWcucHJvdG8SAnYxImYKCUh1YkNvbmZpZxItCglpbnN0YW5jZXMYASADKAsyGi52MS5IdWJDb25maWcuSW5zdGFuY2VJbmZvGioKDEluc3RhbmNlSW5mbxIKCgJpZBgBIAEoCRIOCgZzZWNyZXQYAiABKAkirAEKBkNvbmZpZxINCgVtb2RubxgBIAEoBRIPCgd2ZXJzaW9uGAYgASgFEhAKCGluc3RhbmNlGAIgASgJEhcKBXJlcG9zGAMgAygLMggudjEuUmVwbxIXCgVwbGFucxgEIAMoCzIILnYxLlBsYW4SFgoEYXV0aBgFIAEoCzIILnYxLkF1dGgSJgoJbXVsdGlob3N0GAcgASgLMg0udjEuTXVsdGlob3N0UgRzeW5jItcBCglNdWx0aWhvc3QSJwoLa25vd25faG9zdHMYASADKAsyEi52MS5NdWx0aWhvc3QuUGVlchIuChJhdXRob3JpemVkX2NsaWVudHMYAiADKAsyEi52MS5NdWx0aWhvc3QuUGVlchpxCgRQZWVyEhMKC2luc3RhbmNlX2lkGAEgASgJEiEKCnB1YmxpY19rZXkYAyABKAsyDS52MS5QdWJsaWNLZXkSGwoTcHVibGljX2tleV92ZXJpZmllZBgEIAEoCBIUCgxpbnN0YW5jZV91cmwYAiABKAkizAIKBFJlcG8SCgoCaWQYASABKAkSCwoDdXJpGAIgASgJEgwKBGd1aWQYCyABKAkSEAoIcGFzc3dvcmQYAyABKAkSCwoDZW52GAQgAygJEg0KBWZsYWdzGAUgAygJEiUKDHBydW5lX3BvbGljeRgGIAEoCzIPLnYxLlBydW5lUG9saWN5EiUKDGNoZWNrX3BvbGljeRgJIAEoCzIPLnYxLkNoZWNrUG9saWN5EhcKBWhvb2tzGAcgAygLMggudjEuSG9vaxITCgthdXRvX3VubG9jaxgIIAEoCBIXCg9hdXRvX2luaXRpYWxpemUYDCABKAgSKQoOY29tbWFuZF9wcmVmaXgYCiABKAsyES52MS5Db21tYW5kUHJlZml4Ei8KGWFsbG93ZWRfcGVlcl9pbnN0YW5jZV9pZHMYZCADKAlSDGFsbG93ZWRQZWVycyKGAgoEUGxhbhIKCgJpZBgBIAEoCRIMCgRyZXBvGAIgASgJEg0KBXBhdGhzGAQgAygJEhAKCGV4Y2x1ZGVzGAUgAygJEhEKCWlleGNsdWRlcxgJIAMoCRIeCghzY2hlZHVsZRgMIAEoCzIMLnYxLlNjaGVkdWxlEiYKCXJldGVudGlvbhgHIAEoCzITLnYxLlJldGVudGlvblBvbGljeRIXCgVob29rcxgIIAMoCzIILnYxLkhvb2sSIgoMYmFja3VwX2ZsYWdzGAogAygJUgxiYWNrdXBfZmxhZ3MSGQoRc2tpcF9pZl91bmNoYW5nZWQYDSABKAhKBAgDEARKBAgGEAdKBAgLEAwiigIKDUNvbW1hbmRQcmVmaXgSLgoHaW9fbmljZRgBIAEoDjIdLnYxLkNvbW1hbmRQcmVmaXguSU9OaWNlTGV2ZWwSMAoIY3B1X25pY2UYAiABKA4yHi52MS5Db21tYW5kUHJlZml4LkNQVU5pY2VMZXZlbCJbCgtJT05pY2VMZXZlbBIOCgpJT19ERUZBVUxUEAASFgoSSU9fQkVTVF9FRkZPUlRfTE9XEAESFwoTSU9fQkVTVF9FRkZPUlRfSElHSBACEgsKB0lPX0lETEUQAyI6CgxDUFVOaWNlTGV2ZWwSDwoLQ1BVX0RFRkFVTFQQABIMCghDUFVfSElHSBABEgsKB0NQVV9MT1cQAiKXAgoPUmV0ZW50aW9uUG9saWN5EhwKEnBvbGljeV9rZWVwX2xhc3RfbhgKIAEoBUgAEkYKFHBvbGljeV90aW1lX2J1Y2tldGVkGAsgASgLMiYudjEuUmV0ZW50aW9uUG9saWN5LlRpbWVCdWNrZXRlZENvdW50c0gAEhkKD3BvbGljeV9rZWVwX2FsbBgMIAEoCEgAGnkKElRpbWVCdWNrZXRlZENvdW50cxIOCgZob3VybHkYASABKAUSDQoFZGFpbHkYAiABKAUSDgoGd2Vla2x5GAMgASgFEg8KB21vbnRobHkYBCABKAUSDgoGeWVhcmx5GAUgASgFEhMKC2tlZXBfbGFzdF9uGAYgASgFQggKBnBvbGljeSJjCgtQcnVuZVBvbGljeRIeCghzY2hlZHVsZRgCIAEoCzIMLnYxLlNjaGVkdWxlEhgKEG1heF91bnVzZWRfYnl0ZXMYAyABKAMSGgoSbWF4X3VudXNlZF9wZXJjZW50GAQgASgBInMKC0NoZWNrUG9saWN5Eh4KCHNjaGVkdWxlGAEgASgLMgwudjEuU2NoZWR1bGUSGAoOc3RydWN0dXJlX29ubHkYZCABKAhIABIiChhyZWFkX2RhdGFfc3Vic2V0X3BlcmNlbnQYZSABKAFIAEIGCgRtb2RlIusBCghTY2hlZHVsZRISCghkaXNhYmxlZBgBIAEoCEgAEg4KBGNyb24YAiABKAlIABIaChBtYXhGcmVxdWVuY3lEYXlzGAMgASgFSAASGwoRbWF4RnJlcXVlbmN5SG91cnMYBCABKAVIABIhCgVjbG9jaxgFIAEoDjISLnYxLlNjaGVkdWxlLkNsb2NrIlMKBUNsb2NrEhEKDUNMT0NLX0RFRkFVTFQQABIPCgtDTE9DS19MT0NBTBABEg0KCUNMT0NLX1VUQxACEhcKE0NMT0NLX0xBU1RfUlVOX1RJTUUQA0IKCghzY2hlZHVsZSKQDAoESG9vaxImCgpjb25kaXRpb25zGAEgAygOMhIudjEuSG9vay5Db25kaXRpb24SIgoIb25fZXJyb3IYAiABKA4yEC52MS5Ib29rLk9uRXJyb3ISKgoOYWN0aW9uX2NvbW1hbmQYZCABKAsyEC52MS5Ib29rLkNvbW1hbmRIABIqCg5hY3Rpb25fd2ViaG9vaxhlIAEoCzIQLnYxLkhvb2suV2ViaG9va0gAEioKDmFjdGlvbl9kaXNjb3JkGGYgASgLMhAudjEuSG9vay5EaXNjb3JkSAASKAoNYWN0aW9uX2dvdGlmeRhnIAEoCzIPLnYxLkhvb2suR290aWZ5SAASJgoMYWN0aW9uX3NsYWNrGGggASgLMg4udjEuSG9vay5TbGFja0gAEiwKD2FjdGlvbl9zaG91dHJychhpIAEoCzIRLnYxLkhvb2suU2hvdXRycnJIABI0ChNhY3Rpb25faGVhbHRoY2hlY2tzGGogASgLMhUudjEuSG9vay5IZWFsdGhjaGVja3NIABoaCgdDb21tYW5kEg8KB2NvbW1hbmQYASABKAkagwEKB1dlYmhvb2sSEwoLd2ViaG9va191cmwYASABKAkSJwoGbWV0aG9kGAIgASgOMhcudjEuSG9vay5XZWJob29rLk1ldGhvZBIQCgh0ZW1wbGF0ZRhkIAEoCSIoCgZNZXRob2QSCwoHVU5LTk9XThAAEgcKA0dFVBABEggKBFBPU1QQAhowCgdEaXNjb3JkEhMKC3dlYmhvb2tfdXJsGAEgASgJEhAKCHRlbXBsYXRlGAIgASgJGmUKBkdvdGlmeRIQCghiYXNlX3VybBgBIAEoCRINCgV0b2tlbhgDIAEoCRIQCgh0ZW1wbGF0ZRhkIAEoCRIWCg50aXRsZV90ZW1wbGF0ZRhlIAEoCRIQCghwcmlvcml0eRhmIAEoBRouCgVTbGFjaxITCgt3ZWJob29rX3VybBgBIAEoCRIQCgh0ZW1wbGF0ZRgCIAEoCRoyCghTaG91dHJychIUCgxzaG91dHJycl91cmwYASABKAkSEAoIdGVtcGxhdGUYAiABKAkaNQoMSGVhbHRoY2hlY2tzEhMKC3dlYmhvb2tfdXJsGAEgASgJEhAKCHRlbXBsYXRlGAIgASgJIvUDCglDb25kaXRpb24SFQoRQ09ORElUSU9OX1VOS05PV04QABIXChNDT05ESVRJT05fQU5ZX0VSUk9SEAESHAoYQ09ORElUSU9OX1NOQVBTSE9UX1NUQVJUEAISGgoWQ09ORElUSU9OX1NOQVBTSE9UX0VORBADEhwKGENPTkRJVElPTl9TTkFQU0hPVF9FUlJPUhAEEh4KGkNPTkRJVElPTl9TTkFQU0hPVF9XQVJOSU5HEAUSHgoaQ09ORElUSU9OX1NOQVBTSE9UX1NVQ0NFU1MQBhIeChpDT05ESVRJT05fU05BUFNIT1RfU0tJUFBFRBAHEhkKFUNPTkRJVElPTl9QUlVORV9TVEFSVBBkEhkKFUNPTkRJVElPTl9QUlVORV9FUlJPUhBlEhsKF0NPTkRJVElPTl9QUlVORV9TVUNDRVNTEGYSGgoVQ09ORElUSU9OX0NIRUNLX1NUQVJUEMgBEhoKFUNPTkRJVElPTl9DSEVDS19FUlJPUhDJARIcChdDT05ESVRJT05fQ0hFQ0tfU1VDQ0VTUxDKARIbChZDT05ESVRJT05fRk9SR0VUX1NUQVJUEKwCEhsKFkNPTkRJVElPTl9GT1JHRVRfRVJST1IQrQISHQoYQ09ORElUSU9OX0ZPUkdFVF9TVUNDRVNTEK4CIqkBCgdPbkVycm9yEhMKD09OX0VSUk9SX0lHTk9SRRAAEhMKD09OX0VSUk9SX0NBTkNFTBABEhIKDk9OX0VSUk9SX0ZBVEFMEAISGgoWT05fRVJST1JfUkVUUllfMU1JTlVURRBkEhwKGE9OX0VSUk9SX1JFVFJZXzEwTUlOVVRFUxBlEiYKIk9OX0VSUk9SX1JFVFJZX0VYUE9ORU5USUFMX0JBQ0tPRkYQZ0IICgZhY3Rpb24iMQoEQXV0aBIQCghkaXNhYmxlZBgBIAEoCBIXCgV1c2VycxgCIAMoCzIILnYxLlVzZXIiOwoEVXNlchIMCgRuYW1lGAEgASgJEhkKD3Bhc3N3b3JkX2JjcnlwdBgCIAEoCUgAQgoKCHBhc3N3b3JkQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z", [file_google_protobuf_empty, file_v1_crypto]);
/**
* @generated from message v1.HubConfig
@@ -517,6 +517,13 @@ export type RetentionPolicy_TimeBucketedCounts = Message<"v1.RetentionPolicy.Tim
* @generated from field: int32 yearly = 5;
*/
yearly: number;
+
+ /**
+ * keep the last n snapshots regardless of age.
+ *
+ * @generated from field: int32 keep_last_n = 6;
+ */
+ keepLastN: number;
};
/**
diff --git a/webui/package.json b/webui/package.json
index 3fa23d36..628127ff 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -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",
diff --git a/webui/pnpm-lock.yaml b/webui/pnpm-lock.yaml
index 6e1757b6..197abe94 100644
--- a/webui/pnpm-lock.yaml
+++ b/webui/pnpm-lock.yaml
@@ -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
diff --git a/webui/src/components/OperationRow.tsx b/webui/src/components/OperationRow.tsx
index 2a5a0167..4f023993 100644
--- a/webui/src/components/OperationRow.tsx
+++ b/webui/src/components/OperationRow.tsx
@@ -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`);
+ }
}
}
diff --git a/webui/src/lib/cronutil.ts b/webui/src/lib/cronutil.ts
new file mode 100644
index 00000000..cd0e0078
--- /dev/null
+++ b/webui/src/lib/cronutil.ts
@@ -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);
+};
\ No newline at end of file
diff --git a/webui/src/lib/formatting.ts b/webui/src/lib/formatting.ts
index be966187..45f8efa0 100644
--- a/webui/src/lib/formatting.ts
+++ b/webui/src/lib/formatting.ts
@@ -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) => {
diff --git a/webui/src/views/AddPlanModal.tsx b/webui/src/views/AddPlanModal.tsx
index 144722ee..72072738 100644
--- a/webui/src/views/AddPlanModal.tsx
+++ b/webui/src/views/AddPlanModal.tsx
@@ -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 = (
-