From 6e982d6bdcdbe39532f7d7abfd1423ce1c26bf51 Mon Sep 17 00:00:00 2001 From: Keepers Date: Thu, 20 Apr 2023 18:17:40 -0600 Subject: [PATCH] print stats after backup (#3128) Prints backup stats to the CLI following completion. In case of multiple users, the stats for each backup is printed at the end of the backup, rather than at the end of the command. --- #### Does this PR need a docs update or release note? - [x] :white_check_mark: Yes, it's included #### Type of change - [x] :sunflower: Feature #### Test Plan - [x] :muscle: Manual - [x] :zap: Unit test --- src/cli/backup/backup.go | 18 ++++- src/cli/print/print.go | 4 +- src/pkg/backup/backup.go | 113 ++++++++++++++++++++++++------ src/pkg/backup/backup_test.go | 70 +++++++++++++++--- src/pkg/backup/details/details.go | 2 +- 5 files changed, 175 insertions(+), 32 deletions(-) diff --git a/src/cli/backup/backup.go b/src/cli/backup/backup.go index c3233e231..673266272 100644 --- a/src/cli/backup/backup.go +++ b/src/cli/backup/backup.go @@ -230,7 +230,13 @@ func runBackups( } bIDs = append(bIDs, string(bo.Results.BackupID)) - Infof(ctx, "Done - ID: %v\n", bo.Results.BackupID) + + if !DisplayJSONFormat() { + Infof(ctx, "Done\n") + printBackupStats(ctx, r, string(bo.Results.BackupID)) + } else { + Infof(ctx, "Done - ID: %v\n", bo.Results.BackupID) + } } bups, berrs := r.Backups(ctx, bIDs) @@ -335,3 +341,13 @@ func getAccountAndConnect(ctx context.Context) (repository.Repository, *account. func ifShow(flag string) bool { return strings.ToLower(strings.TrimSpace(flag)) == "show" } + +func printBackupStats(ctx context.Context, r repository.Repository, bid string) { + b, err := r.Backup(ctx, bid) + if err != nil { + logger.CtxErr(ctx, err).Error("finding backup immediately after backup operation completion") + } + + b.ToPrintable().Stats.Print(ctx) + Info(ctx, " ") +} diff --git a/src/cli/print/print.go b/src/cli/print/print.go index 5ab61acca..91ef1e581 100644 --- a/src/cli/print/print.go +++ b/src/cli/print/print.go @@ -50,8 +50,8 @@ func AddOutputFlag(cmd *cobra.Command) { cobra.CheckErr(fs.MarkHidden("json-debug")) } -// JSONFormat returns true if the printer plans to output as json. -func JSONFormat() bool { +// DisplayJSONFormat returns true if the printer plans to output as json. +func DisplayJSONFormat() bool { return outputAsJSON || outputAsJSONDebug } diff --git a/src/pkg/backup/backup.go b/src/pkg/backup/backup.go index d9b52c9d3..8d792c1a7 100644 --- a/src/pkg/backup/backup.go +++ b/src/pkg/backup/backup.go @@ -3,9 +3,12 @@ package backup import ( "context" "fmt" + "strconv" "strings" "time" + "github.com/dustin/go-humanize" + "github.com/alcionai/corso/src/cli/print" "github.com/alcionai/corso/src/internal/common" "github.com/alcionai/corso/src/internal/model" @@ -141,6 +144,8 @@ func New( // CLI Output // -------------------------------------------------------------------------------- +// ----- print backups + // Print writes the Backup to StdOut, in the format requested by the caller. func (b Backup) Print(ctx context.Context) { print.Item(ctx, b) @@ -162,36 +167,36 @@ func PrintAll(ctx context.Context, bs []*Backup) { } type Printable struct { - ID model.StableID `json:"id"` - ErrorCount int `json:"errorCount"` - StartedAt time.Time `json:"started at"` - Status string `json:"status"` - Version string `json:"version"` - BytesRead int64 `json:"bytesRead"` - BytesUploaded int64 `json:"bytesUploaded"` - Owner string `json:"owner"` + ID model.StableID `json:"id"` + Status string `json:"status"` + Version string `json:"version"` + Owner string `json:"owner"` + Stats backupStats `json:"stats"` +} + +// ToPrintable reduces the Backup to its minimally printable details. +func (b Backup) ToPrintable() Printable { + return Printable{ + ID: b.ID, + Status: b.Status, + Version: "0", + Owner: b.Selector.DiscreteOwner, + Stats: b.toStats(), + } } // MinimumPrintable reduces the Backup to its minimally printable details. func (b Backup) MinimumPrintable() any { - return Printable{ - ID: b.ID, - ErrorCount: b.ErrorCount, - StartedAt: b.StartedAt, - Status: b.Status, - Version: "0", - BytesRead: b.BytesRead, - BytesUploaded: b.BytesUploaded, - Owner: b.Selector.DiscreteOwner, - } + return b.ToPrintable() } // Headers returns the human-readable names of properties in a Backup // for printing out to a terminal in a columnar display. func (b Backup) Headers() []string { return []string{ - "Started At", "ID", + "Started At", + "Duration", "Status", "Resource Owner", } @@ -255,10 +260,78 @@ func (b Backup) Values() []string { name = b.Selector.DiscreteOwner } + bs := b.toStats() + return []string{ - common.FormatTabularDisplayTime(b.StartedAt), string(b.ID), + common.FormatTabularDisplayTime(b.StartedAt), + bs.EndedAt.Sub(bs.StartedAt).String(), status, name, } } + +// ----- print backup stats + +func (b Backup) toStats() backupStats { + return backupStats{ + ID: string(b.ID), + BytesRead: b.BytesRead, + BytesUploaded: b.BytesUploaded, + EndedAt: b.CompletedAt, + ErrorCount: b.ErrorCount, + ItemsRead: b.ItemsRead, + ItemsSkipped: b.TotalSkippedItems, + ItemsWritten: b.ItemsWritten, + StartedAt: b.StartedAt, + } +} + +// interface compliance checks +var _ print.Printable = &backupStats{} + +type backupStats struct { + ID string `json:"id"` + BytesRead int64 `json:"bytesRead"` + BytesUploaded int64 `json:"bytesUploaded"` + EndedAt time.Time `json:"endedAt"` + ErrorCount int `json:"errorCount"` + ItemsRead int `json:"itemsRead"` + ItemsSkipped int `json:"itemsSkipped"` + ItemsWritten int `json:"itemsWritten"` + StartedAt time.Time `json:"startedAt"` +} + +// Print writes the Backup to StdOut, in the format requested by the caller. +func (bs backupStats) Print(ctx context.Context) { + print.Item(ctx, bs) +} + +// MinimumPrintable reduces the Backup to its minimally printable details. +func (bs backupStats) MinimumPrintable() any { + return bs +} + +// Headers returns the human-readable names of properties in a Backup +// for printing out to a terminal in a columnar display. +func (bs backupStats) Headers() []string { + return []string{ + "ID", + "Bytes Uploaded", + "Items Uploaded", + "Items Skipped", + "Errors", + } +} + +// Values returns the values matching the Headers list for printing +// out to a terminal in a columnar display. +func (bs backupStats) Values() []string { + return []string{ + bs.ID, + humanize.Bytes(uint64(bs.BytesUploaded)), + strconv.Itoa(bs.ItemsWritten), + strconv.Itoa(bs.ItemsSkipped), + strconv.Itoa(bs.ErrorCount), + } +} diff --git a/src/pkg/backup/backup_test.go b/src/pkg/backup/backup_test.go index 91bde1a17..67892ac98 100644 --- a/src/pkg/backup/backup_test.go +++ b/src/pkg/backup/backup_test.go @@ -1,9 +1,11 @@ package backup_test import ( + "strconv" "testing" "time" + "github.com/dustin/go-humanize" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -50,7 +52,7 @@ func stubBackup(t time.Time, ownerID, ownerName string) backup.Backup { }, StartAndEndTime: stats.StartAndEndTime{ StartedAt: t, - CompletedAt: t, + CompletedAt: t.Add(1 * time.Minute), }, SkippedCounts: stats.SkippedCounts{ TotalSkippedItems: 1, @@ -63,22 +65,27 @@ func (suite *BackupUnitSuite) TestBackup_HeadersValues() { var ( t = suite.T() now = time.Now() + later = now.Add(1 * time.Minute) b = stubBackup(now, "id", "name") expectHs = []string{ - "Started At", "ID", + "Started At", + "Duration", "Status", "Resource Owner", } nowFmt = common.FormatTabularDisplayTime(now) expectVs = []string{ - nowFmt, "id", + nowFmt, + "1m0s", "status (2 errors, 1 skipped: 1 malware)", "test", } ) + b.StartAndEndTime.CompletedAt = later + // single skipped malware hs := b.Headers() assert.Equal(t, expectHs, hs) @@ -182,7 +189,7 @@ func (suite *BackupUnitSuite) TestBackup_Values_statusVariations() { for _, test := range table { suite.Run(test.name, func() { result := test.bup.Values() - assert.Equal(suite.T(), test.expect, result[2], "status value") + assert.Equal(suite.T(), test.expect, result[3], "status value") }) } } @@ -197,10 +204,57 @@ func (suite *BackupUnitSuite) TestBackup_MinimumPrintable() { require.True(t, ok) assert.Equal(t, b.ID, result.ID, "id") - assert.Equal(t, 2, result.ErrorCount, "error count") - assert.Equal(t, now, result.StartedAt, "started at") + assert.Equal(t, 2, result.Stats.ErrorCount, "error count") + assert.Equal(t, now, result.Stats.StartedAt, "started at") assert.Equal(t, b.Status, result.Status, "status") - assert.Equal(t, b.BytesRead, result.BytesRead, "size") - assert.Equal(t, b.BytesUploaded, result.BytesUploaded, "stored size") + assert.Equal(t, b.BytesRead, result.Stats.BytesRead, "size") + assert.Equal(t, b.BytesUploaded, result.Stats.BytesUploaded, "stored size") assert.Equal(t, b.Selector.DiscreteOwner, result.Owner, "owner") } + +func (suite *BackupUnitSuite) TestStats() { + var ( + t = suite.T() + start = time.Now() + b = stubBackup(start, "owner", "ownername") + s = b.ToPrintable().Stats + ) + + assert.Equal(t, b.BytesRead, s.BytesRead, "bytes read") + assert.Equal(t, b.BytesUploaded, s.BytesUploaded, "bytes uploaded") + assert.Equal(t, b.CompletedAt, s.EndedAt, "completion time") + assert.Equal(t, b.ErrorCount, s.ErrorCount, "error count") + assert.Equal(t, b.ItemsRead, s.ItemsRead, "items read") + assert.Equal(t, b.TotalSkippedItems, s.ItemsSkipped, "items skipped") + assert.Equal(t, b.ItemsWritten, s.ItemsWritten, "items written") + assert.Equal(t, b.StartedAt, s.StartedAt, "started at") +} + +func (suite *BackupUnitSuite) TestStats_headersValues() { + var ( + t = suite.T() + start = time.Now() + b = stubBackup(start, "owner", "ownername") + s = b.ToPrintable().Stats + ) + + expectHeaders := []string{ + "ID", + "Bytes Uploaded", + "Items Uploaded", + "Items Skipped", + "Errors", + } + + assert.Equal(t, expectHeaders, s.Headers()) + + expectValues := []string{ + "id", + humanize.Bytes(uint64(b.BytesUploaded)), + strconv.Itoa(b.ItemsWritten), + strconv.Itoa(b.TotalSkippedItems), + strconv.Itoa(b.ErrorCount), + } + + assert.Equal(t, expectValues, s.Values()) +} diff --git a/src/pkg/backup/details/details.go b/src/pkg/backup/details/details.go index 32074c9c6..c0835dddf 100644 --- a/src/pkg/backup/details/details.go +++ b/src/pkg/backup/details/details.go @@ -139,7 +139,7 @@ type DetailsModel struct { // Print writes the DetailModel Entries to StdOut, in the format // requested by the caller. func (dm DetailsModel) PrintEntries(ctx context.Context) { - if print.JSONFormat() { + if print.DisplayJSONFormat() { printJSON(ctx, dm) } else { printTable(ctx, dm)