mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-08-25 10:26:35 +00:00
fix: dashboard displays 'overdue' missed backups
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: google/api/annotations.proto
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: google/api/http.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: types/value.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: v1/authentication.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc (unknown)
|
||||
// source: v1/authentication.proto
|
||||
|
||||
@@ -77,10 +77,10 @@ type AuthenticationServer interface {
|
||||
type UnimplementedAuthenticationServer struct{}
|
||||
|
||||
func (UnimplementedAuthenticationServer) Login(context.Context, *LoginRequest) (*LoginResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Login not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Login not implemented")
|
||||
}
|
||||
func (UnimplementedAuthenticationServer) HashPassword(context.Context, *types.StringValue) (*types.StringValue, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method HashPassword not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method HashPassword not implemented")
|
||||
}
|
||||
func (UnimplementedAuthenticationServer) mustEmbedUnimplementedAuthenticationServer() {}
|
||||
func (UnimplementedAuthenticationServer) testEmbeddedByValue() {}
|
||||
@@ -93,7 +93,7 @@ type UnsafeAuthenticationServer interface {
|
||||
}
|
||||
|
||||
func RegisterAuthenticationServer(s grpc.ServiceRegistrar, srv AuthenticationServer) {
|
||||
// If the following call panics, it indicates UnimplementedAuthenticationServer was
|
||||
// If the following call pancis, it indicates UnimplementedAuthenticationServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: v1/config.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: v1/crypto.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: v1/operations.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: v1/restic.proto
|
||||
|
||||
|
||||
+13
-4
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: v1/service.proto
|
||||
|
||||
@@ -1645,6 +1645,7 @@ type SummaryDashboardResponse_DayStatusBucket struct {
|
||||
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
|
||||
Overdue bool `protobuf:"varint,5,opt,name=overdue,proto3" json:"overdue,omitempty"` // gap since the last good backup exceeded the schedule's nominal period during this day
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1707,6 +1708,13 @@ func (x *SummaryDashboardResponse_DayStatusBucket) GetStatusCounts() []*SummaryD
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *SummaryDashboardResponse_DayStatusBucket) GetOverdue() bool {
|
||||
if x != nil {
|
||||
return x.Overdue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type SummaryDashboardResponse_StatusAndCount struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Count int64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"`
|
||||
@@ -1873,7 +1881,7 @@ 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\"\x95\v\n" +
|
||||
"\acommand\x18\x02 \x01(\tR\acommand\"\xaf\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" +
|
||||
@@ -1903,13 +1911,14 @@ 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\x1a\xcc\x01\n" +
|
||||
"bytesAdded\x1a\xe6\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" +
|
||||
"\rstatus_counts\x18\x04 \x03(\v2+.v1.SummaryDashboardResponse.StatusAndCountR\fstatusCounts\x12\x18\n" +
|
||||
"\aoverdue\x18\x05 \x01(\bR\aoverdue\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" +
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc (unknown)
|
||||
// source: v1/service.proto
|
||||
|
||||
@@ -382,70 +382,70 @@ type BackrestServer interface {
|
||||
type UnimplementedBackrestServer struct{}
|
||||
|
||||
func (UnimplementedBackrestServer) GetConfig(context.Context, *emptypb.Empty) (*Config, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetConfig not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) SetConfig(context.Context, *Config) (*Config, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetConfig not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetConfig not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) SetupSftp(context.Context, *SetupSftpRequest) (*SetupSftpResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetupSftp not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetupSftp not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) CheckRepoExists(context.Context, *CheckRepoExistsRequest) (*CheckRepoExistsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method CheckRepoExists not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CheckRepoExists not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) AddRepo(context.Context, *AddRepoRequest) (*Config, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method AddRepo not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AddRepo not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) RemoveRepo(context.Context, *types.StringValue) (*Config, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RemoveRepo not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RemoveRepo not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) GetOperationEvents(*emptypb.Empty, grpc.ServerStreamingServer[OperationEvent]) error {
|
||||
return status.Error(codes.Unimplemented, "method GetOperationEvents not implemented")
|
||||
return status.Errorf(codes.Unimplemented, "method GetOperationEvents not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) GetOperations(context.Context, *GetOperationsRequest) (*OperationList, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetOperations not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetOperations not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) ListSnapshots(context.Context, *ListSnapshotsRequest) (*ResticSnapshotList, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListSnapshots not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListSnapshots not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) ListSnapshotFiles(context.Context, *ListSnapshotFilesRequest) (*ListSnapshotFilesResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListSnapshotFiles not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListSnapshotFiles not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) Backup(context.Context, *BackupRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Backup not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Backup not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) DoRepoTask(context.Context, *DoRepoTaskRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method DoRepoTask not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DoRepoTask not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) Forget(context.Context, *ForgetRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Forget not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Forget not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) Restore(context.Context, *RestoreSnapshotRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Restore not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Restore not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) Cancel(context.Context, *types.Int64Value) (*emptypb.Empty, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method Cancel not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Cancel not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) GetLogs(*LogDataRequest, grpc.ServerStreamingServer[types.BytesValue]) error {
|
||||
return status.Error(codes.Unimplemented, "method GetLogs not implemented")
|
||||
return status.Errorf(codes.Unimplemented, "method GetLogs not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) RunCommand(context.Context, *RunCommandRequest) (*types.Int64Value, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method RunCommand not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RunCommand not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) GetDownloadURL(context.Context, *GetDownloadURLRequest) (*types.StringValue, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetDownloadURL not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetDownloadURL not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) ClearHistory(context.Context, *ClearHistoryRequest) (*emptypb.Empty, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ClearHistory not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ClearHistory not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) PathAutocomplete(context.Context, *types.StringValue) (*types.StringList, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method PathAutocomplete not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method PathAutocomplete not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) GetSummaryDashboard(context.Context, *emptypb.Empty) (*SummaryDashboardResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetSummaryDashboard not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetSummaryDashboard not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) GeneratePairingToken(context.Context, *GeneratePairingTokenRequest) (*GeneratePairingTokenResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GeneratePairingToken not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GeneratePairingToken not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestServer) mustEmbedUnimplementedBackrestServer() {}
|
||||
func (UnimplementedBackrestServer) testEmbeddedByValue() {}
|
||||
@@ -458,7 +458,7 @@ type UnsafeBackrestServer interface {
|
||||
}
|
||||
|
||||
func RegisterBackrestServer(s grpc.ServiceRegistrar, srv BackrestServer) {
|
||||
// If the following call panics, it indicates UnimplementedBackrestServer was
|
||||
// If the following call pancis, it indicates UnimplementedBackrestServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc-gen-go v1.36.10
|
||||
// protoc (unknown)
|
||||
// source: v1sync/syncservice.proto
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc (unknown)
|
||||
// source: v1sync/syncservice.proto
|
||||
|
||||
@@ -72,7 +72,7 @@ type BackrestSyncServiceServer interface {
|
||||
type UnimplementedBackrestSyncServiceServer struct{}
|
||||
|
||||
func (UnimplementedBackrestSyncServiceServer) Sync(grpc.BidiStreamingServer[SyncStreamItem, SyncStreamItem]) error {
|
||||
return status.Error(codes.Unimplemented, "method Sync not implemented")
|
||||
return status.Errorf(codes.Unimplemented, "method Sync not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestSyncServiceServer) mustEmbedUnimplementedBackrestSyncServiceServer() {}
|
||||
func (UnimplementedBackrestSyncServiceServer) testEmbeddedByValue() {}
|
||||
@@ -85,7 +85,7 @@ type UnsafeBackrestSyncServiceServer interface {
|
||||
}
|
||||
|
||||
func RegisterBackrestSyncServiceServer(s grpc.ServiceRegistrar, srv BackrestSyncServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedBackrestSyncServiceServer was
|
||||
// If the following call pancis, it indicates UnimplementedBackrestSyncServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
@@ -195,10 +195,10 @@ type BackrestSyncStateServiceServer interface {
|
||||
type UnimplementedBackrestSyncStateServiceServer struct{}
|
||||
|
||||
func (UnimplementedBackrestSyncStateServiceServer) GetPeerSyncStatesStream(*SyncStateStreamRequest, grpc.ServerStreamingServer[PeerState]) error {
|
||||
return status.Error(codes.Unimplemented, "method GetPeerSyncStatesStream not implemented")
|
||||
return status.Errorf(codes.Unimplemented, "method GetPeerSyncStatesStream not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestSyncStateServiceServer) SetRemoteClientConfig(context.Context, *SetRemoteClientConfigRequest) (*SetRemoteClientConfigResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method SetRemoteClientConfig not implemented")
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetRemoteClientConfig not implemented")
|
||||
}
|
||||
func (UnimplementedBackrestSyncStateServiceServer) mustEmbedUnimplementedBackrestSyncStateServiceServer() {
|
||||
}
|
||||
@@ -212,7 +212,7 @@ type UnsafeBackrestSyncStateServiceServer interface {
|
||||
}
|
||||
|
||||
func RegisterBackrestSyncStateServiceServer(s grpc.ServiceRegistrar, srv BackrestSyncStateServiceServer) {
|
||||
// If the following call panics, it indicates UnimplementedBackrestSyncStateServiceServer was
|
||||
// If the following call pancis, it indicates UnimplementedBackrestSyncStateServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
|
||||
@@ -830,199 +830,6 @@ func (s *BackrestHandler) PathAutocomplete(ctx context.Context, path *connect.Re
|
||||
return connect.NewResponse(&types.StringList{Values: paths}), nil
|
||||
}
|
||||
|
||||
func (s *BackrestHandler) GetSummaryDashboard(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[v1.SummaryDashboardResponse], error) {
|
||||
config, err := s.config.Get()
|
||||
if err != nil {
|
||||
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
|
||||
var bytesAdded30 int64
|
||||
var backupsFailed30 int64
|
||||
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())
|
||||
|
||||
// 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++
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
if backupsExamined == 0 {
|
||||
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,
|
||||
BytesAddedLast_30Days: bytesAdded30,
|
||||
BackupsFailed_30Days: backupsFailed30,
|
||||
BackupsWarningLast_30Days: backupsWarning30,
|
||||
BackupsSuccessLast_30Days: backupsSuccess30,
|
||||
BytesScannedAvg: bytesScanned30 / backupsExamined,
|
||||
BytesAddedAvg: bytesAdded30 / backupsExamined,
|
||||
NextBackupTimeMs: nextBackupTime,
|
||||
RecentBackups: backupChart,
|
||||
ProtectedBytes: protectedBytes,
|
||||
HistoryLast_30Days: history,
|
||||
}, nil
|
||||
}
|
||||
|
||||
response := &v1.SummaryDashboardResponse{
|
||||
ConfigPath: env.ConfigFilePath(),
|
||||
DataPath: env.DataDir(),
|
||||
}
|
||||
|
||||
for _, repo := range config.Repos {
|
||||
resp, err := generateSummaryHelper(repo.Id, oplog.Query{}.
|
||||
SetInstanceID(config.Instance).
|
||||
SetRepoGUID(repo.GetGuid()).
|
||||
SetReversed(true).
|
||||
SetLimit(1000))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("summary for repo %q: %w", repo.Id, err)
|
||||
}
|
||||
|
||||
response.RepoSummaries = append(response.RepoSummaries, resp)
|
||||
}
|
||||
|
||||
for _, plan := range config.Plans {
|
||||
resp, err := generateSummaryHelper(plan.Id, oplog.Query{}.
|
||||
SetInstanceID(config.Instance).
|
||||
SetPlanID(plan.Id).
|
||||
SetReversed(true).
|
||||
SetLimit(1000))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("summary for plan %q: %w", plan.Id, err)
|
||||
}
|
||||
|
||||
response.PlanSummaries = append(response.PlanSummaries, resp)
|
||||
}
|
||||
|
||||
return connect.NewResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *BackrestHandler) GeneratePairingToken(ctx context.Context, req *connect.Request[v1.GeneratePairingTokenRequest]) (*connect.Response[v1.GeneratePairingTokenResponse], error) {
|
||||
// Generate the one-time secret before taking the lock.
|
||||
secret, err := cryptoutil.GeneratePairingSecret()
|
||||
|
||||
@@ -1464,3 +1464,184 @@ func TestSanitizeRepoFlags(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSummaryDashboardOverdue verifies that day buckets are flagged overdue once the
|
||||
// gap since the last good backup exceeds the plan schedule's nominal period plus grace.
|
||||
func TestGetSummaryDashboardOverdue(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", Paths: []string{t.TempDir()},
|
||||
Schedule: &v1.Schedule{Schedule: &v1.Schedule_MaxFrequencyDays{MaxFrequencyDays: 7}}},
|
||||
},
|
||||
}))
|
||||
|
||||
now := time.Now()
|
||||
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
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() }
|
||||
|
||||
// Weekly cadence kept between day-25 and day-18, then the plan stalls. Allowed
|
||||
// staleness is 7d * 1.25 = 8.75d, so the plan goes overdue during day-9 at 06:00.
|
||||
var flowID int64
|
||||
addBackup := func(daysAgo int) {
|
||||
flowID++
|
||||
start := dayMs(daysAgo)
|
||||
if err := sut.oplog.Add(&v1.Operation{
|
||||
InstanceId: "test",
|
||||
RepoId: "local",
|
||||
RepoGuid: repoGUID,
|
||||
PlanId: "test",
|
||||
FlowId: flowID,
|
||||
Status: v1.OperationStatus_STATUS_SUCCESS,
|
||||
UnixTimeStartMs: start,
|
||||
UnixTimeEndMs: start + 60*1000,
|
||||
Op: &v1.Operation_OperationBackup{OperationBackup: &v1.OperationBackup{}},
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to add operation: %v", err)
|
||||
}
|
||||
}
|
||||
addBackup(25)
|
||||
addBackup(18)
|
||||
|
||||
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]
|
||||
|
||||
// The strip starts at the oldest backup day: 26 buckets from day-25 through today.
|
||||
if len(summary.HistoryLast_30Days) != 26 {
|
||||
t.Fatalf("expected 26 history buckets, got %d", len(summary.HistoryLast_30Days))
|
||||
}
|
||||
overdueFor := func(daysAgo int) bool {
|
||||
want := bucketMs(daysAgo)
|
||||
for _, b := range summary.HistoryLast_30Days {
|
||||
if b.TimestampMs == want {
|
||||
return b.Overdue
|
||||
}
|
||||
}
|
||||
t.Fatalf("no bucket for %d days ago (ts %d)", daysAgo, want)
|
||||
return false
|
||||
}
|
||||
|
||||
for _, daysAgo := range []int{25, 18, 15, 10} {
|
||||
if overdueFor(daysAgo) {
|
||||
t.Errorf("day-%d should not be overdue", daysAgo)
|
||||
}
|
||||
}
|
||||
for _, daysAgo := range []int{9, 5, 0} {
|
||||
if !overdueFor(daysAgo) {
|
||||
t.Errorf("day-%d should be overdue", daysAgo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetSummaryDashboardDispatch verifies the single-pass summary correctly routes
|
||||
// interleaved operations from multiple plans: plan summaries stay isolated while the
|
||||
// shared repo summary aggregates both.
|
||||
func TestGetSummaryDashboardDispatch(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: "plan-a", Repo: "local", Paths: []string{t.TempDir()}, Schedule: &v1.Schedule{Schedule: &v1.Schedule_Disabled{Disabled: true}}},
|
||||
{Id: "plan-b", Repo: "local", Paths: []string{t.TempDir()}, 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())
|
||||
dayMs := func(daysAgo int) int64 { return midnight.AddDate(0, 0, -daysAgo).Add(12 * time.Hour).UnixMilli() }
|
||||
|
||||
var flowID int64
|
||||
addBackup := func(planID string, daysAgo int, status v1.OperationStatus, dataAdded int64) {
|
||||
flowID++
|
||||
start := dayMs(daysAgo)
|
||||
if err := sut.oplog.Add(&v1.Operation{
|
||||
InstanceId: "test",
|
||||
RepoId: "local",
|
||||
RepoGuid: repoGUID,
|
||||
PlanId: planID,
|
||||
FlowId: flowID,
|
||||
Status: status,
|
||||
UnixTimeStartMs: start,
|
||||
UnixTimeEndMs: start + 60*1000,
|
||||
Op: &v1.Operation_OperationBackup{OperationBackup: &v1.OperationBackup{
|
||||
LastStatus: &v1.BackupProgressEntry{Entry: &v1.BackupProgressEntry_Summary{
|
||||
Summary: &v1.BackupProgressSummary{DataAdded: dataAdded, TotalBytesProcessed: dataAdded * 10},
|
||||
}},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to add operation: %v", err)
|
||||
}
|
||||
}
|
||||
// Interleave the two plans' backups.
|
||||
addBackup("plan-a", 3, v1.OperationStatus_STATUS_SUCCESS, 100)
|
||||
addBackup("plan-b", 2, v1.OperationStatus_STATUS_ERROR, 0)
|
||||
addBackup("plan-a", 1, v1.OperationStatus_STATUS_SUCCESS, 30)
|
||||
addBackup("plan-b", 0, v1.OperationStatus_STATUS_SUCCESS, 7)
|
||||
|
||||
resp, err := sut.handler.GetSummaryDashboard(context.Background(), connect.NewRequest(&emptypb.Empty{}))
|
||||
if err != nil {
|
||||
t.Fatalf("GetSummaryDashboard() error = %v", err)
|
||||
}
|
||||
|
||||
bySummaryID := func(summaries []*v1.SummaryDashboardResponse_Summary, id string) *v1.SummaryDashboardResponse_Summary {
|
||||
for _, s := range summaries {
|
||||
if s.Id == id {
|
||||
return s
|
||||
}
|
||||
}
|
||||
t.Fatalf("no summary with id %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
planA := bySummaryID(resp.Msg.PlanSummaries, "plan-a")
|
||||
if planA.BackupsSuccessLast_30Days != 2 || planA.BackupsFailed_30Days != 0 {
|
||||
t.Errorf("plan-a: expected 2 successes / 0 failures, got %d / %d",
|
||||
planA.BackupsSuccessLast_30Days, planA.BackupsFailed_30Days)
|
||||
}
|
||||
if planA.BytesAddedLast_30Days != 130 {
|
||||
t.Errorf("plan-a: expected 130 bytes added, got %d", planA.BytesAddedLast_30Days)
|
||||
}
|
||||
|
||||
planB := bySummaryID(resp.Msg.PlanSummaries, "plan-b")
|
||||
if planB.BackupsSuccessLast_30Days != 1 || planB.BackupsFailed_30Days != 1 {
|
||||
t.Errorf("plan-b: expected 1 success / 1 failure, got %d / %d",
|
||||
planB.BackupsSuccessLast_30Days, planB.BackupsFailed_30Days)
|
||||
}
|
||||
|
||||
// The shared repo aggregates both plans' backups.
|
||||
repo := bySummaryID(resp.Msg.RepoSummaries, "local")
|
||||
if repo.BackupsSuccessLast_30Days != 3 || repo.BackupsFailed_30Days != 1 {
|
||||
t.Errorf("repo: expected 3 successes / 1 failure, got %d / %d",
|
||||
repo.BackupsSuccessLast_30Days, repo.BackupsFailed_30Days)
|
||||
}
|
||||
if repo.BytesAddedLast_30Days != 137 {
|
||||
t.Errorf("repo: expected 137 bytes added, got %d", repo.BytesAddedLast_30Days)
|
||||
}
|
||||
// protected_bytes reflects the newest good backup seen by each summary.
|
||||
if planA.ProtectedBytes != 300 || repo.ProtectedBytes != 70 {
|
||||
t.Errorf("expected protected bytes plan-a=300 repo=70, got %d / %d",
|
||||
planA.ProtectedBytes, repo.ProtectedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
v1 "github.com/garethgeorge/backrest/gen/go/v1"
|
||||
"github.com/garethgeorge/backrest/internal/env"
|
||||
"github.com/garethgeorge/backrest/internal/oplog"
|
||||
"github.com/garethgeorge/backrest/internal/protoutil"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
|
||||
const (
|
||||
// summaryHistoryDays is the dashboard window: today plus the prior N-1 days.
|
||||
summaryHistoryDays = 30
|
||||
|
||||
// summaryChartBackups caps how many recent backups each summary's chart includes.
|
||||
summaryChartBackups = 60
|
||||
|
||||
// overdueGraceFactor pads a schedule's nominal period before a day is flagged
|
||||
// overdue, tolerating scheduler jitter and backup run time.
|
||||
overdueGraceFactor = 1.25
|
||||
)
|
||||
|
||||
func (s *BackrestHandler) GetSummaryDashboard(ctx context.Context, req *connect.Request[emptypb.Empty]) (*connect.Response[v1.SummaryDashboardResponse], error) {
|
||||
cfg, err := s.config.Get()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
cutoffMidnight := localMidnight(now).AddDate(0, 0, -(summaryHistoryDays - 1))
|
||||
|
||||
// One accumulator per repo and per plan; each operation in the pass below is
|
||||
// dispatched to at most one of each.
|
||||
repoAccs := make(map[string]*summaryAcc) // keyed by repo GUID
|
||||
planAccs := make(map[string]*summaryAcc) // keyed by plan ID
|
||||
for _, repo := range cfg.Repos {
|
||||
repoAccs[repo.GetGuid()] = newSummaryAcc(cutoffMidnight)
|
||||
}
|
||||
for _, plan := range cfg.Plans {
|
||||
planAccs[plan.Id] = newSummaryAcc(cutoffMidnight)
|
||||
}
|
||||
// Walk every operation for this instance, newest to oldest, dispatching each
|
||||
// backup to its plan's and its repo's accumulator.
|
||||
if err := s.oplog.Query(oplog.Query{}.SetInstanceID(cfg.Instance).SetReversed(true), func(op *v1.Operation) error {
|
||||
backupOp := op.GetOperationBackup()
|
||||
if backupOp == nil {
|
||||
return nil
|
||||
}
|
||||
if acc, ok := planAccs[op.PlanId]; ok {
|
||||
acc.observe(op, backupOp)
|
||||
}
|
||||
if acc, ok := repoAccs[op.RepoGuid]; ok {
|
||||
acc.observe(op, backupOp)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("failed to query operations: %w", err)
|
||||
}
|
||||
|
||||
response := &v1.SummaryDashboardResponse{
|
||||
ConfigPath: env.ConfigFilePath(),
|
||||
DataPath: env.DataDir(),
|
||||
}
|
||||
for _, repo := range cfg.Repos {
|
||||
response.RepoSummaries = append(response.RepoSummaries,
|
||||
repoAccs[repo.GetGuid()].finalize(repo.Id, now, repoAllowedStaleness(cfg, repo.Id, now)))
|
||||
}
|
||||
for _, plan := range cfg.Plans {
|
||||
response.PlanSummaries = append(response.PlanSummaries,
|
||||
planAccs[plan.Id].finalize(plan.Id, now, allowedStaleness(plan.Schedule, now)))
|
||||
}
|
||||
|
||||
return connect.NewResponse(response), nil
|
||||
}
|
||||
|
||||
// allowedStaleness converts a schedule to the maximum acceptable gap between
|
||||
// OK backups, or 0 when there is no expectation (disabled or unparseable).
|
||||
func allowedStaleness(sched *v1.Schedule, now time.Time) time.Duration {
|
||||
period, err := protoutil.NominalPeriod(sched, now)
|
||||
if err != nil {
|
||||
if !errors.Is(err, protoutil.ErrScheduleDisabled) {
|
||||
zap.S().Warnf("summary dashboard: nominal period: %v", err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return time.Duration(float64(period) * overdueGraceFactor)
|
||||
}
|
||||
|
||||
// repoAllowedStaleness is the widest allowed staleness among plans targeting
|
||||
// the repo: a repo day is overdue only when even the slowest plan should have run.
|
||||
func repoAllowedStaleness(cfg *v1.Config, repoID string, now time.Time) time.Duration {
|
||||
var widest time.Duration
|
||||
for _, plan := range cfg.Plans {
|
||||
if plan.Repo != repoID {
|
||||
continue
|
||||
}
|
||||
if a := allowedStaleness(plan.Schedule, now); a > widest {
|
||||
widest = a
|
||||
}
|
||||
}
|
||||
return widest
|
||||
}
|
||||
|
||||
// localMidnight truncates t to midnight in its own location.
|
||||
func localMidnight(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
// summaryDayAcc accumulates per-day backup stats for the dashboard history strip.
|
||||
type summaryDayAcc struct {
|
||||
bytesAdded int64
|
||||
bytesScanned int64
|
||||
statusCounts map[v1.OperationStatus]int64
|
||||
}
|
||||
|
||||
// summaryAcc accumulates the backup operations for one plan or repo, observed
|
||||
// newest to oldest, into a dashboard summary.
|
||||
type summaryAcc struct {
|
||||
cutoffMidnight time.Time
|
||||
|
||||
backupsExamined int64
|
||||
bytesScanned30 int64
|
||||
bytesAdded30 int64
|
||||
backupsFailed30 int64
|
||||
backupsSuccess30 int64
|
||||
backupsWarning30 int64
|
||||
nextBackupTime int64
|
||||
protectedBytes int64
|
||||
backupChart *v1.SummaryDashboardResponse_BackupChart
|
||||
|
||||
// Per-day accumulators keyed by the day's local-midnight unix millis. oldestDay
|
||||
// tracks the earliest in-window day with a backup; reachedCutoff means backups
|
||||
// exist beyond the window, so the history strip spans the full window.
|
||||
perDay map[int64]*summaryDayAcc
|
||||
oldestDay time.Time
|
||||
reachedCutoff bool
|
||||
|
||||
// Times of in-window OK backups (success or warning, not a dry run), which
|
||||
// reset the staleness clock, plus the most recent OK backup before the window.
|
||||
okBackupDates []time.Time
|
||||
lastOkBackupBeforeWindow time.Time
|
||||
}
|
||||
|
||||
func newSummaryAcc(cutoffMidnight time.Time) *summaryAcc {
|
||||
return &summaryAcc{
|
||||
cutoffMidnight: cutoffMidnight,
|
||||
backupChart: &v1.SummaryDashboardResponse_BackupChart{},
|
||||
perDay: make(map[int64]*summaryDayAcc),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *summaryAcc) observe(op *v1.Operation, backupOp *v1.OperationBackup) {
|
||||
startTime := time.UnixMilli(op.UnixTimeStartMs)
|
||||
opMidnight := localMidnight(startTime)
|
||||
// Dry runs don't reset the staleness clock, matching the scheduler's view.
|
||||
isOkBackup := (op.Status == v1.OperationStatus_STATUS_SUCCESS ||
|
||||
op.Status == v1.OperationStatus_STATUS_WARNING) && !backupOp.DryRun
|
||||
|
||||
// Backups older than the window only contribute the staleness anchor; walking
|
||||
// newest-first, the first OK backup seen here is the most recent.
|
||||
if opMidnight.Before(a.cutoffMidnight) {
|
||||
a.reachedCutoff = true
|
||||
if isOkBackup && a.lastOkBackupBeforeWindow.IsZero() {
|
||||
a.lastOkBackupBeforeWindow = startTime
|
||||
}
|
||||
return
|
||||
}
|
||||
if op.GetStatus() == v1.OperationStatus_STATUS_PENDING {
|
||||
a.nextBackupTime = op.UnixTimeStartMs
|
||||
return
|
||||
}
|
||||
a.backupsExamined++
|
||||
|
||||
switch op.Status {
|
||||
case v1.OperationStatus_STATUS_SUCCESS:
|
||||
a.backupsSuccess30++
|
||||
case v1.OperationStatus_STATUS_ERROR:
|
||||
a.backupsFailed30++
|
||||
case v1.OperationStatus_STATUS_WARNING:
|
||||
a.backupsWarning30++
|
||||
}
|
||||
|
||||
if isOkBackup {
|
||||
a.okBackupDates = append(a.okBackupDates, startTime)
|
||||
}
|
||||
|
||||
summary := backupOp.GetLastStatus().GetSummary()
|
||||
if summary != nil {
|
||||
a.bytesScanned30 += summary.TotalBytesProcessed
|
||||
a.bytesAdded30 += summary.DataAdded
|
||||
}
|
||||
|
||||
// protected_bytes: the most recent (first seen) good backup's total size.
|
||||
if a.protectedBytes == 0 && summary != nil && isOkBackup {
|
||||
a.protectedBytes = summary.TotalBytesProcessed
|
||||
}
|
||||
|
||||
// Update the per-day aggregate for this backup's day.
|
||||
dayMs := opMidnight.UnixMilli()
|
||||
acc := a.perDay[dayMs]
|
||||
if acc == nil {
|
||||
acc = &summaryDayAcc{statusCounts: make(map[v1.OperationStatus]int64)}
|
||||
a.perDay[dayMs] = acc
|
||||
}
|
||||
acc.statusCounts[op.Status]++
|
||||
if summary != nil {
|
||||
acc.bytesAdded += summary.DataAdded
|
||||
acc.bytesScanned += summary.TotalBytesProcessed
|
||||
}
|
||||
if a.oldestDay.IsZero() || opMidnight.Before(a.oldestDay) {
|
||||
a.oldestDay = opMidnight
|
||||
}
|
||||
|
||||
if len(a.backupChart.TimestampMs) < summaryChartBackups {
|
||||
duration := op.UnixTimeEndMs - op.UnixTimeStartMs
|
||||
if duration <= 1000 {
|
||||
duration = 1000
|
||||
}
|
||||
|
||||
a.backupChart.FlowId = append(a.backupChart.FlowId, op.FlowId)
|
||||
a.backupChart.TimestampMs = append(a.backupChart.TimestampMs, op.UnixTimeStartMs)
|
||||
a.backupChart.DurationMs = append(a.backupChart.DurationMs, duration)
|
||||
a.backupChart.Status = append(a.backupChart.Status, op.Status)
|
||||
a.backupChart.BytesAdded = append(a.backupChart.BytesAdded, summary.GetDataAdded())
|
||||
}
|
||||
}
|
||||
|
||||
// finalize builds the summary proto. allowedStaleness > 0 enables overdue
|
||||
// detection on the day history.
|
||||
func (a *summaryAcc) finalize(id string, now time.Time, allowedStaleness time.Duration) *v1.SummaryDashboardResponse_Summary {
|
||||
todayMidnight := localMidnight(now)
|
||||
|
||||
backupsExamined := a.backupsExamined
|
||||
if backupsExamined == 0 {
|
||||
backupsExamined = 1 // prevent division by zero for avg calculations
|
||||
}
|
||||
|
||||
// OK backups ascending: these reset the staleness clock. The pre-window
|
||||
// anchor counts only when the window has no OK backup of its own, so a fully
|
||||
// stalled plan still flags every day, while an overdue stretch that ended
|
||||
// before the window's first OK backup is resolved history and stays muted.
|
||||
okBackupDates := a.okBackupDates
|
||||
if len(okBackupDates) == 0 && !a.lastOkBackupBeforeWindow.IsZero() {
|
||||
okBackupDates = append(okBackupDates, a.lastOkBackupBeforeWindow)
|
||||
}
|
||||
slices.SortFunc(okBackupDates, func(x, y time.Time) int { return x.Compare(y) })
|
||||
|
||||
// Flatten the per-day map into buckets ordered oldest-first, one per consecutive day
|
||||
// from the oldest active day (or the window start, if older backups exist) through
|
||||
// today; absent days get empty buckets. The client matches buckets to days by their
|
||||
// distance from the newest bucket, and renders days before the span as "before start".
|
||||
start := todayMidnight
|
||||
if a.reachedCutoff {
|
||||
start = a.cutoffMidnight
|
||||
} else if !a.oldestDay.IsZero() {
|
||||
start = a.oldestDay
|
||||
}
|
||||
var history []*v1.SummaryDashboardResponse_DayStatusBucket
|
||||
var lastOkBackup time.Time
|
||||
nextOkBackup := 0
|
||||
for day := start; !day.After(todayMidnight); day = day.AddDate(0, 0, 1) {
|
||||
bucket := &v1.SummaryDashboardResponse_DayStatusBucket{
|
||||
TimestampMs: day.UnixMilli(),
|
||||
}
|
||||
if acc := a.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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A day is overdue when, at its close (clamped to now for today), the newest
|
||||
// OK backup is older than the allowed staleness. Days before the first OK
|
||||
// backup ever are exempt: the scheduler anchors to the first real run. A day
|
||||
// where a late backup finally lands renders as backed-up, so checking only the
|
||||
// day's close loses nothing visible.
|
||||
if allowedStaleness > 0 {
|
||||
checkpoint := day.AddDate(0, 0, 1)
|
||||
if checkpoint.After(now) {
|
||||
checkpoint = now
|
||||
}
|
||||
for nextOkBackup < len(okBackupDates) && !okBackupDates[nextOkBackup].After(checkpoint) {
|
||||
lastOkBackup = okBackupDates[nextOkBackup]
|
||||
nextOkBackup++
|
||||
}
|
||||
bucket.Overdue = !lastOkBackup.IsZero() && checkpoint.Sub(lastOkBackup) > allowedStaleness
|
||||
}
|
||||
|
||||
history = append(history, bucket)
|
||||
}
|
||||
|
||||
return &v1.SummaryDashboardResponse_Summary{
|
||||
Id: id,
|
||||
BytesScannedLast_30Days: a.bytesScanned30,
|
||||
BytesAddedLast_30Days: a.bytesAdded30,
|
||||
BackupsFailed_30Days: a.backupsFailed30,
|
||||
BackupsWarningLast_30Days: a.backupsWarning30,
|
||||
BackupsSuccessLast_30Days: a.backupsSuccess30,
|
||||
BytesScannedAvg: a.bytesScanned30 / backupsExamined,
|
||||
BytesAddedAvg: a.bytesAdded30 / backupsExamined,
|
||||
NextBackupTimeMs: a.nextBackupTime,
|
||||
RecentBackups: a.backupChart,
|
||||
ProtectedBytes: a.protectedBytes,
|
||||
HistoryLast_30Days: history,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSummaryOverdueFlags(t *testing.T) {
|
||||
// Fixed UTC reference: "today" is Jun 30 2026; the window is the prior 9 days.
|
||||
day := func(n int) time.Time { return time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC).AddDate(0, 0, n) }
|
||||
at := func(n int, hour int) time.Time { return day(n).Add(time.Duration(hour) * time.Hour) }
|
||||
now := at(9, 12) // noon on the last day
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lastOkBackupBeforeWindow time.Time
|
||||
okBackupDates []time.Time
|
||||
allowedGap time.Duration
|
||||
expected []int // indices of days expected to be flagged overdue
|
||||
}{
|
||||
{
|
||||
name: "no backups ever means no expectation",
|
||||
okBackupDates: nil,
|
||||
allowedGap: 24 * time.Hour,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "daily cadence kept",
|
||||
okBackupDates: []time.Time{at(0, 1), at(1, 1), at(2, 1), at(3, 1), at(4, 1), at(5, 1), at(6, 1), at(7, 1), at(8, 1), at(9, 1)},
|
||||
allowedGap: 30 * time.Hour, // 24h * 1.25 grace
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "daily cadence with one skipped day",
|
||||
okBackupDates: []time.Time{at(0, 1), at(1, 1), at(2, 1), at(4, 1), at(5, 1), at(6, 1), at(7, 1), at(8, 1), at(9, 1)},
|
||||
allowedGap: 30 * time.Hour,
|
||||
// At day3's close the day2 01:00 backup is 47h old; day4 recovers with a
|
||||
// backup of its own (and renders as backed-up, not overdue).
|
||||
expected: []int{3},
|
||||
},
|
||||
{
|
||||
name: "weekly cadence never overdue within window",
|
||||
okBackupDates: []time.Time{at(1, 2), at(8, 2)},
|
||||
allowedGap: 8 * 24 * time.Hour,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "stalled plan flags tail days",
|
||||
okBackupDates: []time.Time{at(0, 1), at(1, 1)},
|
||||
allowedGap: 30 * time.Hour,
|
||||
// At day2's close the day1 01:00 backup is 47h old, and it only ages from there.
|
||||
expected: []int{2, 3, 4, 5, 6, 7, 8, 9},
|
||||
},
|
||||
{
|
||||
name: "pre-window staleness resolved in window stays muted",
|
||||
lastOkBackupBeforeWindow: day(-5),
|
||||
okBackupDates: []time.Time{at(3, 1), at(5, 1), at(7, 1), at(9, 1)},
|
||||
allowedGap: 48 * time.Hour,
|
||||
// The stale day(-5) anchor is ignored because the window has its own good
|
||||
// backups: the overdue stretch ended before day3 and is resolved history.
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "fully stalled plan flags every day",
|
||||
lastOkBackupBeforeWindow: day(-5),
|
||||
okBackupDates: nil,
|
||||
allowedGap: 48 * time.Hour,
|
||||
// No good backup in the window: the anchor drives staleness, and every
|
||||
// day closes more than 48h past it.
|
||||
expected: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
},
|
||||
{
|
||||
name: "gap exactly at allowed staleness is not overdue",
|
||||
okBackupDates: []time.Time{at(0, 0), at(2, 0), at(4, 0), at(6, 0), at(8, 0)},
|
||||
allowedGap: 48 * time.Hour,
|
||||
// Every day's close lands at most exactly on a deadline, never past one.
|
||||
expected: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
acc := newSummaryAcc(day(0))
|
||||
acc.reachedCutoff = true // span the full window from day 0
|
||||
acc.okBackupDates = tt.okBackupDates
|
||||
acc.lastOkBackupBeforeWindow = tt.lastOkBackupBeforeWindow
|
||||
|
||||
history := acc.finalize("test", now, tt.allowedGap).HistoryLast_30Days
|
||||
if len(history) != 10 {
|
||||
t.Fatalf("expected 10 day buckets, got %d", len(history))
|
||||
}
|
||||
expectedSet := make(map[int]bool)
|
||||
for _, i := range tt.expected {
|
||||
expectedSet[i] = true
|
||||
}
|
||||
for i, bucket := range history {
|
||||
if bucket.Overdue != expectedSet[i] {
|
||||
t.Errorf("day %d: overdue = %v, want %v", i, bucket.Overdue, expectedSet[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,44 @@ func ResolveSchedule(sched *v1.Schedule, lastRan time.Time, curTime time.Time) (
|
||||
}
|
||||
}
|
||||
|
||||
// cronPeriodSamples is the number of consecutive fire-to-fire gaps sampled to
|
||||
// estimate a cron schedule's nominal period; enough to span irregular patterns
|
||||
// like weekdays-only (whose widest gap is the weekend).
|
||||
const cronPeriodSamples = 8
|
||||
|
||||
// NominalPeriod returns the largest expected gap between consecutive runs of the
|
||||
// schedule. It is a display-grade staleness bound, not a scheduling calculation.
|
||||
func NominalPeriod(sched *v1.Schedule, from time.Time) (time.Duration, error) {
|
||||
switch s := sched.GetSchedule().(type) {
|
||||
case *v1.Schedule_Disabled, nil:
|
||||
return 0, ErrScheduleDisabled
|
||||
case *v1.Schedule_MaxFrequencyDays:
|
||||
return time.Duration(s.MaxFrequencyDays) * 24 * time.Hour, nil
|
||||
case *v1.Schedule_MaxFrequencyHours:
|
||||
return time.Duration(s.MaxFrequencyHours) * time.Hour, nil
|
||||
case *v1.Schedule_Cron:
|
||||
cron, err := cronexpr.ParseInLocation(s.Cron, from.Location().String())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse cron %q: %w", s.Cron, err)
|
||||
}
|
||||
var maxGap time.Duration
|
||||
t := cron.Next(from)
|
||||
for i := 0; i < cronPeriodSamples; i++ {
|
||||
next := cron.Next(t)
|
||||
if next.IsZero() || !next.After(t) {
|
||||
return 0, fmt.Errorf("cron %q: could not compute consecutive run times", s.Cron)
|
||||
}
|
||||
if gap := next.Sub(t); gap > maxGap {
|
||||
maxGap = gap
|
||||
}
|
||||
t = next
|
||||
}
|
||||
return maxGap, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown schedule type: %T", s)
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateSchedule(sched *v1.Schedule) error {
|
||||
switch s := sched.GetSchedule().(type) {
|
||||
case *v1.Schedule_MaxFrequencyDays:
|
||||
|
||||
@@ -126,3 +126,69 @@ func TestValidateSchedule(t *testing.T) {
|
||||
func transactionalTimeEqual(t1, t2 time.Time) bool {
|
||||
return t1.Equal(t2)
|
||||
}
|
||||
|
||||
func TestNominalPeriod(t *testing.T) {
|
||||
// Fixed reference time in UTC so DST transitions can't widen sampled cron gaps.
|
||||
from := time.Date(2023, 10, 1, 10, 0, 0, 0, time.UTC) // Sunday, Oct 1st 2023 10:00
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
schedule *v1.Schedule
|
||||
expected time.Duration
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "MaxFrequencyDays",
|
||||
schedule: &v1.Schedule{Schedule: &v1.Schedule_MaxFrequencyDays{MaxFrequencyDays: 7}},
|
||||
expected: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "MaxFrequencyHours",
|
||||
schedule: &v1.Schedule{Schedule: &v1.Schedule_MaxFrequencyHours{MaxFrequencyHours: 6}},
|
||||
expected: 6 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "Cron daily",
|
||||
schedule: &v1.Schedule{Schedule: &v1.Schedule_Cron{Cron: "0 0 * * *"}},
|
||||
expected: 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "Cron weekly",
|
||||
schedule: &v1.Schedule{Schedule: &v1.Schedule_Cron{Cron: "0 0 * * 0"}},
|
||||
expected: 7 * 24 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "Cron weekdays only uses widest gap",
|
||||
// 9am Mon-Fri: the widest quiet stretch is Friday 9am -> Monday 9am.
|
||||
schedule: &v1.Schedule{Schedule: &v1.Schedule_Cron{Cron: "0 9 * * 1-5"}},
|
||||
expected: 72 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: "Disabled",
|
||||
schedule: &v1.Schedule{Schedule: &v1.Schedule_Disabled{Disabled: true}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Nil schedule",
|
||||
schedule: &v1.Schedule{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid cron",
|
||||
schedule: &v1.Schedule{Schedule: &v1.Schedule_Cron{Cron: "not a cron"}},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := protoutil.NominalPeriod(tt.schedule, from)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ message SummaryDashboardResponse {
|
||||
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
|
||||
bool overdue = 5; // gap since the last good backup exceeded the schedule's nominal period during this day
|
||||
}
|
||||
|
||||
message StatusAndCount {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -505,6 +505,8 @@
|
||||
"dashboard_history_tooltip_status_inprogress": "{count} in progress",
|
||||
"dashboard_history_tooltip_added": "Added {bytes}",
|
||||
"dashboard_history_tooltip_scanned": "Scanned {bytes}",
|
||||
"dashboard_history_tooltip_overdue": "Backup overdue as of this day",
|
||||
"dashboard_history_tooltip_on_schedule": "Backups up to date as of this day",
|
||||
"dashboard_state_label_err": "Backup failed",
|
||||
"dashboard_state_label_idle": "No backups yet",
|
||||
"dashboard_state_label_ok": "Backed up",
|
||||
|
||||
@@ -10,7 +10,8 @@ const HISTORY_DAYS = 30;
|
||||
// Display category for one day; drives color, border, dimming, and tooltip via CELL_STYLE.
|
||||
type CellKind =
|
||||
| "beforeStart"
|
||||
| "missed"
|
||||
| "idle"
|
||||
| "overdue"
|
||||
| "inprogress"
|
||||
| "ok"
|
||||
| "warn"
|
||||
@@ -26,8 +27,10 @@ interface DayCell {
|
||||
|
||||
const CELL_STYLE: Record<CellKind, { bg: string; dim: boolean }> = {
|
||||
beforeStart: { bg: "bg.muted", dim: true },
|
||||
// A subtle but clearly-visible neutral fill so gaps read as "missed", not blank.
|
||||
missed: { bg: "bg.emphasized", dim: false },
|
||||
// No backup and none expected yet (e.g. a weekly plan between runs): stay quiet.
|
||||
idle: { bg: "bg.muted", dim: false },
|
||||
// The schedule expected a backup that never came: clearly visible neutral fill.
|
||||
overdue: { bg: "bg.emphasized", dim: false },
|
||||
inprogress: { bg: "blue.400", dim: false },
|
||||
ok: { bg: "green.500", dim: false },
|
||||
warn: { bg: "orange.400", dim: false },
|
||||
@@ -70,7 +73,10 @@ function cellKind(
|
||||
bucket: SummaryDashboardResponse_DayStatusBucket | undefined,
|
||||
): CellKind {
|
||||
const counts = bucket?.statusCounts ?? [];
|
||||
if (counts.length === 0) return "missed";
|
||||
if (counts.length === 0) {
|
||||
// No backup this day: only alarming if the server says one was due.
|
||||
return bucket?.overdue ? "overdue" : "idle";
|
||||
}
|
||||
let worst: StatusCat | undefined;
|
||||
for (const { status } of counts) {
|
||||
const cat = STATUS_CAT[status];
|
||||
@@ -82,22 +88,34 @@ function cellKind(
|
||||
return worst ?? "other";
|
||||
}
|
||||
|
||||
// Fixed 30-cell strip, most-recent day first (left). The newest bucket is always
|
||||
// today; older days follow to the right, and days before the plan's first backup
|
||||
// render as dimmed "before start" cells on the trailing (right) edge.
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
// Fixed 30-cell strip, most-recent day first (left). Buckets are matched to cells
|
||||
// by day distance from the newest bucket (always "today" on the server), which
|
||||
// tolerates server/browser timezone differences and any gaps in the bucket list.
|
||||
// Days before the plan's first backup render as dimmed "before start" cells.
|
||||
function toCells(buckets: SummaryDashboardResponse_DayStatusBucket[]): DayCell[] {
|
||||
const recent = buckets.slice(-HISTORY_DAYS); // oldest-first from the server
|
||||
const midnight = new Date();
|
||||
midnight.setHours(0, 0, 0, 0);
|
||||
|
||||
return Array.from({ length: HISTORY_DAYS }, (_, i): DayCell => {
|
||||
const daysAgo = i; // i === 0 is today, at the left edge
|
||||
const newestMs = buckets.length
|
||||
? Number(buckets[buckets.length - 1].timestampMs)
|
||||
: 0;
|
||||
const byDaysAgo = new Map<number, SummaryDashboardResponse_DayStatusBucket>();
|
||||
let maxDaysAgo = -1;
|
||||
for (const b of buckets) {
|
||||
// Round to absorb DST-shifted midnights.
|
||||
const daysAgo = Math.round((newestMs - Number(b.timestampMs)) / MS_PER_DAY);
|
||||
byDaysAgo.set(daysAgo, b);
|
||||
maxDaysAgo = Math.max(maxDaysAgo, daysAgo);
|
||||
}
|
||||
|
||||
return Array.from({ length: HISTORY_DAYS }, (_, daysAgo): DayCell => {
|
||||
const date = new Date(midnight);
|
||||
date.setDate(midnight.getDate() - daysAgo);
|
||||
const bucketIdx = recent.length - 1 - i; // newest bucket at i === 0
|
||||
const bucket = bucketIdx >= 0 ? recent[bucketIdx] : undefined;
|
||||
const bucket = byDaysAgo.get(daysAgo);
|
||||
return {
|
||||
kind: bucketIdx < 0 ? "beforeStart" : cellKind(bucket),
|
||||
kind: daysAgo > maxDaysAgo ? "beforeStart" : cellKind(bucket),
|
||||
label: date.toLocaleDateString(),
|
||||
isToday: daysAgo === 0,
|
||||
bucket,
|
||||
@@ -108,7 +126,7 @@ function toCells(buckets: SummaryDashboardResponse_DayStatusBucket[]): DayCell[]
|
||||
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((c) => c.kind === "missed").length;
|
||||
const missed = active.filter((c) => c.kind === "overdue").length;
|
||||
const issues = active.filter(
|
||||
(c) => c.kind === "warn" || c.kind === "err",
|
||||
).length;
|
||||
@@ -158,8 +176,14 @@ const DayTooltip = ({ cell }: { cell: DayCell }) => {
|
||||
{m.dashboard_history_tooltip_before_start()}
|
||||
</Text>
|
||||
) : !hasBackups ? (
|
||||
<Text fontSize="11px" color="fg.muted">
|
||||
{m.dashboard_history_tooltip_no_backup()}
|
||||
<Text
|
||||
fontSize="11px"
|
||||
fontWeight={cell.bucket?.overdue ? "600" : "400"}
|
||||
color={cell.bucket?.overdue ? "orange.400" : "fg.muted"}
|
||||
>
|
||||
{cell.bucket?.overdue
|
||||
? m.dashboard_history_tooltip_overdue()
|
||||
: m.dashboard_history_tooltip_on_schedule()}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="3px">
|
||||
@@ -195,6 +219,11 @@ const DayTooltip = ({ cell }: { cell: DayCell }) => {
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{cell.bucket?.overdue && (
|
||||
<Text fontSize="11px" fontWeight="600" color="orange.400">
|
||||
{m.dashboard_history_tooltip_overdue()}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
@@ -252,6 +281,10 @@ export const HistoryStrip = ({
|
||||
{ label: m.dashboard_history_legend_backed_up(), color: "green.500" },
|
||||
{ label: m.dashboard_history_legend_issue(), color: "orange.400" },
|
||||
{ label: m.dashboard_history_legend_inprogress(), color: "blue.400" },
|
||||
{
|
||||
label: m.dashboard_history_legend_missed(),
|
||||
color: "bg.emphasized",
|
||||
},
|
||||
].map(({ label, color }) => (
|
||||
<Flex key={label} align="center" gap="5px">
|
||||
<Box w="9px" h="9px" borderRadius="2px" bg={color} flexShrink={0} />
|
||||
@@ -260,18 +293,6 @@ export const HistoryStrip = ({
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
<Flex align="center" gap="5px">
|
||||
<Box
|
||||
w="9px"
|
||||
h="9px"
|
||||
borderRadius="2px"
|
||||
bg="bg.emphasized"
|
||||
flexShrink={0}
|
||||
/>
|
||||
<Text fontSize="11px" color="fg.muted">
|
||||
{m.dashboard_history_legend_missed()}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { FiCheck, FiDatabase, FiRefreshCw, FiServer } from "react-icons/fi";
|
||||
import { LuTriangle, LuX } from "react-icons/lu";
|
||||
import { useNavigate } from "react-router";
|
||||
@@ -164,12 +164,32 @@ const STATE_SEVERITY: Record<PlanState, number> = {
|
||||
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();
|
||||
if (state === "err") return m.dashboard_state_label_err();
|
||||
if (state === "run") return m.dashboard_state_label_run();
|
||||
return m.dashboard_state_label_idle();
|
||||
const STATE_LABEL: Record<PlanState, () => string> = {
|
||||
ok: m.dashboard_state_label_ok,
|
||||
warn: m.dashboard_state_label_warn,
|
||||
err: m.dashboard_state_label_err,
|
||||
run: m.dashboard_state_label_run,
|
||||
idle: m.dashboard_state_label_idle,
|
||||
};
|
||||
|
||||
// Status fields shared by plan cards, repo cards, and the hero banner,
|
||||
// derived from a summary's most recent backup.
|
||||
interface SummaryStatus {
|
||||
latestTs: number;
|
||||
running: boolean;
|
||||
state: PlanState;
|
||||
color: string;
|
||||
}
|
||||
|
||||
function summaryStatus(summary: SummaryDashboardResponse_Summary): SummaryStatus {
|
||||
const rb = summary.recentBackups;
|
||||
const latestStatus = rb?.status[0];
|
||||
const latestTs = Number(rb?.timestampMs[0] ?? 0);
|
||||
const running =
|
||||
latestStatus === OperationStatus.STATUS_INPROGRESS ||
|
||||
latestStatus === OperationStatus.STATUS_PENDING;
|
||||
const state = planState(latestStatus, running);
|
||||
return { latestTs, running, state, color: STATE_COLORS[state] };
|
||||
}
|
||||
|
||||
// ─── Progress bar (animated shimmer) ─────────────────────────────────────────
|
||||
@@ -192,9 +212,7 @@ const ProgressBar = ({ pct }: { pct: number }) => (
|
||||
|
||||
// ─── Hero banner ──────────────────────────────────────────────────────────────
|
||||
|
||||
type HeroState = PlanState;
|
||||
|
||||
const HERO_ICON: Record<HeroState, React.ReactNode> = {
|
||||
const HERO_ICON: Record<PlanState, React.ReactNode> = {
|
||||
ok: <FiCheck strokeWidth={2.4} />,
|
||||
run: <FiRefreshCw strokeWidth={2.4} />,
|
||||
warn: <LuTriangle strokeWidth={2.4} />,
|
||||
@@ -202,55 +220,46 @@ const HERO_ICON: Record<HeroState, React.ReactNode> = {
|
||||
idle: <FiDatabase strokeWidth={2.4} />,
|
||||
};
|
||||
|
||||
function heroTitleText(state: HeroState): string {
|
||||
if (state === "ok") return m.dashboard_hero_ok();
|
||||
if (state === "run") return m.dashboard_hero_run();
|
||||
if (state === "warn") return m.dashboard_hero_warn();
|
||||
if (state === "err") return m.dashboard_hero_err();
|
||||
return m.dashboard_hero_idle();
|
||||
}
|
||||
const HERO_TITLE: Record<PlanState, () => string> = {
|
||||
ok: m.dashboard_hero_ok,
|
||||
run: m.dashboard_hero_run,
|
||||
warn: m.dashboard_hero_warn,
|
||||
err: m.dashboard_hero_err,
|
||||
idle: m.dashboard_hero_idle,
|
||||
};
|
||||
|
||||
const HeroBanner = ({
|
||||
state,
|
||||
newestMs,
|
||||
nextMs,
|
||||
}: {
|
||||
state: HeroState;
|
||||
interface HeroStats {
|
||||
state: PlanState;
|
||||
newestMs: number;
|
||||
nextMs: number | null;
|
||||
}) => {
|
||||
const color = STATE_COLORS[state];
|
||||
const bgColor = STATE_BG[state];
|
||||
const subParts: React.ReactNode[] = [];
|
||||
if (newestMs) {
|
||||
subParts.push(
|
||||
<Text as="span" fontWeight="semibold" color="fg.default" key="last">
|
||||
{m.dashboard_hero_last_backup({ ago: agoText(newestMs) })}
|
||||
</Text>,
|
||||
);
|
||||
} else {
|
||||
subParts.push(
|
||||
<Text as="span" key="last">
|
||||
{m.dashboard_hero_no_backups()}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
if (nextMs) {
|
||||
const u = untilText(nextMs);
|
||||
if (u) {
|
||||
subParts.push(
|
||||
<Text as="span" key="sep">
|
||||
{" · "}
|
||||
</Text>,
|
||||
);
|
||||
subParts.push(
|
||||
<Text as="span" key="next">
|
||||
{m.dashboard_hero_next({ when: u })}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
// Worst state across all plans (a running backup takes over the hero), the
|
||||
// newest backup time, and the soonest upcoming backup time.
|
||||
function heroStats(plans: SummaryDashboardResponse_Summary[]): HeroStats {
|
||||
let state: PlanState = "idle";
|
||||
let newestMs = 0;
|
||||
let nextMs: number | null = null;
|
||||
let anyRunning = false;
|
||||
|
||||
for (const summary of plans) {
|
||||
const status = summaryStatus(summary);
|
||||
anyRunning ||= status.running;
|
||||
newestMs = Math.max(newestMs, status.latestTs);
|
||||
if (STATE_SEVERITY[status.state] > STATE_SEVERITY[state]) {
|
||||
state = status.state;
|
||||
}
|
||||
const next = Number(summary.nextBackupTimeMs ?? 0);
|
||||
if (next > 0 && (nextMs === null || next < nextMs)) nextMs = next;
|
||||
}
|
||||
|
||||
if (anyRunning) state = "run";
|
||||
return { state, newestMs, nextMs };
|
||||
}
|
||||
|
||||
const HeroBanner = ({ state, newestMs, nextMs }: HeroStats) => {
|
||||
const nextIn = nextMs ? untilText(nextMs) : null;
|
||||
|
||||
return (
|
||||
<Card.Root borderRadius="2xl" shadow="sm" mb={6}>
|
||||
<Card.Body py={6} px={7}>
|
||||
@@ -260,8 +269,8 @@ const HeroBanner = ({
|
||||
w="60px"
|
||||
h="60px"
|
||||
borderRadius="full"
|
||||
bg={bgColor}
|
||||
color={color}
|
||||
bg={STATE_BG[state]}
|
||||
color={STATE_COLORS[state]}
|
||||
align="center"
|
||||
justify="center"
|
||||
fontSize="2xl"
|
||||
@@ -275,10 +284,17 @@ const HeroBanner = ({
|
||||
letterSpacing="-0.02em"
|
||||
lineHeight={1.2}
|
||||
>
|
||||
{heroTitleText(state)}
|
||||
{HERO_TITLE[state]()}
|
||||
</Text>
|
||||
<Text fontSize="14.5px" color="fg.muted" mt="3px">
|
||||
{subParts}
|
||||
{newestMs ? (
|
||||
<Text as="span" fontWeight="semibold" color="fg.default">
|
||||
{m.dashboard_hero_last_backup({ ago: agoText(newestMs) })}
|
||||
</Text>
|
||||
) : (
|
||||
m.dashboard_hero_no_backups()
|
||||
)}
|
||||
{nextIn && ` · ${m.dashboard_hero_next({ when: nextIn })}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
@@ -307,7 +323,11 @@ function progressFromOp(op: Operation): LiveProgress | null {
|
||||
};
|
||||
}
|
||||
|
||||
const useLiveProgress = (planId: string, running: boolean) => {
|
||||
const useLiveProgress = (
|
||||
planId: string,
|
||||
running: boolean,
|
||||
onFinished: () => void,
|
||||
) => {
|
||||
const [progress, setProgress] = useState<LiveProgress | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -316,9 +336,23 @@ const useLiveProgress = (planId: string, running: boolean) => {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let finished = false;
|
||||
const selector = create(OpSelectorSchema, { planId });
|
||||
|
||||
const apply = (op: Operation) => {
|
||||
if (op.op.case !== "operationBackup") return;
|
||||
// Once the backup leaves the in-progress/pending state it's done: clear the
|
||||
// live bar and refresh the summary so the card leaves its running state.
|
||||
if (
|
||||
op.status !== OperationStatus.STATUS_INPROGRESS &&
|
||||
op.status !== OperationStatus.STATUS_PENDING
|
||||
) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
setProgress(null);
|
||||
onFinished();
|
||||
return;
|
||||
}
|
||||
const p = progressFromOp(op);
|
||||
if (p) setProgress(p);
|
||||
};
|
||||
@@ -347,11 +381,70 @@ const useLiveProgress = (planId: string, running: boolean) => {
|
||||
cancelled = true;
|
||||
unsubscribeFromOperations(handler);
|
||||
};
|
||||
}, [planId, running]);
|
||||
}, [planId, running, onFinished]);
|
||||
|
||||
return progress;
|
||||
};
|
||||
|
||||
// ─── Shared card building blocks ─────────────────────────────────────────────
|
||||
|
||||
const CardTitle = ({ children }: { children: React.ReactNode }) => (
|
||||
<Text fontSize="16px" fontWeight="640" letterSpacing="-0.01em">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const StatusDot = ({ color, pulsing }: { color: string; pulsing?: boolean }) =>
|
||||
pulsing ? (
|
||||
<motion.div
|
||||
animate={{ opacity: [1, 0.4, 1], scale: [1, 0.82, 1] }}
|
||||
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "50%",
|
||||
background: "var(--chakra-colors-blue-500)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box w="9px" h="9px" borderRadius="full" bg={color} />
|
||||
);
|
||||
|
||||
// Large colored state label with a muted "X ago" beside it.
|
||||
const StatusLine = ({ status }: { status: SummaryStatus }) => (
|
||||
<Flex align="baseline" gap={2} mt={4} mb={1}>
|
||||
<Text
|
||||
fontSize="20px"
|
||||
fontWeight="660"
|
||||
letterSpacing="-0.02em"
|
||||
color={status.color}
|
||||
>
|
||||
{STATE_LABEL[status.state]()}
|
||||
</Text>
|
||||
{status.latestTs > 0 && !status.running && (
|
||||
<Text fontSize="13px" color="fg.muted">
|
||||
{agoText(status.latestTs)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// One "Label value" pair in a card's meta row.
|
||||
const MetaItem = ({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
{label}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// ─── Plan card ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PlanCard = ({
|
||||
@@ -360,14 +453,8 @@ const PlanCard = ({
|
||||
summary: SummaryDashboardResponse_Summary;
|
||||
}) => {
|
||||
const [config] = useConfig();
|
||||
const rb = summary.recentBackups;
|
||||
const latestStatus = rb?.status[0];
|
||||
const latestTs = Number(rb?.timestampMs[0] ?? 0);
|
||||
const running =
|
||||
latestStatus === OperationStatus.STATUS_INPROGRESS ||
|
||||
latestStatus === OperationStatus.STATUS_PENDING;
|
||||
const state = planState(latestStatus, running);
|
||||
const color = STATE_COLORS[state];
|
||||
const status = summaryStatus(summary);
|
||||
const { running } = status;
|
||||
|
||||
const progress = useLiveProgress(summary.id, running);
|
||||
|
||||
@@ -383,7 +470,8 @@ const PlanCard = ({
|
||||
);
|
||||
const destLabel = planCfg?.repo ?? "";
|
||||
const nextMs = Number(summary.nextBackupTimeMs ?? 0);
|
||||
const lastUploadBytes = Number(rb?.bytesAdded[0] ?? 0);
|
||||
const protectedBytes = Number(summary.protectedBytes);
|
||||
const lastUploadBytes = Number(summary.recentBackups?.bytesAdded[0] ?? 0);
|
||||
|
||||
let retLine: string | null = null;
|
||||
if (planCfg?.retention?.policy.case === "policyTimeBucketed") {
|
||||
@@ -401,54 +489,19 @@ const PlanCard = ({
|
||||
{/* Title row */}
|
||||
<Flex justify="space-between" align="flex-start" gap={3}>
|
||||
<Box>
|
||||
<Text fontSize="16px" fontWeight="640" letterSpacing="-0.01em">
|
||||
{prettyPlanId(summary.id)}
|
||||
</Text>
|
||||
<CardTitle>{prettyPlanId(summary.id)}</CardTitle>
|
||||
{schedLine && (
|
||||
<Text fontSize="12.5px" color="fg.muted" mt="2px">
|
||||
{schedLine}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{/* Status dot */}
|
||||
<Box mt="6px" flexShrink={0}>
|
||||
{running ? (
|
||||
<motion.div
|
||||
animate={{ opacity: [1, 0.4, 1], scale: [1, 0.82, 1] }}
|
||||
transition={{
|
||||
duration: 1.6,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "50%",
|
||||
background: "var(--chakra-colors-blue-500)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box w="9px" h="9px" borderRadius="full" bg={color} />
|
||||
)}
|
||||
<StatusDot color={status.color} pulsing={running} />
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* Big status line */}
|
||||
<Flex align="baseline" gap={2} mt={4} mb={1}>
|
||||
<Text
|
||||
fontSize="20px"
|
||||
fontWeight="660"
|
||||
letterSpacing="-0.02em"
|
||||
color={color}
|
||||
>
|
||||
{stateLabelText(state)}
|
||||
</Text>
|
||||
{latestTs > 0 && !running && (
|
||||
<Text fontSize="13px" color="fg.muted">
|
||||
{agoText(latestTs)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<StatusLine status={status} />
|
||||
|
||||
{/* Progress bar when running */}
|
||||
{running && (
|
||||
@@ -472,36 +525,24 @@ const PlanCard = ({
|
||||
{!running && (
|
||||
<Flex flexWrap="wrap" gap="4px 18px" mt={3}>
|
||||
{nextMs > 0 && (
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
{m.dashboard_card_next_run()}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{untilText(nextMs) ?? m.dashboard_time_soon()}
|
||||
</Text>
|
||||
</Box>
|
||||
<MetaItem label={m.dashboard_card_next_run()}>
|
||||
{untilText(nextMs) ?? m.dashboard_time_soon()}
|
||||
</MetaItem>
|
||||
)}
|
||||
{destLabel && (
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
{m.dashboard_card_destination()}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{destLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
<MetaItem label={m.dashboard_card_destination()}>
|
||||
{destLabel}
|
||||
</MetaItem>
|
||||
)}
|
||||
{Number(summary.protectedBytes) > 0 && (
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
{m.dashboard_card_protected()}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{formatBytes(Number(summary.protectedBytes))}
|
||||
</Text>
|
||||
</Box>
|
||||
{protectedBytes > 0 && (
|
||||
<MetaItem label={m.dashboard_card_protected()}>
|
||||
{formatBytes(protectedBytes)}
|
||||
</MetaItem>
|
||||
)}
|
||||
{lastUploadBytes > 0 && (
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
{m.dashboard_card_last_upload()}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{formatBytes(lastUploadBytes)}
|
||||
</Text>
|
||||
</Box>
|
||||
<MetaItem label={m.dashboard_card_last_upload()}>
|
||||
{formatBytes(lastUploadBytes)}
|
||||
</MetaItem>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
@@ -534,42 +575,21 @@ const RepoCard = ({
|
||||
}: {
|
||||
summary: SummaryDashboardResponse_Summary;
|
||||
}) => {
|
||||
const rb = summary.recentBackups;
|
||||
const latestStatus = rb?.status[0];
|
||||
const latestTs = Number(rb?.timestampMs[0] ?? 0);
|
||||
const running =
|
||||
latestStatus === OperationStatus.STATUS_INPROGRESS ||
|
||||
latestStatus === OperationStatus.STATUS_PENDING;
|
||||
const state = planState(latestStatus, running);
|
||||
const color = STATE_COLORS[state];
|
||||
const status = summaryStatus(summary);
|
||||
const protectedBytes = Number(summary.protectedBytes);
|
||||
const bytesAdded30d = Number(summary.bytesAddedLast30days);
|
||||
|
||||
return (
|
||||
<Card.Root borderRadius="2xl" shadow="sm">
|
||||
<Card.Body px={5} py={5}>
|
||||
<Flex justify="space-between" align="flex-start" gap={3}>
|
||||
<Text fontSize="16px" fontWeight="640" letterSpacing="-0.01em">
|
||||
{summary.id}
|
||||
</Text>
|
||||
<CardTitle>{summary.id}</CardTitle>
|
||||
<Box mt="6px" flexShrink={0}>
|
||||
<Box w="9px" h="9px" borderRadius="full" bg={color} />
|
||||
<StatusDot color={status.color} />
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex align="baseline" gap={2} mt={4}>
|
||||
<Text
|
||||
fontSize="20px"
|
||||
fontWeight="660"
|
||||
letterSpacing="-0.02em"
|
||||
color={color}
|
||||
>
|
||||
{stateLabelText(state)}
|
||||
</Text>
|
||||
{latestTs > 0 && !running && (
|
||||
<Text fontSize="13px" color="fg.muted">
|
||||
{agoText(latestTs)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<StatusLine status={status} />
|
||||
|
||||
<Flex flexWrap="wrap" gap="4px 18px" mt={3}>
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
@@ -589,21 +609,15 @@ const RepoCard = ({
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
{Number(summary.protectedBytes) > 0 && (
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
{m.dashboard_card_protected()}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{formatBytes(Number(summary.protectedBytes))}
|
||||
</Text>
|
||||
</Box>
|
||||
{protectedBytes > 0 && (
|
||||
<MetaItem label={m.dashboard_card_protected()}>
|
||||
{formatBytes(protectedBytes)}
|
||||
</MetaItem>
|
||||
)}
|
||||
{Number(summary.bytesAddedLast30days) > 0 && (
|
||||
<Box fontSize="12.5px" color="fg.muted">
|
||||
{m.dashboard_repo_added()}{" "}
|
||||
<Text as="span" fontWeight="600" color="fg.default">
|
||||
{formatBytes(Number(summary.bytesAddedLast30days))}
|
||||
</Text>
|
||||
</Box>
|
||||
{bytesAdded30d > 0 && (
|
||||
<MetaItem label={m.dashboard_repo_added()}>
|
||||
{formatBytes(bytesAdded30d)}
|
||||
</MetaItem>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -618,6 +632,7 @@ const RepoCard = ({
|
||||
|
||||
interface ActivityRow {
|
||||
planId: string;
|
||||
flowId: bigint;
|
||||
status: OperationStatus;
|
||||
timestampMs: number;
|
||||
durationMs: number;
|
||||
@@ -664,6 +679,7 @@ const RecentActivity = ({
|
||||
continue;
|
||||
all.push({
|
||||
planId: s.id,
|
||||
flowId: rb.flowId[i],
|
||||
status,
|
||||
timestampMs: Number(rb.timestampMs[i]),
|
||||
durationMs: Number(rb.durationMs[i]),
|
||||
@@ -690,8 +706,7 @@ const RecentActivity = ({
|
||||
return (
|
||||
<Card.Root borderRadius="2xl" shadow="sm" overflow="hidden">
|
||||
{rows.map((row, i) => {
|
||||
const stateKey = planState(row.status, false);
|
||||
const dotColor = STATE_COLORS[stateKey];
|
||||
const dotColor = STATE_COLORS[planState(row.status, false)];
|
||||
const dest = destByPlan.get(row.planId);
|
||||
|
||||
// Muted secondary line: relative + absolute time, duration, destination.
|
||||
@@ -708,7 +723,7 @@ const RecentActivity = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={i}
|
||||
key={`${row.planId}-${row.flowId}`}
|
||||
px={5}
|
||||
py="13px"
|
||||
borderTop={i === 0 ? "none" : "1px solid"}
|
||||
@@ -796,7 +811,7 @@ export const SummaryDashboard = () => {
|
||||
) {
|
||||
navigate("/getting-started");
|
||||
}
|
||||
}, [config]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [config, navigate]);
|
||||
|
||||
if (!summaryData) {
|
||||
return (
|
||||
@@ -806,34 +821,8 @@ export const SummaryDashboard = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Compute hero state: worst across plans, with running override
|
||||
const plans = summaryData.planSummaries;
|
||||
let heroState: HeroState = "idle";
|
||||
let newestMs = 0;
|
||||
let nextMs: number | null = null;
|
||||
let anyRun = false;
|
||||
|
||||
for (const s of plans) {
|
||||
const rb = s.recentBackups;
|
||||
const latest = rb?.status[0];
|
||||
const running =
|
||||
latest === OperationStatus.STATUS_INPROGRESS ||
|
||||
latest === OperationStatus.STATUS_PENDING;
|
||||
if (running) anyRun = true;
|
||||
|
||||
const ts = Number(rb?.timestampMs[0] ?? 0);
|
||||
if (ts > newestMs) newestMs = ts;
|
||||
|
||||
const st = planState(latest, running);
|
||||
if (STATE_SEVERITY[st] > STATE_SEVERITY[heroState]) {
|
||||
heroState = st;
|
||||
}
|
||||
|
||||
const nx = Number(s.nextBackupTimeMs ?? 0);
|
||||
if (nx > 0 && (nextMs === null || nx < nextMs)) nextMs = nx;
|
||||
}
|
||||
|
||||
if (anyRun) heroState = "run";
|
||||
const hero = heroStats(plans);
|
||||
|
||||
return (
|
||||
<Stack gap={8} width="full">
|
||||
@@ -841,9 +830,7 @@ export const SummaryDashboard = () => {
|
||||
<MultihostSummary multihostConfig={config?.multihost ?? null} />
|
||||
|
||||
{/* Hero */}
|
||||
{plans.length > 0 && (
|
||||
<HeroBanner state={heroState} newestMs={newestMs} nextMs={nextMs} />
|
||||
)}
|
||||
{plans.length > 0 && <HeroBanner {...hero} />}
|
||||
|
||||
{/* Plan cards */}
|
||||
{plans.length > 0 && (
|
||||
@@ -926,7 +913,7 @@ export const SummaryDashboard = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Multihost (preserved verbatim from original) ─────────────────────────────
|
||||
// ─── Multihost ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MultihostSummary = ({
|
||||
multihostConfig,
|
||||
|
||||
Reference in New Issue
Block a user