fix: update backresthandler's SummaryDashboard endpoint to support new summary view

This commit is contained in:
Gareth
2026-06-29 21:57:48 -07:00
parent fb3b2c8945
commit 53eb340ada
8 changed files with 990 additions and 407 deletions
+251 -99
View File
@@ -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
View File
@@ -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
}
+290
View File
@@ -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()
+15
View File
@@ -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
View File
@@ -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"
}
+114 -104
View File
@@ -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()}`}
/>
);
})}
+95 -170
View File
@@ -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) => (