diff --git a/gen/go/google/api/annotations.pb.go b/gen/go/google/api/annotations.pb.go index d3764c8d..9c17136e 100644 --- a/gen/go/google/api/annotations.pb.go +++ b/gen/go/google/api/annotations.pb.go @@ -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 diff --git a/gen/go/google/api/http.pb.go b/gen/go/google/api/http.pb.go index b72638e2..212e065e 100644 --- a/gen/go/google/api/http.pb.go +++ b/gen/go/google/api/http.pb.go @@ -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 diff --git a/gen/go/types/value.pb.go b/gen/go/types/value.pb.go index b8a49147..7bf66a30 100644 --- a/gen/go/types/value.pb.go +++ b/gen/go/types/value.pb.go @@ -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 diff --git a/gen/go/v1/authentication.pb.go b/gen/go/v1/authentication.pb.go index a2a00c5f..4c524605 100644 --- a/gen/go/v1/authentication.pb.go +++ b/gen/go/v1/authentication.pb.go @@ -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 diff --git a/gen/go/v1/authentication_grpc.pb.go b/gen/go/v1/authentication_grpc.pb.go index 77f3efb9..9be66eda 100644 --- a/gen/go/v1/authentication_grpc.pb.go +++ b/gen/go/v1/authentication_grpc.pb.go @@ -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. diff --git a/gen/go/v1/config.pb.go b/gen/go/v1/config.pb.go index 9e374187..248b2a53 100644 --- a/gen/go/v1/config.pb.go +++ b/gen/go/v1/config.pb.go @@ -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 diff --git a/gen/go/v1/crypto.pb.go b/gen/go/v1/crypto.pb.go index 3f3f3a4f..78679cd8 100644 --- a/gen/go/v1/crypto.pb.go +++ b/gen/go/v1/crypto.pb.go @@ -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 diff --git a/gen/go/v1/operations.pb.go b/gen/go/v1/operations.pb.go index 85e504b1..ebb51fc5 100644 --- a/gen/go/v1/operations.pb.go +++ b/gen/go/v1/operations.pb.go @@ -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 diff --git a/gen/go/v1/restic.pb.go b/gen/go/v1/restic.pb.go index e6f917d7..e588c223 100644 --- a/gen/go/v1/restic.pb.go +++ b/gen/go/v1/restic.pb.go @@ -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 diff --git a/gen/go/v1/service.pb.go b/gen/go/v1/service.pb.go index b7b96083..1e55c77d 100644 --- a/gen/go/v1/service.pb.go +++ b/gen/go/v1/service.pb.go @@ -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" + diff --git a/gen/go/v1/service_grpc.pb.go b/gen/go/v1/service_grpc.pb.go index 6f51b9e5..cbea140c 100644 --- a/gen/go/v1/service_grpc.pb.go +++ b/gen/go/v1/service_grpc.pb.go @@ -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. diff --git a/gen/go/v1sync/syncservice.pb.go b/gen/go/v1sync/syncservice.pb.go index b4d311e3..e8d25e66 100644 --- a/gen/go/v1sync/syncservice.pb.go +++ b/gen/go/v1sync/syncservice.pb.go @@ -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 diff --git a/gen/go/v1sync/syncservice_grpc.pb.go b/gen/go/v1sync/syncservice_grpc.pb.go index fa4bf153..5499bd37 100644 --- a/gen/go/v1sync/syncservice_grpc.pb.go +++ b/gen/go/v1sync/syncservice_grpc.pb.go @@ -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. diff --git a/internal/api/backresthandler.go b/internal/api/backresthandler.go index 7b3228a8..b1fc0665 100644 --- a/internal/api/backresthandler.go +++ b/internal/api/backresthandler.go @@ -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() diff --git a/internal/api/backresthandler_test.go b/internal/api/backresthandler_test.go index 6b38f253..ed5c7c6d 100644 --- a/internal/api/backresthandler_test.go +++ b/internal/api/backresthandler_test.go @@ -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) + } +} diff --git a/internal/api/summarydashboard.go b/internal/api/summarydashboard.go new file mode 100644 index 00000000..3dfcbe7d --- /dev/null +++ b/internal/api/summarydashboard.go @@ -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, + } +} diff --git a/internal/api/summarydashboard_test.go b/internal/api/summarydashboard_test.go new file mode 100644 index 00000000..39802b13 --- /dev/null +++ b/internal/api/summarydashboard_test.go @@ -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]) + } + } + }) + } +} diff --git a/internal/protoutil/schedule.go b/internal/protoutil/schedule.go index 49627187..93919419 100644 --- a/internal/protoutil/schedule.go +++ b/internal/protoutil/schedule.go @@ -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: diff --git a/internal/protoutil/schedule_test.go b/internal/protoutil/schedule_test.go index 23552f51..48e90f00 100644 --- a/internal/protoutil/schedule_test.go +++ b/internal/protoutil/schedule_test.go @@ -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) + } + }) + } +} diff --git a/proto/v1/service.proto b/proto/v1/service.proto index 3d0bac48..11346c2c 100644 --- a/proto/v1/service.proto +++ b/proto/v1/service.proto @@ -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 { diff --git a/webui/gen/ts/v1/service_pb.ts b/webui/gen/ts/v1/service_pb.ts index f47a5b6f..29c8997c 100644 --- a/webui/gen/ts/v1/service_pb.ts +++ b/webui/gen/ts/v1/service_pb.ts @@ -21,7 +21,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file v1/service.proto. */ export const file_v1_service: GenFile = /*@__PURE__*/ - fileDesc("ChB2MS9zZXJ2aWNlLnByb3RvEgJ2MSIvCg1CYWNrdXBSZXF1ZXN0Eg0KBXZhbHVlGAEgASgJEg8KB2RyeV9ydW4YAiABKAgivwIKCk9wU2VsZWN0b3ISCwoDaWRzGAEgAygDEhgKC2luc3RhbmNlX2lkGAYgASgJSACIAQESJAoXb3JpZ2luYWxfaW5zdGFuY2Vfa2V5aWQYCCABKAlIAYgBARIWCglyZXBvX2d1aWQYByABKAlIAogBARIUCgdwbGFuX2lkGAMgASgJSAOIAQESGAoLc25hcHNob3RfaWQYBCABKAlIBIgBARIUCgdmbG93X2lkGAUgASgDSAWIAQESFgoJbW9kbm9fZ3RlGAkgASgDSAaIAQFCDgoMX2luc3RhbmNlX2lkQhoKGF9vcmlnaW5hbF9pbnN0YW5jZV9rZXlpZEIMCgpfcmVwb19ndWlkQgoKCF9wbGFuX2lkQg4KDF9zbmFwc2hvdF9pZEIKCghfZmxvd19pZEIMCgpfbW9kbm9fZ3RlImQKEFNldHVwU2Z0cFJlcXVlc3QSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgJEhAKCHVzZXJuYW1lGAMgASgJEhUKCHBhc3N3b3JkGAQgASgJSACIAQFCCwoJX3Bhc3N3b3JkImIKEVNldHVwU2Z0cFJlc3BvbnNlEhIKCnB1YmxpY19rZXkYASABKAkSEAoIa2V5X3BhdGgYAiABKAkSGAoQa25vd25faG9zdHNfcGF0aBgDIAEoCRINCgVlcnJvchgEIAEoCSIwChZDaGVja1JlcG9FeGlzdHNSZXF1ZXN0EhYKBHJlcG8YASABKAsyCC52MS5SZXBvIlQKF0NoZWNrUmVwb0V4aXN0c1Jlc3BvbnNlEg4KBmV4aXN0cxgBIAEoCBINCgVlcnJvchgCIAEoCRIaChJob3N0X2tleV91bnRydXN0ZWQYBSABKAgiKAoOQWRkUmVwb1JlcXVlc3QSFgoEcmVwbxgBIAEoCzIILnYxLlJlcG8i0gEKEURvUmVwb1Rhc2tSZXF1ZXN0Eg8KB3JlcG9faWQYASABKAkSKAoEdGFzaxgCIAEoDjIaLnYxLkRvUmVwb1Rhc2tSZXF1ZXN0LlRhc2sigQEKBFRhc2sSDQoJVEFTS19OT05FEAASGAoUVEFTS19JTkRFWF9TTkFQU0hPVFMQARIOCgpUQVNLX1BSVU5FEAISDgoKVEFTS19DSEVDSxADEg4KClRBU0tfU1RBVFMQBBIPCgtUQVNLX1VOTE9DSxAFEg8KC1RBU0tfRk9SR0VUEAYiTAoTQ2xlYXJIaXN0b3J5UmVxdWVzdBIgCghzZWxlY3RvchgBIAEoCzIOLnYxLk9wU2VsZWN0b3ISEwoLb25seV9mYWlsZWQYAiABKAgiRgoNRm9yZ2V0UmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB3BsYW5faWQYAiABKAkSEwoLc25hcHNob3RfaWQYAyABKAkiOAoUTGlzdFNuYXBzaG90c1JlcXVlc3QSDwoHcmVwb19pZBgBIAEoCRIPCgdwbGFuX2lkGAIgASgJIkgKFEdldE9wZXJhdGlvbnNSZXF1ZXN0EiAKCHNlbGVjdG9yGAEgASgLMg4udjEuT3BTZWxlY3RvchIOCgZsYXN0X24YAiABKAMibQoWUmVzdG9yZVNuYXBzaG90UmVxdWVzdBIPCgdwbGFuX2lkGAEgASgJEg8KB3JlcG9faWQYBSABKAkSEwoLc25hcHNob3RfaWQYAiABKAkSDAoEcGF0aBgDIAEoCRIOCgZ0YXJnZXQYBCABKAkiUAoYTGlzdFNuYXBzaG90RmlsZXNSZXF1ZXN0EhEKCXJlcG9fZ3VpZBgBIAEoCRITCgtzbmFwc2hvdF9pZBgCIAEoCRIMCgRwYXRoGAMgASgJIkcKGUxpc3RTbmFwc2hvdEZpbGVzUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIcCgdlbnRyaWVzGAIgAygLMgsudjEuTHNFbnRyeSIdCg5Mb2dEYXRhUmVxdWVzdBILCgNyZWYYASABKAkiOQoVR2V0RG93bmxvYWRVUkxSZXF1ZXN0Eg0KBW9wX2lkGAEgASgDEhEKCWZpbGVfcGF0aBgCIAEoCSKWAQoHTHNFbnRyeRIMCgRuYW1lGAEgASgJEgwKBHR5cGUYAiABKAkSDAoEcGF0aBgDIAEoCRILCgN1aWQYBCABKAMSCwoDZ2lkGAUgASgDEgwKBHNpemUYBiABKAMSDAoEbW9kZRgHIAEoAxINCgVtdGltZRgIIAEoCRINCgVhdGltZRgJIAEoCRINCgVjdGltZRgKIAEoCSI1ChFSdW5Db21tYW5kUmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB2NvbW1hbmQYAiABKAki+QcKGFN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZRI8Cg5yZXBvX3N1bW1hcmllcxgBIAMoCzIkLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZS5TdW1tYXJ5EjwKDnBsYW5fc3VtbWFyaWVzGAIgAygLMiQudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLlN1bW1hcnkSEwoLY29uZmlnX3BhdGgYCiABKAkSEQoJZGF0YV9wYXRoGAsgASgJGtIDCgdTdW1tYXJ5EgoKAmlkGAEgASgJEh0KFWJhY2t1cHNfZmFpbGVkXzMwZGF5cxgCIAEoAxIjChtiYWNrdXBzX3dhcm5pbmdfbGFzdF8zMGRheXMYAyABKAMSIwobYmFja3Vwc19zdWNjZXNzX2xhc3RfMzBkYXlzGAQgASgDEiEKGWJ5dGVzX3NjYW5uZWRfbGFzdF8zMGRheXMYBSABKAMSHwoXYnl0ZXNfYWRkZWRfbGFzdF8zMGRheXMYBiABKAMSFwoPdG90YWxfc25hcHNob3RzGAcgASgDEhkKEWJ5dGVzX3NjYW5uZWRfYXZnGAggASgDEhcKD2J5dGVzX2FkZGVkX2F2ZxgJIAEoAxIbChNuZXh0X2JhY2t1cF90aW1lX21zGAogASgDEkAKDnJlY2VudF9iYWNrdXBzGAsgASgLMigudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLkJhY2t1cENoYXJ0EhcKD3Byb3RlY3RlZF9ieXRlcxgMIAEoAxJJChNoaXN0b3J5X2xhc3RfMzBkYXlzGA0gAygLMiwudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLkRheVN0YXR1c0J1Y2tldBqDAQoLQmFja3VwQ2hhcnQSDwoHZmxvd19pZBgBIAMoAxIUCgx0aW1lc3RhbXBfbXMYAiADKAMSEwoLZHVyYXRpb25fbXMYAyADKAMSIwoGc3RhdHVzGAQgAygOMhMudjEuT3BlcmF0aW9uU3RhdHVzEhMKC2J5dGVzX2FkZGVkGAUgAygDGpcBCg9EYXlTdGF0dXNCdWNrZXQSFAoMdGltZXN0YW1wX21zGAEgASgDEhMKC2J5dGVzX2FkZGVkGAIgASgDEhUKDWJ5dGVzX3NjYW5uZWQYAyABKAMSQgoNc3RhdHVzX2NvdW50cxgEIAMoCzIrLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZS5TdGF0dXNBbmRDb3VudBpECg5TdGF0dXNBbmRDb3VudBINCgVjb3VudBgBIAEoAxIjCgZzdGF0dXMYAiABKA4yEy52MS5PcGVyYXRpb25TdGF0dXMiggEKG0dlbmVyYXRlUGFpcmluZ1Rva2VuUmVxdWVzdBINCgVsYWJlbBgBIAEoCRITCgt0dGxfc2Vjb25kcxgCIAEoAxIQCghtYXhfdXNlcxgDIAEoBRItCgtwZXJtaXNzaW9ucxgEIAMoCzIYLnYxLk11bHRpaG9zdC5QZXJtaXNzaW9uIi0KHEdlbmVyYXRlUGFpcmluZ1Rva2VuUmVzcG9uc2USDQoFdG9rZW4YASABKAky7goKCEJhY2tyZXN0EjEKCUdldENvbmZpZxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoKLnYxLkNvbmZpZyIAEiUKCVNldENvbmZpZxIKLnYxLkNvbmZpZxoKLnYxLkNvbmZpZyIAEjoKCVNldHVwU2Z0cBIULnYxLlNldHVwU2Z0cFJlcXVlc3QaFS52MS5TZXR1cFNmdHBSZXNwb25zZSIAEkwKD0NoZWNrUmVwb0V4aXN0cxIaLnYxLkNoZWNrUmVwb0V4aXN0c1JlcXVlc3QaGy52MS5DaGVja1JlcG9FeGlzdHNSZXNwb25zZSIAEisKB0FkZFJlcG8SEi52MS5BZGRSZXBvUmVxdWVzdBoKLnYxLkNvbmZpZyIAEi4KClJlbW92ZVJlcG8SEi50eXBlcy5TdHJpbmdWYWx1ZRoKLnYxLkNvbmZpZyIAEkQKEkdldE9wZXJhdGlvbkV2ZW50cxIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRoSLnYxLk9wZXJhdGlvbkV2ZW50IgAwARI+Cg1HZXRPcGVyYXRpb25zEhgudjEuR2V0T3BlcmF0aW9uc1JlcXVlc3QaES52MS5PcGVyYXRpb25MaXN0IgASQwoNTGlzdFNuYXBzaG90cxIYLnYxLkxpc3RTbmFwc2hvdHNSZXF1ZXN0GhYudjEuUmVzdGljU25hcHNob3RMaXN0IgASUgoRTGlzdFNuYXBzaG90RmlsZXMSHC52MS5MaXN0U25hcHNob3RGaWxlc1JlcXVlc3QaHS52MS5MaXN0U25hcHNob3RGaWxlc1Jlc3BvbnNlIgASNQoGQmFja3VwEhEudjEuQmFja3VwUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEj0KCkRvUmVwb1Rhc2sSFS52MS5Eb1JlcG9UYXNrUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEjUKBkZvcmdldBIRLnYxLkZvcmdldFJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI/CgdSZXN0b3JlEhoudjEuUmVzdG9yZVNuYXBzaG90UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEjUKBkNhbmNlbBIRLnR5cGVzLkludDY0VmFsdWUaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI0CgdHZXRMb2dzEhIudjEuTG9nRGF0YVJlcXVlc3QaES50eXBlcy5CeXRlc1ZhbHVlIgAwARI4CgpSdW5Db21tYW5kEhUudjEuUnVuQ29tbWFuZFJlcXVlc3QaES50eXBlcy5JbnQ2NFZhbHVlIgASQQoOR2V0RG93bmxvYWRVUkwSGS52MS5HZXREb3dubG9hZFVSTFJlcXVlc3QaEi50eXBlcy5TdHJpbmdWYWx1ZSIAEkEKDENsZWFySGlzdG9yeRIXLnYxLkNsZWFySGlzdG9yeVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiABI7ChBQYXRoQXV0b2NvbXBsZXRlEhIudHlwZXMuU3RyaW5nVmFsdWUaES50eXBlcy5TdHJpbmdMaXN0IgASTQoTR2V0U3VtbWFyeURhc2hib2FyZBIWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eRocLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZSIAElsKFEdlbmVyYXRlUGFpcmluZ1Rva2VuEh8udjEuR2VuZXJhdGVQYWlyaW5nVG9rZW5SZXF1ZXN0GiAudjEuR2VuZXJhdGVQYWlyaW5nVG9rZW5SZXNwb25zZSIAQixaKmdpdGh1Yi5jb20vZ2FyZXRoZ2VvcmdlL2JhY2tyZXN0L2dlbi9nby92MWIGcHJvdG8z", [file_v1_config, file_v1_restic, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations]); + fileDesc("ChB2MS9zZXJ2aWNlLnByb3RvEgJ2MSIvCg1CYWNrdXBSZXF1ZXN0Eg0KBXZhbHVlGAEgASgJEg8KB2RyeV9ydW4YAiABKAgivwIKCk9wU2VsZWN0b3ISCwoDaWRzGAEgAygDEhgKC2luc3RhbmNlX2lkGAYgASgJSACIAQESJAoXb3JpZ2luYWxfaW5zdGFuY2Vfa2V5aWQYCCABKAlIAYgBARIWCglyZXBvX2d1aWQYByABKAlIAogBARIUCgdwbGFuX2lkGAMgASgJSAOIAQESGAoLc25hcHNob3RfaWQYBCABKAlIBIgBARIUCgdmbG93X2lkGAUgASgDSAWIAQESFgoJbW9kbm9fZ3RlGAkgASgDSAaIAQFCDgoMX2luc3RhbmNlX2lkQhoKGF9vcmlnaW5hbF9pbnN0YW5jZV9rZXlpZEIMCgpfcmVwb19ndWlkQgoKCF9wbGFuX2lkQg4KDF9zbmFwc2hvdF9pZEIKCghfZmxvd19pZEIMCgpfbW9kbm9fZ3RlImQKEFNldHVwU2Z0cFJlcXVlc3QSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgJEhAKCHVzZXJuYW1lGAMgASgJEhUKCHBhc3N3b3JkGAQgASgJSACIAQFCCwoJX3Bhc3N3b3JkImIKEVNldHVwU2Z0cFJlc3BvbnNlEhIKCnB1YmxpY19rZXkYASABKAkSEAoIa2V5X3BhdGgYAiABKAkSGAoQa25vd25faG9zdHNfcGF0aBgDIAEoCRINCgVlcnJvchgEIAEoCSIwChZDaGVja1JlcG9FeGlzdHNSZXF1ZXN0EhYKBHJlcG8YASABKAsyCC52MS5SZXBvIlQKF0NoZWNrUmVwb0V4aXN0c1Jlc3BvbnNlEg4KBmV4aXN0cxgBIAEoCBINCgVlcnJvchgCIAEoCRIaChJob3N0X2tleV91bnRydXN0ZWQYBSABKAgiKAoOQWRkUmVwb1JlcXVlc3QSFgoEcmVwbxgBIAEoCzIILnYxLlJlcG8i0gEKEURvUmVwb1Rhc2tSZXF1ZXN0Eg8KB3JlcG9faWQYASABKAkSKAoEdGFzaxgCIAEoDjIaLnYxLkRvUmVwb1Rhc2tSZXF1ZXN0LlRhc2sigQEKBFRhc2sSDQoJVEFTS19OT05FEAASGAoUVEFTS19JTkRFWF9TTkFQU0hPVFMQARIOCgpUQVNLX1BSVU5FEAISDgoKVEFTS19DSEVDSxADEg4KClRBU0tfU1RBVFMQBBIPCgtUQVNLX1VOTE9DSxAFEg8KC1RBU0tfRk9SR0VUEAYiTAoTQ2xlYXJIaXN0b3J5UmVxdWVzdBIgCghzZWxlY3RvchgBIAEoCzIOLnYxLk9wU2VsZWN0b3ISEwoLb25seV9mYWlsZWQYAiABKAgiRgoNRm9yZ2V0UmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB3BsYW5faWQYAiABKAkSEwoLc25hcHNob3RfaWQYAyABKAkiOAoUTGlzdFNuYXBzaG90c1JlcXVlc3QSDwoHcmVwb19pZBgBIAEoCRIPCgdwbGFuX2lkGAIgASgJIkgKFEdldE9wZXJhdGlvbnNSZXF1ZXN0EiAKCHNlbGVjdG9yGAEgASgLMg4udjEuT3BTZWxlY3RvchIOCgZsYXN0X24YAiABKAMibQoWUmVzdG9yZVNuYXBzaG90UmVxdWVzdBIPCgdwbGFuX2lkGAEgASgJEg8KB3JlcG9faWQYBSABKAkSEwoLc25hcHNob3RfaWQYAiABKAkSDAoEcGF0aBgDIAEoCRIOCgZ0YXJnZXQYBCABKAkiUAoYTGlzdFNuYXBzaG90RmlsZXNSZXF1ZXN0EhEKCXJlcG9fZ3VpZBgBIAEoCRITCgtzbmFwc2hvdF9pZBgCIAEoCRIMCgRwYXRoGAMgASgJIkcKGUxpc3RTbmFwc2hvdEZpbGVzUmVzcG9uc2USDAoEcGF0aBgBIAEoCRIcCgdlbnRyaWVzGAIgAygLMgsudjEuTHNFbnRyeSIdCg5Mb2dEYXRhUmVxdWVzdBILCgNyZWYYASABKAkiOQoVR2V0RG93bmxvYWRVUkxSZXF1ZXN0Eg0KBW9wX2lkGAEgASgDEhEKCWZpbGVfcGF0aBgCIAEoCSKWAQoHTHNFbnRyeRIMCgRuYW1lGAEgASgJEgwKBHR5cGUYAiABKAkSDAoEcGF0aBgDIAEoCRILCgN1aWQYBCABKAMSCwoDZ2lkGAUgASgDEgwKBHNpemUYBiABKAMSDAoEbW9kZRgHIAEoAxINCgVtdGltZRgIIAEoCRINCgVhdGltZRgJIAEoCRINCgVjdGltZRgKIAEoCSI1ChFSdW5Db21tYW5kUmVxdWVzdBIPCgdyZXBvX2lkGAEgASgJEg8KB2NvbW1hbmQYAiABKAkiiggKGFN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZRI8Cg5yZXBvX3N1bW1hcmllcxgBIAMoCzIkLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZS5TdW1tYXJ5EjwKDnBsYW5fc3VtbWFyaWVzGAIgAygLMiQudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLlN1bW1hcnkSEwoLY29uZmlnX3BhdGgYCiABKAkSEQoJZGF0YV9wYXRoGAsgASgJGtIDCgdTdW1tYXJ5EgoKAmlkGAEgASgJEh0KFWJhY2t1cHNfZmFpbGVkXzMwZGF5cxgCIAEoAxIjChtiYWNrdXBzX3dhcm5pbmdfbGFzdF8zMGRheXMYAyABKAMSIwobYmFja3Vwc19zdWNjZXNzX2xhc3RfMzBkYXlzGAQgASgDEiEKGWJ5dGVzX3NjYW5uZWRfbGFzdF8zMGRheXMYBSABKAMSHwoXYnl0ZXNfYWRkZWRfbGFzdF8zMGRheXMYBiABKAMSFwoPdG90YWxfc25hcHNob3RzGAcgASgDEhkKEWJ5dGVzX3NjYW5uZWRfYXZnGAggASgDEhcKD2J5dGVzX2FkZGVkX2F2ZxgJIAEoAxIbChNuZXh0X2JhY2t1cF90aW1lX21zGAogASgDEkAKDnJlY2VudF9iYWNrdXBzGAsgASgLMigudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLkJhY2t1cENoYXJ0EhcKD3Byb3RlY3RlZF9ieXRlcxgMIAEoAxJJChNoaXN0b3J5X2xhc3RfMzBkYXlzGA0gAygLMiwudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlLkRheVN0YXR1c0J1Y2tldBqDAQoLQmFja3VwQ2hhcnQSDwoHZmxvd19pZBgBIAMoAxIUCgx0aW1lc3RhbXBfbXMYAiADKAMSEwoLZHVyYXRpb25fbXMYAyADKAMSIwoGc3RhdHVzGAQgAygOMhMudjEuT3BlcmF0aW9uU3RhdHVzEhMKC2J5dGVzX2FkZGVkGAUgAygDGqgBCg9EYXlTdGF0dXNCdWNrZXQSFAoMdGltZXN0YW1wX21zGAEgASgDEhMKC2J5dGVzX2FkZGVkGAIgASgDEhUKDWJ5dGVzX3NjYW5uZWQYAyABKAMSQgoNc3RhdHVzX2NvdW50cxgEIAMoCzIrLnYxLlN1bW1hcnlEYXNoYm9hcmRSZXNwb25zZS5TdGF0dXNBbmRDb3VudBIPCgdvdmVyZHVlGAUgASgIGkQKDlN0YXR1c0FuZENvdW50Eg0KBWNvdW50GAEgASgDEiMKBnN0YXR1cxgCIAEoDjITLnYxLk9wZXJhdGlvblN0YXR1cyKCAQobR2VuZXJhdGVQYWlyaW5nVG9rZW5SZXF1ZXN0Eg0KBWxhYmVsGAEgASgJEhMKC3R0bF9zZWNvbmRzGAIgASgDEhAKCG1heF91c2VzGAMgASgFEi0KC3Blcm1pc3Npb25zGAQgAygLMhgudjEuTXVsdGlob3N0LlBlcm1pc3Npb24iLQocR2VuZXJhdGVQYWlyaW5nVG9rZW5SZXNwb25zZRINCgV0b2tlbhgBIAEoCTLuCgoIQmFja3Jlc3QSMQoJR2V0Q29uZmlnEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GgoudjEuQ29uZmlnIgASJQoJU2V0Q29uZmlnEgoudjEuQ29uZmlnGgoudjEuQ29uZmlnIgASOgoJU2V0dXBTZnRwEhQudjEuU2V0dXBTZnRwUmVxdWVzdBoVLnYxLlNldHVwU2Z0cFJlc3BvbnNlIgASTAoPQ2hlY2tSZXBvRXhpc3RzEhoudjEuQ2hlY2tSZXBvRXhpc3RzUmVxdWVzdBobLnYxLkNoZWNrUmVwb0V4aXN0c1Jlc3BvbnNlIgASKwoHQWRkUmVwbxISLnYxLkFkZFJlcG9SZXF1ZXN0GgoudjEuQ29uZmlnIgASLgoKUmVtb3ZlUmVwbxISLnR5cGVzLlN0cmluZ1ZhbHVlGgoudjEuQ29uZmlnIgASRAoSR2V0T3BlcmF0aW9uRXZlbnRzEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhIudjEuT3BlcmF0aW9uRXZlbnQiADABEj4KDUdldE9wZXJhdGlvbnMSGC52MS5HZXRPcGVyYXRpb25zUmVxdWVzdBoRLnYxLk9wZXJhdGlvbkxpc3QiABJDCg1MaXN0U25hcHNob3RzEhgudjEuTGlzdFNuYXBzaG90c1JlcXVlc3QaFi52MS5SZXN0aWNTbmFwc2hvdExpc3QiABJSChFMaXN0U25hcHNob3RGaWxlcxIcLnYxLkxpc3RTbmFwc2hvdEZpbGVzUmVxdWVzdBodLnYxLkxpc3RTbmFwc2hvdEZpbGVzUmVzcG9uc2UiABI1CgZCYWNrdXASES52MS5CYWNrdXBSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASPQoKRG9SZXBvVGFzaxIVLnYxLkRvUmVwb1Rhc2tSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASNQoGRm9yZ2V0EhEudjEuRm9yZ2V0UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEj8KB1Jlc3RvcmUSGi52MS5SZXN0b3JlU25hcHNob3RSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IgASNQoGQ2FuY2VsEhEudHlwZXMuSW50NjRWYWx1ZRoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEjQKB0dldExvZ3MSEi52MS5Mb2dEYXRhUmVxdWVzdBoRLnR5cGVzLkJ5dGVzVmFsdWUiADABEjgKClJ1bkNvbW1hbmQSFS52MS5SdW5Db21tYW5kUmVxdWVzdBoRLnR5cGVzLkludDY0VmFsdWUiABJBCg5HZXREb3dubG9hZFVSTBIZLnYxLkdldERvd25sb2FkVVJMUmVxdWVzdBoSLnR5cGVzLlN0cmluZ1ZhbHVlIgASQQoMQ2xlYXJIaXN0b3J5EhcudjEuQ2xlYXJIaXN0b3J5UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSIAEjsKEFBhdGhBdXRvY29tcGxldGUSEi50eXBlcy5TdHJpbmdWYWx1ZRoRLnR5cGVzLlN0cmluZ0xpc3QiABJNChNHZXRTdW1tYXJ5RGFzaGJvYXJkEhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5GhwudjEuU3VtbWFyeURhc2hib2FyZFJlc3BvbnNlIgASWwoUR2VuZXJhdGVQYWlyaW5nVG9rZW4SHy52MS5HZW5lcmF0ZVBhaXJpbmdUb2tlblJlcXVlc3QaIC52MS5HZW5lcmF0ZVBhaXJpbmdUb2tlblJlc3BvbnNlIgBCLFoqZ2l0aHViLmNvbS9nYXJldGhnZW9yZ2UvYmFja3Jlc3QvZ2VuL2dvL3YxYgZwcm90bzM", [file_v1_config, file_v1_restic, file_v1_operations, file_types_value, file_google_protobuf_empty, file_google_api_annotations]); /** * @generated from message v1.BackupRequest @@ -787,6 +787,13 @@ export type SummaryDashboardResponse_DayStatusBucket = Message<"v1.SummaryDashbo * @generated from field: repeated v1.SummaryDashboardResponse.StatusAndCount status_counts = 4; */ statusCounts: SummaryDashboardResponse_StatusAndCount[]; + + /** + * gap since the last good backup exceeded the schedule's nominal period during this day + * + * @generated from field: bool overdue = 5; + */ + overdue: boolean; }; /** diff --git a/webui/messages/en.json b/webui/messages/en.json index 09b75530..ea988f83 100644 --- a/webui/messages/en.json +++ b/webui/messages/en.json @@ -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", diff --git a/webui/src/features/dashboard/HistoryStrip.tsx b/webui/src/features/dashboard/HistoryStrip.tsx index 5c43f378..87d62e14 100644 --- a/webui/src/features/dashboard/HistoryStrip.tsx +++ b/webui/src/features/dashboard/HistoryStrip.tsx @@ -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 = { 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(); + 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()} ) : !hasBackups ? ( - - {m.dashboard_history_tooltip_no_backup()} + + {cell.bucket?.overdue + ? m.dashboard_history_tooltip_overdue() + : m.dashboard_history_tooltip_on_schedule()} ) : ( @@ -195,6 +219,11 @@ const DayTooltip = ({ cell }: { cell: DayCell }) => { )} )} + {cell.bucket?.overdue && ( + + {m.dashboard_history_tooltip_overdue()} + + )} )} @@ -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 }) => ( @@ -260,18 +293,6 @@ export const HistoryStrip = ({ ))} - - - - {m.dashboard_history_legend_missed()} - - ); diff --git a/webui/src/features/dashboard/SummaryDashboard.tsx b/webui/src/features/dashboard/SummaryDashboard.tsx index 774e2c82..1b440e03 100644 --- a/webui/src/features/dashboard/SummaryDashboard.tsx +++ b/webui/src/features/dashboard/SummaryDashboard.tsx @@ -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 = { 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 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 = { +const HERO_ICON: Record = { ok: , run: , warn: , @@ -202,55 +220,46 @@ const HERO_ICON: Record = { idle: , }; -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 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( - - {m.dashboard_hero_last_backup({ ago: agoText(newestMs) })} - , - ); - } else { - subParts.push( - - {m.dashboard_hero_no_backups()} - , - ); - } - if (nextMs) { - const u = untilText(nextMs); - if (u) { - subParts.push( - - {" · "} - , - ); - subParts.push( - - {m.dashboard_hero_next({ when: u })} - , - ); +} + +// 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 ( @@ -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]()} - {subParts} + {newestMs ? ( + + {m.dashboard_hero_last_backup({ ago: agoText(newestMs) })} + + ) : ( + m.dashboard_hero_no_backups() + )} + {nextIn && ` · ${m.dashboard_hero_next({ when: nextIn })}`} @@ -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(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 }) => ( + + {children} + +); + +const StatusDot = ({ color, pulsing }: { color: string; pulsing?: boolean }) => + pulsing ? ( + + ) : ( + + ); + +// Large colored state label with a muted "X ago" beside it. +const StatusLine = ({ status }: { status: SummaryStatus }) => ( + + + {STATE_LABEL[status.state]()} + + {status.latestTs > 0 && !status.running && ( + + {agoText(status.latestTs)} + + )} + +); + +// One "Label value" pair in a card's meta row. +const MetaItem = ({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) => ( + + {label}{" "} + + {children} + + +); + // ─── 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 */} - - {prettyPlanId(summary.id)} - + {prettyPlanId(summary.id)} {schedLine && ( {schedLine} )} - {/* Status dot */} - {running ? ( - - ) : ( - - )} + - {/* Big status line */} - - - {stateLabelText(state)} - - {latestTs > 0 && !running && ( - - {agoText(latestTs)} - - )} - + {/* Progress bar when running */} {running && ( @@ -472,36 +525,24 @@ const PlanCard = ({ {!running && ( {nextMs > 0 && ( - - {m.dashboard_card_next_run()}{" "} - - {untilText(nextMs) ?? m.dashboard_time_soon()} - - + + {untilText(nextMs) ?? m.dashboard_time_soon()} + )} {destLabel && ( - - {m.dashboard_card_destination()}{" "} - - {destLabel} - - + + {destLabel} + )} - {Number(summary.protectedBytes) > 0 && ( - - {m.dashboard_card_protected()}{" "} - - {formatBytes(Number(summary.protectedBytes))} - - + {protectedBytes > 0 && ( + + {formatBytes(protectedBytes)} + )} {lastUploadBytes > 0 && ( - - {m.dashboard_card_last_upload()}{" "} - - {formatBytes(lastUploadBytes)} - - + + {formatBytes(lastUploadBytes)} + )} )} @@ -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 ( - - {summary.id} - + {summary.id} - + - - - {stateLabelText(state)} - - {latestTs > 0 && !running && ( - - {agoText(latestTs)} - - )} - + @@ -589,21 +609,15 @@ const RepoCard = ({ ) : null} - {Number(summary.protectedBytes) > 0 && ( - - {m.dashboard_card_protected()}{" "} - - {formatBytes(Number(summary.protectedBytes))} - - + {protectedBytes > 0 && ( + + {formatBytes(protectedBytes)} + )} - {Number(summary.bytesAddedLast30days) > 0 && ( - - {m.dashboard_repo_added()}{" "} - - {formatBytes(Number(summary.bytesAddedLast30days))} - - + {bytesAdded30d > 0 && ( + + {formatBytes(bytesAdded30d)} + )} @@ -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 ( {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 ( { ) { 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 ( @@ -841,9 +830,7 @@ export const SummaryDashboard = () => { {/* Hero */} - {plans.length > 0 && ( - - )} + {plans.length > 0 && } {/* Plan cards */} {plans.length > 0 && ( @@ -926,7 +913,7 @@ export const SummaryDashboard = () => { ); }; -// ─── Multihost (preserved verbatim from original) ───────────────────────────── +// ─── Multihost ──────────────────────────────────────────────────────────────── const MultihostSummary = ({ multihostConfig,