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 = ( - - - - Yearly} - type="number" - /> - - - Monthly} - type="number" - /> - - - Weekly} - type="number" - /> - - - - - Daily} - type="number" - /> - - - Hourly} - type="number" - /> - - - + <> + + + + Yearly} + type="number" + /> + + + Monthly} + type="number" + /> + + + Weekly} + type="number" + /> + + + + + Daily} + type="number" + /> + + + Hourly} + type="number" + /> + + + + { + 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.", + }, + ]} + > + + + + } + /> + + ); }