From f7b9c23b2ff99fc3e491d0285e25463e81838236 Mon Sep 17 00:00:00 2001 From: garethgeorge Date: Sat, 2 Dec 2023 18:20:37 -0800 Subject: [PATCH 1/2] feat: implement prune support --- gen/go/v1/config.pb.go | 61 +++------ gen/go/v1/service.pb.go | 42 ++++-- gen/go/v1/service.pb.gw.go | 170 +++++++++++++++++++++++++ gen/go/v1/service_grpc.pb.go | 74 +++++++++++ internal/api/server.go | 27 ++++ internal/orchestrator/backup.go | 15 ++- internal/orchestrator/forget.go | 10 +- internal/orchestrator/orchestrator.go | 9 +- internal/orchestrator/prune.go | 146 ++++++++++++++++++++- internal/orchestrator/repo.go | 15 ++- pkg/restic/restic.go | 2 + proto/v1/config.proto | 7 +- proto/v1/service.proto | 14 ++ webui/gen/ts/v1/config.pb.ts | 19 +-- webui/gen/ts/v1/service.pb.ts | 6 + webui/src/components/OperationList.tsx | 23 ++++ webui/src/components/OperationTree.tsx | 3 + webui/src/views/PlanView.tsx | 7 +- 18 files changed, 550 insertions(+), 100 deletions(-) diff --git a/gen/go/v1/config.pb.go b/gen/go/v1/config.pb.go index 0581899ed..06eb1bcd0 100644 --- a/gen/go/v1/config.pb.go +++ b/gen/go/v1/config.pb.go @@ -377,12 +377,9 @@ type PrunePolicy struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - MaxFrequencyDays int32 `protobuf:"varint,1,opt,name=max_frequency_days,json=maxFrequencyDays,proto3" json:"max_frequency_days,omitempty"` // max frequency of prune runs in days. If 0, prune will be run on every backup. - // Types that are assignable to Policy: - // - // *PrunePolicy_MaxUnusedPercent - // *PrunePolicy_MaxUnusedBytes - Policy isPrunePolicy_Policy `protobuf_oneof:"policy"` + MaxFrequencyDays int32 `protobuf:"varint,1,opt,name=max_frequency_days,json=maxFrequencyDays,proto3" json:"max_frequency_days,omitempty"` // max frequency of prune runs in days. If 0, prune will be run on every backup. + MaxUnusedPercent int32 `protobuf:"varint,100,opt,name=max_unused_percent,json=maxUnusedPercent,proto3" json:"max_unused_percent,omitempty"` // max percentage of repo size that can be unused before prune is run. + MaxUnusedBytes int32 `protobuf:"varint,101,opt,name=max_unused_bytes,json=maxUnusedBytes,proto3" json:"max_unused_bytes,omitempty"` // max number of bytes that can be unused before prune is run. } func (x *PrunePolicy) Reset() { @@ -424,43 +421,20 @@ func (x *PrunePolicy) GetMaxFrequencyDays() int32 { return 0 } -func (m *PrunePolicy) GetPolicy() isPrunePolicy_Policy { - if m != nil { - return m.Policy - } - return nil -} - func (x *PrunePolicy) GetMaxUnusedPercent() int32 { - if x, ok := x.GetPolicy().(*PrunePolicy_MaxUnusedPercent); ok { + if x != nil { return x.MaxUnusedPercent } return 0 } func (x *PrunePolicy) GetMaxUnusedBytes() int32 { - if x, ok := x.GetPolicy().(*PrunePolicy_MaxUnusedBytes); ok { + if x != nil { return x.MaxUnusedBytes } return 0 } -type isPrunePolicy_Policy interface { - isPrunePolicy_Policy() -} - -type PrunePolicy_MaxUnusedPercent struct { - MaxUnusedPercent int32 `protobuf:"varint,100,opt,name=max_unused_percent,json=maxUnusedPercent,proto3,oneof"` // max percentage of repo size that can be unused before prune is run. -} - -type PrunePolicy_MaxUnusedBytes struct { - MaxUnusedBytes int32 `protobuf:"varint,101,opt,name=max_unused_bytes,json=maxUnusedBytes,proto3,oneof"` // max number of bytes that can be unused before prune is run. -} - -func (*PrunePolicy_MaxUnusedPercent) isPrunePolicy_Policy() {} - -func (*PrunePolicy_MaxUnusedBytes) isPrunePolicy_Policy() {} - var File_v1_config_proto protoreflect.FileDescriptor var file_v1_config_proto_rawDesc = []byte{ @@ -512,21 +486,20 @@ var file_v1_config_proto_rawDesc = []byte{ 0x65, 0x61, 0x72, 0x6c, 0x79, 0x12, 0x30, 0x0a, 0x14, 0x6b, 0x65, 0x65, 0x70, 0x5f, 0x77, 0x69, 0x74, 0x68, 0x69, 0x6e, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x6b, 0x65, 0x65, 0x70, 0x57, 0x69, 0x74, 0x68, 0x69, 0x6e, 0x44, - 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xa1, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x75, 0x6e, + 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x93, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x75, 0x6e, 0x65, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x12, 0x2c, 0x0a, 0x12, 0x6d, 0x61, 0x78, 0x5f, 0x66, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, 0x79, 0x5f, 0x64, 0x61, 0x79, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x10, 0x6d, 0x61, 0x78, 0x46, 0x72, 0x65, 0x71, 0x75, 0x65, 0x6e, 0x63, - 0x79, 0x44, 0x61, 0x79, 0x73, 0x12, 0x2e, 0x0a, 0x12, 0x6d, 0x61, 0x78, 0x5f, 0x75, 0x6e, 0x75, + 0x79, 0x44, 0x61, 0x79, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x6d, 0x61, 0x78, 0x5f, 0x75, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x5f, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x18, 0x64, 0x20, 0x01, 0x28, - 0x05, 0x48, 0x00, 0x52, 0x10, 0x6d, 0x61, 0x78, 0x55, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x50, 0x65, - 0x72, 0x63, 0x65, 0x6e, 0x74, 0x12, 0x2a, 0x0a, 0x10, 0x6d, 0x61, 0x78, 0x5f, 0x75, 0x6e, 0x75, - 0x73, 0x65, 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x65, 0x20, 0x01, 0x28, 0x05, 0x48, - 0x00, 0x52, 0x0e, 0x6d, 0x61, 0x78, 0x55, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x42, 0x79, 0x74, 0x65, - 0x73, 0x42, 0x08, 0x0a, 0x06, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x42, 0x2e, 0x5a, 0x2c, 0x67, - 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x61, 0x72, 0x65, 0x74, 0x68, - 0x67, 0x65, 0x6f, 0x72, 0x67, 0x65, 0x2f, 0x72, 0x65, 0x73, 0x74, 0x69, 0x63, 0x75, 0x69, 0x2f, - 0x67, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x05, 0x52, 0x10, 0x6d, 0x61, 0x78, 0x55, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x50, 0x65, 0x72, 0x63, + 0x65, 0x6e, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x6d, 0x61, 0x78, 0x5f, 0x75, 0x6e, 0x75, 0x73, 0x65, + 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x65, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0e, 0x6d, + 0x61, 0x78, 0x55, 0x6e, 0x75, 0x73, 0x65, 0x64, 0x42, 0x79, 0x74, 0x65, 0x73, 0x42, 0x2e, 0x5a, + 0x2c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x61, 0x72, 0x65, + 0x74, 0x68, 0x67, 0x65, 0x6f, 0x72, 0x67, 0x65, 0x2f, 0x72, 0x65, 0x73, 0x74, 0x69, 0x63, 0x75, + 0x69, 0x2f, 0x67, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -628,10 +601,6 @@ func file_v1_config_proto_init() { } } } - file_v1_config_proto_msgTypes[4].OneofWrappers = []interface{}{ - (*PrunePolicy_MaxUnusedPercent)(nil), - (*PrunePolicy_MaxUnusedBytes)(nil), - } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/gen/go/v1/service.pb.go b/gen/go/v1/service.pb.go index 4a612e1e8..eb910b129 100644 --- a/gen/go/v1/service.pb.go +++ b/gen/go/v1/service.pb.go @@ -446,7 +446,7 @@ var file_v1_service_proto_rawDesc = []byte{ 0x05, 0x6d, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x61, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, 0x74, 0x69, - 0x6d, 0x65, 0x32, 0x81, 0x06, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x74, 0x69, 0x63, 0x55, 0x49, 0x12, + 0x6d, 0x65, 0x32, 0xa1, 0x07, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x74, 0x69, 0x63, 0x55, 0x49, 0x12, 0x43, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0a, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, @@ -488,7 +488,17 @@ var file_v1_service_proto_rawDesc = []byte{ 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x19, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x63, - 0x6d, 0x64, 0x2f, 0x62, 0x61, 0x63, 0x6b, 0x75, 0x70, 0x12, 0x5b, 0x0a, 0x10, 0x50, 0x61, 0x74, + 0x6d, 0x64, 0x2f, 0x62, 0x61, 0x63, 0x6b, 0x75, 0x70, 0x12, 0x4d, 0x0a, 0x05, 0x50, 0x72, 0x75, + 0x6e, 0x65, 0x12, 0x12, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, + 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x18, + 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x12, 0x3a, 0x01, 0x2a, 0x22, 0x0d, 0x2f, 0x76, 0x31, 0x2f, 0x63, + 0x6d, 0x64, 0x2f, 0x70, 0x72, 0x75, 0x6e, 0x65, 0x12, 0x4f, 0x0a, 0x06, 0x46, 0x6f, 0x72, 0x67, + 0x65, 0x74, 0x12, 0x12, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, + 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x19, + 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x13, 0x3a, 0x01, 0x2a, 0x22, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x63, + 0x6d, 0x64, 0x2f, 0x66, 0x6f, 0x72, 0x67, 0x65, 0x74, 0x12, 0x5b, 0x0a, 0x10, 0x50, 0x61, 0x74, 0x68, 0x41, 0x75, 0x74, 0x6f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x12, 0x12, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x11, 0x2e, 0x74, 0x79, 0x70, 0x65, 0x73, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, @@ -538,18 +548,22 @@ var file_v1_service_proto_depIdxs = []int32{ 0, // 6: v1.ResticUI.ListSnapshots:input_type -> v1.ListSnapshotsRequest 2, // 7: v1.ResticUI.ListSnapshotFiles:input_type -> v1.ListSnapshotFilesRequest 8, // 8: v1.ResticUI.Backup:input_type -> types.StringValue - 8, // 9: v1.ResticUI.PathAutocomplete:input_type -> types.StringValue - 6, // 10: v1.ResticUI.GetConfig:output_type -> v1.Config - 6, // 11: v1.ResticUI.SetConfig:output_type -> v1.Config - 6, // 12: v1.ResticUI.AddRepo:output_type -> v1.Config - 9, // 13: v1.ResticUI.GetOperationEvents:output_type -> v1.OperationEvent - 10, // 14: v1.ResticUI.GetOperations:output_type -> v1.OperationList - 11, // 15: v1.ResticUI.ListSnapshots:output_type -> v1.ResticSnapshotList - 3, // 16: v1.ResticUI.ListSnapshotFiles:output_type -> v1.ListSnapshotFilesResponse - 5, // 17: v1.ResticUI.Backup:output_type -> google.protobuf.Empty - 12, // 18: v1.ResticUI.PathAutocomplete:output_type -> types.StringList - 10, // [10:19] is the sub-list for method output_type - 1, // [1:10] is the sub-list for method input_type + 8, // 9: v1.ResticUI.Prune:input_type -> types.StringValue + 8, // 10: v1.ResticUI.Forget:input_type -> types.StringValue + 8, // 11: v1.ResticUI.PathAutocomplete:input_type -> types.StringValue + 6, // 12: v1.ResticUI.GetConfig:output_type -> v1.Config + 6, // 13: v1.ResticUI.SetConfig:output_type -> v1.Config + 6, // 14: v1.ResticUI.AddRepo:output_type -> v1.Config + 9, // 15: v1.ResticUI.GetOperationEvents:output_type -> v1.OperationEvent + 10, // 16: v1.ResticUI.GetOperations:output_type -> v1.OperationList + 11, // 17: v1.ResticUI.ListSnapshots:output_type -> v1.ResticSnapshotList + 3, // 18: v1.ResticUI.ListSnapshotFiles:output_type -> v1.ListSnapshotFilesResponse + 5, // 19: v1.ResticUI.Backup:output_type -> google.protobuf.Empty + 5, // 20: v1.ResticUI.Prune:output_type -> google.protobuf.Empty + 5, // 21: v1.ResticUI.Forget:output_type -> google.protobuf.Empty + 12, // 22: v1.ResticUI.PathAutocomplete:output_type -> types.StringList + 12, // [12:23] is the sub-list for method output_type + 1, // [1:12] is the sub-list for method input_type 1, // [1:1] is the sub-list for extension type_name 1, // [1:1] is the sub-list for extension extendee 0, // [0:1] is the sub-list for field type_name diff --git a/gen/go/v1/service.pb.gw.go b/gen/go/v1/service.pb.gw.go index 61ab461c6..04fc9f53b 100644 --- a/gen/go/v1/service.pb.gw.go +++ b/gen/go/v1/service.pb.gw.go @@ -272,6 +272,74 @@ func local_request_ResticUI_Backup_0(ctx context.Context, marshaler runtime.Mars } +func request_ResticUI_Prune_0(ctx context.Context, marshaler runtime.Marshaler, client ResticUIClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq types.StringValue + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.Prune(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_ResticUI_Prune_0(ctx context.Context, marshaler runtime.Marshaler, server ResticUIServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq types.StringValue + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.Prune(ctx, &protoReq) + return msg, metadata, err + +} + +func request_ResticUI_Forget_0(ctx context.Context, marshaler runtime.Marshaler, client ResticUIClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq types.StringValue + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.Forget(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_ResticUI_Forget_0(ctx context.Context, marshaler runtime.Marshaler, server ResticUIServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq types.StringValue + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.Forget(ctx, &protoReq) + return msg, metadata, err + +} + func request_ResticUI_PathAutocomplete_0(ctx context.Context, marshaler runtime.Marshaler, client ResticUIClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var protoReq types.StringValue var metadata runtime.ServerMetadata @@ -494,6 +562,56 @@ func RegisterResticUIHandlerServer(ctx context.Context, mux *runtime.ServeMux, s }) + mux.Handle("POST", pattern_ResticUI_Prune_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/v1.ResticUI/Prune", runtime.WithHTTPPathPattern("/v1/cmd/prune")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ResticUI_Prune_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_ResticUI_Prune_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_ResticUI_Forget_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/v1.ResticUI/Forget", runtime.WithHTTPPathPattern("/v1/cmd/forget")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_ResticUI_Forget_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_ResticUI_Forget_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_ResticUI_PathAutocomplete_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -736,6 +854,50 @@ func RegisterResticUIHandlerClient(ctx context.Context, mux *runtime.ServeMux, c }) + mux.Handle("POST", pattern_ResticUI_Prune_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/v1.ResticUI/Prune", runtime.WithHTTPPathPattern("/v1/cmd/prune")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ResticUI_Prune_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_ResticUI_Prune_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + mux.Handle("POST", pattern_ResticUI_Forget_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/v1.ResticUI/Forget", runtime.WithHTTPPathPattern("/v1/cmd/forget")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_ResticUI_Forget_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_ResticUI_Forget_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_ResticUI_PathAutocomplete_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -778,6 +940,10 @@ var ( pattern_ResticUI_Backup_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "cmd", "backup"}, "")) + pattern_ResticUI_Prune_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "cmd", "prune"}, "")) + + pattern_ResticUI_Forget_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "cmd", "forget"}, "")) + pattern_ResticUI_PathAutocomplete_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "autocomplete", "path"}, "")) ) @@ -798,5 +964,9 @@ var ( forward_ResticUI_Backup_0 = runtime.ForwardResponseMessage + forward_ResticUI_Prune_0 = runtime.ForwardResponseMessage + + forward_ResticUI_Forget_0 = runtime.ForwardResponseMessage + forward_ResticUI_PathAutocomplete_0 = runtime.ForwardResponseMessage ) diff --git a/gen/go/v1/service_grpc.pb.go b/gen/go/v1/service_grpc.pb.go index e28d4f2a0..573514b80 100644 --- a/gen/go/v1/service_grpc.pb.go +++ b/gen/go/v1/service_grpc.pb.go @@ -29,6 +29,8 @@ const ( ResticUI_ListSnapshots_FullMethodName = "/v1.ResticUI/ListSnapshots" ResticUI_ListSnapshotFiles_FullMethodName = "/v1.ResticUI/ListSnapshotFiles" ResticUI_Backup_FullMethodName = "/v1.ResticUI/Backup" + ResticUI_Prune_FullMethodName = "/v1.ResticUI/Prune" + ResticUI_Forget_FullMethodName = "/v1.ResticUI/Forget" ResticUI_PathAutocomplete_FullMethodName = "/v1.ResticUI/PathAutocomplete" ) @@ -45,6 +47,8 @@ type ResticUIClient interface { ListSnapshotFiles(ctx context.Context, in *ListSnapshotFilesRequest, opts ...grpc.CallOption) (*ListSnapshotFilesResponse, error) // Backup schedules a backup operation. It accepts a plan id and returns empty if the task is enqueued. Backup(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, error) + Prune(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, error) + Forget(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, error) // PathAutocomplete provides path autocompletion options for a given filesystem path. PathAutocomplete(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*types.StringList, error) } @@ -152,6 +156,24 @@ func (c *resticUIClient) Backup(ctx context.Context, in *types.StringValue, opts return out, nil } +func (c *resticUIClient) Prune(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, error) { + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ResticUI_Prune_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *resticUIClient) Forget(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, error) { + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, ResticUI_Forget_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *resticUIClient) PathAutocomplete(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*types.StringList, error) { out := new(types.StringList) err := c.cc.Invoke(ctx, ResticUI_PathAutocomplete_FullMethodName, in, out, opts...) @@ -174,6 +196,8 @@ type ResticUIServer interface { ListSnapshotFiles(context.Context, *ListSnapshotFilesRequest) (*ListSnapshotFilesResponse, error) // Backup schedules a backup operation. It accepts a plan id and returns empty if the task is enqueued. Backup(context.Context, *types.StringValue) (*emptypb.Empty, error) + Prune(context.Context, *types.StringValue) (*emptypb.Empty, error) + Forget(context.Context, *types.StringValue) (*emptypb.Empty, error) // PathAutocomplete provides path autocompletion options for a given filesystem path. PathAutocomplete(context.Context, *types.StringValue) (*types.StringList, error) mustEmbedUnimplementedResticUIServer() @@ -207,6 +231,12 @@ func (UnimplementedResticUIServer) ListSnapshotFiles(context.Context, *ListSnaps func (UnimplementedResticUIServer) Backup(context.Context, *types.StringValue) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method Backup not implemented") } +func (UnimplementedResticUIServer) Prune(context.Context, *types.StringValue) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method Prune not implemented") +} +func (UnimplementedResticUIServer) Forget(context.Context, *types.StringValue) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method Forget not implemented") +} func (UnimplementedResticUIServer) PathAutocomplete(context.Context, *types.StringValue) (*types.StringList, error) { return nil, status.Errorf(codes.Unimplemented, "method PathAutocomplete not implemented") } @@ -370,6 +400,42 @@ func _ResticUI_Backup_Handler(srv interface{}, ctx context.Context, dec func(int return interceptor(ctx, in, info, handler) } +func _ResticUI_Prune_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(types.StringValue) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResticUIServer).Prune(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResticUI_Prune_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResticUIServer).Prune(ctx, req.(*types.StringValue)) + } + return interceptor(ctx, in, info, handler) +} + +func _ResticUI_Forget_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(types.StringValue) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ResticUIServer).Forget(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ResticUI_Forget_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ResticUIServer).Forget(ctx, req.(*types.StringValue)) + } + return interceptor(ctx, in, info, handler) +} + func _ResticUI_PathAutocomplete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(types.StringValue) if err := dec(in); err != nil { @@ -423,6 +489,14 @@ var ResticUI_ServiceDesc = grpc.ServiceDesc{ MethodName: "Backup", Handler: _ResticUI_Backup_Handler, }, + { + MethodName: "Prune", + Handler: _ResticUI_Prune_Handler, + }, + { + MethodName: "Forget", + Handler: _ResticUI_Forget_Handler, + }, { MethodName: "PathAutocomplete", Handler: _ResticUI_PathAutocomplete_Handler, diff --git a/internal/api/server.go b/internal/api/server.go index 813f084f4..e6c858524 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -244,6 +244,33 @@ func (s *Server) Backup(ctx context.Context, req *types.StringValue) (*emptypb.E return &emptypb.Empty{}, nil } +func (s *Server) Forget(ctx context.Context, req *types.StringValue) (*emptypb.Empty, error) { + plan, err := s.orchestrator.GetPlan(req.Value) + if err != nil { + return nil, fmt.Errorf("failed to get plan %q: %w", req.Value, err) + } + + at := time.Now() + + s.orchestrator.ScheduleTask(orchestrator.NewOneofForgetTask(s.orchestrator, plan, "", at), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityForget) + s.orchestrator.ScheduleTask(orchestrator.NewOneofForgetTask(s.orchestrator, plan, "", at), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityIndexSnapshots) + + return &emptypb.Empty{}, nil +} + +func (s *Server) Prune(ctx context.Context, req *types.StringValue) (*emptypb.Empty, error) { + plan, err := s.orchestrator.GetPlan(req.Value) + if err != nil { + return nil, fmt.Errorf("failed to get plan %q: %w", req.Value, err) + } + + at := time.Now() + + s.orchestrator.ScheduleTask(orchestrator.NewOneofPruneTask(s.orchestrator, plan, "", at, true), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityPrune) + + return &emptypb.Empty{}, nil +} + func (s *Server) PathAutocomplete(ctx context.Context, path *types.StringValue) (*types.StringList, error) { ents, err := os.ReadDir(path.Value) if errors.Is(err, os.ErrNotExist) { diff --git a/internal/orchestrator/backup.go b/internal/orchestrator/backup.go index a8af27ec5..f6ed5f9d2 100644 --- a/internal/orchestrator/backup.go +++ b/internal/orchestrator/backup.go @@ -142,17 +142,20 @@ func backupHelper(ctx context.Context, orchestrator *Orchestrator, plan *v1.Plan } zap.L().Info("Backup complete", zap.String("plan", plan.Id), zap.Duration("duration", time.Since(startTime)), zap.Any("summary", summary)) + + // schedule followup tasks + at := time.Now() + if plan.Retention != nil { + orchestrator.ScheduleTask(NewOneofForgetTask(orchestrator, plan, op.SnapshotId, at), TaskPriorityForget) + } + + orchestrator.ScheduleTask(NewOneofIndexSnapshotsTask(orchestrator, plan, at), TaskPriorityIndexSnapshots) + return nil }) if err != nil { return fmt.Errorf("backup operation: %w", err) } - at := time.Now() - if plan.Retention != nil { - orchestrator.ScheduleTask(NewOneofForgetTask(orchestrator, plan, op.SnapshotId, at), taskPriorityForget) - } - orchestrator.ScheduleTask(NewOneofIndexSnapshotsTask(orchestrator, plan, at), taskPriorityIndexSnapshots) - return nil } diff --git a/internal/orchestrator/forget.go b/internal/orchestrator/forget.go index ea4135571..f1d2bbf56 100644 --- a/internal/orchestrator/forget.go +++ b/internal/orchestrator/forget.go @@ -9,7 +9,6 @@ import ( v1 "github.com/garethgeorge/resticui/gen/go/v1" "github.com/garethgeorge/resticui/internal/oplog/indexutil" "github.com/hashicorp/go-multierror" - "go.uber.org/zap" ) // ForgetTask tracks a forget operation. @@ -104,16 +103,15 @@ func (t *ForgetTask) Run(ctx context.Context) error { } } + if len(forgot) > 0 { + t.orchestrator.ScheduleTask(NewOneofPruneTask(t.orchestrator, t.plan, t.op.SnapshotId, time.Now(), false), TaskPriorityPrune) + } + return err }); err != nil { return err } - if repo.repoConfig.PrunePolicy != nil { - // TODO: schedule a prune task. - zap.S().Warn("repo specified a prune policy, automatic pruning is not yet implemented.") - } - return nil } diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index c851bb0a8..7137d9eb1 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -20,10 +20,11 @@ var ErrRepoInitializationFailed = errors.New("repo initialization failed") var ErrPlanNotFound = errors.New("plan not found") const ( - TaskPriorityDefault = iota - TaskPriorityInteractive // higher priority than default scheduled operations e.g. the user clicked to run an operation - taskPriorityIndexSnapshots // higher priority than interactive operations e.g. a system operation like a forget or index (typically scheduled by another task that wants work done immediately after it's completion). - taskPriorityForget + TaskPriorityDefault = iota + TaskPriorityIndexSnapshots + TaskPriorityPrune + TaskPriorityForget + TaskPriorityInteractive // highest priority (add other priorities to this value for offsets) ) // Orchestrator is responsible for managing repos and backups. diff --git a/internal/orchestrator/prune.go b/internal/orchestrator/prune.go index 450119141..d16eca2e8 100644 --- a/internal/orchestrator/prune.go +++ b/internal/orchestrator/prune.go @@ -2,12 +2,154 @@ package orchestrator import ( "bytes" + "context" + "fmt" "sync" + "time" + + v1 "github.com/garethgeorge/resticui/gen/go/v1" + "github.com/garethgeorge/resticui/internal/oplog/indexutil" + "go.uber.org/zap" ) -func pruneHelper() { - // TODO: This is a stub. +// PruneTask tracks a forget operation. +type PruneTask struct { + name string + orchestrator *Orchestrator // owning orchestrator + plan *v1.Plan + linkSnapshot string // snapshot to link the task to. + op *v1.Operation + at *time.Time + force bool +} + +var _ Task = &PruneTask{} + +func NewOneofPruneTask(orchestrator *Orchestrator, plan *v1.Plan, linkSnapshot string, at time.Time, force bool) *PruneTask { + return &PruneTask{ + orchestrator: orchestrator, + plan: plan, + at: &at, + linkSnapshot: linkSnapshot, + force: force, // overrides the PrunePolicy's MaxFrequencyDays + } +} + +func (t *PruneTask) Name() string { + return fmt.Sprintf("prune for plan %q", t.plan.Id) +} + +func (t *PruneTask) Next(now time.Time) *time.Time { + ret := t.at + if ret != nil { + t.at = nil + t.op = &v1.Operation{ + PlanId: t.plan.Id, + RepoId: t.plan.Repo, + SnapshotId: t.linkSnapshot, + UnixTimeStartMs: timeToUnixMillis(*ret), + Status: v1.OperationStatus_STATUS_PENDING, + Op: &v1.Operation_OperationForget{}, + } + } + return ret +} + +func (t *PruneTask) getNextPruneTime(repo *RepoOrchestrator, policy *v1.PrunePolicy) (time.Time, error) { + var lastPruneTime time.Time + t.orchestrator.OpLog.ForEachByRepo(t.plan.Repo, indexutil.CollectLastN(1000), func(op *v1.Operation) error { + if _, ok := op.Op.(*v1.Operation_OperationPrune); ok { + lastPruneTime = time.Unix(0, op.UnixTimeStartMs*int64(time.Millisecond)) + } + return nil + }) + + if repo.repoConfig.PrunePolicy != nil { + return lastPruneTime.Add(time.Duration(repo.repoConfig.PrunePolicy.MaxFrequencyDays) * 24 * time.Hour), nil + } else { + return lastPruneTime.Add(7 * 24 * time.Hour), nil // default to 7 days. + } +} + +func (t *PruneTask) Run(ctx context.Context) error { + t.op.UnixTimeStartMs = curTimeMillis() + + return WithOperation(t.orchestrator.OpLog, t.op, func() error { + repo, err := t.orchestrator.GetRepo(t.plan.Repo) + if err != nil { + return fmt.Errorf("get repo %v: %w", t.plan.Repo, err) + } + + opPrune := &v1.Operation_OperationPrune{ + OperationPrune: &v1.OperationPrune{}, + } + t.op.Op = opPrune + + if !t.force { + nextPruneTime, err := t.getNextPruneTime(repo, repo.repoConfig.PrunePolicy) + if err != nil { + return fmt.Errorf("get next prune time: %w", err) + } + if nextPruneTime.After(time.Now()) { + opPrune.OperationPrune.Output = "Skipping prune operation.\nPrune will next run at (or after): " + nextPruneTime.String() + "\nAdjust prune policy's MaxFrequencyDays to increase or decrease the interval." + return nil + } + } + + ctx, cancel := context.WithCancel(ctx) + interval := time.NewTicker(1 * time.Second) + defer interval.Stop() + var buf synchronizedBuffer + var wg sync.WaitGroup + wg.Add(1) + go func() { + + defer wg.Done() + for { + select { + case <-interval.C: + output := buf.String() + if len(output) > 8*1024 { // only provide live status upto the first 8K of output. + output = output[:len(output)-8*1024] + } + + if opPrune.OperationPrune.Output != output { + opPrune.OperationPrune.Output = buf.String() + + if err := t.orchestrator.OpLog.Update(t.op); err != nil { + zap.L().Error("update prune operation with status output", zap.Error(err)) + } + } + case <-ctx.Done(): + return + } + } + }() + + if err := repo.Prune(ctx, &buf); err != nil { + cancel() + return fmt.Errorf("prune: %w", err) + } + cancel() + wg.Wait() + + // TODO: it would be best to store the output in separate storage for large status data. + output := buf.String() + if len(output) > 8*1024 { // only provide live status upto the first 8K of output. + output = output[:len(output)-8*1024] + } + t.op.Op = &v1.Operation_OperationPrune{ + OperationPrune: &v1.OperationPrune{ + Output: output, + }, + } + + return nil + }) +} +func (t *PruneTask) Cancel(withStatus v1.OperationStatus) error { + return nil } // synchronizedBuffer is used for collecting prune command's output diff --git a/internal/orchestrator/repo.go b/internal/orchestrator/repo.go index dd107b1b9..ff6d80753 100644 --- a/internal/orchestrator/repo.go +++ b/internal/orchestrator/repo.go @@ -139,10 +139,23 @@ func (r *RepoOrchestrator) Prune(ctx context.Context, output io.Writer) error { r.mu.Lock() defer r.mu.Unlock() + policy := r.repoConfig.PrunePolicy + l := zap.L().With(zap.String("repo", r.repoConfig.Id)) + var opts []restic.GenericOption + if policy != nil { + if policy.MaxUnusedBytes != 0 { + opts = append(opts, restic.WithFlags("--max-unused", fmt.Sprintf("%v", policy.MaxUnusedBytes))) + } else if policy.MaxUnusedPercent != 0 { + opts = append(opts, restic.WithFlags("--max-unused", fmt.Sprintf("%v", policy.MaxUnusedPercent))) + } + } else { + opts = append(opts, restic.WithFlags("--max-unused", "25%")) + } + l.Debug("Prune snapshots") - err := r.repo.Prune(ctx, output) + err := r.repo.Prune(ctx, output, opts...) if err != nil { return fmt.Errorf("prune snapshots for repo %v: %w", r.repoConfig.Id, err) } diff --git a/pkg/restic/restic.go b/pkg/restic/restic.go index 9cf31215e..6ff7f2470 100644 --- a/pkg/restic/restic.go +++ b/pkg/restic/restic.go @@ -239,6 +239,8 @@ func (r *Repo) Prune(ctx context.Context, pruneOutput io.Writer, opts ...Generic cmd.Stdout = writer cmd.Stderr = writer + writer.Write([]byte("command: " + strings.Join(cmd.Args, " ") + "\n")) + if err := cmd.Run(); err != nil { return NewCmdError(cmd, buf.Bytes(), err) } diff --git a/proto/v1/config.proto b/proto/v1/config.proto index 10cd82e74..ddf7b8350 100644 --- a/proto/v1/config.proto +++ b/proto/v1/config.proto @@ -49,9 +49,6 @@ message RetentionPolicy { message PrunePolicy { int32 max_frequency_days = 1; // max frequency of prune runs in days. If 0, prune will be run on every backup. - - oneof policy { - int32 max_unused_percent = 100; // max percentage of repo size that can be unused before prune is run. - int32 max_unused_bytes = 101; // max number of bytes that can be unused before prune is run. - } + int32 max_unused_percent = 100; // max percentage of repo size that can be unused before prune is run. + int32 max_unused_bytes = 101; // max number of bytes that can be unused before prune is run. } \ No newline at end of file diff --git a/proto/v1/service.proto b/proto/v1/service.proto index 8fdf39be6..8f5a23fbd 100644 --- a/proto/v1/service.proto +++ b/proto/v1/service.proto @@ -67,6 +67,20 @@ service ResticUI { }; } + rpc Prune(types.StringValue) returns (google.protobuf.Empty) { + option (google.api.http) = { + post: "/v1/cmd/prune" + body: "*" + }; + } + + rpc Forget(types.StringValue) returns (google.protobuf.Empty) { + option (google.api.http) = { + post: "/v1/cmd/forget" + body: "*" + }; + } + // PathAutocomplete provides path autocompletion options for a given filesystem path. rpc PathAutocomplete (types.StringValue) returns (types.StringList) { option (google.api.http) = { diff --git a/webui/gen/ts/v1/config.pb.ts b/webui/gen/ts/v1/config.pb.ts index 98b237962..78d01f94a 100644 --- a/webui/gen/ts/v1/config.pb.ts +++ b/webui/gen/ts/v1/config.pb.ts @@ -3,15 +3,6 @@ /* * This file is a generated Typescript file for GRPC Gateway, DO NOT MODIFY */ - -type Absent = { [k in Exclude]?: undefined }; -type OneOf = - | { [k in keyof T]?: undefined } - | ( - keyof T extends infer K ? - (K extends string & keyof T ? { [k in K]: T[K] } & Absent - : never) - : never); export type Config = { modno?: number host?: string @@ -48,10 +39,8 @@ export type RetentionPolicy = { keepWithinDuration?: string } - -type BasePrunePolicy = { +export type PrunePolicy = { maxFrequencyDays?: number -} - -export type PrunePolicy = BasePrunePolicy - & OneOf<{ maxUnusedPercent: number; maxUnusedBytes: number }> \ No newline at end of file + maxUnusedPercent?: number + maxUnusedBytes?: number +} \ No newline at end of file diff --git a/webui/gen/ts/v1/service.pb.ts b/webui/gen/ts/v1/service.pb.ts index 6fb4ba4bd..6fc7294bc 100644 --- a/webui/gen/ts/v1/service.pb.ts +++ b/webui/gen/ts/v1/service.pb.ts @@ -72,6 +72,12 @@ export class ResticUI { static Backup(req: TypesValue.StringValue, initReq?: fm.InitReq): Promise { return fm.fetchReq(`/v1/cmd/backup`, {...initReq, method: "POST", body: JSON.stringify(req, fm.replacer)}) } + static Prune(req: TypesValue.StringValue, initReq?: fm.InitReq): Promise { + return fm.fetchReq(`/v1/cmd/prune`, {...initReq, method: "POST", body: JSON.stringify(req, fm.replacer)}) + } + static Forget(req: TypesValue.StringValue, initReq?: fm.InitReq): Promise { + return fm.fetchReq(`/v1/cmd/forget`, {...initReq, method: "POST", body: JSON.stringify(req, fm.replacer)}) + } static PathAutocomplete(req: TypesValue.StringValue, initReq?: fm.InitReq): Promise { return fm.fetchReq(`/v1/autocomplete/path`, {...initReq, method: "POST", body: JSON.stringify(req, fm.replacer)}) } diff --git a/webui/src/components/OperationList.tsx b/webui/src/components/OperationList.tsx index f35613aae..450eac0c5 100644 --- a/webui/src/components/OperationList.tsx +++ b/webui/src/components/OperationList.tsx @@ -300,6 +300,29 @@ export const OperationRow = ({ /> ); + } else if (operation.operationPrune) { + const prune = operation.operationPrune; + return ( + + Prune Operation} + avatar={} + description={ + {prune.output}, + }, + ]} + /> + } + /> + + ); } }; diff --git a/webui/src/components/OperationTree.tsx b/webui/src/components/OperationTree.tsx index 8804389b4..fceeadb1b 100644 --- a/webui/src/components/OperationTree.tsx +++ b/webui/src/components/OperationTree.tsx @@ -131,6 +131,9 @@ export const OperationTree = ({ if (b.operations[0].operationForget) { return <>Forget {formatTime(b.displayTime)}; } + if (b.operations[0].operationPrune) { + return <>Prune {formatTime(b.displayTime)}; + } } if (b.status === OperationStatus.STATUS_PENDING) { diff --git a/webui/src/views/PlanView.tsx b/webui/src/views/PlanView.tsx index 96b288f5e..b53530350 100644 --- a/webui/src/views/PlanView.tsx +++ b/webui/src/views/PlanView.tsx @@ -40,7 +40,12 @@ export const PlanView = ({ plan }: React.PropsWithChildren<{ plan: Plan }>) => { }; const handlePruneNow = () => { - alertsApi.warning("Not implemented yet :("); + try { + ResticUI.Prune({ value: plan.id }, { pathPrefix: "/api" }); + alertsApi.success("Prune scheduled."); + } catch (e: any) { + alertsApi.error("Failed to schedule prune: " + e.message); + } }; const handleUnlockNow = () => { From d81da1191337abd5de14e9cfe3cb4bb6e3d09308 Mon Sep 17 00:00:00 2001 From: garethgeorge Date: Sat, 2 Dec 2023 18:27:13 -0800 Subject: [PATCH 2/2] fix: more minor UI patches --- webui/src/views/AddPlanModal.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/webui/src/views/AddPlanModal.tsx b/webui/src/views/AddPlanModal.tsx index 9f0657464..72a89e7df 100644 --- a/webui/src/views/AddPlanModal.tsx +++ b/webui/src/views/AddPlanModal.tsx @@ -234,7 +234,7 @@ export const AddPlanModal = ({ noStyle > form.validateFields()} /> @@ -252,7 +252,7 @@ export const AddPlanModal = ({