diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6bf12cb..58d73a8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [0.9.0](https://github.com/garethgeorge/backrest/compare/v0.8.1...v0.9.0) (2023-12-31) + + +### Features + +* add backrest logo ([5add0d8](https://github.com/garethgeorge/backrest/commit/5add0d8ffa829a71103520c94eacae17966f2a9f)) +* add mobile layout ([9c7f227](https://github.com/garethgeorge/backrest/commit/9c7f227ad0f5df34d66390c94b64e9f5181d24f0)) +* index snapshots created outside of backrest ([7711297](https://github.com/garethgeorge/backrest/commit/7711297a84170a733c5ccdb3e89617efc878cf69)) +* schedule index operations and stats refresh from repo view ([851bd12](https://github.com/garethgeorge/backrest/commit/851bd125b640e65a5b98b67d28d2f29e94411646)) + + +### Bug Fixes + +* operations associated with incorrect ID when tasks are rescheduled ([25871c9](https://github.com/garethgeorge/backrest/commit/25871c99920d8717e91bf1a921109b9df82a59a1)) +* reduce stats refresh frequency ([adbe005](https://github.com/garethgeorge/backrest/commit/adbe0056d82a5d9f890ce79b1120f5084bdc7124)) +* stat never runs ([3f3252d](https://github.com/garethgeorge/backrest/commit/3f3252d47951270fbf5f21b0831effb121d3ba3f)) +* stats task priority ([6bfe769](https://github.com/garethgeorge/backrest/commit/6bfe769fe037a5f2d35947574a5ed7e26ba981a8)) +* tasks run late when laptops resume from sleep ([cb78298](https://github.com/garethgeorge/backrest/commit/cb78298cffb492560717d5f8bdcd5941f7976f2e)) +* UI and code quality improvements ([c5e435d](https://github.com/garethgeorge/backrest/commit/c5e435d640bc8e79ceacf7f64d4cf75644859204)) + ## [0.8.0](https://github.com/garethgeorge/backrest/compare/v0.7.0...v0.8.0) (2023-12-25) diff --git a/README.md b/README.md index 7bc1066e2..2593812f6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Backrest + [![Build and Test](https://github.com/garethgeorge/backrest/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/garethgeorge/backrest/actions/workflows/build-and-test.yml) diff --git a/gen/go/v1/service.pb.go b/gen/go/v1/service.pb.go index 641012972..9419402b4 100644 --- a/gen/go/v1/service.pb.go +++ b/gen/go/v1/service.pb.go @@ -594,7 +594,7 @@ var file_v1_service_proto_rawDesc = []byte{ 0x69, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 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, 0xb4, 0x07, 0x0a, + 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x63, 0x74, 0x69, 0x6d, 0x65, 0x32, 0xf4, 0x07, 0x0a, 0x08, 0x42, 0x61, 0x63, 0x6b, 0x72, 0x65, 0x73, 0x74, 0x12, 0x31, 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, @@ -621,7 +621,11 @@ var file_v1_service_proto_rawDesc = []byte{ 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x46, 0x69, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x36, 0x0a, 0x06, 0x42, 0x61, 0x63, 0x6b, 0x75, 0x70, 0x12, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x3e, 0x0a, 0x0e, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x53, 0x6e, + 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x73, 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, 0x00, 0x12, 0x36, 0x0a, 0x06, 0x42, 0x61, 0x63, 0x6b, 0x75, 0x70, 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, 0x00, 0x12, 0x35, 0x0a, @@ -700,33 +704,35 @@ var file_v1_service_proto_depIdxs = []int32{ 2, // 5: v1.Backrest.GetOperations:input_type -> v1.GetOperationsRequest 1, // 6: v1.Backrest.ListSnapshots:input_type -> v1.ListSnapshotsRequest 4, // 7: v1.Backrest.ListSnapshotFiles:input_type -> v1.ListSnapshotFilesRequest - 10, // 8: v1.Backrest.Backup:input_type -> types.StringValue - 10, // 9: v1.Backrest.Prune:input_type -> types.StringValue - 10, // 10: v1.Backrest.Forget:input_type -> types.StringValue - 3, // 11: v1.Backrest.Restore:input_type -> v1.RestoreSnapshotRequest - 10, // 12: v1.Backrest.Unlock:input_type -> types.StringValue - 10, // 13: v1.Backrest.Stats:input_type -> types.StringValue - 11, // 14: v1.Backrest.Cancel:input_type -> types.Int64Value - 0, // 15: v1.Backrest.ClearHistory:input_type -> v1.ClearHistoryRequest - 10, // 16: v1.Backrest.PathAutocomplete:input_type -> types.StringValue - 8, // 17: v1.Backrest.GetConfig:output_type -> v1.Config - 8, // 18: v1.Backrest.SetConfig:output_type -> v1.Config - 8, // 19: v1.Backrest.AddRepo:output_type -> v1.Config - 12, // 20: v1.Backrest.GetOperationEvents:output_type -> v1.OperationEvent - 13, // 21: v1.Backrest.GetOperations:output_type -> v1.OperationList - 14, // 22: v1.Backrest.ListSnapshots:output_type -> v1.ResticSnapshotList - 5, // 23: v1.Backrest.ListSnapshotFiles:output_type -> v1.ListSnapshotFilesResponse - 7, // 24: v1.Backrest.Backup:output_type -> google.protobuf.Empty - 7, // 25: v1.Backrest.Prune:output_type -> google.protobuf.Empty - 7, // 26: v1.Backrest.Forget:output_type -> google.protobuf.Empty - 7, // 27: v1.Backrest.Restore:output_type -> google.protobuf.Empty - 7, // 28: v1.Backrest.Unlock:output_type -> google.protobuf.Empty - 7, // 29: v1.Backrest.Stats:output_type -> google.protobuf.Empty - 7, // 30: v1.Backrest.Cancel:output_type -> google.protobuf.Empty - 7, // 31: v1.Backrest.ClearHistory:output_type -> google.protobuf.Empty - 15, // 32: v1.Backrest.PathAutocomplete:output_type -> types.StringList - 17, // [17:33] is the sub-list for method output_type - 1, // [1:17] is the sub-list for method input_type + 10, // 8: v1.Backrest.IndexSnapshots:input_type -> types.StringValue + 10, // 9: v1.Backrest.Backup:input_type -> types.StringValue + 10, // 10: v1.Backrest.Prune:input_type -> types.StringValue + 10, // 11: v1.Backrest.Forget:input_type -> types.StringValue + 3, // 12: v1.Backrest.Restore:input_type -> v1.RestoreSnapshotRequest + 10, // 13: v1.Backrest.Unlock:input_type -> types.StringValue + 10, // 14: v1.Backrest.Stats:input_type -> types.StringValue + 11, // 15: v1.Backrest.Cancel:input_type -> types.Int64Value + 0, // 16: v1.Backrest.ClearHistory:input_type -> v1.ClearHistoryRequest + 10, // 17: v1.Backrest.PathAutocomplete:input_type -> types.StringValue + 8, // 18: v1.Backrest.GetConfig:output_type -> v1.Config + 8, // 19: v1.Backrest.SetConfig:output_type -> v1.Config + 8, // 20: v1.Backrest.AddRepo:output_type -> v1.Config + 12, // 21: v1.Backrest.GetOperationEvents:output_type -> v1.OperationEvent + 13, // 22: v1.Backrest.GetOperations:output_type -> v1.OperationList + 14, // 23: v1.Backrest.ListSnapshots:output_type -> v1.ResticSnapshotList + 5, // 24: v1.Backrest.ListSnapshotFiles:output_type -> v1.ListSnapshotFilesResponse + 7, // 25: v1.Backrest.IndexSnapshots:output_type -> google.protobuf.Empty + 7, // 26: v1.Backrest.Backup:output_type -> google.protobuf.Empty + 7, // 27: v1.Backrest.Prune:output_type -> google.protobuf.Empty + 7, // 28: v1.Backrest.Forget:output_type -> google.protobuf.Empty + 7, // 29: v1.Backrest.Restore:output_type -> google.protobuf.Empty + 7, // 30: v1.Backrest.Unlock:output_type -> google.protobuf.Empty + 7, // 31: v1.Backrest.Stats:output_type -> google.protobuf.Empty + 7, // 32: v1.Backrest.Cancel:output_type -> google.protobuf.Empty + 7, // 33: v1.Backrest.ClearHistory:output_type -> google.protobuf.Empty + 15, // 34: v1.Backrest.PathAutocomplete:output_type -> types.StringList + 18, // [18:35] is the sub-list for method output_type + 1, // [1:18] 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_grpc.pb.go b/gen/go/v1/service_grpc.pb.go index 0e37654c2..cde041278 100644 --- a/gen/go/v1/service_grpc.pb.go +++ b/gen/go/v1/service_grpc.pb.go @@ -28,6 +28,7 @@ const ( Backrest_GetOperations_FullMethodName = "/v1.Backrest/GetOperations" Backrest_ListSnapshots_FullMethodName = "/v1.Backrest/ListSnapshots" Backrest_ListSnapshotFiles_FullMethodName = "/v1.Backrest/ListSnapshotFiles" + Backrest_IndexSnapshots_FullMethodName = "/v1.Backrest/IndexSnapshots" Backrest_Backup_FullMethodName = "/v1.Backrest/Backup" Backrest_Prune_FullMethodName = "/v1.Backrest/Prune" Backrest_Forget_FullMethodName = "/v1.Backrest/Forget" @@ -50,11 +51,13 @@ type BackrestClient interface { GetOperations(ctx context.Context, in *GetOperationsRequest, opts ...grpc.CallOption) (*OperationList, error) ListSnapshots(ctx context.Context, in *ListSnapshotsRequest, opts ...grpc.CallOption) (*ResticSnapshotList, error) ListSnapshotFiles(ctx context.Context, in *ListSnapshotFilesRequest, opts ...grpc.CallOption) (*ListSnapshotFilesResponse, error) + // IndexSnapshots triggers indexin. It accepts a repo id and returns empty if the task is enqueued. + IndexSnapshots(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, 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 schedules a prune operation. + // Prune schedules a prune operation. It accepts a plan id and returns empty if the task is enqueued. Prune(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, error) - // Forget schedules a forget operation. + // Forget schedules a forget operation. It accepts a plan id and returns empty if the task is enqueued. Forget(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, error) // Restore schedules a restore operation. Restore(ctx context.Context, in *RestoreSnapshotRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) @@ -164,6 +167,15 @@ func (c *backrestClient) ListSnapshotFiles(ctx context.Context, in *ListSnapshot return out, nil } +func (c *backrestClient) IndexSnapshots(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, error) { + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, Backrest_IndexSnapshots_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *backrestClient) Backup(ctx context.Context, in *types.StringValue, opts ...grpc.CallOption) (*emptypb.Empty, error) { out := new(emptypb.Empty) err := c.cc.Invoke(ctx, Backrest_Backup_FullMethodName, in, out, opts...) @@ -256,11 +268,13 @@ type BackrestServer interface { GetOperations(context.Context, *GetOperationsRequest) (*OperationList, error) ListSnapshots(context.Context, *ListSnapshotsRequest) (*ResticSnapshotList, error) ListSnapshotFiles(context.Context, *ListSnapshotFilesRequest) (*ListSnapshotFilesResponse, error) + // IndexSnapshots triggers indexin. It accepts a repo id and returns empty if the task is enqueued. + IndexSnapshots(context.Context, *types.StringValue) (*emptypb.Empty, 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 schedules a prune operation. + // Prune schedules a prune operation. It accepts a plan id and returns empty if the task is enqueued. Prune(context.Context, *types.StringValue) (*emptypb.Empty, error) - // Forget schedules a forget operation. + // Forget schedules a forget operation. It accepts a plan id and returns empty if the task is enqueued. Forget(context.Context, *types.StringValue) (*emptypb.Empty, error) // Restore schedules a restore operation. Restore(context.Context, *RestoreSnapshotRequest) (*emptypb.Empty, error) @@ -302,6 +316,9 @@ func (UnimplementedBackrestServer) ListSnapshots(context.Context, *ListSnapshots func (UnimplementedBackrestServer) ListSnapshotFiles(context.Context, *ListSnapshotFilesRequest) (*ListSnapshotFilesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListSnapshotFiles not implemented") } +func (UnimplementedBackrestServer) IndexSnapshots(context.Context, *types.StringValue) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method IndexSnapshots not implemented") +} func (UnimplementedBackrestServer) Backup(context.Context, *types.StringValue) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method Backup not implemented") } @@ -471,6 +488,24 @@ func _Backrest_ListSnapshotFiles_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _Backrest_IndexSnapshots_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.(BackrestServer).IndexSnapshots(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Backrest_IndexSnapshots_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BackrestServer).IndexSnapshots(ctx, req.(*types.StringValue)) + } + return interceptor(ctx, in, info, handler) +} + func _Backrest_Backup_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 { @@ -664,6 +699,10 @@ var Backrest_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListSnapshotFiles", Handler: _Backrest_ListSnapshotFiles_Handler, }, + { + MethodName: "IndexSnapshots", + Handler: _Backrest_IndexSnapshots_Handler, + }, { MethodName: "Backup", Handler: _Backrest_Backup_Handler, diff --git a/gen/go/v1/v1connect/service.connect.go b/gen/go/v1/v1connect/service.connect.go index 63a7570b9..711c5a32d 100644 --- a/gen/go/v1/v1connect/service.connect.go +++ b/gen/go/v1/v1connect/service.connect.go @@ -51,6 +51,8 @@ const ( // BackrestListSnapshotFilesProcedure is the fully-qualified name of the Backrest's // ListSnapshotFiles RPC. BackrestListSnapshotFilesProcedure = "/v1.Backrest/ListSnapshotFiles" + // BackrestIndexSnapshotsProcedure is the fully-qualified name of the Backrest's IndexSnapshots RPC. + BackrestIndexSnapshotsProcedure = "/v1.Backrest/IndexSnapshots" // BackrestBackupProcedure is the fully-qualified name of the Backrest's Backup RPC. BackrestBackupProcedure = "/v1.Backrest/Backup" // BackrestPruneProcedure is the fully-qualified name of the Backrest's Prune RPC. @@ -82,6 +84,7 @@ var ( backrestGetOperationsMethodDescriptor = backrestServiceDescriptor.Methods().ByName("GetOperations") backrestListSnapshotsMethodDescriptor = backrestServiceDescriptor.Methods().ByName("ListSnapshots") backrestListSnapshotFilesMethodDescriptor = backrestServiceDescriptor.Methods().ByName("ListSnapshotFiles") + backrestIndexSnapshotsMethodDescriptor = backrestServiceDescriptor.Methods().ByName("IndexSnapshots") backrestBackupMethodDescriptor = backrestServiceDescriptor.Methods().ByName("Backup") backrestPruneMethodDescriptor = backrestServiceDescriptor.Methods().ByName("Prune") backrestForgetMethodDescriptor = backrestServiceDescriptor.Methods().ByName("Forget") @@ -102,11 +105,13 @@ type BackrestClient interface { GetOperations(context.Context, *connect.Request[v1.GetOperationsRequest]) (*connect.Response[v1.OperationList], error) ListSnapshots(context.Context, *connect.Request[v1.ListSnapshotsRequest]) (*connect.Response[v1.ResticSnapshotList], error) ListSnapshotFiles(context.Context, *connect.Request[v1.ListSnapshotFilesRequest]) (*connect.Response[v1.ListSnapshotFilesResponse], error) + // IndexSnapshots triggers indexin. It accepts a repo id and returns empty if the task is enqueued. + IndexSnapshots(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) // Backup schedules a backup operation. It accepts a plan id and returns empty if the task is enqueued. Backup(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) - // Prune schedules a prune operation. + // Prune schedules a prune operation. It accepts a plan id and returns empty if the task is enqueued. Prune(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) - // Forget schedules a forget operation. + // Forget schedules a forget operation. It accepts a plan id and returns empty if the task is enqueued. Forget(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) // Restore schedules a restore operation. Restore(context.Context, *connect.Request[v1.RestoreSnapshotRequest]) (*connect.Response[emptypb.Empty], error) @@ -174,6 +179,12 @@ func NewBackrestClient(httpClient connect.HTTPClient, baseURL string, opts ...co connect.WithSchema(backrestListSnapshotFilesMethodDescriptor), connect.WithClientOptions(opts...), ), + indexSnapshots: connect.NewClient[types.StringValue, emptypb.Empty]( + httpClient, + baseURL+BackrestIndexSnapshotsProcedure, + connect.WithSchema(backrestIndexSnapshotsMethodDescriptor), + connect.WithClientOptions(opts...), + ), backup: connect.NewClient[types.StringValue, emptypb.Empty]( httpClient, baseURL+BackrestBackupProcedure, @@ -240,6 +251,7 @@ type backrestClient struct { getOperations *connect.Client[v1.GetOperationsRequest, v1.OperationList] listSnapshots *connect.Client[v1.ListSnapshotsRequest, v1.ResticSnapshotList] listSnapshotFiles *connect.Client[v1.ListSnapshotFilesRequest, v1.ListSnapshotFilesResponse] + indexSnapshots *connect.Client[types.StringValue, emptypb.Empty] backup *connect.Client[types.StringValue, emptypb.Empty] prune *connect.Client[types.StringValue, emptypb.Empty] forget *connect.Client[types.StringValue, emptypb.Empty] @@ -286,6 +298,11 @@ func (c *backrestClient) ListSnapshotFiles(ctx context.Context, req *connect.Req return c.listSnapshotFiles.CallUnary(ctx, req) } +// IndexSnapshots calls v1.Backrest.IndexSnapshots. +func (c *backrestClient) IndexSnapshots(ctx context.Context, req *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) { + return c.indexSnapshots.CallUnary(ctx, req) +} + // Backup calls v1.Backrest.Backup. func (c *backrestClient) Backup(ctx context.Context, req *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) { return c.backup.CallUnary(ctx, req) @@ -340,11 +357,13 @@ type BackrestHandler interface { GetOperations(context.Context, *connect.Request[v1.GetOperationsRequest]) (*connect.Response[v1.OperationList], error) ListSnapshots(context.Context, *connect.Request[v1.ListSnapshotsRequest]) (*connect.Response[v1.ResticSnapshotList], error) ListSnapshotFiles(context.Context, *connect.Request[v1.ListSnapshotFilesRequest]) (*connect.Response[v1.ListSnapshotFilesResponse], error) + // IndexSnapshots triggers indexin. It accepts a repo id and returns empty if the task is enqueued. + IndexSnapshots(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) // Backup schedules a backup operation. It accepts a plan id and returns empty if the task is enqueued. Backup(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) - // Prune schedules a prune operation. + // Prune schedules a prune operation. It accepts a plan id and returns empty if the task is enqueued. Prune(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) - // Forget schedules a forget operation. + // Forget schedules a forget operation. It accepts a plan id and returns empty if the task is enqueued. Forget(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) // Restore schedules a restore operation. Restore(context.Context, *connect.Request[v1.RestoreSnapshotRequest]) (*connect.Response[emptypb.Empty], error) @@ -408,6 +427,12 @@ func NewBackrestHandler(svc BackrestHandler, opts ...connect.HandlerOption) (str connect.WithSchema(backrestListSnapshotFilesMethodDescriptor), connect.WithHandlerOptions(opts...), ) + backrestIndexSnapshotsHandler := connect.NewUnaryHandler( + BackrestIndexSnapshotsProcedure, + svc.IndexSnapshots, + connect.WithSchema(backrestIndexSnapshotsMethodDescriptor), + connect.WithHandlerOptions(opts...), + ) backrestBackupHandler := connect.NewUnaryHandler( BackrestBackupProcedure, svc.Backup, @@ -478,6 +503,8 @@ func NewBackrestHandler(svc BackrestHandler, opts ...connect.HandlerOption) (str backrestListSnapshotsHandler.ServeHTTP(w, r) case BackrestListSnapshotFilesProcedure: backrestListSnapshotFilesHandler.ServeHTTP(w, r) + case BackrestIndexSnapshotsProcedure: + backrestIndexSnapshotsHandler.ServeHTTP(w, r) case BackrestBackupProcedure: backrestBackupHandler.ServeHTTP(w, r) case BackrestPruneProcedure: @@ -533,6 +560,10 @@ func (UnimplementedBackrestHandler) ListSnapshotFiles(context.Context, *connect. return nil, connect.NewError(connect.CodeUnimplemented, errors.New("v1.Backrest.ListSnapshotFiles is not implemented")) } +func (UnimplementedBackrestHandler) IndexSnapshots(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("v1.Backrest.IndexSnapshots is not implemented")) +} + func (UnimplementedBackrestHandler) Backup(context.Context, *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("v1.Backrest.Backup is not implemented")) } diff --git a/internal/api/server.go b/internal/api/server.go index a908975b5..1e0f0537d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -111,6 +111,9 @@ func (s *Server) AddRepo(ctx context.Context, req *connect.Request[v1.Repo]) (*c s.orchestrator.ApplyConfig(c) + // index snapshots for the newly added repository. + s.orchestrator.ScheduleTask(orchestrator.NewOneoffIndexSnapshotsTask(s.orchestrator, req.Msg.Id, time.Now()), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityIndexSnapshots) + return connect.NewResponse(c), nil } @@ -249,12 +252,23 @@ func (s *Server) GetOperations(ctx context.Context, req *connect.Request[v1.GetO }), nil } +func (s *Server) IndexSnapshots(ctx context.Context, req *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) { + _, err := s.orchestrator.GetRepo(req.Msg.Value) + if err != nil { + return nil, fmt.Errorf("failed to get repo %q: %w", req.Msg.Value, err) + } + + s.orchestrator.ScheduleTask(orchestrator.NewOneoffIndexSnapshotsTask(s.orchestrator, req.Msg.Value, time.Now()), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityIndexSnapshots) + + return connect.NewResponse(&emptypb.Empty{}), nil +} + func (s *Server) Backup(ctx context.Context, req *connect.Request[types.StringValue]) (*connect.Response[emptypb.Empty], error) { plan, err := s.orchestrator.GetPlan(req.Msg.Value) if err != nil { return nil, fmt.Errorf("failed to get plan %q: %w", req.Msg.Value, err) } - s.orchestrator.ScheduleTask(orchestrator.NewOneofBackupTask(s.orchestrator, plan, time.Now()), orchestrator.TaskPriorityInteractive) + s.orchestrator.ScheduleTask(orchestrator.NewOneoffBackupTask(s.orchestrator, plan, time.Now()), orchestrator.TaskPriorityInteractive) return connect.NewResponse(&emptypb.Empty{}), nil } @@ -266,8 +280,8 @@ func (s *Server) Forget(ctx context.Context, req *connect.Request[types.StringVa at := time.Now() - s.orchestrator.ScheduleTask(orchestrator.NewOneofForgetTask(s.orchestrator, plan, "", at), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityForget) - s.orchestrator.ScheduleTask(orchestrator.NewOneofIndexSnapshotsTask(s.orchestrator, plan, at), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityIndexSnapshots) + s.orchestrator.ScheduleTask(orchestrator.NewOneoffForgetTask(s.orchestrator, plan, "", at), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityForget) + s.orchestrator.ScheduleTask(orchestrator.NewOneoffIndexSnapshotsTask(s.orchestrator, plan.Repo, at), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityIndexSnapshots) return connect.NewResponse(&emptypb.Empty{}), nil } @@ -279,7 +293,7 @@ func (s *Server) Prune(ctx context.Context, req *connect.Request[types.StringVal } at := time.Now() - s.orchestrator.ScheduleTask(orchestrator.NewOneofPruneTask(s.orchestrator, plan, "", at, true), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityPrune) + s.orchestrator.ScheduleTask(orchestrator.NewOneoffPruneTask(s.orchestrator, plan, "", at, true), orchestrator.TaskPriorityInteractive+orchestrator.TaskPriorityPrune) return connect.NewResponse(&emptypb.Empty{}), nil } @@ -302,7 +316,7 @@ func (s *Server) Restore(ctx context.Context, req *connect.Request[v1.RestoreSna at := time.Now() - s.orchestrator.ScheduleTask(orchestrator.NewOneofRestoreTask(s.orchestrator, orchestrator.RestoreTaskOpts{ + s.orchestrator.ScheduleTask(orchestrator.NewOneoffRestoreTask(s.orchestrator, orchestrator.RestoreTaskOpts{ Plan: plan, SnapshotId: req.Msg.SnapshotId, Path: req.Msg.Path, diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 93b25af95..97a2884af 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -27,7 +27,7 @@ const ( TaskPriorityIndexSnapshots = 101 TaskPriorityForget = 102 TaskPriorityPrune = 103 - TaskPriorityStats = 104 + TaskPriorityStats = -1 // very low priority. ) // Orchestrator is responsible for managing repos and backups. @@ -54,11 +54,9 @@ func NewOrchestrator(resticBin string, cfg *v1.Config, oplog *oplog.OpLog) (*Orc config: cfg, // repoPool created with a memory store to ensure the config is updated in an atomic operation with the repo pool's config value. repoPool: newResticRepoPool(resticBin, &config.MemoryStore{Config: cfg}), - taskQueue: taskQueue{ - Now: func() time.Time { - return o.curTime() - }, - }, + taskQueue: newTaskQueue(func() time.Time { + return o.curTime() + }), } // verify the operation log and mark any incomplete operations as failed. @@ -231,10 +229,11 @@ func (o *Orchestrator) Run(mainCtx context.Context) { zap.L().Fatal("failed to start task, another task is already running. Was Run() called twice?") } + start := time.Now() if err := t.task.Run(taskCtx); err != nil { zap.L().Error("task failed", zap.String("task", t.task.Name()), zap.Error(err)) } else { - zap.L().Info("task finished", zap.String("task", t.task.Name())) + zap.L().Info("task finished", zap.String("task", t.task.Name()), zap.Duration("duration", time.Since(start))) } o.runningTask.Store(nil) diff --git a/internal/orchestrator/scheduledtaskheap.go b/internal/orchestrator/scheduledtaskheap.go index 8fc6f34dd..405f884be 100644 --- a/internal/orchestrator/scheduledtaskheap.go +++ b/internal/orchestrator/scheduledtaskheap.go @@ -7,16 +7,28 @@ import ( "time" ) +var taskQueueDefaultPollInterval = 15 * time.Minute + type taskQueue struct { - dequeueMu sync.Mutex - mu sync.Mutex - heap scheduledTaskHeapByTime - notify chan struct{} - ready scheduledTaskHeapByPriorityThenTime + dequeueMu sync.Mutex + mu sync.Mutex + heap scheduledTaskHeapByTime + notify chan struct{} + ready scheduledTaskHeapByPriorityThenTime + pollInterval time.Duration Now func() time.Time } +func newTaskQueue(now func() time.Time) taskQueue { + return taskQueue{ + heap: scheduledTaskHeapByTime{}, + ready: scheduledTaskHeapByPriorityThenTime{}, + pollInterval: taskQueueDefaultPollInterval, + Now: now, + } +} + func (t *taskQueue) curTime() time.Time { if t.Now != nil { return t.Now() @@ -94,7 +106,13 @@ func (t *taskQueue) Dequeue(ctx context.Context) *scheduledTask { } t.mu.Unlock() - timer := time.NewTimer(first.runAt.Sub(now)) + d := first.runAt.Sub(now) + if t.pollInterval > 0 && d > t.pollInterval { + // A poll interval may be set to work around clock changes + // e.g. when a laptop wakes from sleep or the system clock is adjusted. + d = t.pollInterval + } + timer := time.NewTimer(d) select { case <-timer.C: diff --git a/internal/orchestrator/taskbackup.go b/internal/orchestrator/taskbackup.go index aa99575b7..1dcbe15ae 100644 --- a/internal/orchestrator/taskbackup.go +++ b/internal/orchestrator/taskbackup.go @@ -42,7 +42,7 @@ func NewScheduledBackupTask(orchestrator *Orchestrator, plan *v1.Plan) (*BackupT }, nil } -func NewOneofBackupTask(orchestrator *Orchestrator, plan *v1.Plan, at time.Time) *BackupTask { +func NewOneoffBackupTask(orchestrator *Orchestrator, plan *v1.Plan, at time.Time) *BackupTask { didOnce := false return &BackupTask{ name: fmt.Sprintf("onetime backup for plan %q", plan.Id), @@ -142,11 +142,11 @@ func backupHelper(ctx context.Context, orchestrator *Orchestrator, plan *v1.Plan // schedule followup tasks at := time.Now() if plan.Retention != nil { - orchestrator.ScheduleTask(NewOneofForgetTask(orchestrator, plan, op.SnapshotId, at), TaskPriorityForget) + orchestrator.ScheduleTask(NewOneoffForgetTask(orchestrator, plan, op.SnapshotId, at), TaskPriorityForget) } - orchestrator.ScheduleTask(NewOneofIndexSnapshotsTask(orchestrator, plan, at), TaskPriorityIndexSnapshots) - orchestrator.ScheduleTask(NewOneofStatsTask(orchestrator, plan, op.SnapshotId, at), TaskPriorityStats) + orchestrator.ScheduleTask(NewOneoffIndexSnapshotsTask(orchestrator, plan.Repo, at), TaskPriorityIndexSnapshots) + orchestrator.ScheduleTask(NewOneoffStatsTask(orchestrator, plan, op.SnapshotId, at), TaskPriorityStats) return nil } diff --git a/internal/orchestrator/taskforget.go b/internal/orchestrator/taskforget.go index 88ad8b296..77a6733a3 100644 --- a/internal/orchestrator/taskforget.go +++ b/internal/orchestrator/taskforget.go @@ -23,7 +23,7 @@ type ForgetTask struct { var _ Task = &ForgetTask{} -func NewOneofForgetTask(orchestrator *Orchestrator, plan *v1.Plan, linkSnapshot string, at time.Time) *ForgetTask { +func NewOneoffForgetTask(orchestrator *Orchestrator, plan *v1.Plan, linkSnapshot string, at time.Time) *ForgetTask { return &ForgetTask{ TaskWithOperation: TaskWithOperation{ orch: orchestrator, @@ -102,7 +102,7 @@ func (t *ForgetTask) Run(ctx context.Context) error { } if len(forgot) > 0 { - t.orch.ScheduleTask(NewOneofPruneTask(t.orch, t.plan, op.SnapshotId, time.Now(), false), TaskPriorityPrune) + t.orch.ScheduleTask(NewOneoffPruneTask(t.orch, t.plan, op.SnapshotId, time.Now(), false), TaskPriorityPrune) } return err diff --git a/internal/orchestrator/taskindexsnapshots.go b/internal/orchestrator/taskindexsnapshots.go index 9346e2680..aded8735d 100644 --- a/internal/orchestrator/taskindexsnapshots.go +++ b/internal/orchestrator/taskindexsnapshots.go @@ -3,6 +3,7 @@ package orchestrator import ( "context" "fmt" + "strings" "time" v1 "github.com/garethgeorge/backrest/gen/go/v1" @@ -12,25 +13,27 @@ import ( "go.uber.org/zap" ) +const planForUntrackedSnapshots = "_unassociated_" + // IndexSnapshotsTask tracks a forget operation. type IndexSnapshotsTask struct { orchestrator *Orchestrator // owning orchestrator - plan *v1.Plan + repoId string at *time.Time } var _ Task = &IndexSnapshotsTask{} -func NewOneofIndexSnapshotsTask(orchestrator *Orchestrator, plan *v1.Plan, at time.Time) *IndexSnapshotsTask { +func NewOneoffIndexSnapshotsTask(orchestrator *Orchestrator, repoId string, at time.Time) *IndexSnapshotsTask { return &IndexSnapshotsTask{ orchestrator: orchestrator, - plan: plan, + repoId: repoId, at: &at, } } func (t *IndexSnapshotsTask) Name() string { - return fmt.Sprintf("index snapshots for plan %q", t.plan.Id) + return fmt.Sprintf("index snapshots for plan %q", t.repoId) } func (t *IndexSnapshotsTask) Next(now time.Time) *time.Time { @@ -42,7 +45,7 @@ func (t *IndexSnapshotsTask) Next(now time.Time) *time.Time { } func (t *IndexSnapshotsTask) Run(ctx context.Context) error { - return indexSnapshotsHelper(ctx, t.orchestrator, t.plan) + return indexSnapshotsHelper(ctx, t.orchestrator, t.repoId) } func (t *IndexSnapshotsTask) Cancel(withStatus v1.OperationStatus) error { @@ -57,22 +60,22 @@ func (t *IndexSnapshotsTask) OperationId() int64 { // - If the snapshot is already indexed, it is skipped. // - If the snapshot is not indexed, an index snapshot operation with it's metadata is added. // - If an index snapshot operation is found for a snapshot that is not returned by the repo, it is marked as forgotten. -func indexSnapshotsHelper(ctx context.Context, orchestrator *Orchestrator, plan *v1.Plan) error { - repo, err := orchestrator.GetRepo(plan.Repo) +func indexSnapshotsHelper(ctx context.Context, orchestrator *Orchestrator, repoId string) error { + repo, err := orchestrator.GetRepo(repoId) if err != nil { - return fmt.Errorf("couldn't get repo %q: %w", plan.Repo, err) + return fmt.Errorf("couldn't get repo %q: %w", repoId, err) } // collect all tracked snapshots for the plan. - snapshots, err := repo.SnapshotsForPlan(ctx, plan) + snapshots, err := repo.Snapshots(ctx) if err != nil { - return fmt.Errorf("get snapshots for plan %q: %w", plan.Id, err) + return fmt.Errorf("get snapshots for repo %q: %w", repoId, err) } // collect all current snapshot IDs. - currentIds, err := indexCurrentSnapshotIdsForPlan(orchestrator.OpLog, plan.Id) + currentIds, err := indexCurrentSnapshotIdsForRepo(orchestrator.OpLog, repoId) if err != nil { - return fmt.Errorf("get known snapshot IDs for plan %q: %w", plan.Id, err) + return fmt.Errorf("get known snapshot IDs for repo %q: %w", repoId, err) } foundIds := make(map[string]bool) @@ -87,9 +90,10 @@ func indexSnapshotsHelper(ctx context.Context, orchestrator *Orchestrator, plan } snapshotProto := protoutil.SnapshotToProto(snapshot) + planId := planForSnapshot(snapshotProto) indexOps = append(indexOps, &v1.Operation{ - RepoId: plan.Repo, - PlanId: plan.Id, + RepoId: repoId, + PlanId: planId, UnixTimeStartMs: snapshotProto.UnixTimeMs, UnixTimeEndMs: snapshotProto.UnixTimeMs, Status: v1.OperationStatus_STATUS_SUCCESS, @@ -107,14 +111,13 @@ func indexSnapshotsHelper(ctx context.Context, orchestrator *Orchestrator, plan } // Mark missing operations as newly forgotten. - var forgetIds []int64 for id, opId := range currentIds { - if _, ok := foundIds[id]; !ok { - forgetIds = append(forgetIds, opId) + if _, ok := foundIds[id]; ok { + // skip snapshots that were found. + continue } - } - for _, opId := range forgetIds { + // mark snapshot forgotten. op, err := orchestrator.OpLog.Get(opId) if err != nil { // should only be possible in the case of a data race (e.g. operation was somehow deleted). @@ -134,7 +137,7 @@ func indexSnapshotsHelper(ctx context.Context, orchestrator *Orchestrator, plan // Print stats at the end of indexing. zap.L().Debug("Indexed snapshots", - zap.String("plan", plan.Id), + zap.String("repo", repoId), zap.Duration("duration", time.Since(startTime)), zap.Int("alreadyIndexed", len(foundIds)), zap.Int("newlyAdded", len(indexOps)), @@ -145,14 +148,14 @@ func indexSnapshotsHelper(ctx context.Context, orchestrator *Orchestrator, plan } // returns a map of current (e.g. not forgotten) snapshot IDs for the plan. -func indexCurrentSnapshotIdsForPlan(log *oplog.OpLog, planId string) (map[string]int64, error) { +func indexCurrentSnapshotIdsForRepo(log *oplog.OpLog, repoId string) (map[string]int64, error) { knownIds := make(map[string]int64) startTime := time.Now() - if err := log.ForEachByPlan(planId, indexutil.CollectAll(), func(op *v1.Operation) error { + if err := log.ForEachByRepo(repoId, indexutil.CollectAll(), func(op *v1.Operation) error { if snapshotOp, ok := op.Op.(*v1.Operation_OperationIndexSnapshot); ok { if snapshotOp.OperationIndexSnapshot == nil { - return fmt.Errorf("operation %q has nil OperationIndexSnapshot, this shouldn't be possible.", op.Id) + return fmt.Errorf("operation %q has nil OperationIndexSnapshot, this shouldn't be possible", op.Id) } if !snapshotOp.OperationIndexSnapshot.Forgot { knownIds[snapshotOp.OperationIndexSnapshot.Snapshot.Id] = op.Id @@ -162,6 +165,15 @@ func indexCurrentSnapshotIdsForPlan(log *oplog.OpLog, planId string) (map[string }); err != nil { return nil, err } - zap.S().Debugf("Indexed known (and not forgotten) snapshot IDs for plan %v in %v", planId, time.Since(startTime)) + zap.S().Debugf("Indexed known (and not forgotten) snapshot IDs for plan %v in %v", repoId, time.Since(startTime)) return knownIds, nil } + +func planForSnapshot(snapshot *v1.ResticSnapshot) string { + for _, tag := range snapshot.Tags { + if strings.HasPrefix(tag, "plan:") { + return tag[5:] + } + } + return planForUntrackedSnapshots +} diff --git a/internal/orchestrator/taskprune.go b/internal/orchestrator/taskprune.go index c6a84f75c..7cdaf36a6 100644 --- a/internal/orchestrator/taskprune.go +++ b/internal/orchestrator/taskprune.go @@ -23,7 +23,7 @@ type PruneTask struct { var _ Task = &PruneTask{} -func NewOneofPruneTask(orchestrator *Orchestrator, plan *v1.Plan, linkSnapshot string, at time.Time, force bool) *PruneTask { +func NewOneoffPruneTask(orchestrator *Orchestrator, plan *v1.Plan, linkSnapshot string, at time.Time, force bool) *PruneTask { return &PruneTask{ TaskWithOperation: TaskWithOperation{ orch: orchestrator, @@ -40,6 +40,14 @@ func (t *PruneTask) Name() string { } func (t *PruneTask) Next(now time.Time) *time.Time { + shouldRun, err := t.shouldRun(now) + if err != nil { + zap.S().Errorf("task %v failed to check if it should run: %v", t.Name(), err) + } + if !shouldRun { + return nil + } + ret := t.at if ret != nil { t.at = nil @@ -58,9 +66,27 @@ func (t *PruneTask) Next(now time.Time) *time.Time { return ret } +func (t *PruneTask) shouldRun(now time.Time) (bool, error) { + if t.force { + return true, nil + } + + repo, err := t.orch.GetRepo(t.plan.Repo) + if err != nil { + return false, fmt.Errorf("get repo %v: %w", t.plan.Repo, err) + } + + nextPruneTime, err := t.getNextPruneTime(repo, repo.repoConfig.PrunePolicy) + if err != nil { + return false, fmt.Errorf("get next prune time: %w", err) + } + + return nextPruneTime.Before(now), nil +} + func (t *PruneTask) getNextPruneTime(repo *RepoOrchestrator, policy *v1.PrunePolicy) (time.Time, error) { var lastPruneTime time.Time - t.orch.OpLog.ForEachByRepo(t.plan.Repo, indexutil.CollectLastN(1000), func(op *v1.Operation) error { + t.orch.OpLog.ForEachByRepo(t.plan.Repo, indexutil.CollectLastN(100), func(op *v1.Operation) error { if _, ok := op.Op.(*v1.Operation_OperationPrune); ok { lastPruneTime = time.Unix(0, op.UnixTimeStartMs*int64(time.Millisecond)) } @@ -86,17 +112,6 @@ func (t *PruneTask) Run(ctx context.Context) error { } 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()) { - op.Status = v1.OperationStatus_STATUS_SYSTEM_CANCELLED - return nil - } - } - ctx, cancel := context.WithCancel(ctx) interval := time.NewTicker(1 * time.Second) defer interval.Stop() @@ -145,6 +160,8 @@ func (t *PruneTask) Run(ctx context.Context) error { }, } + // Schedule a task to update persisted stats for the repo + return nil }) } diff --git a/internal/orchestrator/taskrestore.go b/internal/orchestrator/taskrestore.go index 540d11a05..30de1361b 100644 --- a/internal/orchestrator/taskrestore.go +++ b/internal/orchestrator/taskrestore.go @@ -25,7 +25,7 @@ type RestoreTask struct { var _ Task = &RestoreTask{} -func NewOneofRestoreTask(orchestrator *Orchestrator, opts RestoreTaskOpts, at time.Time) *RestoreTask { +func NewOneoffRestoreTask(orchestrator *Orchestrator, opts RestoreTaskOpts, at time.Time) *RestoreTask { return &RestoreTask{ TaskWithOperation: TaskWithOperation{ orch: orchestrator, diff --git a/internal/orchestrator/tasks.go b/internal/orchestrator/tasks.go index 8d1e48789..8c3e420df 100644 --- a/internal/orchestrator/tasks.go +++ b/internal/orchestrator/tasks.go @@ -46,7 +46,7 @@ func (t *TaskWithOperation) setOperation(op *v1.Operation) error { func (t *TaskWithOperation) runWithOpAndContext(ctx context.Context, do func(ctx context.Context, op *v1.Operation) error) error { if t.op == nil { - return errors.New("task has no operation, a call to setOperation first is required.") + return errors.New("task has no operation, a call to setOperation first is required") } if t.running.Load() { return errors.New("task is already running") @@ -54,6 +54,9 @@ func (t *TaskWithOperation) runWithOpAndContext(ctx context.Context, do func(ctx t.running.Store(true) defer t.running.Store(false) + defer func() { + t.op = nil + }() return WithOperation(t.orch.OpLog, t.op, func() error { return do(ctx, t.op) diff --git a/internal/orchestrator/taskstats.go b/internal/orchestrator/taskstats.go index 619ddf336..d696475ab 100644 --- a/internal/orchestrator/taskstats.go +++ b/internal/orchestrator/taskstats.go @@ -7,9 +7,12 @@ import ( "time" v1 "github.com/garethgeorge/backrest/gen/go/v1" + "github.com/garethgeorge/backrest/internal/oplog/indexutil" "go.uber.org/zap" ) +var statBytesThreshold int64 = 10 * 1024 * 1024 * 1024 // 10 GB added. + // StatsTask tracks a restic stats operation. type StatsTask struct { TaskWithOperation @@ -18,9 +21,9 @@ type StatsTask struct { at *time.Time } -var _ Task = &ForgetTask{} +var _ Task = &StatsTask{} -func NewOneofStatsTask(orchestrator *Orchestrator, plan *v1.Plan, linkSnapshot string, at time.Time) *StatsTask { +func NewOneoffStatsTask(orchestrator *Orchestrator, plan *v1.Plan, linkSnapshot string, at time.Time) *StatsTask { return &StatsTask{ TaskWithOperation: TaskWithOperation{ orch: orchestrator, @@ -35,7 +38,38 @@ func (t *StatsTask) Name() string { return fmt.Sprintf("stats for plan %q", t.plan.Id) } +func (t *StatsTask) shouldRun() (bool, error) { + var bytesSinceLastStat int64 = -1 + if err := t.orch.OpLog.ForEachByRepo(t.plan.Repo, indexutil.CollectLastN(50), func(op *v1.Operation) error { + if _, ok := op.Op.(*v1.Operation_OperationStats); ok { + bytesSinceLastStat = 0 + } else if backup, ok := op.Op.(*v1.Operation_OperationBackup); ok && backup.OperationBackup.LastStatus != nil { + if summary, ok := backup.OperationBackup.LastStatus.Entry.(*v1.BackupProgressEntry_Summary); ok { + bytesSinceLastStat += summary.Summary.DataAdded + } + } + return nil + }); err != nil { + return false, fmt.Errorf("iterate oplog: %w", err) + } + + zap.L().Debug("bytes since last stat", zap.Int64("bytes", bytesSinceLastStat), zap.String("repo", t.plan.Repo)) + if bytesSinceLastStat == -1 || bytesSinceLastStat > statBytesThreshold { + zap.S().Debugf("bytes since last stat (%v) exceeds threshold (%v)", bytesSinceLastStat, statBytesThreshold) + return true, nil + } + return false, nil +} + func (t *StatsTask) Next(now time.Time) *time.Time { + shouldRun, err := t.shouldRun() + if err != nil { + zap.S().Errorf("task %v failed to check if it should run: %v", t.Name(), err) + } + if !shouldRun { + return nil + } + ret := t.at if ret != nil { t.at = nil diff --git a/proto/v1/service.proto b/proto/v1/service.proto index b5dd52c21..ee3f244eb 100644 --- a/proto/v1/service.proto +++ b/proto/v1/service.proto @@ -26,13 +26,16 @@ service Backrest { rpc ListSnapshotFiles(ListSnapshotFilesRequest) returns (ListSnapshotFilesResponse) {} + // IndexSnapshots triggers indexin. It accepts a repo id and returns empty if the task is enqueued. + rpc IndexSnapshots(types.StringValue) returns (google.protobuf.Empty) {} + // Backup schedules a backup operation. It accepts a plan id and returns empty if the task is enqueued. rpc Backup(types.StringValue) returns (google.protobuf.Empty) {} - // Prune schedules a prune operation. + // Prune schedules a prune operation. It accepts a plan id and returns empty if the task is enqueued. rpc Prune(types.StringValue) returns (google.protobuf.Empty) {} - // Forget schedules a forget operation. + // Forget schedules a forget operation. It accepts a plan id and returns empty if the task is enqueued. rpc Forget(types.StringValue) returns (google.protobuf.Empty) {} // Restore schedules a restore operation. @@ -54,6 +57,15 @@ service Backrest { rpc PathAutocomplete (types.StringValue) returns (types.StringList) {} } +message LoginRequest { + string username = 1; + string password = 2; +} + +message LoginResponse { + string jwt = 1; +} + message ClearHistoryRequest { string repo_id = 1; string plan_id = 2; diff --git a/webui/assets/logo-black.svg b/webui/assets/logo-black.svg new file mode 100644 index 000000000..d96dade48 --- /dev/null +++ b/webui/assets/logo-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webui/assets/logo.svg b/webui/assets/logo.svg new file mode 100644 index 000000000..709910464 --- /dev/null +++ b/webui/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webui/gen/ts/v1/service_connect.ts b/webui/gen/ts/v1/service_connect.ts index fffbd6c65..dad69b091 100644 --- a/webui/gen/ts/v1/service_connect.ts +++ b/webui/gen/ts/v1/service_connect.ts @@ -79,6 +79,17 @@ export const Backrest = { O: ListSnapshotFilesResponse, kind: MethodKind.Unary, }, + /** + * IndexSnapshots triggers indexin. It accepts a repo id and returns empty if the task is enqueued. + * + * @generated from rpc v1.Backrest.IndexSnapshots + */ + indexSnapshots: { + name: "IndexSnapshots", + I: StringValue, + O: Empty, + kind: MethodKind.Unary, + }, /** * Backup schedules a backup operation. It accepts a plan id and returns empty if the task is enqueued. * @@ -91,7 +102,7 @@ export const Backrest = { kind: MethodKind.Unary, }, /** - * Prune schedules a prune operation. + * Prune schedules a prune operation. It accepts a plan id and returns empty if the task is enqueued. * * @generated from rpc v1.Backrest.Prune */ @@ -102,7 +113,7 @@ export const Backrest = { kind: MethodKind.Unary, }, /** - * Forget schedules a forget operation. + * Forget schedules a forget operation. It accepts a plan id and returns empty if the task is enqueued. * * @generated from rpc v1.Backrest.Forget */ diff --git a/webui/src/api.ts b/webui/src/api.ts index 7e27a4fc6..ce6bb909d 100644 --- a/webui/src/api.ts +++ b/webui/src/api.ts @@ -5,6 +5,7 @@ import { Backrest } from "../gen/ts/v1/service_connect"; const transport = createConnectTransport({ baseUrl: "/", + useBinaryFormat: true, }); export const backrestService = createPromiseClient(Backrest, transport); diff --git a/webui/src/components/OperationList.tsx b/webui/src/components/OperationList.tsx index afa27df80..57309b252 100644 --- a/webui/src/components/OperationList.tsx +++ b/webui/src/components/OperationList.tsx @@ -49,6 +49,8 @@ import { useAlertApi } from "./Alerts"; import { MessageInstance } from "antd/es/message/interface"; import { backrestService } from "../api"; +// OperationList displays a list of operations that are either fetched based on 'req' or passed in via 'useBackups'. +// If showPlan is provided the planId will be displayed next to each operation in the operation list. export const OperationList = ({ req, useBackups, @@ -209,12 +211,7 @@ export const OperationRow = ({ let body: React.ReactNode | undefined; - if ( - operation.displayMessage && - operation.status === OperationStatus.STATUS_ERROR - ) { - body =
{operation.displayMessage}
; - } else if (operation.op.case === "operationBackup") { + if (operation.op.case === "operationBackup") { const backupOp = operation.op.value; body = ( <> @@ -274,6 +271,15 @@ export const OperationRow = ({ ); } + if (operation.displayMessage) { + body = ( + <> +
{details.state}: {operation.displayMessage}
+ {body} + + ); + } + return ( diff --git a/webui/src/components/OperationTree.tsx b/webui/src/components/OperationTree.tsx index bf4028899..f76569f67 100644 --- a/webui/src/components/OperationTree.tsx +++ b/webui/src/components/OperationTree.tsx @@ -9,7 +9,7 @@ import { subscribeToOperations, unsubscribeFromOperations, } from "../state/oplog"; -import { Col, Divider, Empty, Row, Tree } from "antd"; +import { Col, Divider, Empty, Modal, Row, Tree } from "antd"; import _ from "lodash"; import { DataNode } from "antd/es/tree"; import { @@ -29,6 +29,8 @@ import { OperationEvent, OperationEventType, OperationStatus } from "../../gen/t import { useAlertApi } from "./Alerts"; import { OperationList } from "./OperationList"; import { GetOperationsRequest } from "../../gen/ts/v1/service_pb"; +import { isMobile } from "../lib/browserutil"; +import { useShowModal } from "./ModalManager"; type OpTreeNode = DataNode & { backup?: BackupInfo; @@ -38,6 +40,7 @@ export const OperationTree = ({ req, }: React.PropsWithoutRef<{ req: GetOperationsRequest }>) => { const alertApi = useAlertApi(); + const showModal = useShowModal(); const [backups, setBackups] = useState([]); const [selectedBackupId, setSelectedBackupId] = useState(null); @@ -93,99 +96,99 @@ export const OperationTree = ({ ); } - let oplist: React.ReactNode | null = null; - if (selectedBackupId) { - const backup = backups.find((b) => b.id === selectedBackupId); + const useMobileLayout = isMobile(); - if (!backup) { - oplist = ; - } else { - oplist = ( - <> -

Backup on {formatTime(backup.displayTime)}

- - + const backupTree = + treeData={treeData} + showIcon + defaultExpandedKeys={backups.slice(0, Math.min(5, backups.length)).map((b) => b.id!)} + onSelect={(keys, info) => { + if (info.selectedNodes.length === 0) return; + const backup = info.selectedNodes[0].backup; + if (!backup) { + setSelectedBackupId(null); + return; + } + setSelectedBackupId(backup.id!); + + if (useMobileLayout) { + showModal( + { showModal(null); }}> + + + ); + } + }} + titleRender={(node: OpTreeNode): React.ReactNode => { + if (node.title) { + return <>{node.title}; + } + if (node.backup) { + const b = node.backup; + const details: string[] = []; + + if (b.status === OperationStatus.STATUS_PENDING) { + details.push("scheduled, waiting"); + } else if (b.status === OperationStatus.STATUS_SYSTEM_CANCELLED) { + details.push("system cancel"); + } else if (b.status === OperationStatus.STATUS_USER_CANCELLED) { + details.push("cancelled"); + } + + if (b.backupLastStatus) { + if (b.backupLastStatus.entry.case === "summary") { + const s = b.backupLastStatus.entry.value; + details.push( + `${formatBytes(Number(s.totalBytesProcessed))} in ${formatDuration( + s.totalDuration! * 1000.0 // convert to ms + )}` + ); + } else if (b.backupLastStatus.entry.case === "status") { + const s = b.backupLastStatus.entry.value; + const percent = Math.floor((Number(s.bytesDone) / Number(s.totalBytes)) * 100); + details.push( + `${percent}% processed ${formatBytes( + Number(s.bytesDone) + )} / ${formatBytes(Number(s.totalBytes))}` + ); + } + } + if (b.snapshotInfo) { + details.push(`ID: ${normalizeSnapshotId(b.snapshotInfo.id)}`); + } + + let detailsElem: React.ReactNode | null = null; + if (details.length > 0) { + detailsElem = ( + + [{details.join(", ")}] + + ); + } + + const type = getTypeForDisplay(b.operations[0]); + return ( + <> + {displayTypeToString(type)} {formatTime(b.displayTime)} {detailsElem} + + ); + } + return ( + ERROR: this element should not appear, this is a bug. ); - } + }} + />; + + if (useMobileLayout) { + return backupTree; } return ( - - treeData={treeData} - showIcon - defaultExpandedKeys={backups.slice(0, Math.min(5, backups.length)).map((b) => b.id!)} - onSelect={(keys, info) => { - if (info.selectedNodes.length === 0) return; - const backup = info.selectedNodes[0].backup; - if (!backup) { - setSelectedBackupId(null); - return; - } - setSelectedBackupId(backup.id!); - }} - titleRender={(node: OpTreeNode): React.ReactNode => { - if (node.title) { - return <>{node.title}; - } - if (node.backup) { - const b = node.backup; - const details: string[] = []; - - if (b.status === OperationStatus.STATUS_PENDING) { - details.push("scheduled, waiting"); - } else if (b.status === OperationStatus.STATUS_SYSTEM_CANCELLED) { - details.push("system cancel"); - } else if (b.status === OperationStatus.STATUS_USER_CANCELLED) { - details.push("cancelled"); - } - - if (b.backupLastStatus) { - if (b.backupLastStatus.entry.case === "summary") { - const s = b.backupLastStatus.entry.value; - details.push( - `${formatBytes(Number(s.totalBytesProcessed))} in ${formatDuration( - s.totalDuration! * 1000.0 // convert to ms - )}` - ); - } else if (b.backupLastStatus.entry.case === "status") { - const s = b.backupLastStatus.entry.value; - const percent = Math.floor((Number(s.bytesDone) / Number(s.totalBytes)) * 100); - details.push( - `${percent}% processed ${formatBytes( - Number(s.bytesDone) - )} / ${formatBytes(Number(s.totalBytes))}` - ); - } - } - if (b.snapshotInfo) { - details.push(`ID: ${normalizeSnapshotId(b.snapshotInfo.id)}`); - } - - let detailsElem: React.ReactNode | null = null; - if (details.length > 0) { - detailsElem = ( - - [{details.join(", ")}] - - ); - } - - const type = getTypeForDisplay(b.operations[0]); - return ( - <> - {displayTypeToString(type)} {formatTime(b.displayTime)} {detailsElem} - - ); - } - return ( - ERROR: this element should not appear, this is a bug. - ); - }} - > + {backupTree} - {oplist} + {selectedBackupId ? b.id === selectedBackupId)} /> : null} ); }; @@ -256,3 +259,14 @@ const sortByKey = (a: OpTreeNode, b: OpTreeNode) => { } return 0; }; + +const BackupView = ({ backup }: { backup?: BackupInfo }) => { + if (!backup) { + return ; + } else { + return <> +

Backup on {formatTime(backup.displayTime)}

+ + ; + } +} \ No newline at end of file diff --git a/webui/src/components/SpinButton.tsx b/webui/src/components/SpinButton.tsx new file mode 100644 index 000000000..0d4ae98eb --- /dev/null +++ b/webui/src/components/SpinButton.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Button, ButtonProps } from "antd"; +import { useState } from "react"; + +export const SpinButton: React.FC Promise; +}> = ({ onClickAsync, ...props }) => { + const [loading, setLoading] = useState(false); + + const onClick = async () => { + if (loading) { + return; + } + try { + setLoading(true); + await onClickAsync(); + } finally { + setLoading(false); + } + }; + + return ( + + - + - + - + ) => { const alertsApi = useAlertApi()!; - const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [statsOperation, setStatsOperation] = useState(null); useEffect(() => { - setStats(null); - getOperations(new GetOperationsRequest({ repoId: repo.id!, lastN: BigInt(10) })).then((operations) => { + setLoading(true); + setStatsOperation(null); + getOperations(new GetOperationsRequest({ repoId: repo.id!, lastN: BigInt(STATS_OPERATION_HISTORY) })).then((operations) => { for (const op of operations) { if (op.op.case === "operationStats") { const stats = op.op.value.stats; if (stats) { - setStats(stats); + setStatsOperation(op); } } } }).catch((e) => { console.error(e); + }).finally(() => { + setLoading(false); }); }, [repo.id]); + // Task handlers + const handleIndexNow = async () => { + await backrestService.indexSnapshots(new StringValue({ value: repo.id! })); + } // Gracefully handle deletions by checking if the plan is still in the config. const config = useRecoilValue(configState); @@ -41,8 +53,8 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => { } repo = repoInConfig; - if (!stats) { - return ; + if (loading) { + return ; } const items = [ @@ -51,25 +63,16 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => { label: "Stats", children: ( <> -

Repo Stats

- - -

Total Size:

-

Total Size Uncompressed:

-

Blob Count:

-

Snapshot Count:

-

Compression Ratio:

- - -

{formatBytes(Number(stats.totalSize))}

-

{formatBytes(Number(stats.totalUncompressedSize))}

-

{Number(stats.totalBlobCount)} blobs

-

{Number(stats.snapshotCount)} snapshots

-

{Math.round(stats.compressionRatio * 1000) / 1000}

- -
+ {statsOperation === null ? : + <> +

Repo stats computed on {formatTime(Number(statsOperation.unixTimeStartMs))}

+ {statsOperation.op.case === "operationStats" && } + Stats are refreshed periodically in the background as new data is added. + + } ), + destroyInactiveTabPane: true, }, { key: "2", @@ -82,6 +85,7 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => { /> ), + destroyInactiveTabPane: true, }, { key: "3", @@ -95,6 +99,7 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => { /> ), + destroyInactiveTabPane: true, }, ] return ( @@ -104,6 +109,13 @@ export const RepoView = ({ repo }: React.PropsWithChildren<{ repo: Repo }>) => { {repo.id} + + + + Index Snapshots + + + ) => { ); }; + +const StatsTable = ({ stats }: { stats: RepoStats }) => { + return + +

Total Size:

+

Total Size Uncompressed:

+

Blob Count:

+

Snapshot Count:

+

Compression Ratio:

+ + +

{formatBytes(Number(stats.totalSize))}

+

{formatBytes(Number(stats.totalUncompressedSize))}

+

{Number(stats.totalBlobCount)} blobs

+

{Number(stats.snapshotCount)} snapshots

+

{Math.round(stats.compressionRatio * 1000) / 1000}

+ +
+} \ No newline at end of file