mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-07-09 03:30:44 +00:00
fix: update backresthandler's SummaryDashboard endpoint to support new summary view
This commit is contained in:
+251
-99
@@ -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,
|
||||
},
|
||||
|
||||
+114
-32
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+34
-1
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<string, OperationStatus>();
|
||||
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 (
|
||||
<Box mt={4}>
|
||||
<Text fontSize="13px" fontWeight="520" mb={2} color="fg.default">
|
||||
{summary}
|
||||
{summaryText(cells)}
|
||||
</Text>
|
||||
<Flex gap="3px" w="full">
|
||||
{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 (
|
||||
<Box
|
||||
key={i}
|
||||
@@ -123,26 +143,16 @@ export const HistoryStrip = ({ buckets }: { buckets: DayBucket[] }) => {
|
||||
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()}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<PlanState, string> = {
|
||||
idle: "gray.100",
|
||||
};
|
||||
|
||||
// Worst-wins ordering used to derive the hero state across all plans.
|
||||
const STATE_SEVERITY: Record<PlanState, number> = {
|
||||
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<HeroState, React.ReactNode> = {
|
||||
ok: <FiCheck strokeWidth={2.4} />,
|
||||
@@ -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<typeof buildDayBuckets>["buckets"];
|
||||
protectedBytes: number;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const usePlanOps = (planIds: string[]) => {
|
||||
const [data, setData] = useState<Map<string, PlanOpsData>>(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 = ({
|
||||
<Text fontSize="12.5px" color="fg.muted">
|
||||
{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()}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -532,7 +475,7 @@ const PlanCard = ({
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
{m.dashboard_card_next_run()}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{untilText(nextMs) ?? "soon"}
|
||||
{untilText(nextMs) ?? m.dashboard_time_soon()}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
@@ -544,11 +487,11 @@ const PlanCard = ({
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{opsData && opsData.protectedBytes > 0 && (
|
||||
{Number(summary.protectedBytes) > 0 && (
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
{m.dashboard_card_protected()}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{formatBytes(opsData.protectedBytes)}
|
||||
{formatBytes(Number(summary.protectedBytes))}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
@@ -564,14 +507,7 @@ const PlanCard = ({
|
||||
)}
|
||||
|
||||
{/* 30-day history strip */}
|
||||
{opsData && !opsData.loading && (
|
||||
<HistoryStrip buckets={opsData.buckets} />
|
||||
)}
|
||||
{opsData?.loading && (
|
||||
<Box mt={4}>
|
||||
<Spinner size="xs" />
|
||||
</Box>
|
||||
)}
|
||||
<HistoryStrip buckets={summary.historyLast30days} />
|
||||
|
||||
{/* Retention footer */}
|
||||
{retLine && (
|
||||
@@ -637,21 +573,25 @@ const RepoCard = ({
|
||||
|
||||
<Flex flexWrap="wrap" gap="4px 18px" mt={3}>
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
30d{" "}
|
||||
{m.dashboard_repo_window_30d()}{" "}
|
||||
<Text as="span" fontWeight="600" color="green.500">
|
||||
{summary.backupsSuccessLast30days
|
||||
? `${Number(summary.backupsSuccessLast30days)} ok`
|
||||
? m.dashboard_repo_ok({
|
||||
count: Number(summary.backupsSuccessLast30days),
|
||||
})
|
||||
: ""}
|
||||
</Text>
|
||||
{summary.backupsFailed30days ? (
|
||||
<Text as="span" fontWeight="600" color="red.500" ml={2}>
|
||||
{Number(summary.backupsFailed30days)} failed
|
||||
{m.dashboard_repo_failed({
|
||||
count: Number(summary.backupsFailed30days),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
{Number(summary.bytesAddedLast30days) > 0 && (
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
Added{" "}
|
||||
{m.dashboard_repo_added()}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{formatBytes(Number(summary.bytesAddedLast30days))}
|
||||
</Text>
|
||||
@@ -756,7 +696,7 @@ const RecentActivity = ({
|
||||
<Text fontSize="12.5px" color="fg.muted">
|
||||
{agoText(row.timestampMs)}
|
||||
{row.durationMs > 0 &&
|
||||
` · took ${formatDuration(row.durationMs)}`}
|
||||
` · ${m.dashboard_activity_took({ duration: formatDuration(row.durationMs) })}`}
|
||||
</Text>
|
||||
</Box>
|
||||
{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 (
|
||||
<Center h="200px">
|
||||
@@ -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 = () => {
|
||||
<Heading size="md">{m.dashboard_plans_title()}</Heading>
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} gap={4}>
|
||||
{plans.map((s) => (
|
||||
<PlanCard key={s.id} summary={s} opsData={opsCache.get(s.id)} />
|
||||
<PlanCard key={s.id} summary={s} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
@@ -1074,7 +999,7 @@ const PeerStateTile = ({
|
||||
/>
|
||||
{peerState.knownRepos.length > 0 && (
|
||||
<DataListItem
|
||||
label="Shared Repos"
|
||||
label={m.dashboard_peer_shared_repos()}
|
||||
value={
|
||||
<Flex gap={1} flexWrap="wrap">
|
||||
{peerState.knownRepos.map((repo) => (
|
||||
@@ -1095,7 +1020,7 @@ const PeerStateTile = ({
|
||||
)}
|
||||
{sharedRepoIds && sharedRepoIds.length > 0 && (
|
||||
<DataListItem
|
||||
label="Shared Repos"
|
||||
label={m.dashboard_peer_shared_repos()}
|
||||
value={
|
||||
<Flex gap={1} flexWrap="wrap">
|
||||
{sharedRepoIds.map((repoId) => (
|
||||
|
||||
Reference in New Issue
Block a user