diff --git a/gen/go/v1/service.pb.go b/gen/go/v1/service.pb.go index fda8c675..b7b96083 100644 --- a/gen/go/v1/service.pb.go +++ b/gen/go/v1/service.pb.go @@ -1435,9 +1435,11 @@ type SummaryDashboardResponse_Summary struct { BytesAddedAvg int64 `protobuf:"varint,9,opt,name=bytes_added_avg,json=bytesAddedAvg,proto3" json:"bytes_added_avg,omitempty"` NextBackupTimeMs int64 `protobuf:"varint,10,opt,name=next_backup_time_ms,json=nextBackupTimeMs,proto3" json:"next_backup_time_ms,omitempty"` // Charts - RecentBackups *SummaryDashboardResponse_BackupChart `protobuf:"bytes,11,opt,name=recent_backups,json=recentBackups,proto3" json:"recent_backups,omitempty"` // recent backups - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + RecentBackups *SummaryDashboardResponse_BackupChart `protobuf:"bytes,11,opt,name=recent_backups,json=recentBackups,proto3" json:"recent_backups,omitempty"` // recent backups + ProtectedBytes int64 `protobuf:"varint,12,opt,name=protected_bytes,json=protectedBytes,proto3" json:"protected_bytes,omitempty"` // total bytes processed by the most recent successful/warning backup + HistoryLast_30Days []*SummaryDashboardResponse_DayStatusBucket `protobuf:"bytes,13,rep,name=history_last_30days,json=historyLast30days,proto3" json:"history_last_30days,omitempty"` // per-day backup status for the dashboard history strip, oldest first; absent days precede the plan's first backup + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SummaryDashboardResponse_Summary) Reset() { @@ -1547,6 +1549,20 @@ func (x *SummaryDashboardResponse_Summary) GetRecentBackups() *SummaryDashboardR return nil } +func (x *SummaryDashboardResponse_Summary) GetProtectedBytes() int64 { + if x != nil { + return x.ProtectedBytes + } + return 0 +} + +func (x *SummaryDashboardResponse_Summary) GetHistoryLast_30Days() []*SummaryDashboardResponse_DayStatusBucket { + if x != nil { + return x.HistoryLast_30Days + } + return nil +} + type SummaryDashboardResponse_BackupChart struct { state protoimpl.MessageState `protogen:"open.v1"` FlowId []int64 `protobuf:"varint,1,rep,packed,name=flow_id,json=flowId,proto3" json:"flow_id,omitempty"` @@ -1623,6 +1639,126 @@ func (x *SummaryDashboardResponse_BackupChart) GetBytesAdded() []int64 { return nil } +type SummaryDashboardResponse_DayStatusBucket struct { + state protoimpl.MessageState `protogen:"open.v1"` + TimestampMs int64 `protobuf:"varint,1,opt,name=timestamp_ms,json=timestampMs,proto3" json:"timestamp_ms,omitempty"` // start-of-day timestamp this bucket represents + BytesAdded int64 `protobuf:"varint,2,opt,name=bytes_added,json=bytesAdded,proto3" json:"bytes_added,omitempty"` // bytes added by backups that day + BytesScanned int64 `protobuf:"varint,3,opt,name=bytes_scanned,json=bytesScanned,proto3" json:"bytes_scanned,omitempty"` // bytes scanned by backups that day + StatusCounts []*SummaryDashboardResponse_StatusAndCount `protobuf:"bytes,4,rep,name=status_counts,json=statusCounts,proto3" json:"status_counts,omitempty"` // backup count per status for the day + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SummaryDashboardResponse_DayStatusBucket) Reset() { + *x = SummaryDashboardResponse_DayStatusBucket{} + mi := &file_v1_service_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SummaryDashboardResponse_DayStatusBucket) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SummaryDashboardResponse_DayStatusBucket) ProtoMessage() {} + +func (x *SummaryDashboardResponse_DayStatusBucket) ProtoReflect() protoreflect.Message { + mi := &file_v1_service_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SummaryDashboardResponse_DayStatusBucket.ProtoReflect.Descriptor instead. +func (*SummaryDashboardResponse_DayStatusBucket) Descriptor() ([]byte, []int) { + return file_v1_service_proto_rawDescGZIP(), []int{19, 2} +} + +func (x *SummaryDashboardResponse_DayStatusBucket) GetTimestampMs() int64 { + if x != nil { + return x.TimestampMs + } + return 0 +} + +func (x *SummaryDashboardResponse_DayStatusBucket) GetBytesAdded() int64 { + if x != nil { + return x.BytesAdded + } + return 0 +} + +func (x *SummaryDashboardResponse_DayStatusBucket) GetBytesScanned() int64 { + if x != nil { + return x.BytesScanned + } + return 0 +} + +func (x *SummaryDashboardResponse_DayStatusBucket) GetStatusCounts() []*SummaryDashboardResponse_StatusAndCount { + if x != nil { + return x.StatusCounts + } + return nil +} + +type SummaryDashboardResponse_StatusAndCount struct { + state protoimpl.MessageState `protogen:"open.v1"` + Count int64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` + Status OperationStatus `protobuf:"varint,2,opt,name=status,proto3,enum=v1.OperationStatus" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SummaryDashboardResponse_StatusAndCount) Reset() { + *x = SummaryDashboardResponse_StatusAndCount{} + mi := &file_v1_service_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SummaryDashboardResponse_StatusAndCount) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SummaryDashboardResponse_StatusAndCount) ProtoMessage() {} + +func (x *SummaryDashboardResponse_StatusAndCount) ProtoReflect() protoreflect.Message { + mi := &file_v1_service_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SummaryDashboardResponse_StatusAndCount.ProtoReflect.Descriptor instead. +func (*SummaryDashboardResponse_StatusAndCount) Descriptor() ([]byte, []int) { + return file_v1_service_proto_rawDescGZIP(), []int{19, 3} +} + +func (x *SummaryDashboardResponse_StatusAndCount) GetCount() int64 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *SummaryDashboardResponse_StatusAndCount) GetStatus() OperationStatus { + if x != nil { + return x.Status + } + return OperationStatus_STATUS_UNKNOWN +} + var File_v1_service_proto protoreflect.FileDescriptor const file_v1_service_proto_rawDesc = "" + @@ -1737,14 +1873,14 @@ const file_v1_service_proto_rawDesc = "" + " \x01(\tR\x05ctime\"F\n" + "\x11RunCommandRequest\x12\x17\n" + "\arepo_id\x18\x01 \x01(\tR\x06repoId\x12\x18\n" + - "\acommand\x18\x02 \x01(\tR\acommand\"\xea\a\n" + + "\acommand\x18\x02 \x01(\tR\acommand\"\x95\v\n" + "\x18SummaryDashboardResponse\x12K\n" + "\x0erepo_summaries\x18\x01 \x03(\v2$.v1.SummaryDashboardResponse.SummaryR\rrepoSummaries\x12K\n" + "\x0eplan_summaries\x18\x02 \x03(\v2$.v1.SummaryDashboardResponse.SummaryR\rplanSummaries\x12\x1f\n" + "\vconfig_path\x18\n" + " \x01(\tR\n" + "configPath\x12\x1b\n" + - "\tdata_path\x18\v \x01(\tR\bdataPath\x1a\xba\x04\n" + + "\tdata_path\x18\v \x01(\tR\bdataPath\x1a\xc1\x05\n" + "\aSummary\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x122\n" + "\x15backups_failed_30days\x18\x02 \x01(\x03R\x13backupsFailed30days\x12=\n" + @@ -1757,7 +1893,9 @@ const file_v1_service_proto_rawDesc = "" + "\x0fbytes_added_avg\x18\t \x01(\x03R\rbytesAddedAvg\x12-\n" + "\x13next_backup_time_ms\x18\n" + " \x01(\x03R\x10nextBackupTimeMs\x12O\n" + - "\x0erecent_backups\x18\v \x01(\v2(.v1.SummaryDashboardResponse.BackupChartR\rrecentBackups\x1a\xb8\x01\n" + + "\x0erecent_backups\x18\v \x01(\v2(.v1.SummaryDashboardResponse.BackupChartR\rrecentBackups\x12'\n" + + "\x0fprotected_bytes\x18\f \x01(\x03R\x0eprotectedBytes\x12\\\n" + + "\x13history_last_30days\x18\r \x03(\v2,.v1.SummaryDashboardResponse.DayStatusBucketR\x11historyLast30days\x1a\xb8\x01\n" + "\vBackupChart\x12\x17\n" + "\aflow_id\x18\x01 \x03(\x03R\x06flowId\x12!\n" + "\ftimestamp_ms\x18\x02 \x03(\x03R\vtimestampMs\x12\x1f\n" + @@ -1765,7 +1903,16 @@ const file_v1_service_proto_rawDesc = "" + "durationMs\x12+\n" + "\x06status\x18\x04 \x03(\x0e2\x13.v1.OperationStatusR\x06status\x12\x1f\n" + "\vbytes_added\x18\x05 \x03(\x03R\n" + - "bytesAdded\"\xab\x01\n" + + "bytesAdded\x1a\xcc\x01\n" + + "\x0fDayStatusBucket\x12!\n" + + "\ftimestamp_ms\x18\x01 \x01(\x03R\vtimestampMs\x12\x1f\n" + + "\vbytes_added\x18\x02 \x01(\x03R\n" + + "bytesAdded\x12#\n" + + "\rbytes_scanned\x18\x03 \x01(\x03R\fbytesScanned\x12P\n" + + "\rstatus_counts\x18\x04 \x03(\v2+.v1.SummaryDashboardResponse.StatusAndCountR\fstatusCounts\x1aS\n" + + "\x0eStatusAndCount\x12\x14\n" + + "\x05count\x18\x01 \x01(\x03R\x05count\x12+\n" + + "\x06status\x18\x02 \x01(\x0e2\x13.v1.OperationStatusR\x06status\"\xab\x01\n" + "\x1bGeneratePairingTokenRequest\x12\x14\n" + "\x05label\x18\x01 \x01(\tR\x05label\x12\x1f\n" + "\vttl_seconds\x18\x02 \x01(\x03R\n" + @@ -1820,107 +1967,112 @@ func file_v1_service_proto_rawDescGZIP() []byte { } var file_v1_service_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_v1_service_proto_msgTypes = make([]protoimpl.MessageInfo, 24) +var file_v1_service_proto_msgTypes = make([]protoimpl.MessageInfo, 26) var file_v1_service_proto_goTypes = []any{ - (DoRepoTaskRequest_Task)(0), // 0: v1.DoRepoTaskRequest.Task - (*BackupRequest)(nil), // 1: v1.BackupRequest - (*OpSelector)(nil), // 2: v1.OpSelector - (*SetupSftpRequest)(nil), // 3: v1.SetupSftpRequest - (*SetupSftpResponse)(nil), // 4: v1.SetupSftpResponse - (*CheckRepoExistsRequest)(nil), // 5: v1.CheckRepoExistsRequest - (*CheckRepoExistsResponse)(nil), // 6: v1.CheckRepoExistsResponse - (*AddRepoRequest)(nil), // 7: v1.AddRepoRequest - (*DoRepoTaskRequest)(nil), // 8: v1.DoRepoTaskRequest - (*ClearHistoryRequest)(nil), // 9: v1.ClearHistoryRequest - (*ForgetRequest)(nil), // 10: v1.ForgetRequest - (*ListSnapshotsRequest)(nil), // 11: v1.ListSnapshotsRequest - (*GetOperationsRequest)(nil), // 12: v1.GetOperationsRequest - (*RestoreSnapshotRequest)(nil), // 13: v1.RestoreSnapshotRequest - (*ListSnapshotFilesRequest)(nil), // 14: v1.ListSnapshotFilesRequest - (*ListSnapshotFilesResponse)(nil), // 15: v1.ListSnapshotFilesResponse - (*LogDataRequest)(nil), // 16: v1.LogDataRequest - (*GetDownloadURLRequest)(nil), // 17: v1.GetDownloadURLRequest - (*LsEntry)(nil), // 18: v1.LsEntry - (*RunCommandRequest)(nil), // 19: v1.RunCommandRequest - (*SummaryDashboardResponse)(nil), // 20: v1.SummaryDashboardResponse - (*GeneratePairingTokenRequest)(nil), // 21: v1.GeneratePairingTokenRequest - (*GeneratePairingTokenResponse)(nil), // 22: v1.GeneratePairingTokenResponse - (*SummaryDashboardResponse_Summary)(nil), // 23: v1.SummaryDashboardResponse.Summary - (*SummaryDashboardResponse_BackupChart)(nil), // 24: v1.SummaryDashboardResponse.BackupChart - (*Repo)(nil), // 25: v1.Repo - (*Multihost_Permission)(nil), // 26: v1.Multihost.Permission - (OperationStatus)(0), // 27: v1.OperationStatus - (*emptypb.Empty)(nil), // 28: google.protobuf.Empty - (*Config)(nil), // 29: v1.Config - (*types.StringValue)(nil), // 30: types.StringValue - (*types.Int64Value)(nil), // 31: types.Int64Value - (*OperationEvent)(nil), // 32: v1.OperationEvent - (*OperationList)(nil), // 33: v1.OperationList - (*ResticSnapshotList)(nil), // 34: v1.ResticSnapshotList - (*types.BytesValue)(nil), // 35: types.BytesValue - (*types.StringList)(nil), // 36: types.StringList + (DoRepoTaskRequest_Task)(0), // 0: v1.DoRepoTaskRequest.Task + (*BackupRequest)(nil), // 1: v1.BackupRequest + (*OpSelector)(nil), // 2: v1.OpSelector + (*SetupSftpRequest)(nil), // 3: v1.SetupSftpRequest + (*SetupSftpResponse)(nil), // 4: v1.SetupSftpResponse + (*CheckRepoExistsRequest)(nil), // 5: v1.CheckRepoExistsRequest + (*CheckRepoExistsResponse)(nil), // 6: v1.CheckRepoExistsResponse + (*AddRepoRequest)(nil), // 7: v1.AddRepoRequest + (*DoRepoTaskRequest)(nil), // 8: v1.DoRepoTaskRequest + (*ClearHistoryRequest)(nil), // 9: v1.ClearHistoryRequest + (*ForgetRequest)(nil), // 10: v1.ForgetRequest + (*ListSnapshotsRequest)(nil), // 11: v1.ListSnapshotsRequest + (*GetOperationsRequest)(nil), // 12: v1.GetOperationsRequest + (*RestoreSnapshotRequest)(nil), // 13: v1.RestoreSnapshotRequest + (*ListSnapshotFilesRequest)(nil), // 14: v1.ListSnapshotFilesRequest + (*ListSnapshotFilesResponse)(nil), // 15: v1.ListSnapshotFilesResponse + (*LogDataRequest)(nil), // 16: v1.LogDataRequest + (*GetDownloadURLRequest)(nil), // 17: v1.GetDownloadURLRequest + (*LsEntry)(nil), // 18: v1.LsEntry + (*RunCommandRequest)(nil), // 19: v1.RunCommandRequest + (*SummaryDashboardResponse)(nil), // 20: v1.SummaryDashboardResponse + (*GeneratePairingTokenRequest)(nil), // 21: v1.GeneratePairingTokenRequest + (*GeneratePairingTokenResponse)(nil), // 22: v1.GeneratePairingTokenResponse + (*SummaryDashboardResponse_Summary)(nil), // 23: v1.SummaryDashboardResponse.Summary + (*SummaryDashboardResponse_BackupChart)(nil), // 24: v1.SummaryDashboardResponse.BackupChart + (*SummaryDashboardResponse_DayStatusBucket)(nil), // 25: v1.SummaryDashboardResponse.DayStatusBucket + (*SummaryDashboardResponse_StatusAndCount)(nil), // 26: v1.SummaryDashboardResponse.StatusAndCount + (*Repo)(nil), // 27: v1.Repo + (*Multihost_Permission)(nil), // 28: v1.Multihost.Permission + (OperationStatus)(0), // 29: v1.OperationStatus + (*emptypb.Empty)(nil), // 30: google.protobuf.Empty + (*Config)(nil), // 31: v1.Config + (*types.StringValue)(nil), // 32: types.StringValue + (*types.Int64Value)(nil), // 33: types.Int64Value + (*OperationEvent)(nil), // 34: v1.OperationEvent + (*OperationList)(nil), // 35: v1.OperationList + (*ResticSnapshotList)(nil), // 36: v1.ResticSnapshotList + (*types.BytesValue)(nil), // 37: types.BytesValue + (*types.StringList)(nil), // 38: types.StringList } var file_v1_service_proto_depIdxs = []int32{ - 25, // 0: v1.CheckRepoExistsRequest.repo:type_name -> v1.Repo - 25, // 1: v1.AddRepoRequest.repo:type_name -> v1.Repo + 27, // 0: v1.CheckRepoExistsRequest.repo:type_name -> v1.Repo + 27, // 1: v1.AddRepoRequest.repo:type_name -> v1.Repo 0, // 2: v1.DoRepoTaskRequest.task:type_name -> v1.DoRepoTaskRequest.Task 2, // 3: v1.ClearHistoryRequest.selector:type_name -> v1.OpSelector 2, // 4: v1.GetOperationsRequest.selector:type_name -> v1.OpSelector 18, // 5: v1.ListSnapshotFilesResponse.entries:type_name -> v1.LsEntry 23, // 6: v1.SummaryDashboardResponse.repo_summaries:type_name -> v1.SummaryDashboardResponse.Summary 23, // 7: v1.SummaryDashboardResponse.plan_summaries:type_name -> v1.SummaryDashboardResponse.Summary - 26, // 8: v1.GeneratePairingTokenRequest.permissions:type_name -> v1.Multihost.Permission + 28, // 8: v1.GeneratePairingTokenRequest.permissions:type_name -> v1.Multihost.Permission 24, // 9: v1.SummaryDashboardResponse.Summary.recent_backups:type_name -> v1.SummaryDashboardResponse.BackupChart - 27, // 10: v1.SummaryDashboardResponse.BackupChart.status:type_name -> v1.OperationStatus - 28, // 11: v1.Backrest.GetConfig:input_type -> google.protobuf.Empty - 29, // 12: v1.Backrest.SetConfig:input_type -> v1.Config - 3, // 13: v1.Backrest.SetupSftp:input_type -> v1.SetupSftpRequest - 5, // 14: v1.Backrest.CheckRepoExists:input_type -> v1.CheckRepoExistsRequest - 7, // 15: v1.Backrest.AddRepo:input_type -> v1.AddRepoRequest - 30, // 16: v1.Backrest.RemoveRepo:input_type -> types.StringValue - 28, // 17: v1.Backrest.GetOperationEvents:input_type -> google.protobuf.Empty - 12, // 18: v1.Backrest.GetOperations:input_type -> v1.GetOperationsRequest - 11, // 19: v1.Backrest.ListSnapshots:input_type -> v1.ListSnapshotsRequest - 14, // 20: v1.Backrest.ListSnapshotFiles:input_type -> v1.ListSnapshotFilesRequest - 1, // 21: v1.Backrest.Backup:input_type -> v1.BackupRequest - 8, // 22: v1.Backrest.DoRepoTask:input_type -> v1.DoRepoTaskRequest - 10, // 23: v1.Backrest.Forget:input_type -> v1.ForgetRequest - 13, // 24: v1.Backrest.Restore:input_type -> v1.RestoreSnapshotRequest - 31, // 25: v1.Backrest.Cancel:input_type -> types.Int64Value - 16, // 26: v1.Backrest.GetLogs:input_type -> v1.LogDataRequest - 19, // 27: v1.Backrest.RunCommand:input_type -> v1.RunCommandRequest - 17, // 28: v1.Backrest.GetDownloadURL:input_type -> v1.GetDownloadURLRequest - 9, // 29: v1.Backrest.ClearHistory:input_type -> v1.ClearHistoryRequest - 30, // 30: v1.Backrest.PathAutocomplete:input_type -> types.StringValue - 28, // 31: v1.Backrest.GetSummaryDashboard:input_type -> google.protobuf.Empty - 21, // 32: v1.Backrest.GeneratePairingToken:input_type -> v1.GeneratePairingTokenRequest - 29, // 33: v1.Backrest.GetConfig:output_type -> v1.Config - 29, // 34: v1.Backrest.SetConfig:output_type -> v1.Config - 4, // 35: v1.Backrest.SetupSftp:output_type -> v1.SetupSftpResponse - 6, // 36: v1.Backrest.CheckRepoExists:output_type -> v1.CheckRepoExistsResponse - 29, // 37: v1.Backrest.AddRepo:output_type -> v1.Config - 29, // 38: v1.Backrest.RemoveRepo:output_type -> v1.Config - 32, // 39: v1.Backrest.GetOperationEvents:output_type -> v1.OperationEvent - 33, // 40: v1.Backrest.GetOperations:output_type -> v1.OperationList - 34, // 41: v1.Backrest.ListSnapshots:output_type -> v1.ResticSnapshotList - 15, // 42: v1.Backrest.ListSnapshotFiles:output_type -> v1.ListSnapshotFilesResponse - 28, // 43: v1.Backrest.Backup:output_type -> google.protobuf.Empty - 28, // 44: v1.Backrest.DoRepoTask:output_type -> google.protobuf.Empty - 28, // 45: v1.Backrest.Forget:output_type -> google.protobuf.Empty - 28, // 46: v1.Backrest.Restore:output_type -> google.protobuf.Empty - 28, // 47: v1.Backrest.Cancel:output_type -> google.protobuf.Empty - 35, // 48: v1.Backrest.GetLogs:output_type -> types.BytesValue - 31, // 49: v1.Backrest.RunCommand:output_type -> types.Int64Value - 30, // 50: v1.Backrest.GetDownloadURL:output_type -> types.StringValue - 28, // 51: v1.Backrest.ClearHistory:output_type -> google.protobuf.Empty - 36, // 52: v1.Backrest.PathAutocomplete:output_type -> types.StringList - 20, // 53: v1.Backrest.GetSummaryDashboard:output_type -> v1.SummaryDashboardResponse - 22, // 54: v1.Backrest.GeneratePairingToken:output_type -> v1.GeneratePairingTokenResponse - 33, // [33:55] is the sub-list for method output_type - 11, // [11:33] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 25, // 10: v1.SummaryDashboardResponse.Summary.history_last_30days:type_name -> v1.SummaryDashboardResponse.DayStatusBucket + 29, // 11: v1.SummaryDashboardResponse.BackupChart.status:type_name -> v1.OperationStatus + 26, // 12: v1.SummaryDashboardResponse.DayStatusBucket.status_counts:type_name -> v1.SummaryDashboardResponse.StatusAndCount + 29, // 13: v1.SummaryDashboardResponse.StatusAndCount.status:type_name -> v1.OperationStatus + 30, // 14: v1.Backrest.GetConfig:input_type -> google.protobuf.Empty + 31, // 15: v1.Backrest.SetConfig:input_type -> v1.Config + 3, // 16: v1.Backrest.SetupSftp:input_type -> v1.SetupSftpRequest + 5, // 17: v1.Backrest.CheckRepoExists:input_type -> v1.CheckRepoExistsRequest + 7, // 18: v1.Backrest.AddRepo:input_type -> v1.AddRepoRequest + 32, // 19: v1.Backrest.RemoveRepo:input_type -> types.StringValue + 30, // 20: v1.Backrest.GetOperationEvents:input_type -> google.protobuf.Empty + 12, // 21: v1.Backrest.GetOperations:input_type -> v1.GetOperationsRequest + 11, // 22: v1.Backrest.ListSnapshots:input_type -> v1.ListSnapshotsRequest + 14, // 23: v1.Backrest.ListSnapshotFiles:input_type -> v1.ListSnapshotFilesRequest + 1, // 24: v1.Backrest.Backup:input_type -> v1.BackupRequest + 8, // 25: v1.Backrest.DoRepoTask:input_type -> v1.DoRepoTaskRequest + 10, // 26: v1.Backrest.Forget:input_type -> v1.ForgetRequest + 13, // 27: v1.Backrest.Restore:input_type -> v1.RestoreSnapshotRequest + 33, // 28: v1.Backrest.Cancel:input_type -> types.Int64Value + 16, // 29: v1.Backrest.GetLogs:input_type -> v1.LogDataRequest + 19, // 30: v1.Backrest.RunCommand:input_type -> v1.RunCommandRequest + 17, // 31: v1.Backrest.GetDownloadURL:input_type -> v1.GetDownloadURLRequest + 9, // 32: v1.Backrest.ClearHistory:input_type -> v1.ClearHistoryRequest + 32, // 33: v1.Backrest.PathAutocomplete:input_type -> types.StringValue + 30, // 34: v1.Backrest.GetSummaryDashboard:input_type -> google.protobuf.Empty + 21, // 35: v1.Backrest.GeneratePairingToken:input_type -> v1.GeneratePairingTokenRequest + 31, // 36: v1.Backrest.GetConfig:output_type -> v1.Config + 31, // 37: v1.Backrest.SetConfig:output_type -> v1.Config + 4, // 38: v1.Backrest.SetupSftp:output_type -> v1.SetupSftpResponse + 6, // 39: v1.Backrest.CheckRepoExists:output_type -> v1.CheckRepoExistsResponse + 31, // 40: v1.Backrest.AddRepo:output_type -> v1.Config + 31, // 41: v1.Backrest.RemoveRepo:output_type -> v1.Config + 34, // 42: v1.Backrest.GetOperationEvents:output_type -> v1.OperationEvent + 35, // 43: v1.Backrest.GetOperations:output_type -> v1.OperationList + 36, // 44: v1.Backrest.ListSnapshots:output_type -> v1.ResticSnapshotList + 15, // 45: v1.Backrest.ListSnapshotFiles:output_type -> v1.ListSnapshotFilesResponse + 30, // 46: v1.Backrest.Backup:output_type -> google.protobuf.Empty + 30, // 47: v1.Backrest.DoRepoTask:output_type -> google.protobuf.Empty + 30, // 48: v1.Backrest.Forget:output_type -> google.protobuf.Empty + 30, // 49: v1.Backrest.Restore:output_type -> google.protobuf.Empty + 30, // 50: v1.Backrest.Cancel:output_type -> google.protobuf.Empty + 37, // 51: v1.Backrest.GetLogs:output_type -> types.BytesValue + 33, // 52: v1.Backrest.RunCommand:output_type -> types.Int64Value + 32, // 53: v1.Backrest.GetDownloadURL:output_type -> types.StringValue + 30, // 54: v1.Backrest.ClearHistory:output_type -> google.protobuf.Empty + 38, // 55: v1.Backrest.PathAutocomplete:output_type -> types.StringList + 20, // 56: v1.Backrest.GetSummaryDashboard:output_type -> v1.SummaryDashboardResponse + 22, // 57: v1.Backrest.GeneratePairingToken:output_type -> v1.GeneratePairingTokenResponse + 36, // [36:58] is the sub-list for method output_type + 14, // [14:36] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_v1_service_proto_init() } @@ -1939,7 +2091,7 @@ func file_v1_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_service_proto_rawDesc), len(file_v1_service_proto_rawDesc)), NumEnums: 1, - NumMessages: 24, + NumMessages: 26, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/api/backresthandler.go b/internal/api/backresthandler.go index 79ec6ed9..7e66e804 100644 --- a/internal/api/backresthandler.go +++ b/internal/api/backresthandler.go @@ -836,6 +836,18 @@ func (s *BackrestHandler) GetSummaryDashboard(ctx context.Context, req *connect. return nil, fmt.Errorf("failed to get config: %w", err) } + // Local midnight today; day buckets are aligned to calendar days in the server's timezone. + now := time.Now() + todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + // Oldest day the 30-day window can show (today plus the prior 29 days). + cutoffMidnight := todayMidnight.AddDate(0, 0, -29) + + type dayAcc struct { + bytesAdded int64 + bytesScanned int64 + statusCounts map[v1.OperationStatus]int64 + } + generateSummaryHelper := func(id string, q oplog.Query) (*v1.SummaryDashboardResponse_Summary, error) { var backupsExamined int64 var bytesScanned30 int64 @@ -844,46 +856,86 @@ func (s *BackrestHandler) GetSummaryDashboard(ctx context.Context, req *connect. var backupsSuccess30 int64 var backupsWarning30 int64 var nextBackupTime int64 + var protectedBytes int64 backupChart := &v1.SummaryDashboardResponse_BackupChart{} + // Per-day accumulators keyed by the day's local-midnight unix millis. oldestDay tracks the + // earliest day with a backup; reachedCutoff means there are backups older than the 30-day + // window, so missed days within it render as "missed" rather than "before start". + perDay := make(map[int64]*dayAcc) + var oldestDay time.Time + reachedCutoff := false + + // Walk backups from newest to oldest (the query is reversed), accumulating stats as we go. s.oplog.Query(q, func(op *v1.Operation) error { + backupOp := op.GetOperationBackup() + if backupOp == nil { + return nil + } + t := time.UnixMilli(op.UnixTimeStartMs) + opMidnight := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) - if backupOp := op.GetOperationBackup(); backupOp != nil { - if time.Since(t) > 30*24*time.Hour { - return oplog.ErrStopIteration - } else if op.GetStatus() == v1.OperationStatus_STATUS_PENDING { - nextBackupTime = op.UnixTimeStartMs - return nil - } - backupsExamined++ + // Backups older than the window terminate the scan (everything after is older still). + if opMidnight.Before(cutoffMidnight) { + reachedCutoff = true + return oplog.ErrStopIteration + } + if op.GetStatus() == v1.OperationStatus_STATUS_PENDING { + nextBackupTime = op.UnixTimeStartMs + return nil + } + backupsExamined++ - if op.Status == v1.OperationStatus_STATUS_SUCCESS { - backupsSuccess30++ - } else if op.Status == v1.OperationStatus_STATUS_ERROR { - backupsFailed30++ - } else if op.Status == v1.OperationStatus_STATUS_WARNING { - backupsWarning30++ + switch op.Status { + case v1.OperationStatus_STATUS_SUCCESS: + backupsSuccess30++ + case v1.OperationStatus_STATUS_ERROR: + backupsFailed30++ + case v1.OperationStatus_STATUS_WARNING: + backupsWarning30++ + } + + summary := backupOp.GetLastStatus().GetSummary() + if summary != nil { + bytesScanned30 += summary.TotalBytesProcessed + bytesAdded30 += summary.DataAdded + } + + // protected_bytes: the most recent (first seen) good backup's total size. + if protectedBytes == 0 && summary != nil && + (op.Status == v1.OperationStatus_STATUS_SUCCESS || op.Status == v1.OperationStatus_STATUS_WARNING) { + protectedBytes = summary.TotalBytesProcessed + } + + // Update the per-day aggregate for this backup's day. + dayMs := opMidnight.UnixMilli() + acc := perDay[dayMs] + if acc == nil { + acc = &dayAcc{statusCounts: make(map[v1.OperationStatus]int64)} + perDay[dayMs] = acc + } + acc.statusCounts[op.Status]++ + if summary != nil { + acc.bytesAdded += summary.DataAdded + acc.bytesScanned += summary.TotalBytesProcessed + } + if oldestDay.IsZero() || opMidnight.Before(oldestDay) { + oldestDay = opMidnight + } + + // recent backups chart: only include the latest 60 backups. + if len(backupChart.TimestampMs) < 60 { + duration := op.UnixTimeEndMs - op.UnixTimeStartMs + if duration <= 1000 { + duration = 1000 } - if summary := backupOp.GetLastStatus().GetSummary(); summary != nil { - bytesScanned30 += summary.TotalBytesProcessed - bytesAdded30 += summary.DataAdded - } - - // recent backups chart - if len(backupChart.TimestampMs) < 60 { // only include the latest 90 backups in the chart - duration := op.UnixTimeEndMs - op.UnixTimeStartMs - if duration <= 1000 { - duration = 1000 - } - - backupChart.FlowId = append(backupChart.FlowId, op.FlowId) - backupChart.TimestampMs = append(backupChart.TimestampMs, op.UnixTimeStartMs) - backupChart.DurationMs = append(backupChart.DurationMs, duration) - backupChart.Status = append(backupChart.Status, op.Status) - backupChart.BytesAdded = append(backupChart.BytesAdded, backupOp.GetLastStatus().GetSummary().GetDataAdded()) - } + backupChart.FlowId = append(backupChart.FlowId, op.FlowId) + backupChart.TimestampMs = append(backupChart.TimestampMs, op.UnixTimeStartMs) + backupChart.DurationMs = append(backupChart.DurationMs, duration) + backupChart.Status = append(backupChart.Status, op.Status) + backupChart.BytesAdded = append(backupChart.BytesAdded, summary.GetDataAdded()) } return nil @@ -893,6 +945,34 @@ func (s *BackrestHandler) GetSummaryDashboard(ctx context.Context, req *connect. backupsExamined = 1 // prevent division by zero for avg calculations } + // Flatten the per-day map into buckets ordered oldest-first. We emit one bucket per + // consecutive day from the oldest active day (or the window start, if older backups exist) + // through today; the client maps these positionally, so absent days are filled with empty + // "missed" buckets. Days before this span are omitted and rendered as "before start". + start := todayMidnight + if reachedCutoff { + start = cutoffMidnight + } else if !oldestDay.IsZero() { + start = oldestDay + } + var history []*v1.SummaryDashboardResponse_DayStatusBucket + for day := start; !day.After(todayMidnight); day = day.AddDate(0, 0, 1) { + bucket := &v1.SummaryDashboardResponse_DayStatusBucket{ + TimestampMs: day.UnixMilli(), + } + if acc := perDay[day.UnixMilli()]; acc != nil { + bucket.BytesAdded = acc.bytesAdded + bucket.BytesScanned = acc.bytesScanned + for status, count := range acc.statusCounts { + bucket.StatusCounts = append(bucket.StatusCounts, &v1.SummaryDashboardResponse_StatusAndCount{ + Status: status, + Count: count, + }) + } + } + history = append(history, bucket) + } + return &v1.SummaryDashboardResponse_Summary{ Id: id, BytesScannedLast_30Days: bytesScanned30, @@ -904,6 +984,8 @@ func (s *BackrestHandler) GetSummaryDashboard(ctx context.Context, req *connect. BytesAddedAvg: bytesAdded30 / backupsExamined, NextBackupTimeMs: nextBackupTime, RecentBackups: backupChart, + ProtectedBytes: protectedBytes, + HistoryLast_30Days: history, }, nil } diff --git a/internal/api/backresthandler_test.go b/internal/api/backresthandler_test.go index 78e42cae..015aaaeb 100644 --- a/internal/api/backresthandler_test.go +++ b/internal/api/backresthandler_test.go @@ -31,6 +31,7 @@ import ( "github.com/hashicorp/go-multierror" "golang.org/x/sync/errgroup" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/emptypb" ) func createConfigManager(cfg *v1.Config) *config.ConfigManager { @@ -248,6 +249,295 @@ func TestBackup(t *testing.T) { } } +func TestGetSummaryDashboard(t *testing.T) { + t.Parallel() + + backupDataDir := t.TempDir() + if err := os.WriteFile(filepath.Join(backupDataDir, "data.txt"), []byte("test data"), 0644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + sut := createSystemUnderTest(t, createConfigManager(&v1.Config{ + Version: 4, + Modno: 1234, + Instance: "test", + Repos: []*v1.Repo{ + { + Id: "local", + Guid: cryptoutil.MustRandomID(cryptoutil.DefaultIDBits), + Uri: t.TempDir(), + Password: "test", + Flags: []string{"--no-cache"}, + }, + }, + Plans: []*v1.Plan{ + { + Id: "test", + Repo: "local", + Paths: []string{ + backupDataDir, + }, + Schedule: &v1.Schedule{ + Schedule: &v1.Schedule_Disabled{Disabled: true}, + }, + }, + }, + })) + + ctx, cancel := testutil.WithDeadlineFromTest(t, context.Background()) + defer cancel() + go func() { + sut.orch.Run(ctx) + }() + + if _, err := sut.handler.Backup(ctx, connect.NewRequest(&v1.BackupRequest{Value: "test"})); err != nil { + t.Fatalf("Backup() error = %v", err) + } + + // Wait for the backup to complete. + if err := testutil.Retry(t, ctx, func() error { + if slices.IndexFunc(getOperations(t, sut.oplog), func(op *v1.Operation) bool { + _, ok := op.GetOp().(*v1.Operation_OperationBackup) + return op.Status == v1.OperationStatus_STATUS_SUCCESS && ok + }) == -1 { + return errors.New("expected a successful backup operation") + } + return nil + }); err != nil { + t.Fatalf("Couldn't find backup operation in oplog") + } + + resp, err := sut.handler.GetSummaryDashboard(ctx, connect.NewRequest(&emptypb.Empty{})) + if err != nil { + t.Fatalf("GetSummaryDashboard() error = %v", err) + } + + if len(resp.Msg.PlanSummaries) != 1 { + t.Fatalf("expected 1 plan summary, got %d", len(resp.Msg.PlanSummaries)) + } + summary := resp.Msg.PlanSummaries[0] + if summary.Id != "test" { + t.Errorf("expected plan summary id %q, got %q", "test", summary.Id) + } + if summary.ProtectedBytes <= 0 { + t.Errorf("expected protected_bytes > 0, got %d", summary.ProtectedBytes) + } + + // The history strip should end on today's bucket carrying a successful backup. + if len(summary.HistoryLast_30Days) == 0 { + t.Fatalf("expected a non-empty history strip") + } + today := summary.HistoryLast_30Days[len(summary.HistoryLast_30Days)-1] + var successCount int64 + for _, sc := range today.StatusCounts { + if sc.Status == v1.OperationStatus_STATUS_SUCCESS { + successCount = sc.Count + } + } + if successCount == 0 { + t.Errorf("expected today's bucket to record a successful backup, got %+v", today.StatusCounts) + } +} + +// TestGetSummaryDashboardHistory exercises the day-bucketing of GetSummaryDashboard by inserting +// synthetic backup operations at controlled timestamps directly into the oplog. +func TestGetSummaryDashboardHistory(t *testing.T) { + t.Parallel() + + repoGUID := cryptoutil.MustRandomID(cryptoutil.DefaultIDBits) + sut := createSystemUnderTest(t, createConfigManager(&v1.Config{ + Version: 4, + Modno: 1234, + Instance: "test", + Repos: []*v1.Repo{ + {Id: "local", Guid: repoGUID, Uri: t.TempDir(), Password: "test", Flags: []string{"--no-cache"}}, + }, + Plans: []*v1.Plan{ + {Id: "test", Repo: "local", Schedule: &v1.Schedule{Schedule: &v1.Schedule_Disabled{Disabled: true}}}, + }, + })) + + now := time.Now() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + // A backup landing at midday of the day `daysAgo` before today. + dayMs := func(daysAgo int) int64 { return midnight.AddDate(0, 0, -daysAgo).Add(12 * time.Hour).UnixMilli() } + bucketMs := func(daysAgo int) int64 { return midnight.AddDate(0, 0, -daysAgo).UnixMilli() } + + var flowID int64 + addBackup := func(daysAgo int, status v1.OperationStatus, summary *v1.BackupProgressSummary) { + flowID++ + start := dayMs(daysAgo) + op := &v1.Operation{ + InstanceId: "test", + RepoGuid: repoGUID, + PlanId: "test", + FlowId: flowID, + Status: status, + UnixTimeStartMs: start, + UnixTimeEndMs: start + 60*1000, + } + backup := &v1.OperationBackup{} + if summary != nil { + backup.LastStatus = &v1.BackupProgressEntry{Entry: &v1.BackupProgressEntry_Summary{Summary: summary}} + } + op.Op = &v1.Operation_OperationBackup{OperationBackup: backup} + if err := sut.oplog.Add(op); err != nil { + t.Fatalf("failed to add operation: %v", err) + } + } + + // Window: today success, two backups two days ago (warning + success), an error five days ago + // with no summary, and one backup beyond the 30-day window (which forces a full 30-day strip). + addBackup(0, v1.OperationStatus_STATUS_SUCCESS, &v1.BackupProgressSummary{DataAdded: 100, TotalBytesProcessed: 1000}) + addBackup(2, v1.OperationStatus_STATUS_WARNING, &v1.BackupProgressSummary{DataAdded: 50, TotalBytesProcessed: 500}) + addBackup(2, v1.OperationStatus_STATUS_SUCCESS, &v1.BackupProgressSummary{DataAdded: 10, TotalBytesProcessed: 200}) + addBackup(5, v1.OperationStatus_STATUS_ERROR, nil) + addBackup(40, v1.OperationStatus_STATUS_SUCCESS, &v1.BackupProgressSummary{DataAdded: 7, TotalBytesProcessed: 70}) + + resp, err := sut.handler.GetSummaryDashboard(context.Background(), connect.NewRequest(&emptypb.Empty{})) + if err != nil { + t.Fatalf("GetSummaryDashboard() error = %v", err) + } + if len(resp.Msg.PlanSummaries) != 1 { + t.Fatalf("expected 1 plan summary, got %d", len(resp.Msg.PlanSummaries)) + } + summary := resp.Msg.PlanSummaries[0] + + // 30-day stats exclude the out-of-window backup. + if summary.BackupsSuccessLast_30Days != 2 { + t.Errorf("expected 2 successful backups, got %d", summary.BackupsSuccessLast_30Days) + } + if summary.BackupsWarningLast_30Days != 1 { + t.Errorf("expected 1 warning backup, got %d", summary.BackupsWarningLast_30Days) + } + if summary.BackupsFailed_30Days != 1 { + t.Errorf("expected 1 failed backup, got %d", summary.BackupsFailed_30Days) + } + if summary.BytesAddedLast_30Days != 160 { + t.Errorf("expected 160 bytes added, got %d", summary.BytesAddedLast_30Days) + } + if summary.BytesScannedLast_30Days != 1700 { + t.Errorf("expected 1700 bytes scanned, got %d", summary.BytesScannedLast_30Days) + } + // protected_bytes is the most recent good backup's total size (today's success). + if summary.ProtectedBytes != 1000 { + t.Errorf("expected protected_bytes 1000, got %d", summary.ProtectedBytes) + } + + // Because a backup predates the window, the strip spans the full 30 days, oldest first. + if len(summary.HistoryLast_30Days) != 30 { + t.Fatalf("expected 30 history buckets, got %d", len(summary.HistoryLast_30Days)) + } + bucketFor := func(daysAgo int) *v1.SummaryDashboardResponse_DayStatusBucket { + want := bucketMs(daysAgo) + for _, b := range summary.HistoryLast_30Days { + if b.TimestampMs == want { + return b + } + } + t.Fatalf("no bucket for %d days ago (ts %d)", daysAgo, want) + return nil + } + statusCount := func(b *v1.SummaryDashboardResponse_DayStatusBucket, status v1.OperationStatus) int64 { + for _, sc := range b.StatusCounts { + if sc.Status == status { + return sc.Count + } + } + return 0 + } + + // Buckets are emitted oldest-first ending on today. + if got := summary.HistoryLast_30Days[29].TimestampMs; got != bucketMs(0) { + t.Errorf("expected last bucket to be today (%d), got %d", bucketMs(0), got) + } + today := bucketFor(0) + if c := statusCount(today, v1.OperationStatus_STATUS_SUCCESS); c != 1 { + t.Errorf("expected today bucket success count 1, got %d", c) + } + // Two backups share the day-2 bucket, and their bytes accumulate. + twoAgo := bucketFor(2) + if c := statusCount(twoAgo, v1.OperationStatus_STATUS_SUCCESS); c != 1 { + t.Errorf("expected day-2 success count 1, got %d", c) + } + if c := statusCount(twoAgo, v1.OperationStatus_STATUS_WARNING); c != 1 { + t.Errorf("expected day-2 warning count 1, got %d", c) + } + if twoAgo.BytesAdded != 60 || twoAgo.BytesScanned != 700 { + t.Errorf("expected day-2 bytes (60,700), got (%d,%d)", twoAgo.BytesAdded, twoAgo.BytesScanned) + } + // The error five days ago is recorded even though it carried no summary. + fiveAgo := bucketFor(5) + if c := statusCount(fiveAgo, v1.OperationStatus_STATUS_ERROR); c != 1 { + t.Errorf("expected day-5 error count 1, got %d", c) + } + // A day with no backup (one day ago) is a gap-filled empty bucket rendered as "missed". + if oneAgo := bucketFor(1); len(oneAgo.StatusCounts) != 0 { + t.Errorf("expected day-1 bucket to be empty, got %+v", oneAgo.StatusCounts) + } +} + +// TestGetSummaryDashboardHistoryNoCutoff verifies the strip starts at the oldest backup day (rather +// than spanning a full 30 days) when no backups predate the window. +func TestGetSummaryDashboardHistoryNoCutoff(t *testing.T) { + t.Parallel() + + repoGUID := cryptoutil.MustRandomID(cryptoutil.DefaultIDBits) + sut := createSystemUnderTest(t, createConfigManager(&v1.Config{ + Version: 4, + Modno: 1234, + Instance: "test", + Repos: []*v1.Repo{ + {Id: "local", Guid: repoGUID, Uri: t.TempDir(), Password: "test", Flags: []string{"--no-cache"}}, + }, + Plans: []*v1.Plan{ + {Id: "test", Repo: "local", Schedule: &v1.Schedule{Schedule: &v1.Schedule_Disabled{Disabled: true}}}, + }, + })) + + now := time.Now() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + addBackup := func(daysAgo int) { + start := midnight.AddDate(0, 0, -daysAgo).Add(12 * time.Hour).UnixMilli() + op := &v1.Operation{ + InstanceId: "test", + RepoGuid: repoGUID, + PlanId: "test", + FlowId: int64(daysAgo + 1), + Status: v1.OperationStatus_STATUS_SUCCESS, + UnixTimeStartMs: start, + UnixTimeEndMs: start + 60*1000, + Op: &v1.Operation_OperationBackup{OperationBackup: &v1.OperationBackup{ + LastStatus: &v1.BackupProgressEntry{Entry: &v1.BackupProgressEntry_Summary{ + Summary: &v1.BackupProgressSummary{DataAdded: 1, TotalBytesProcessed: 2}, + }}, + }}, + } + if err := sut.oplog.Add(op); err != nil { + t.Fatalf("failed to add operation: %v", err) + } + } + + // Oldest backup is three days ago, so the strip should be exactly four buckets (days 3..0). + addBackup(0) + addBackup(3) + + resp, err := sut.handler.GetSummaryDashboard(context.Background(), connect.NewRequest(&emptypb.Empty{})) + if err != nil { + t.Fatalf("GetSummaryDashboard() error = %v", err) + } + summary := resp.Msg.PlanSummaries[0] + if len(summary.HistoryLast_30Days) != 4 { + t.Fatalf("expected 4 history buckets, got %d", len(summary.HistoryLast_30Days)) + } + if got, want := summary.HistoryLast_30Days[0].TimestampMs, midnight.AddDate(0, 0, -3).UnixMilli(); got != want { + t.Errorf("expected first bucket %d (3 days ago), got %d", want, got) + } + if got, want := summary.HistoryLast_30Days[3].TimestampMs, midnight.UnixMilli(); got != want { + t.Errorf("expected last bucket %d (today), got %d", want, got) + } +} + func TestMultipleBackup(t *testing.T) { t.Parallel() diff --git a/proto/v1/service.proto b/proto/v1/service.proto index a6aac9d1..3d0bac48 100644 --- a/proto/v1/service.proto +++ b/proto/v1/service.proto @@ -217,6 +217,9 @@ message SummaryDashboardResponse { // Charts BackupChart recent_backups = 11; // recent backups + + int64 protected_bytes = 12; // total bytes processed by the most recent successful/warning backup + repeated DayStatusBucket history_last_30days = 13; // per-day backup status for the dashboard history strip, oldest first; absent days precede the plan's first backup } message BackupChart { @@ -226,6 +229,18 @@ message SummaryDashboardResponse { repeated OperationStatus status = 4; repeated int64 bytes_added = 5; } + + message DayStatusBucket { + int64 timestamp_ms = 1; // start-of-day timestamp this bucket represents + int64 bytes_added = 2; // bytes added by backups that day + int64 bytes_scanned = 3; // bytes scanned by backups that day + repeated StatusAndCount status_counts = 4; // backup count per status for the day + } + + message StatusAndCount { + int64 count = 1; + OperationStatus status = 2; + } } message GeneratePairingTokenRequest { diff --git a/webui/gen/ts/v1/service_pb.ts b/webui/gen/ts/v1/service_pb.ts index 96a236ca..f47a5b6f 100644 --- a/webui/gen/ts/v1/service_pb.ts +++ b/webui/gen/ts/v1/service_pb.ts @@ -21,7 +21,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file v1/service.proto. */ export const file_v1_service: GenFile = /*@__PURE__*/ - fileDesc("ChB2MS9zZXJ2aWNlLnByb3RvEgJ2MSIvCg1CYWNrdXBSZXF1ZXN0Eg0KBXZhbHVlGAEgASgJEg8KB2RyeV9ydW4YAiABKAgivwIKCk9wU2VsZWN0b3ISCwoDaWRzGAEgAygDEhgKC2luc3RhbmNlX2lkGAYgASgJSACIAQESJAoXb3JpZ2luYWxfaW5zdGFuY2Vfa2V5aWQYCCABKAlIAYgBARIWCglyZXBvX2d1aWQYByABKAlIAogBARIUCgdwbGFuX2lkGAMgASgJSAOIAQESGAoLc25hcHNob3RfaWQYBCABKAlIBIgBARIUCgdmbG93X2lkGAUgASgDSAWIAQESFgoJbW9kbm9fZ3RlGAkgASgDSAaIAQFCDgoMX2luc3RhbmNlX2lkQhoKGF9vcmlnaW5hbF9pbnN0YW5jZV9rZXlpZEIMCgpfcmVwb19ndWlkQgoKCF9wbGFuX2lkQg4KDF9zbmFwc2hvdF9pZEIKCghfZmxvd19pZEIMCgpfbW9kbm9fZ3RlImQKEFNldHVwU2Z0cFJlcXVlc3QSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgJEhAKCHVzZXJuYW1lGAMgASgJEhUKCHBhc3N3b3JkGAQgASgJSACIAQFCCwoJX3Bhc3N3b3JkImIKEVNldHVwU2Z0cFJlc3BvbnNlEhIKCnB1YmxpY19rZXkYASABKAkSEAoIa2V5X3BhdGgYAiABKAkSGAoQa25vd25faG9zdHNfcGF0aBgDIAEoCRINCgVlcnJvchgEIAEoCSIwChZDaGVja1JlcG9FeGlzdHNSZXF1ZXN0EhYKBHJlcG8YASABKAsyCC52MS5SZXBvIlQKF0NoZWNrUmVwb0V4aXN0c1Jlc3BvbnNlEg4KBmV4aXN0cxgBIAEoCBINCgVlcnJvchgCIAEoCRIaChJob3N0X2tleV91bnRydXN0ZWQYBSABKAgiKAoOQWRkUmVwb1JlcXVlc3QSFgoEcmVwbxgBIAEoCzIILnYxLlJlcG8i0gEKEURvUmVwb1Rhc2tSZXF1ZXN0Eg8KB3JlcG9faWQYASABKAkSKAoEdGFzaxgCIAEoDjIaLnYxLkRvUmVwb1Rhc2tSZXF1ZXN0LlRhc2sigQEKBFRhc2sSDQoJVEFTS19OT05FEAASGAoUVEFTS19JTkRFWF9TTkFQU0hPVFMQARIOCgpUQVNLX1BSVU5FEAISDgoKVEFTS19DSEVDSxADEg4KClRBU0tfU1RBVFMQBBIPCgtUQVNLX1VOTE9DSxAFEg8KC1RBU0tfRk9SR0VUEAYiTAoTQ2xlYXJIaXN0b3J5UmVxdWVzdBIgCghzZWxlY3RvchgBIAEoCzIOLnYxLk9wU2VsZWN0b3ISEwoLb25seV9mYWlsZWQYAiABKAgiRgoNRm9yZ2V0UmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB3BsYW5faWQYAiABKAkSEwoLc25hcHNob3RfaWQYAyABKAkiOAoUTGlzdFNuYXBzaG90c1JlcXVlc3QSDwoHcmVwb19pZBgBIAEoCRIPCgdwbGFuX2lkGAIgASgJIkgKFEdldE9wZXJhdGlvbnNSZXF1ZXN0EiAKCHNlbGVjdG9yGAEgASgLMg4udjEuT3BTZWxlY3RvchIOCgZsYXN0X24YAiABKAMibQoWUmVzdG9yZVNuYXBzaG90UmVxdWVzdBIPCgdwbGFuX2lkGAEgASgJEg8KB3JlcG9faWQYBSABKAkSEwoLc25hcHNob3RfaWQYAiABKAkSDAoEcGF0aBgDIAEoCRIOCgZ0YXJnZXQYBCABKAkiUAoYTGlzdFNuYXBzaG90RmlsZXNSZXF1ZXN0EhEKCXJlcG9fZ3VpZBgBIAEoCRITCgtzbmFwc2hvdF9pZBgCIAEoCRIMCgRwYXRoGAMgASgJIkcKGUxpc3RTbmFwc2hvdEZpbGVzUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIcCgdlbnRyaWVzGAIgAygLMgsudjEuTHNFbnRyeSIdCg5Mb2dEYXRhUmVxdWVzdBILCgNyZWYYASABKAkiOQoVR2V0RG93bmxvYWRVUkxSZXF1ZXN0Eg0KBW9wX2lkGAEgASgDEhEKCWZpbGVfcGF0aBgCIAEoCSKWAQoHTHNFbnRyeRIMCgRuYW1lGAEgASgJEgwKBHR5cGUYAiABKAkSDAoEcGF0aBgDIAEoCRILCgN1aWQYBCABKAMSCwoDZ2lkGAUgASgDEgwKBHNpemUYBiABKAMSDAoEbW9kZRgHIAEoAxINCgVtdGltZRgIIAEoCRINCgVhdGltZRgJIAEoCRINCgVjdGltZRgKIAEoCSI1ChFSdW5Db21tYW5kUmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB2NvbW1hbmQYAiABKAkitQUKGFN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZRI8Cg5yZXBvX3N1bW1hcmllcxgBIAMoCzIkLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZS5TdW1tYXJ5EjwKDnBsYW5fc3VtbWFyaWVzGAIgAygLMiQudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLlN1bW1hcnkSEwoLY29uZmlnX3BhdGgYCiABKAkSEQoJZGF0YV9wYXRoGAsgASgJGu4CCgdTdW1tYXJ5EgoKAmlkGAEgASgJEh0KFWJhY2t1cHNfZmFpbGVkXzMwZGF5cxgCIAEoAxIjChtiYWNrdXBzX3dhcm5pbmdfbGFzdF8zMGRheXMYAyABKAMSIwobYmFja3Vwc19zdWNjZXNzX2xhc3RfMzBkYXlzGAQgASgDEiEKGWJ5dGVzX3NjYW5uZWRfbGFzdF8zMGRheXMYBSABKAMSHwoXYnl0ZXNfYWRkZWRfbGFzdF8zMGRheXMYBiABKAMSFwoPdG90YWxfc25hcHNob3RzGAcgASgDEhkKEWJ5dGVzX3NjYW5uZWRfYXZnGAggASgDEhcKD2J5dGVzX2FkZGVkX2F2ZxgJIAEoAxIbChNuZXh0X2JhY2t1cF90aW1lX21zGAogASgDEkAKDnJlY2VudF9iYWNrdXBzGAsgASgLMigudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLkJhY2t1cENoYXJ0GoMBCgtCYWNrdXBDaGFydBIPCgdmbG93X2lkGAEgAygDEhQKDHRpbWVzdGFtcF9tcxgCIAMoAxITCgtkdXJhdGlvbl9tcxgDIAMoAxIjCgZzdGF0dXMYBCADKA4yEy52MS5PcGVyYXRpb25TdGF0dXMSEwoLYnl0ZXNfYWRkZWQYBSADKAMiggEKG0dlbmVyYXRlUGFpcmluZ1Rva2VuUmVxdWVzdBINCgVsYWJlbBgBIAEoCRITCgt0dGxfc2Vjb25kcxgCIAEoAxIQCghtYXhfdXNlcxgDIAEoBRItCgtwZXJtaXNzaW9ucxgEIAMoCzIYLnYxLk11bHRpaG9zdC5QZXJtaXNzaW9uIi0KHEdlbmVyYXRlUGFpcmluZ1Rva2VuUmVzcG9uc2USDQoFdG9rZW4YASABKAky7goKCEJhY2tyZXN0EjEKCUdldENvbmZpZxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoKLnYxLkNvbmZpZyIAEiUKCVNldENvbmZpZxIKLnYxLkNvbmZpZxoKLnYxLkNvbmZpZyIAEjoKCVNldHVwU2Z0cBIULnYxLlNldHVwU2Z0cFJlcXVlc3QaFS52MS5TZXR1cFNmdHBSZXNwb25zZSIAEkwKD0NoZWNrUmVwb0V4aXN0cxIaLnYxLkNoZWNrUmVwb0V4aXN0c1JlcXVlc3QaGy52MS5DaGVja1JlcG9FeGlzdHNSZXNwb25zZSIAEisKB0FkZFJlcG8SEi52MS5BZGRSZXBvUmVxdWVzdBoKLnYxLkNvbmZpZyIAEi4KClJlbW92ZVJlcG8SEi50eXBlcy5TdHJpbmdWYWx1ZRoKLnYxLkNvbmZpZyIAEkQKEkdldE9wZXJhdGlvbkV2ZW50cxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoSLnYxLk9wZXJhdGlvbkV2ZW50IgAwARI+Cg1HZXRPcGVyYXRpb25zEhgudjEuR2V0T3BlcmF0aW9uc1JlcXVlc3QaES52MS5PcGVyYXRpb25MaXN0IgASQwoNTGlzdFNuYXBzaG90cxIYLnYxLkxpc3RTbmFwc2hvdHNSZXF1ZXN0GhYudjEuUmVzdGljU25hcHNob3RMaXN0IgASUgoRTGlzdFNuYXBzaG90RmlsZXMSHC52MS5MaXN0U25hcHNob3RGaWxlc1JlcXVlc3QaHS52MS5MaXN0U25hcHNob3RGaWxlc1Jlc3BvbnNlIgASNQoGQmFja3VwEhEudjEuQmFja3VwUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEj0KCkRvUmVwb1Rhc2sSFS52MS5Eb1JlcG9UYXNrUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEjUKBkZvcmdldBIRLnYxLkZvcmdldFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI/CgdSZXN0b3JlEhoudjEuUmVzdG9yZVNuYXBzaG90UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEjUKBkNhbmNlbBIRLnR5cGVzLkludDY0VmFsdWUaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI0CgdHZXRMb2dzEhIudjEuTG9nRGF0YVJlcXVlc3QaES50eXBlcy5CeXRlc1ZhbHVlIgAwARI4CgpSdW5Db21tYW5kEhUudjEuUnVuQ29tbWFuZFJlcXVlc3QaES50eXBlcy5JbnQ2NFZhbHVlIgASQQoOR2V0RG93bmxvYWRVUkwSGS52MS5HZXREb3dubG9hZFVSTFJlcXVlc3QaEi50eXBlcy5TdHJpbmdWYWx1ZSIAEkEKDENsZWFySGlzdG9yeRIXLnYxLkNsZWFySGlzdG9yeVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI7ChBQYXRoQXV0b2NvbXBsZXRlEhIudHlwZXMuU3RyaW5nVmFsdWUaES50eXBlcy5TdHJpbmdMaXN0IgASTQoTR2V0U3VtbWFyeURhc2hib2FyZBIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRocLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZSIAElsKFEdlbmVyYXRlUGFpcmluZ1Rva2VuEh8udjEuR2VuZXJhdGVQYWlyaW5nVG9rZW5SZXF1ZXN0GiAudjEuR2VuZXJhdGVQYWlyaW5nVG9rZW5SZXNwb25zZSIAQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z", [file_v1_config, file_v1_restic, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations]); + fileDesc("ChB2MS9zZXJ2aWNlLnByb3RvEgJ2MSIvCg1CYWNrdXBSZXF1ZXN0Eg0KBXZhbHVlGAEgASgJEg8KB2RyeV9ydW4YAiABKAgivwIKCk9wU2VsZWN0b3ISCwoDaWRzGAEgAygDEhgKC2luc3RhbmNlX2lkGAYgASgJSACIAQESJAoXb3JpZ2luYWxfaW5zdGFuY2Vfa2V5aWQYCCABKAlIAYgBARIWCglyZXBvX2d1aWQYByABKAlIAogBARIUCgdwbGFuX2lkGAMgASgJSAOIAQESGAoLc25hcHNob3RfaWQYBCABKAlIBIgBARIUCgdmbG93X2lkGAUgASgDSAWIAQESFgoJbW9kbm9fZ3RlGAkgASgDSAaIAQFCDgoMX2luc3RhbmNlX2lkQhoKGF9vcmlnaW5hbF9pbnN0YW5jZV9rZXlpZEIMCgpfcmVwb19ndWlkQgoKCF9wbGFuX2lkQg4KDF9zbmFwc2hvdF9pZEIKCghfZmxvd19pZEIMCgpfbW9kbm9fZ3RlImQKEFNldHVwU2Z0cFJlcXVlc3QSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgJEhAKCHVzZXJuYW1lGAMgASgJEhUKCHBhc3N3b3JkGAQgASgJSACIAQFCCwoJX3Bhc3N3b3JkImIKEVNldHVwU2Z0cFJlc3BvbnNlEhIKCnB1YmxpY19rZXkYASABKAkSEAoIa2V5X3BhdGgYAiABKAkSGAoQa25vd25faG9zdHNfcGF0aBgDIAEoCRINCgVlcnJvchgEIAEoCSIwChZDaGVja1JlcG9FeGlzdHNSZXF1ZXN0EhYKBHJlcG8YASABKAsyCC52MS5SZXBvIlQKF0NoZWNrUmVwb0V4aXN0c1Jlc3BvbnNlEg4KBmV4aXN0cxgBIAEoCBINCgVlcnJvchgCIAEoCRIaChJob3N0X2tleV91bnRydXN0ZWQYBSABKAgiKAoOQWRkUmVwb1JlcXVlc3QSFgoEcmVwbxgBIAEoCzIILnYxLlJlcG8i0gEKEURvUmVwb1Rhc2tSZXF1ZXN0Eg8KB3JlcG9faWQYASABKAkSKAoEdGFzaxgCIAEoDjIaLnYxLkRvUmVwb1Rhc2tSZXF1ZXN0LlRhc2sigQEKBFRhc2sSDQoJVEFTS19OT05FEAASGAoUVEFTS19JTkRFWF9TTkFQU0hPVFMQARIOCgpUQVNLX1BSVU5FEAISDgoKVEFTS19DSEVDSxADEg4KClRBU0tfU1RBVFMQBBIPCgtUQVNLX1VOTE9DSxAFEg8KC1RBU0tfRk9SR0VUEAYiTAoTQ2xlYXJIaXN0b3J5UmVxdWVzdBIgCghzZWxlY3RvchgBIAEoCzIOLnYxLk9wU2VsZWN0b3ISEwoLb25seV9mYWlsZWQYAiABKAgiRgoNRm9yZ2V0UmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB3BsYW5faWQYAiABKAkSEwoLc25hcHNob3RfaWQYAyABKAkiOAoUTGlzdFNuYXBzaG90c1JlcXVlc3QSDwoHcmVwb19pZBgBIAEoCRIPCgdwbGFuX2lkGAIgASgJIkgKFEdldE9wZXJhdGlvbnNSZXF1ZXN0EiAKCHNlbGVjdG9yGAEgASgLMg4udjEuT3BTZWxlY3RvchIOCgZsYXN0X24YAiABKAMibQoWUmVzdG9yZVNuYXBzaG90UmVxdWVzdBIPCgdwbGFuX2lkGAEgASgJEg8KB3JlcG9faWQYBSABKAkSEwoLc25hcHNob3RfaWQYAiABKAkSDAoEcGF0aBgDIAEoCRIOCgZ0YXJnZXQYBCABKAkiUAoYTGlzdFNuYXBzaG90RmlsZXNSZXF1ZXN0EhEKCXJlcG9fZ3VpZBgBIAEoCRITCgtzbmFwc2hvdF9pZBgCIAEoCRIMCgRwYXRoGAMgASgJIkcKGUxpc3RTbmFwc2hvdEZpbGVzUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIcCgdlbnRyaWVzGAIgAygLMgsudjEuTHNFbnRyeSIdCg5Mb2dEYXRhUmVxdWVzdBILCgNyZWYYASABKAkiOQoVR2V0RG93bmxvYWRVUkxSZXF1ZXN0Eg0KBW9wX2lkGAEgASgDEhEKCWZpbGVfcGF0aBgCIAEoCSKWAQoHTHNFbnRyeRIMCgRuYW1lGAEgASgJEgwKBHR5cGUYAiABKAkSDAoEcGF0aBgDIAEoCRILCgN1aWQYBCABKAMSCwoDZ2lkGAUgASgDEgwKBHNpemUYBiABKAMSDAoEbW9kZRgHIAEoAxINCgVtdGltZRgIIAEoCRINCgVhdGltZRgJIAEoCRINCgVjdGltZRgKIAEoCSI1ChFSdW5Db21tYW5kUmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB2NvbW1hbmQYAiABKAki+QcKGFN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZRI8Cg5yZXBvX3N1bW1hcmllcxgBIAMoCzIkLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZS5TdW1tYXJ5EjwKDnBsYW5fc3VtbWFyaWVzGAIgAygLMiQudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLlN1bW1hcnkSEwoLY29uZmlnX3BhdGgYCiABKAkSEQoJZGF0YV9wYXRoGAsgASgJGtIDCgdTdW1tYXJ5EgoKAmlkGAEgASgJEh0KFWJhY2t1cHNfZmFpbGVkXzMwZGF5cxgCIAEoAxIjChtiYWNrdXBzX3dhcm5pbmdfbGFzdF8zMGRheXMYAyABKAMSIwobYmFja3Vwc19zdWNjZXNzX2xhc3RfMzBkYXlzGAQgASgDEiEKGWJ5dGVzX3NjYW5uZWRfbGFzdF8zMGRheXMYBSABKAMSHwoXYnl0ZXNfYWRkZWRfbGFzdF8zMGRheXMYBiABKAMSFwoPdG90YWxfc25hcHNob3RzGAcgASgDEhkKEWJ5dGVzX3NjYW5uZWRfYXZnGAggASgDEhcKD2J5dGVzX2FkZGVkX2F2ZxgJIAEoAxIbChNuZXh0X2JhY2t1cF90aW1lX21zGAogASgDEkAKDnJlY2VudF9iYWNrdXBzGAsgASgLMigudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLkJhY2t1cENoYXJ0EhcKD3Byb3RlY3RlZF9ieXRlcxgMIAEoAxJJChNoaXN0b3J5X2xhc3RfMzBkYXlzGA0gAygLMiwudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLkRheVN0YXR1c0J1Y2tldBqDAQoLQmFja3VwQ2hhcnQSDwoHZmxvd19pZBgBIAMoAxIUCgx0aW1lc3RhbXBfbXMYAiADKAMSEwoLZHVyYXRpb25fbXMYAyADKAMSIwoGc3RhdHVzGAQgAygOMhMudjEuT3BlcmF0aW9uU3RhdHVzEhMKC2J5dGVzX2FkZGVkGAUgAygDGpcBCg9EYXlTdGF0dXNCdWNrZXQSFAoMdGltZXN0YW1wX21zGAEgASgDEhMKC2J5dGVzX2FkZGVkGAIgASgDEhUKDWJ5dGVzX3NjYW5uZWQYAyABKAMSQgoNc3RhdHVzX2NvdW50cxgEIAMoCzIrLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZS5TdGF0dXNBbmRDb3VudBpECg5TdGF0dXNBbmRDb3VudBINCgVjb3VudBgBIAEoAxIjCgZzdGF0dXMYAiABKA4yEy52MS5PcGVyYXRpb25TdGF0dXMiggEKG0dlbmVyYXRlUGFpcmluZ1Rva2VuUmVxdWVzdBINCgVsYWJlbBgBIAEoCRITCgt0dGxfc2Vjb25kcxgCIAEoAxIQCghtYXhfdXNlcxgDIAEoBRItCgtwZXJtaXNzaW9ucxgEIAMoCzIYLnYxLk11bHRpaG9zdC5QZXJtaXNzaW9uIi0KHEdlbmVyYXRlUGFpcmluZ1Rva2VuUmVzcG9uc2USDQoFdG9rZW4YASABKAky7goKCEJhY2tyZXN0EjEKCUdldENvbmZpZxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoKLnYxLkNvbmZpZyIAEiUKCVNldENvbmZpZxIKLnYxLkNvbmZpZxoKLnYxLkNvbmZpZyIAEjoKCVNldHVwU2Z0cBIULnYxLlNldHVwU2Z0cFJlcXVlc3QaFS52MS5TZXR1cFNmdHBSZXNwb25zZSIAEkwKD0NoZWNrUmVwb0V4aXN0cxIaLnYxLkNoZWNrUmVwb0V4aXN0c1JlcXVlc3QaGy52MS5DaGVja1JlcG9FeGlzdHNSZXNwb25zZSIAEisKB0FkZFJlcG8SEi52MS5BZGRSZXBvUmVxdWVzdBoKLnYxLkNvbmZpZyIAEi4KClJlbW92ZVJlcG8SEi50eXBlcy5TdHJpbmdWYWx1ZRoKLnYxLkNvbmZpZyIAEkQKEkdldE9wZXJhdGlvbkV2ZW50cxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoSLnYxLk9wZXJhdGlvbkV2ZW50IgAwARI+Cg1HZXRPcGVyYXRpb25zEhgudjEuR2V0T3BlcmF0aW9uc1JlcXVlc3QaES52MS5PcGVyYXRpb25MaXN0IgASQwoNTGlzdFNuYXBzaG90cxIYLnYxLkxpc3RTbmFwc2hvdHNSZXF1ZXN0GhYudjEuUmVzdGljU25hcHNob3RMaXN0IgASUgoRTGlzdFNuYXBzaG90RmlsZXMSHC52MS5MaXN0U25hcHNob3RGaWxlc1JlcXVlc3QaHS52MS5MaXN0U25hcHNob3RGaWxlc1Jlc3BvbnNlIgASNQoGQmFja3VwEhEudjEuQmFja3VwUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEj0KCkRvUmVwb1Rhc2sSFS52MS5Eb1JlcG9UYXNrUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEjUKBkZvcmdldBIRLnYxLkZvcmdldFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI/CgdSZXN0b3JlEhoudjEuUmVzdG9yZVNuYXBzaG90UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEjUKBkNhbmNlbBIRLnR5cGVzLkludDY0VmFsdWUaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI0CgdHZXRMb2dzEhIudjEuTG9nRGF0YVJlcXVlc3QaES50eXBlcy5CeXRlc1ZhbHVlIgAwARI4CgpSdW5Db21tYW5kEhUudjEuUnVuQ29tbWFuZFJlcXVlc3QaES50eXBlcy5JbnQ2NFZhbHVlIgASQQoOR2V0RG93bmxvYWRVUkwSGS52MS5HZXREb3dubG9hZFVSTFJlcXVlc3QaEi50eXBlcy5TdHJpbmdWYWx1ZSIAEkEKDENsZWFySGlzdG9yeRIXLnYxLkNsZWFySGlzdG9yeVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI7ChBQYXRoQXV0b2NvbXBsZXRlEhIudHlwZXMuU3RyaW5nVmFsdWUaES50eXBlcy5TdHJpbmdMaXN0IgASTQoTR2V0U3VtbWFyeURhc2hib2FyZBIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRocLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZSIAElsKFEdlbmVyYXRlUGFpcmluZ1Rva2VuEh8udjEuR2VuZXJhdGVQYWlyaW5nVG9rZW5SZXF1ZXN0GiAudjEuR2VuZXJhdGVQYWlyaW5nVG9rZW5SZXNwb25zZSIAQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z", [file_v1_config, file_v1_restic, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations]); /** * @generated from message v1.BackupRequest @@ -696,6 +696,20 @@ export type SummaryDashboardResponse_Summary = Message<"v1.SummaryDashboardRespo * @generated from field: v1.SummaryDashboardResponse.BackupChart recent_backups = 11; */ recentBackups?: SummaryDashboardResponse_BackupChart; + + /** + * total bytes processed by the most recent successful/warning backup + * + * @generated from field: int64 protected_bytes = 12; + */ + protectedBytes: bigint; + + /** + * per-day backup status for the dashboard history strip, oldest first; absent days precede the plan's first backup + * + * @generated from field: repeated v1.SummaryDashboardResponse.DayStatusBucket history_last_30days = 13; + */ + historyLast30days: SummaryDashboardResponse_DayStatusBucket[]; }; /** @@ -742,6 +756,68 @@ export type SummaryDashboardResponse_BackupChart = Message<"v1.SummaryDashboardR export const SummaryDashboardResponse_BackupChartSchema: GenMessage = /*@__PURE__*/ messageDesc(file_v1_service, 19, 1); +/** + * @generated from message v1.SummaryDashboardResponse.DayStatusBucket + */ +export type SummaryDashboardResponse_DayStatusBucket = Message<"v1.SummaryDashboardResponse.DayStatusBucket"> & { + /** + * start-of-day timestamp this bucket represents + * + * @generated from field: int64 timestamp_ms = 1; + */ + timestampMs: bigint; + + /** + * bytes added by backups that day + * + * @generated from field: int64 bytes_added = 2; + */ + bytesAdded: bigint; + + /** + * bytes scanned by backups that day + * + * @generated from field: int64 bytes_scanned = 3; + */ + bytesScanned: bigint; + + /** + * backup count per status for the day + * + * @generated from field: repeated v1.SummaryDashboardResponse.StatusAndCount status_counts = 4; + */ + statusCounts: SummaryDashboardResponse_StatusAndCount[]; +}; + +/** + * Describes the message v1.SummaryDashboardResponse.DayStatusBucket. + * Use `create(SummaryDashboardResponse_DayStatusBucketSchema)` to create a new message. + */ +export const SummaryDashboardResponse_DayStatusBucketSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_v1_service, 19, 2); + +/** + * @generated from message v1.SummaryDashboardResponse.StatusAndCount + */ +export type SummaryDashboardResponse_StatusAndCount = Message<"v1.SummaryDashboardResponse.StatusAndCount"> & { + /** + * @generated from field: int64 count = 1; + */ + count: bigint; + + /** + * @generated from field: v1.OperationStatus status = 2; + */ + status: OperationStatus; +}; + +/** + * Describes the message v1.SummaryDashboardResponse.StatusAndCount. + * Use `create(SummaryDashboardResponse_StatusAndCountSchema)` to create a new message. + */ +export const SummaryDashboardResponse_StatusAndCountSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_v1_service, 19, 3); + /** * @generated from message v1.GeneratePairingTokenRequest */ diff --git a/webui/messages/en.json b/webui/messages/en.json index a765132c..0c057244 100644 --- a/webui/messages/en.json +++ b/webui/messages/en.json @@ -502,5 +502,38 @@ "dashboard_state_label_idle": "No backups yet", "dashboard_state_label_ok": "Backed up", "dashboard_state_label_run": "Backing up", - "dashboard_state_label_warn": "Backed up, with warnings" + "dashboard_state_label_warn": "Backed up, with warnings", + "dashboard_time_never": "never", + "dashboard_time_just_now": "just now", + "dashboard_time_a_minute_ago": "a minute ago", + "dashboard_time_minutes_ago": "{count} min ago", + "dashboard_time_an_hour_ago": "an hour ago", + "dashboard_time_hour_ago": "{count} hour ago", + "dashboard_time_hours_ago": "{count} hours ago", + "dashboard_time_yesterday": "yesterday", + "dashboard_time_days_ago": "{count} days ago", + "dashboard_time_due_now": "due now", + "dashboard_time_in_minutes": "in {count} min", + "dashboard_time_in_hours": "in {count} hours", + "dashboard_time_in_days": "in {count} days", + "dashboard_time_soon": "soon", + "dashboard_schedule_every_minutes": "Runs every {count} min", + "dashboard_schedule_hourly": "Runs hourly", + "dashboard_schedule_daily": "Runs daily", + "dashboard_schedule_every_hours": "Runs every {count}h", + "dashboard_card_progress_detail": "{pct}% · {done} of {total}", + "dashboard_card_progress_pct": "{pct}% done", + "dashboard_card_progress_scanning": "Scanning…", + "dashboard_repo_window_30d": "30d", + "dashboard_repo_ok": "{count} ok", + "dashboard_repo_failed": "{count} failed", + "dashboard_repo_added": "Added", + "dashboard_activity_took": "took {duration}", + "dashboard_peer_shared_repos": "Shared Repos", + "dashboard_retention_hourly": "{count} hourly", + "dashboard_retention_daily": "{count} daily", + "dashboard_retention_weekly": "{count} weekly", + "dashboard_retention_monthly": "{count} monthly", + "dashboard_retention_yearly": "{count} yearly", + "dashboard_repo_dest_local": "Local" } diff --git a/webui/src/features/dashboard/HistoryStrip.tsx b/webui/src/features/dashboard/HistoryStrip.tsx index da1abb03..416d8826 100644 --- a/webui/src/features/dashboard/HistoryStrip.tsx +++ b/webui/src/features/dashboard/HistoryStrip.tsx @@ -1,99 +1,116 @@ import { Box, Flex, Text } from "@chakra-ui/react"; -import React from "react"; import { OperationStatus } from "../../../gen/ts/v1/operations_pb"; +import { SummaryDashboardResponse_DayStatusBucket } from "../../../gen/ts/v1/service_pb"; import * as m from "../../paraglide/messages"; -export interface DayBucket { - /** worst OperationStatus for this day, or undefined if no ops */ - status?: OperationStatus; - /** true if this day is before the plan's first ever operation */ - beforeStart: boolean; - /** local date label for tooltip */ - label: string; +const HISTORY_DAYS = 30; + +// Display category for one day; drives color, border, dimming, and tooltip via CELL_STYLE. +type CellKind = "beforeStart" | "missed" | "ok" | "warn" | "err" | "other"; + +interface DayCell { + kind: CellKind; + label: string; // local date label for the tooltip isToday: boolean; } -/** Build 30 DayBuckets from a raw ops list for one plan. */ -export function buildDayBuckets( - ops: Array<{ unixTimeStartMs: bigint; status: OperationStatus }>, -): { buckets: DayBucket[]; firstMs: number | null; protectedBytes: number } { - // rank: higher = worse (we want worst-day semantics) - const rank = (s: OperationStatus): number => { - if ( - s === OperationStatus.STATUS_ERROR || - s === OperationStatus.STATUS_SYSTEM_CANCELLED - ) - return 3; - if (s === OperationStatus.STATUS_WARNING) return 2; - if (s === OperationStatus.STATUS_SUCCESS) return 1; - return 0; - }; +const CELL_STYLE: Record< + CellKind, + { bg: string; dashed: boolean; dim: boolean; tooltip: () => string } +> = { + beforeStart: { + bg: "bg.muted", + dashed: false, + dim: true, + tooltip: m.dashboard_history_tooltip_before_start, + }, + missed: { + bg: "transparent", + dashed: true, + dim: false, + tooltip: m.dashboard_history_tooltip_no_backup, + }, + ok: { + bg: "green.500", + dashed: false, + dim: false, + tooltip: m.dashboard_history_legend_backed_up, + }, + warn: { + bg: "orange.400", + dashed: false, + dim: false, + tooltip: m.dashboard_activity_row_completed_warnings, + }, + err: { + bg: "red.500", + dashed: false, + dim: false, + tooltip: m.dashboard_state_label_err, + }, + other: { + bg: "bg.muted", + dashed: false, + dim: false, + tooltip: m.dashboard_state_label_err, + }, +}; - const dayMap = new Map(); - let firstMs: number | null = null; - let protectedBytes = 0; - - for (const op of ops) { - const t = Number(op.unixTimeStartMs); - if (!t) continue; - if (firstMs === null || t < firstMs) firstMs = t; - if (rank(op.status) > 0) { - const d = new Date(t); - d.setHours(0, 0, 0, 0); - const key = d.toISOString(); - const cur = dayMap.get(key); - if (!cur || rank(op.status) > rank(cur)) { - dayMap.set(key, op.status); - } - } - } - - const today = new Date(); - today.setHours(0, 0, 0, 0); - const firstDay = firstMs !== null ? new Date(firstMs) : null; - if (firstDay) firstDay.setHours(0, 0, 0, 0); - - const buckets: DayBucket[] = []; - for (let i = 29; i >= 0; i--) { - const d = new Date(today); - d.setDate(today.getDate() - i); - const beforeStart = firstDay === null || d < firstDay; - const key = d.toISOString(); - const status = beforeStart ? undefined : dayMap.get(key); - buckets.push({ - status, - beforeStart, - label: d.toLocaleDateString(), - isToday: i === 0, - }); - } - - return { buckets, firstMs, protectedBytes }; -} - -function cellBg(bucket: DayBucket): string { - if (bucket.beforeStart) return "transparent"; - if (!bucket.status) return "transparent"; // missed — dashed border below - if (bucket.status === OperationStatus.STATUS_SUCCESS) return "green.500"; - if (bucket.status === OperationStatus.STATUS_WARNING) return "orange.400"; +// rank: higher = worse, so the worst status wins for a day with mixed results. +function rank(s: OperationStatus): number { if ( - bucket.status === OperationStatus.STATUS_ERROR || - bucket.status === OperationStatus.STATUS_SYSTEM_CANCELLED + s === OperationStatus.STATUS_ERROR || + s === OperationStatus.STATUS_SYSTEM_CANCELLED ) - return "red.500"; - return "bg.muted"; + return 3; + if (s === OperationStatus.STATUS_WARNING) return 2; + if (s === OperationStatus.STATUS_SUCCESS) return 1; + return 0; } -function summaryText(buckets: DayBucket[]): string { - const active = buckets.filter((b) => !b.beforeStart); +function cellKind( + bucket: SummaryDashboardResponse_DayStatusBucket | undefined, +): CellKind { + let worst: OperationStatus | undefined; + for (const sc of bucket?.statusCounts ?? []) { + if (worst === undefined || rank(sc.status) > rank(worst)) worst = sc.status; + } + if (worst === undefined) return "missed"; + if (worst === OperationStatus.STATUS_SUCCESS) return "ok"; + if (worst === OperationStatus.STATUS_WARNING) return "warn"; + if ( + worst === OperationStatus.STATUS_ERROR || + worst === OperationStatus.STATUS_SYSTEM_CANCELLED + ) + return "err"; + return "other"; +} + +// Fixed 30-cell strip ending today; days before the plan's first backup are left-padded. +function toCells(buckets: SummaryDashboardResponse_DayStatusBucket[]): DayCell[] { + const recent = buckets.slice(-HISTORY_DAYS); + const padCount = HISTORY_DAYS - recent.length; + const midnight = new Date(); + midnight.setHours(0, 0, 0, 0); + + return Array.from({ length: HISTORY_DAYS }, (_, i): DayCell => { + const daysAgo = HISTORY_DAYS - 1 - i; + const date = new Date(midnight); + date.setDate(midnight.getDate() - daysAgo); + return { + kind: i < padCount ? "beforeStart" : cellKind(recent[i - padCount]), + label: date.toLocaleDateString(), + isToday: daysAgo === 0, + }; + }); +} + +function summaryText(cells: DayCell[]): string { + const active = cells.filter((c) => c.kind !== "beforeStart"); if (active.length === 0) return m.dashboard_history_no_data(); - const missed = active.filter((b) => !b.status).length; + const missed = active.filter((c) => c.kind === "missed").length; const issues = active.filter( - (b) => - b.status && - b.status !== OperationStatus.STATUS_SUCCESS && - b.status !== OperationStatus.STATUS_INPROGRESS && - b.status !== OperationStatus.STATUS_PENDING, + (c) => c.kind === "warn" || c.kind === "err", ).length; if (missed === 0 && issues === 0) return m.dashboard_history_all_backed_up(); const parts: string[] = []; @@ -102,18 +119,21 @@ function summaryText(buckets: DayBucket[]): string { return m.dashboard_history_summary({ details: parts.join(" · ") }); } -export const HistoryStrip = ({ buckets }: { buckets: DayBucket[] }) => { - const summary = summaryText(buckets); +export const HistoryStrip = ({ + buckets, +}: { + buckets: SummaryDashboardResponse_DayStatusBucket[]; +}) => { + const cells = toCells(buckets); return ( - {summary} + {summaryText(cells)} - {buckets.map((b, i) => { - const bg = cellBg(b); - const isMissed = !b.beforeStart && !b.status; + {cells.map((c, i) => { + const style = CELL_STYLE[c.kind]; return ( { minW={0} h="22px" borderRadius="3px" - bg={b.beforeStart ? "bg.muted" : bg} - opacity={b.beforeStart ? 0.35 : 1} - border={isMissed ? "1.5px dashed" : "none"} - borderColor={isMissed ? "border.subtle" : "transparent"} + bg={style.bg} + opacity={style.dim ? 0.35 : 1} + border={style.dashed ? "1.5px dashed" : "none"} + borderColor={style.dashed ? "border.subtle" : "transparent"} boxShadow={ - b.isToday + c.isToday ? "0 0 0 2px var(--chakra-colors-bg-canvas), 0 0 0 3.5px var(--chakra-colors-fg-muted)" : undefined } - title={`${b.label} — ${ - b.beforeStart - ? m.dashboard_history_tooltip_before_start() - : !b.status - ? m.dashboard_history_tooltip_no_backup() - : b.status === OperationStatus.STATUS_SUCCESS - ? m.dashboard_history_legend_backed_up() - : b.status === OperationStatus.STATUS_WARNING - ? m.dashboard_activity_row_completed_warnings() - : m.dashboard_state_label_err() - }`} + title={`${c.label} — ${style.tooltip()}`} /> ); })} diff --git a/webui/src/features/dashboard/SummaryDashboard.tsx b/webui/src/features/dashboard/SummaryDashboard.tsx index d0201b44..8a6e3ff2 100644 --- a/webui/src/features/dashboard/SummaryDashboard.tsx +++ b/webui/src/features/dashboard/SummaryDashboard.tsx @@ -37,7 +37,7 @@ import { formatBytes, formatDuration, formatTime } from "../../lib/formatting"; import { useConfig } from "../../app/provider"; import { useSyncStates } from "../../state/peerStates"; import * as m from "../../paraglide/messages"; -import { buildDayBuckets, HistoryStrip } from "./HistoryStrip"; +import { HistoryStrip } from "./HistoryStrip"; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -47,66 +47,83 @@ function prettyPlanId(id: string): string { /** Relative "X ago" string. ms = unix epoch in ms. */ function agoText(ms: number): string { - if (!ms) return "never"; + if (!ms) return m.dashboard_time_never(); const s = Math.floor((Date.now() - ms) / 1000); - if (s < 45) return "just now"; - if (s < 90) return "a minute ago"; - if (s < 3600) return `${Math.floor(s / 60)} min ago`; - if (s < 5400) return "an hour ago"; + if (s < 45) return m.dashboard_time_just_now(); + if (s < 90) return m.dashboard_time_a_minute_ago(); + if (s < 3600) return m.dashboard_time_minutes_ago({ count: Math.floor(s / 60) }); + if (s < 5400) return m.dashboard_time_an_hour_ago(); if (s < 86400) { const h = Math.floor(s / 3600); - return `${h} hour${h > 1 ? "s" : ""} ago`; + return h === 1 + ? m.dashboard_time_hour_ago({ count: h }) + : m.dashboard_time_hours_ago({ count: h }); } - if (s < 172800) return "yesterday"; - const dd = Math.floor(s / 86400); - return `${dd} day${dd > 1 ? "s" : ""} ago`; + if (s < 172800) return m.dashboard_time_yesterday(); + return m.dashboard_time_days_ago({ count: Math.floor(s / 86400) }); } /** "in X" relative string for future times. ms = unix epoch in ms. */ function untilText(ms: number): string | null { if (!ms) return null; const s = Math.floor((ms - Date.now()) / 1000); - if (s <= 0) return "due now"; - if (s < 5400) return `in ${Math.max(1, Math.round(s / 60))} min`; - if (s < 172800) return `in ${Math.round(s / 3600)} hours`; - return `in ${Math.round(s / 86400)} days`; + if (s <= 0) return m.dashboard_time_due_now(); + if (s < 5400) + return m.dashboard_time_in_minutes({ count: Math.max(1, Math.round(s / 60)) }); + if (s < 172800) + return m.dashboard_time_in_hours({ count: Math.round(s / 3600) }); + return m.dashboard_time_in_days({ count: Math.round(s / 86400) }); } function scheduleText(maxFrequencyHours?: number): string | null { if (!maxFrequencyHours) return null; if (maxFrequencyHours < 1) - return `Runs every ${Math.round(maxFrequencyHours * 60)} min`; - if (maxFrequencyHours === 1) return "Runs hourly"; - if (maxFrequencyHours === 24) return "Runs daily"; - return `Runs every ${maxFrequencyHours}h`; + return m.dashboard_schedule_every_minutes({ + count: Math.round(maxFrequencyHours * 60), + }); + if (maxFrequencyHours === 1) return m.dashboard_schedule_hourly(); + if (maxFrequencyHours === 24) return m.dashboard_schedule_daily(); + return m.dashboard_schedule_every_hours({ count: maxFrequencyHours }); } +// Friendly restic backend names by URI scheme. Names are proper nouns, not localized. +const REPO_BACKEND_NAMES: Record = { + s3: "S3", + b2: "Backblaze B2", + gs: "Google Cloud Storage", + azure: "Azure Blob Storage", + sftp: "SFTP", + rest: "REST Server", + swift: "OpenStack Swift", +}; + function repoDestLabel(uri: string): string { - const u = uri.toLowerCase(); - if (u.includes("backblaze") || u.startsWith("s3:") || u.startsWith("b2:")) - return "Backblaze B2"; - if (u.startsWith("rclone:")) { - const rem = u.split(":")[1] ?? ""; - if (rem.includes("pcloud")) return "pCloud"; - return rem.charAt(0).toUpperCase() + rem.slice(1); + const scheme = uri.match(/^([a-z0-9]+):/i)?.[1]?.toLowerCase(); + if (!scheme) return m.dashboard_repo_dest_local(); // a bare path is a local repo + if (scheme === "rclone") { + const remote = uri.split(":")[1]; + return remote ? `Rclone (${remote})` : "Rclone"; } - if (u.startsWith("sftp:") || u.includes("hetzner")) return "Hetzner"; - return uri.replace(/^[a-z0-9]+:/, ""); + return REPO_BACKEND_NAMES[scheme] ?? scheme.toUpperCase(); } -function retentionText( - hourly: number, - daily: number, - weekly: number, - monthly: number, - yearly: number, -): string | null { - const parts: string[] = []; - if (hourly) parts.push(`${hourly} hourly`); - if (daily) parts.push(`${daily} daily`); - if (weekly) parts.push(`${weekly} weekly`); - if (monthly) parts.push(`${monthly} monthly`); - if (yearly) parts.push(`${yearly} yearly`); +function retentionText(r: { + hourly: number; + daily: number; + weekly: number; + monthly: number; + yearly: number; +}): string | null { + const buckets: [number, (p: { count: number }) => string][] = [ + [r.hourly, m.dashboard_retention_hourly], + [r.daily, m.dashboard_retention_daily], + [r.weekly, m.dashboard_retention_weekly], + [r.monthly, m.dashboard_retention_monthly], + [r.yearly, m.dashboard_retention_yearly], + ]; + const parts = buckets + .filter(([count]) => count > 0) + .map(([count, fmt]) => fmt({ count })); return parts.length ? m.dashboard_card_retention({ parts: parts.join(", ") }) : null; @@ -146,6 +163,15 @@ const STATE_BG: Record = { idle: "gray.100", }; +// Worst-wins ordering used to derive the hero state across all plans. +const STATE_SEVERITY: Record = { + err: 3, + warn: 2, + run: 1, + ok: 0, + idle: -1, +}; + function stateLabelText(state: PlanState): string { if (state === "ok") return m.dashboard_state_label_ok(); if (state === "warn") return m.dashboard_state_label_warn(); @@ -174,7 +200,7 @@ const ProgressBar = ({ pct }: { pct: number }) => ( // ─── Hero banner ────────────────────────────────────────────────────────────── -type HeroState = "ok" | "run" | "warn" | "err" | "idle"; +type HeroState = PlanState; const HERO_ICON: Record = { ok: , @@ -201,8 +227,8 @@ const HeroBanner = ({ newestMs: number; nextMs: number | null; }) => { - const color = STATE_COLORS[state as PlanState] ?? "gray.400"; - const bgColor = STATE_BG[state as PlanState] ?? "gray.100"; + const color = STATE_COLORS[state]; + const bgColor = STATE_BG[state]; const subParts: React.ReactNode[] = []; if (newestMs) { subParts.push( @@ -320,98 +346,12 @@ const useLiveProgress = (planId: string, running: boolean) => { return progress; }; -// ─── Per-plan operations cache ──────────────────────────────────────────────── - -interface PlanOpsData { - buckets: ReturnType["buckets"]; - protectedBytes: number; - loading: boolean; -} - -const usePlanOps = (planIds: string[]) => { - const [data, setData] = useState>(new Map()); - - useEffect(() => { - if (planIds.length === 0) return; - - // Mark all as loading - setData((prev) => { - const next = new Map(prev); - for (const id of planIds) { - if (!next.has(id)) - next.set(id, { buckets: [], protectedBytes: 0, loading: true }); - } - return next; - }); - - const loadPlan = async (planId: string) => { - try { - const res = await backrestService.getOperations({ - lastN: 300n, - selector: { planId }, - }); - const backupOps = res.operations.filter( - (op) => op.op.case === "operationBackup", - ); - - // Build day buckets - const { buckets } = buildDayBuckets( - backupOps.map((op) => ({ - unixTimeStartMs: op.unixTimeStartMs, - status: op.status, - })), - ); - - // Protected = totalBytesProcessed of most recent SUCCESS/WARNING op - let protectedBytes = 0; - let protectedMs = 0; - for (const op of backupOps) { - const t = Number(op.unixTimeStartMs); - if ( - t > protectedMs && - (op.status === OperationStatus.STATUS_SUCCESS || - op.status === OperationStatus.STATUS_WARNING) - ) { - const opBackup = - op.op.case === "operationBackup" ? op.op.value : undefined; - const lastStatus = opBackup?.lastStatus; - if (lastStatus?.entry.case === "summary") { - protectedMs = t; - protectedBytes = Number( - lastStatus.entry.value.totalBytesProcessed, - ); - } - } - } - - setData((prev) => { - const next = new Map(prev); - next.set(planId, { buckets, protectedBytes, loading: false }); - return next; - }); - } catch { - setData((prev) => { - const next = new Map(prev); - next.set(planId, { buckets: [], protectedBytes: 0, loading: false }); - return next; - }); - } - }; - - for (const id of planIds) loadPlan(id); - }, [planIds.join(",")]); // eslint-disable-line react-hooks/exhaustive-deps - - return data; -}; - // ─── Plan card ──────────────────────────────────────────────────────────────── const PlanCard = ({ summary, - opsData, }: { summary: SummaryDashboardResponse_Summary; - opsData: PlanOpsData | undefined; }) => { const [config] = useConfig(); const rb = summary.recentBackups; @@ -447,8 +387,7 @@ const PlanCard = ({ let retLine: string | null = null; if (planCfg?.retention?.policy.case === "policyTimeBucketed") { - const r = planCfg.retention.policy.value; - retLine = retentionText(r.hourly, r.daily, r.weekly, r.monthly, r.yearly); + retLine = retentionText(planCfg.retention.policy.value); } return ( @@ -518,9 +457,13 @@ const PlanCard = ({ {progress ? progress.total > 0 - ? `${progress.pct}% · ${formatBytes(progress.done)} of ${formatBytes(progress.total)}` - : `${progress.pct}% done` - : "Scanning…"} + ? m.dashboard_card_progress_detail({ + pct: progress.pct, + done: formatBytes(progress.done), + total: formatBytes(progress.total), + }) + : m.dashboard_card_progress_pct({ pct: progress.pct }) + : m.dashboard_card_progress_scanning()} )} @@ -532,7 +475,7 @@ const PlanCard = ({ {m.dashboard_card_next_run()}{" "} - {untilText(nextMs) ?? "soon"} + {untilText(nextMs) ?? m.dashboard_time_soon()} )} @@ -544,11 +487,11 @@ const PlanCard = ({ )} - {opsData && opsData.protectedBytes > 0 && ( + {Number(summary.protectedBytes) > 0 && ( {m.dashboard_card_protected()}{" "} - {formatBytes(opsData.protectedBytes)} + {formatBytes(Number(summary.protectedBytes))} )} @@ -564,14 +507,7 @@ const PlanCard = ({ )} {/* 30-day history strip */} - {opsData && !opsData.loading && ( - - )} - {opsData?.loading && ( - - - - )} + {/* Retention footer */} {retLine && ( @@ -637,21 +573,25 @@ const RepoCard = ({ - 30d{" "} + {m.dashboard_repo_window_30d()}{" "} {summary.backupsSuccessLast30days - ? `${Number(summary.backupsSuccessLast30days)} ok` + ? m.dashboard_repo_ok({ + count: Number(summary.backupsSuccessLast30days), + }) : ""} {summary.backupsFailed30days ? ( - {Number(summary.backupsFailed30days)} failed + {m.dashboard_repo_failed({ + count: Number(summary.backupsFailed30days), + })} ) : null} {Number(summary.bytesAddedLast30days) > 0 && ( - Added{" "} + {m.dashboard_repo_added()}{" "} {formatBytes(Number(summary.bytesAddedLast30days))} @@ -756,7 +696,7 @@ const RecentActivity = ({ {agoText(row.timestampMs)} {row.durationMs > 0 && - ` · took ${formatDuration(row.durationMs)}`} + ` · ${m.dashboard_activity_took({ duration: formatDuration(row.durationMs) })}`} {row.bytesAdded > 0 && ( @@ -817,13 +757,6 @@ export const SummaryDashboard = () => { } }, [config]); // eslint-disable-line react-hooks/exhaustive-deps - // Stable array of plan IDs for the ops cache - const planIds = useMemo( - () => (summaryData?.planSummaries ?? []).map((s) => s.id), - [summaryData], - ); - const opsCache = usePlanOps(planIds); - if (!summaryData) { return (
@@ -851,16 +784,8 @@ export const SummaryDashboard = () => { if (ts > newestMs) newestMs = ts; const st = planState(latest, running); - const severity = { err: 3, warn: 2, run: 1, ok: 0, idle: -1 } as Record< - PlanState, - number - >; - const heroSeverity = { err: 3, warn: 2, run: 1, ok: 0, idle: -1 } as Record< - HeroState, - number - >; - if (severity[st] > (heroSeverity[heroState] ?? -1)) { - heroState = st as HeroState; + if (STATE_SEVERITY[st] > STATE_SEVERITY[heroState]) { + heroState = st; } const nx = Number(s.nextBackupTimeMs ?? 0); @@ -885,7 +810,7 @@ export const SummaryDashboard = () => { {m.dashboard_plans_title()} {plans.map((s) => ( - + ))} @@ -1074,7 +999,7 @@ const PeerStateTile = ({ /> {peerState.knownRepos.length > 0 && ( {peerState.knownRepos.map((repo) => ( @@ -1095,7 +1020,7 @@ const PeerStateTile = ({ )} {sharedRepoIds && sharedRepoIds.length > 0 && ( {sharedRepoIds.map((repoId) => (