diff --git a/dist/backupper-2026.06.14.9.1-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.9.1-x86_64-1.txz.sha256 deleted file mode 100644 index 5054ef4..0000000 --- a/dist/backupper-2026.06.14.9.1-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -67e2f22e189eb4aa8f350d48e23e8a9a618bf5714d9c740ef24e5ee413d045ff backupper-2026.06.14.9.1-x86_64-1.txz diff --git a/dist/backupper-2026.06.14.9.1-x86_64-1.txz b/dist/backupper-2026.06.14.9.2-x86_64-1.txz similarity index 55% rename from dist/backupper-2026.06.14.9.1-x86_64-1.txz rename to dist/backupper-2026.06.14.9.2-x86_64-1.txz index 0307d25..806634f 100644 Binary files a/dist/backupper-2026.06.14.9.1-x86_64-1.txz and b/dist/backupper-2026.06.14.9.2-x86_64-1.txz differ diff --git a/dist/backupper-2026.06.14.9.2-x86_64-1.txz.sha256 b/dist/backupper-2026.06.14.9.2-x86_64-1.txz.sha256 new file mode 100644 index 0000000..e11854c --- /dev/null +++ b/dist/backupper-2026.06.14.9.2-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +8440c745eb052c38ce853789d10c1663e6dc0e77ad07b99079341bd4249546a6 backupper-2026.06.14.9.2-x86_64-1.txz diff --git a/dist/backupper.plg b/dist/backupper.plg index 8d7226b..d3b6fb1 100644 --- a/dist/backupper.plg +++ b/dist/backupper.plg @@ -2,13 +2,16 @@ - + - + ]> +### 2026.06.14.9.2 +- Include duration, new and changed files, added data, total backup size, file count, and snapshot ID in backup notifications. + ### 2026.06.14.9.1 - Add native Unraid notifications and replace ntfy with Gotify. - Allow success, warning, and failure events to be selected per notification channel. diff --git a/internal/model/model.go b/internal/model/model.go index 1c60d9e..95ac8ca 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -126,6 +126,7 @@ type Run struct { ErrorCode string `json:"errorCode,omitempty"` BytesAdded int64 `json:"bytesAdded,omitempty"` FilesNew int64 `json:"filesNew,omitempty"` + FilesChanged int64 `json:"filesChanged,omitempty"` BytesProcessed int64 `json:"bytesProcessed,omitempty"` FilesProcessed int64 `json:"filesProcessed,omitempty"` } diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 91c4da8..d62781c 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -30,6 +30,7 @@ type Summary struct { SnapshotID string `json:"snapshot_id"` DataAdded int64 `json:"data_added"` FilesNew int64 `json:"files_new"` + FilesChanged int64 `json:"files_changed"` TotalBytesProcessed int64 `json:"total_bytes_processed"` TotalFilesProcessed int64 `json:"total_files_processed"` } diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index a72359b..3672700 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -20,7 +20,7 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { argsPath := filepath.Join(dir, "args") passwordPath := filepath.Join(dir, "password") script := filepath.Join(dir, "restic") - body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath) + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"files_changed\":2,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath) if err := os.WriteFile(script, []byte(body), 0700); err != nil { t.Fatal(err) } @@ -31,7 +31,7 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) { if err != nil { t.Fatal(err) } - if summary.SnapshotID != "abc123" || summary.DataAdded != 42 || summary.TotalBytesProcessed != 1024 || summary.TotalFilesProcessed != 12 { + if summary.SnapshotID != "abc123" || summary.DataAdded != 42 || summary.FilesChanged != 2 || summary.TotalBytesProcessed != 1024 || summary.TotalFilesProcessed != 12 { t.Fatalf("unexpected summary: %+v", summary) } args, _ := os.ReadFile(argsPath) diff --git a/internal/service/notification_test.go b/internal/service/notification_test.go new file mode 100644 index 0000000..16a15f7 --- /dev/null +++ b/internal/service/notification_test.go @@ -0,0 +1,21 @@ +package service + +import ( + "strings" + "testing" + "time" + + "github.com/backupper-unraid/backupper/internal/model" +) + +func TestNotificationMessageIncludesBackupStatistics(t *testing.T) { + started := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) + finished := started.Add(2*time.Minute + 8*time.Second) + run := model.Run{TaskType: "backup", Status: "success", StartedAt: &started, FinishedAt: &finished, SnapshotID: "abc123", BytesAdded: 1536, FilesNew: 7, FilesChanged: 2, BytesProcessed: 5 * 1024 * 1024, FilesProcessed: 120} + message := notificationMessage(run, finished) + for _, expected := range []string{"Status: Erfolgreich", "Dauer: 2 Min. 8 Sek.", "Neu gespeichert: 1.5 KiB", "Neue Dateien: 7", "Geänderte Dateien: 2", "Gesamtgröße dieses Backups: 5.0 MiB", "Dateien insgesamt: 120", "Snapshot: abc123"} { + if !strings.Contains(message, expected) { + t.Errorf("message %q does not contain %q", message, expected) + } + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 56905a6..11155e2 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -286,7 +286,7 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode } else { run.Status, run.Message = "success", "backup completed" } - run.SnapshotID, run.BytesAdded, run.FilesNew = summary.SnapshotID, summary.DataAdded, summary.FilesNew + run.SnapshotID, run.BytesAdded, run.FilesNew, run.FilesChanged = summary.SnapshotID, summary.DataAdded, summary.FilesNew, summary.FilesChanged run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed return run } @@ -324,10 +324,11 @@ func (s *Service) finishJob(run model.Run, job model.Job, result model.Run) mode } func (s *Service) notify(name string, run model.Run) { + message := notificationMessage(run, time.Now().UTC()) for _, target := range s.Config().Notifications { for _, event := range target.Events { if event == run.Status { - if err := s.notifier.Send(context.Background(), target, "Backupper: "+name, run.Message, run.Status); err != nil { + if err := s.notifier.Send(context.Background(), target, "Backupper: "+name, message, run.Status); err != nil { s.log.Warn("send notification failed", "target", target.ID, "type", target.Type, "error", err) } break @@ -336,6 +337,66 @@ func (s *Service) notify(name string, run model.Run) { } } +func notificationMessage(run model.Run, now time.Time) string { + status := map[string]string{"success": "Erfolgreich", "warning": "Mit Warnung", "failed": "Fehlgeschlagen"}[run.Status] + if status == "" { + status = run.Status + } + lines := []string{"Status: " + status} + if run.StartedAt != nil { + finished := now + if run.FinishedAt != nil { + finished = *run.FinishedAt + } + if duration := finished.Sub(*run.StartedAt); duration >= 0 { + lines = append(lines, "Dauer: "+formatDuration(duration)) + } + } + if run.TaskType == "backup" && (run.Status == "success" || run.Status == "warning") { + lines = append(lines, + "Neu gespeichert: "+formatBytes(run.BytesAdded), + fmt.Sprintf("Neue Dateien: %d", run.FilesNew), + fmt.Sprintf("Geänderte Dateien: %d", run.FilesChanged), + "Gesamtgröße dieses Backups: "+formatBytes(run.BytesProcessed), + fmt.Sprintf("Dateien insgesamt: %d", run.FilesProcessed), + ) + if run.SnapshotID != "" { + lines = append(lines, "Snapshot: "+run.SnapshotID) + } + } + if run.Message != "" && run.Message != "backup completed" { + lines = append(lines, "Details: "+run.Message) + } + return strings.Join(lines, "\n") +} + +func formatBytes(value int64) string { + const unit = int64(1024) + if value < unit { + return fmt.Sprintf("%d B", value) + } + div, exp := unit, 0 + for n := value / unit; n >= unit && exp < 4; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(value)/float64(div), "KMGTPE"[exp]) +} + +func formatDuration(duration time.Duration) string { + duration = duration.Round(time.Second) + if duration < time.Minute { + return fmt.Sprintf("%d Sekunden", int(duration.Seconds())) + } + hours := int(duration / time.Hour) + minutes := int(duration/time.Minute) % 60 + seconds := int(duration/time.Second) % 60 + if hours > 0 { + return fmt.Sprintf("%d Std. %d Min. %d Sek.", hours, minutes, seconds) + } + return fmt.Sprintf("%d Min. %d Sek.", minutes, seconds) +} + func (s *Service) job(id string) (model.Job, bool) { for _, item := range s.Config().Jobs { if item.ID == id { diff --git a/plugin/backupper.plg b/plugin/backupper.plg index 7a8285f..3b15a86 100644 --- a/plugin/backupper.plg +++ b/plugin/backupper.plg @@ -2,13 +2,16 @@ - + ]> +### 2026.06.14.9.2 +- Include duration, new and changed files, added data, total backup size, file count, and snapshot ID in backup notifications. + ### 2026.06.14.9.1 - Add native Unraid notifications and replace ntfy with Gotify. - Allow success, warning, and failure events to be selected per notification channel. diff --git a/webgui/Backupper.page b/webgui/Backupper.page index 287df2f..808fd33 100644 --- a/webgui/Backupper.page +++ b/webgui/Backupper.page @@ -9,12 +9,12 @@ Tag="backup restic snapshots restore" - +
- -

Backupper 2026.06.14.9.1

Encrypted Restic backups for Unraid

+ +

Backupper 2026.06.14.9.2

Encrypted Restic backups for Unraid

Connecting...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/backupper';
- +