diff --git a/cli/command_snapshot_create.go b/cli/command_snapshot_create.go index 7f34127ef..7d671c7cd 100644 --- a/cli/command_snapshot_create.go +++ b/cli/command_snapshot_create.go @@ -280,7 +280,7 @@ func (c *commandSnapshotCreate) snapshotSingleSource( var mwe notifydata.ManifestWithError - mwe.Source = sourceInfo + mwe.Manifest.Source = sourceInfo st.Snapshots = append(st.Snapshots, &mwe) @@ -297,6 +297,10 @@ func (c *commandSnapshotCreate) snapshotSingleSource( return finalErr } + if len(previous) > 0 { + mwe.Previous = previous[0] + } + policyTree, finalErr := policy.TreeForSource(ctx, rep, sourceInfo) if finalErr != nil { return errors.Wrap(finalErr, "unable to get policy tree") diff --git a/notification/notifydata/multi_snapshot_status.go b/notification/notifydata/multi_snapshot_status.go index 4543ea89b..fba5ad1c9 100644 --- a/notification/notifydata/multi_snapshot_status.go +++ b/notification/notifydata/multi_snapshot_status.go @@ -6,44 +6,94 @@ "github.com/kopia/kopia/snapshot" ) +const durationPrecision = 100 * time.Millisecond + // ManifestWithError represents information about the snapshot manifest with optional error. type ManifestWithError struct { - snapshot.Manifest `json:"manifest"` // may not be filled out if there was an error, Manifst.Source is always set. + Manifest snapshot.Manifest `json:"manifest"` // may not be filled out if there was an error, Manifst.Source is always set. + Previous *snapshot.Manifest `json:"previous"` // may not be filled out Error string `json:"error"` // will be present if there was an error } // StartTimestamp returns the start time of the snapshot. func (m *ManifestWithError) StartTimestamp() time.Time { - return m.StartTime.ToTime().UTC().Truncate(time.Second) + return m.Manifest.StartTime.ToTime().UTC().Truncate(time.Second) } // EndTimestamp returns the end time of the snapshot. func (m *ManifestWithError) EndTimestamp() time.Time { - return m.EndTime.ToTime().UTC().Truncate(time.Second) + return m.Manifest.EndTime.ToTime().UTC().Truncate(time.Second) } // TotalSize returns the total size of the snapshot in bytes. func (m *ManifestWithError) TotalSize() int64 { - if m.RootEntry == nil { + if m.Manifest.RootEntry == nil { return 0 } - if m.RootEntry.DirSummary != nil { - return m.RootEntry.DirSummary.TotalFileSize + if m.Manifest.RootEntry.DirSummary != nil { + return m.Manifest.RootEntry.DirSummary.TotalFileSize } - return m.RootEntry.FileSize + return m.Manifest.RootEntry.FileSize +} + +// TotalSizeDelta returns the total size of the snapshot in bytes. +func (m *ManifestWithError) TotalSizeDelta() int64 { + if m.Previous == nil { + return 0 + } + + if m.Manifest.RootEntry == nil { + return 0 + } + + if m.Manifest.RootEntry.DirSummary != nil && m.Previous.RootEntry.DirSummary != nil { + return m.Manifest.RootEntry.DirSummary.TotalFileSize - m.Previous.RootEntry.DirSummary.TotalFileSize + } + + return m.Manifest.RootEntry.FileSize +} + +// New returns the total size of the snapshot in bytes. +func (m *ManifestWithError) New() int64 { + if m.Manifest.RootEntry == nil { + return 0 + } + + if m.Manifest.RootEntry.DirSummary != nil { + return m.Manifest.RootEntry.DirSummary.TotalFileSize + } + + return m.Manifest.RootEntry.FileSize } // TotalFiles returns the total number of files in the snapshot. func (m *ManifestWithError) TotalFiles() int64 { - if m.RootEntry == nil { + if m.Manifest.RootEntry == nil { return 0 } - if m.RootEntry.DirSummary != nil { - return m.RootEntry.DirSummary.TotalFileCount + if m.Manifest.RootEntry.DirSummary != nil { + return m.Manifest.RootEntry.DirSummary.TotalFileCount + } + + return 1 +} + +// TotalFilesDelta returns the total number of new files in the snapshot. +func (m *ManifestWithError) TotalFilesDelta() int64 { + if m.Previous == nil { + return 0 + } + + if m.Manifest.RootEntry == nil || m.Previous.RootEntry == nil { + return 0 + } + + if m.Manifest.RootEntry.DirSummary != nil && m.Previous.RootEntry.DirSummary != nil { + return m.Manifest.RootEntry.DirSummary.TotalFileCount - m.Previous.RootEntry.DirSummary.TotalFileCount } return 1 @@ -51,12 +101,29 @@ func (m *ManifestWithError) TotalFiles() int64 { // TotalDirs returns the total number of directories in the snapshot. func (m *ManifestWithError) TotalDirs() int64 { - if m.RootEntry == nil { + if m.Manifest.RootEntry == nil { return 0 } - if m.RootEntry.DirSummary != nil { - return m.RootEntry.DirSummary.TotalDirCount + if m.Manifest.RootEntry.DirSummary != nil { + return m.Manifest.RootEntry.DirSummary.TotalDirCount + } + + return 0 +} + +// TotalDirsDelta returns the total number of new directories in the snapshot. +func (m *ManifestWithError) TotalDirsDelta() int64 { + if m.Previous == nil { + return 0 + } + + if m.Manifest.RootEntry == nil || m.Previous.RootEntry == nil { + return 0 + } + + if m.Manifest.RootEntry.DirSummary != nil && m.Previous.RootEntry.DirSummary != nil { + return m.Manifest.RootEntry.DirSummary.TotalDirCount - m.Previous.RootEntry.DirSummary.TotalDirCount } return 0 @@ -64,7 +131,7 @@ func (m *ManifestWithError) TotalDirs() int64 { // Duration returns the duration of the snapshot. func (m *ManifestWithError) Duration() time.Duration { - return time.Duration(m.EndTime - m.StartTime) + return time.Duration(m.Manifest.EndTime - m.Manifest.StartTime).Round(durationPrecision) } // StatusCode returns the status code of the manifest. diff --git a/notification/notifytemplate/embeddedtemplate.go b/notification/notifytemplate/embeddedtemplate.go index 0c24cb128..a975a0bd6 100644 --- a/notification/notifytemplate/embeddedtemplate.go +++ b/notification/notifytemplate/embeddedtemplate.go @@ -3,8 +3,10 @@ import ( "embed" + "fmt" "slices" "sort" + "strconv" "text/template" "time" @@ -29,6 +31,10 @@ type Options struct { TimeFormat string } +func formatCount(v int64) string { + return strconv.FormatInt(v, 10) +} + // functions is a map of functions that can be used in templates. func functions(opt Options) template.FuncMap { if opt.Timezone == nil { @@ -40,11 +46,52 @@ func functions(opt Options) template.FuncMap { } return template.FuncMap{ - "bytes": units.BytesString[int64], + "bytes": units.BytesString[int64], + "formatCount": formatCount, + "bytesDelta": func(v int64) string { + switch { + case v == 0: + return "" + case v > 0: + return " (+" + units.BytesString(v) + ")" + default: + return " (-" + units.BytesString(-v) + ")" + } + }, + "bytesDeltaHTML": func(v int64) string { + switch { + case v == 0: + return "" + case v > 0: + return " (↑ " + units.BytesString(v) + ")" + default: + return " (↓ " + units.BytesString(-v) + ")" + } + }, + "countDelta": func(v int64) string { + switch { + case v == 0: + return "" + case v > 0: + return fmt.Sprintf(" (+%v)", formatCount(v)) + default: + return fmt.Sprintf(" (-%v)", formatCount(-v)) + } + }, + "countDeltaHTML": func(v int64) string { + switch { + case v == 0: + return "" + case v > 0: + return fmt.Sprintf(" (↑ %v)", formatCount(v)) + default: + return fmt.Sprintf(" (↓ %v)", formatCount(-v)) + } + }, "sortSnapshotManifestsByName": func(man []*notifydata.ManifestWithError) []*notifydata.ManifestWithError { res := slices.Clone(man) sort.Slice(res, func(i, j int) bool { - return res[i].Source.String() < res[j].Source.String() + return res[i].Manifest.Source.String() < res[j].Manifest.Source.String() }) return res }, diff --git a/notification/notifytemplate/notifytemplate_test.go b/notification/notifytemplate/notifytemplate_test.go index 182c98fbc..8a05d3c20 100644 --- a/notification/notifytemplate/notifytemplate_test.go +++ b/notification/notifytemplate/notifytemplate_test.go @@ -48,10 +48,85 @@ func TestNotifyTemplate_snapshot_report(t *testing.T) { args := notification.MakeTemplateArgs(¬ifydata.MultiSnapshotStatus{ Snapshots: []*notifydata.ManifestWithError{ { + // normal snapshot with positive deltas Manifest: snapshot.Manifest{ Source: snapshot.SourceInfo{Host: "some-host", UserName: "some-user", Path: "/some/path"}, StartTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC).UnixNano()), - EndTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 6, 7, time.UTC).UnixNano()), + EndTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 6, 120000000, time.UTC).UnixNano()), + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 123, + TotalFileSize: 456, + TotalDirCount: 33, + FailedEntries: []*fs.EntryWithError{ + { + EntryPath: "/some/path", + Error: "some error", + }, + { + EntryPath: "/some/path2", + Error: "some error", + }, + }, + }, + }, + }, + Previous: &snapshot.Manifest{ + Source: snapshot.SourceInfo{Host: "some-host", UserName: "some-user", Path: "/some/path"}, + StartTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC).UnixNano()), + EndTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 6, 120000000, time.UTC).UnixNano()), + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 100, + TotalFileSize: 400, + TotalDirCount: 30, + }, + }, + }, + }, + { + // normal snapshot with positive deltas + Manifest: snapshot.Manifest{ + Source: snapshot.SourceInfo{Host: "some-host", UserName: "some-user", Path: "/some/path"}, + StartTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC).UnixNano()), + EndTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 6, 120000000, time.UTC).UnixNano()), + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 123, + TotalFileSize: 456, + TotalDirCount: 33, + FailedEntries: []*fs.EntryWithError{ + { + EntryPath: "/some/path", + Error: "some error", + }, + { + EntryPath: "/some/path2", + Error: "some error", + }, + }, + }, + }, + }, + Previous: &snapshot.Manifest{ + Source: snapshot.SourceInfo{Host: "some-host", UserName: "some-user", Path: "/some/path"}, + StartTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC).UnixNano()), + EndTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 6, 120000000, time.UTC).UnixNano()), + RootEntry: &snapshot.DirEntry{ + DirSummary: &fs.DirectorySummary{ + TotalFileCount: 200, + TotalFileSize: 500, + TotalDirCount: 40, + }, + }, + }, + }, + { + // no previous snapshot + Manifest: snapshot.Manifest{ + Source: snapshot.SourceInfo{Host: "some-host", UserName: "some-user", Path: "/some/path2"}, + StartTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC).UnixNano()), + EndTime: fs.UTCTimestamp(time.Date(2020, 1, 2, 3, 4, 6, 120000000, time.UTC).UnixNano()), RootEntry: &snapshot.DirEntry{ DirSummary: &fs.DirectorySummary{ TotalFileCount: 123, diff --git a/notification/notifytemplate/snapshot-report.html b/notification/notifytemplate/snapshot-report.html index 272d43301..4a279d118 100644 --- a/notification/notifytemplate/snapshot-report.html +++ b/notification/notifytemplate/snapshot-report.html @@ -34,6 +34,16 @@ Subject: Kopia created {{ len .EventArgs.Snapshots }} snapshot{{ if gt (len .Eve font-weight: bold; } + span.increase { + color: green; + font-style: italic; + } + + span.decrease { + color: red; + font-style: italic; + } + tr.snapshotstatus-fatal { background-color: #fde9e4; } @@ -61,12 +71,12 @@ Subject: Kopia created {{ len .EventArgs.Snapshots }} snapshot{{ if gt (len .Eve {{ range .EventArgs.Snapshots | sortSnapshotManifestsByName}}