fix: dashboard displays 'overdue' missed backups

This commit is contained in:
Gareth
2026-07-08 00:02:43 -07:00
committed by garethgeorge
parent 251e91987f
commit bfe5712739
24 changed files with 1016 additions and 476 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+4 -4
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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" +
+24 -24
View File
@@ -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 -1
View File
@@ -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
+6 -6
View File
@@ -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.
-193
View File
@@ -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()
+181
View File
@@ -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)
}
}
+318
View File
@@ -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,
}
}
+103
View File
@@ -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])
}
}
})
}
}
+38
View File
@@ -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:
+66
View File
@@ -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)
}
})
}
}
+1
View File
@@ -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
+2
View File
@@ -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",
+49 -28
View File
@@ -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>
);
+194 -207
View File
@@ -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,