Release 2026.09.27.r002: clarify rsync dry runs and expand run diagnostics

This commit is contained in:
Mikei386
2026-09-27 16:23:01 +02:00
parent 106f9c9387
commit 74a69b1580
13 changed files with 451 additions and 46 deletions
+2
View File
@@ -138,6 +138,8 @@ type NotificationTarget struct {
}
type Run struct {
RsyncDryRun bool `json:"rsyncDryRun,omitempty"`
EntriesDeleted int64 `json:"entriesDeleted,omitempty"`
SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"`
JobID string `json:"jobId,omitempty"`
+37 -19
View File
@@ -40,16 +40,19 @@ type Progress struct {
Bytes int64
Files int64
CurrentFile string
Change string
}
type Summary struct {
Bytes int64
Files int64
Bytes int64
Files int64
Deleted int64
}
var (
progressPattern = regexp.MustCompile(`^\s*([0-9,]+)\s+([0-9]+)%`)
filesPattern = regexp.MustCompile(`^Number of regular files transferred:\s*([0-9,]+)`)
deletedPattern = regexp.MustCompile(`^Number of deleted files:\s*([0-9,]+)`)
bytesPattern = regexp.MustCompile(`^Total transferred file size:\s*([0-9,]+) bytes`)
)
@@ -88,17 +91,12 @@ func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback
done := make(chan struct{})
go func() {
scanOutput(stdout, func(line string) {
if value, ok := parseCount(filesPattern, line); ok {
summary.Files = value
}
if value, ok := parseCount(bytesPattern, line); ok {
summary.Bytes = value
}
summary.parseLine(line)
if callback != nil {
if progress, ok := parseProgress(line); ok {
callback(progress)
} else if strings.HasPrefix(line, "FILE|") {
callback(Progress{CurrentFile: strings.TrimPrefix(line, "FILE|")})
} else if change, ok := parseChange(line); ok {
callback(change)
}
}
})
@@ -106,9 +104,10 @@ func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback
}()
stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
err = cmd.Wait()
// Drain both pipes before Wait closes them, including the final statistics.
<-done
errBytes := <-stderrDone
err = cmd.Wait()
if err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err())
@@ -163,20 +162,16 @@ func (r *Runner) Estimate(ctx context.Context, runID string, job model.Job) (Sum
done := make(chan struct{})
go func() {
scanOutput(stdout, func(line string) {
if value, ok := parseCount(filesPattern, line); ok {
summary.Files = value
}
if value, ok := parseCount(bytesPattern, line); ok {
summary.Bytes = value
}
summary.parseLine(line)
})
close(done)
}()
stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
err = cmd.Wait()
// Drain both pipes before Wait closes them, including the final statistics.
<-done
errBytes := <-stderrDone
err = cmd.Wait()
if err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err())
@@ -188,7 +183,7 @@ func (r *Runner) Estimate(ctx context.Context, runID string, job model.Job) (Sum
func Arguments(job model.Job) []string {
options := job.Rsync
args := []string{"--recursive", "--human-readable", "--info=progress2", "--stats", "--partial", "--out-format=FILE|%n"}
args := []string{"--recursive", "--info=progress2", "--stats", "--partial", "--outbuf=L", "--out-format=CHANGE|%i|%n%L"}
if !options.Overwrite {
args = append(args, "--ignore-existing")
}
@@ -322,3 +317,26 @@ func parseCount(pattern *regexp.Regexp, line string) (int64, bool) {
value, err := strconv.ParseInt(strings.ReplaceAll(match[1], ",", ""), 10, 64)
return value, err == nil
}
func (s *Summary) parseLine(line string) {
if value, ok := parseCount(filesPattern, line); ok {
s.Files = value
}
if value, ok := parseCount(bytesPattern, line); ok {
s.Bytes = value
}
if value, ok := parseCount(deletedPattern, line); ok {
s.Deleted = value
}
}
func parseChange(line string) (Progress, bool) {
if !strings.HasPrefix(line, "CHANGE|") {
return Progress{}, false
}
fields := strings.SplitN(strings.TrimPrefix(line, "CHANGE|"), "|", 2)
if len(fields) != 2 || len(fields[0]) != 11 {
return Progress{}, false
}
return Progress{Change: fields[0], CurrentFile: fields[1]}, true
}
+139
View File
@@ -1,7 +1,11 @@
package rsync
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.casaderoll.de/michael/urbm/internal/model"
@@ -31,3 +35,138 @@ func TestArrayDiskMirrorKeepsDiskDirectory(t *testing.T) {
t.Fatalf("mirror arguments = %v", args)
}
}
func TestMachineReadableArgumentsAndSummary(t *testing.T) {
args := Arguments(model.Job{})
if slices.Contains(args, "--human-readable") {
t.Fatal("human readable sizes break byte parsing")
}
if !slices.Contains(args, "--out-format=CHANGE|%i|%n%L") {
t.Fatal(args)
}
var summary Summary
for _, line := range []string{"Number of regular files transferred: 1,376", "Number of deleted files: 1,214 (reg: 1,200, dir: 14)", "Total transferred file size: 1,234,567,890 bytes"} {
summary.parseLine(line)
}
if summary.Files != 1376 || summary.Deleted != 1214 || summary.Bytes != 1234567890 {
t.Fatalf("summary = %+v", summary)
}
}
func TestParseChangePreservesNames(t *testing.T) {
for _, code := range []string{">f+++++++++", ">f.st......", ".d..t......", "*deleting "} {
got, ok := parseChange("CHANGE|" + code + "|disk1/a | b.txt")
if !ok || got.Change != code || got.CurrentFile != "disk1/a | b.txt" {
t.Fatalf("change = %+v, %v", got, ok)
}
}
}
func TestRunnerReadsFinalStats(t *testing.T) {
binary := filepath.Join(t.TempDir(), "rsync")
script := "#!/bin/sh\nprintf '%s\\n' 'CHANGE|>f+++++++++|disk1/new.txt' 'Number of regular files transferred: 1,376' 'Number of deleted files: 1,214 (reg: 1214)' 'Total transferred file size: 1,234,567,890 bytes'\n"
if err := os.WriteFile(binary, []byte(script), 0700); err != nil {
t.Fatal(err)
}
runner := Runner{Binary: binary}
for _, estimate := range []bool{false, true} {
var summary Summary
var err error
var changes []string
if estimate {
summary, err = runner.Estimate(context.Background(), "test", model.Job{})
} else {
summary, err = runner.Run(context.Background(), "test", model.Job{}, func(p Progress) {
if p.CurrentFile != "" {
changes = append(changes, p.CurrentFile)
}
})
}
if err != nil || summary.Files != 1376 || summary.Deleted != 1214 || summary.Bytes != 1234567890 {
t.Fatalf("estimate=%v summary=%+v err=%v", estimate, summary, err)
}
if !estimate && strings.Join(changes, ",") != "disk1/new.txt" {
t.Fatal(changes)
}
}
}
// Opt in with URBM_TEST_RSYNC pointing to rsync 3.x (Unraid's supported version).
func TestRealRsyncDryRunAndCopy(t *testing.T) {
binary := os.Getenv("URBM_TEST_RSYNC")
if binary == "" {
t.Skip("set URBM_TEST_RSYNC to test with rsync 3.x")
}
dir := t.TempDir()
source, target := filepath.Join(dir, "disk1"), filepath.Join(dir, "USB")
destination := filepath.Join(target, "disk1")
for _, path := range []string{source, filepath.Join(destination, "old-dir")} {
if err := os.MkdirAll(path, 0700); err != nil {
t.Fatal(err)
}
}
for path, data := range map[string]string{
filepath.Join(source, "new"): "new", filepath.Join(source, "changed"): "updated",
filepath.Join(source, "same"): "same", filepath.Join(destination, "same"): "same",
filepath.Join(destination, "changed"): "old", filepath.Join(destination, "obsolete"): "delete",
filepath.Join(destination, "old-dir", "file"): "delete",
} {
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
t.Fatal(err)
}
}
if err := os.Symlink("obsolete", filepath.Join(destination, "old-link")); err != nil {
t.Fatal(err)
}
runner := Runner{Binary: binary}
job := model.Job{Sources: []model.Source{{Path: source}}, Rsync: model.RsyncOptions{Target: target, DryRun: true, Overwrite: true, Delete: true, Checksum: true, PreserveLinks: true}}
var changes []Progress
summary, err := runner.Run(context.Background(), "test", job, func(p Progress) {
if p.Change != "" {
changes = append(changes, p)
}
})
if err != nil || summary.Files != 2 || summary.Bytes != 10 || summary.Deleted != 4 {
t.Fatalf("dry run: %+v %v", summary, err)
}
if len(changes) < 6 {
t.Fatalf("missing change details: %+v", changes)
}
if _, err := os.Stat(filepath.Join(destination, "new")); !os.IsNotExist(err) {
t.Fatal("dry run wrote a file")
}
if _, err := os.Stat(filepath.Join(destination, "obsolete")); err != nil {
t.Fatal("dry run deleted a file")
}
estimate, err := runner.Estimate(context.Background(), "test", job)
if err != nil || estimate != summary {
t.Fatalf("estimate differs: %+v %v", estimate, err)
}
job.Rsync.DryRun = false
copied, err := runner.Run(context.Background(), "test", job, nil)
if err != nil || copied != summary {
t.Fatalf("copy differs: %+v %v", copied, err)
}
for _, name := range []string{"new", "changed", "same"} {
got, err := os.ReadFile(filepath.Join(destination, name))
if err != nil {
t.Fatal(err)
}
want, err := os.ReadFile(filepath.Join(source, name))
if err != nil {
t.Fatal(err)
}
if string(got) != string(want) {
t.Fatalf("different contents: %s", name)
}
}
for _, name := range []string{"obsolete", "old-dir", "old-link"} {
if _, err := os.Lstat(filepath.Join(destination, name)); !os.IsNotExist(err) {
t.Fatalf("not deleted: %s", name)
}
}
identical, err := runner.Estimate(context.Background(), "test", job)
if err != nil || identical.Files != 0 || identical.Bytes != 0 || identical.Deleted != 0 {
t.Fatalf("identical: %+v %v", identical, err)
}
}
+97
View File
@@ -0,0 +1,97 @@
package service
import (
"fmt"
"strings"
"git.casaderoll.de/michael/urbm/internal/model"
"git.casaderoll.de/michael/urbm/internal/rsync"
)
func logRsyncSetup(run *model.Run, job model.Job) {
mode := "Kopierlauf – Änderungen am Ziel werden ausgeführt"
if job.Rsync.DryRun {
mode = "Testlauf – es werden keine Dateien geschrieben oder gelöscht"
}
appendLiveLog(run, "Rsync: "+mode)
for _, source := range job.Sources {
appendLiveLog(run, "Quelle: "+source.Path)
}
appendLiveLog(run, "Ziel: "+job.Rsync.Target)
overwrite := "vorhandene Dateien überspringen"
if job.Rsync.Overwrite {
overwrite = "abweichende vorhandene Dateien aktualisieren"
}
deletion := "aus"
if job.Rsync.Delete {
deletion = "an (zusätzliche Einträge im Ziel entfernen)"
}
comparison := "Dateigröße und Änderungszeit"
if job.Rsync.Checksum {
comparison = "Dateiinhalt per Prüfsumme (liest Dateien vollständig)"
}
appendLiveLog(run, "Vorhandene Dateien: "+overwrite+"; Löschen: "+deletion)
appendLiveLog(run, "Vergleich: "+comparison)
var preserve []string
for _, option := range []struct {
enabled bool
label string
}{
{job.Rsync.PreserveTimes, "Zeitstempel"}, {job.Rsync.PreservePermissions, "Berechtigungen"},
{job.Rsync.PreserveOwner, "Besitzer"}, {job.Rsync.PreserveGroup, "Gruppe"},
{job.Rsync.PreserveLinks, "Symlinks"}, {job.Rsync.PreserveACLs, "ACLs"}, {job.Rsync.PreserveXattrs, "erweiterte Attribute"},
} {
if option.enabled {
preserve = append(preserve, option.label)
}
}
if len(preserve) > 0 {
appendLiveLog(run, "Übernehmen: "+strings.Join(preserve, ", "))
}
appendLiveLog(run, fmt.Sprintf("Ausschlussmuster: %d", len(job.Excludes)))
for index, pattern := range job.Excludes {
if index == 10 {
appendLiveLog(run, "Weitere Ausschlussmuster siehe Job-Konfiguration")
break
}
appendLiveLog(run, "Ausschluss: "+pattern)
}
}
func rsyncChangeLabel(code string) string {
if strings.HasPrefix(code, "*deleting") {
return "Löschen"
}
if len(code) != 11 {
return "Änderung"
}
if code[2:] == "+++++++++" {
return "Neu"
}
if code[0] == '.' {
return "Metadaten ändern"
}
return "Aktualisieren"
}
func applyRsyncSummary(run *model.Run, summary rsync.Summary) {
run.Status, run.Message = "success", "rsync completed"
run.BytesProcessed, run.FilesProcessed = summary.Bytes, summary.Files
run.EntriesDeleted = summary.Deleted
run.BytesAdded = summary.Bytes
if run.RsyncDryRun {
run.BytesAdded = 0
run.Message = "Rsync-Testlauf erfolgreich – keine Änderungen vorgenommen"
}
// Rsync's transferred-file count combines new and updated regular files.
run.FilesNew, run.FilesChanged = 0, 0
}
func logRsyncSummary(run *model.Run, summary rsync.Summary) {
if run.RsyncDryRun {
appendLiveLog(run, fmt.Sprintf("Testlauf: %d Dateien würden kopiert/aktualisiert (%s Dateiinhalt); %d Einträge würden gelöscht (inkl. Ordner/Symlinks)", summary.Files, formatBytes(summary.Bytes), summary.Deleted))
appendLiveLog(run, "Testlauf erfolgreich – keine Änderungen vorgenommen")
} else {
appendLiveLog(run, fmt.Sprintf("Rsync abgeschlossen: %d Dateien kopiert/aktualisiert (%s Dateiinhalt); %d Einträge gelöscht (inkl. Ordner/Symlinks)", summary.Files, formatBytes(summary.Bytes), summary.Deleted))
}
}
+79
View File
@@ -0,0 +1,79 @@
package service
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.casaderoll.de/michael/urbm/internal/model"
"git.casaderoll.de/michael/urbm/internal/queue"
"git.casaderoll.de/michael/urbm/internal/rsync"
)
func TestRsyncSummaryDistinguishesDryRun(t *testing.T) {
for _, dry := range []bool{true, false} {
run := model.Run{TaskType: "rsync", RsyncDryRun: dry}
summary := rsync.Summary{Files: 1376, Deleted: 1214, Bytes: 1234567890}
applyRsyncSummary(&run, summary)
logRsyncSummary(&run, summary)
message := notificationMessage("Mirror", "", run, time.Now())
for _, expected := range []string{"1376", "1214", "1.1 GiB"} {
if !strings.Contains(message, expected) || !strings.Contains(strings.Join(run.LiveLog, "\n"), expected) {
t.Fatalf("missing %s: %s %v", expected, message, run.LiveLog)
}
}
if dry {
if run.BytesAdded != 0 || !strings.Contains(message, "würden") || !strings.Contains(message, "keine Änderungen vorgenommen") || strings.Contains(message, "⚠️") {
t.Fatal(message)
}
} else if run.BytesAdded != summary.Bytes || strings.Contains(message, "würden") {
t.Fatal(message)
}
}
}
func TestExecuteRsyncLogsSetupSamplesAndErrors(t *testing.T) {
for _, fail := range []bool{false, true} {
dir := t.TempDir()
source, target := filepath.Join(dir, "source"), filepath.Join(dir, "target")
for _, path := range []string{source, target} {
if err := os.Mkdir(path, 0700); err != nil {
t.Fatal(err)
}
}
binary := filepath.Join(dir, "rsync")
script := "#!/bin/sh\ncase \" $* \" in *' --dry-run '*) ;; *) exit 99;; esac\n"
if fail {
script += "echo 'test failure' >&2\nexit 23\n"
} else {
script += "i=0; while [ $i -lt 25 ]; do printf 'CHANGE|>f+++++++++|source/file%s\\n' \"$i\"; i=$((i+1)); done\nprintf '%s\\n' 'Number of regular files transferred: 25' 'Number of deleted files: 3 (reg: 3)' 'Total transferred file size: 123456 bytes'\n"
}
if err := os.WriteFile(binary, []byte(script), 0700); err != nil {
t.Fatal(err)
}
job := model.Job{ID: "mirror", Sources: []model.Source{{Path: source}}, Rsync: model.RsyncOptions{Target: target, DryRun: true, Overwrite: true, Delete: true, Checksum: true}}
s := &Service{config: model.Config{Jobs: []model.Job{job}}, rsync: &rsync.Runner{Binary: binary}, queue: queue.New(nil, nil)}
result := s.executeRsync(context.Background(), model.Run{ID: "run", JobID: job.ID, TaskType: "rsync"})
log := strings.Join(result.LiveLog, "\n")
for _, expected := range []string{"Testlauf", "Quelle: " + source, "Ziel: " + target, "Löschen: an", "Prüfsumme"} {
if !strings.Contains(log, expected) {
t.Fatalf("missing %s: %s", expected, log)
}
}
if fail {
if result.Status != "failed" || !strings.Contains(log, "test failure") {
t.Fatal(result)
}
} else {
if result.Status != "success" || result.BytesAdded != 0 || result.BytesProcessed != 123456 || result.EntriesDeleted != 3 {
t.Fatal(result)
}
if strings.Count(log, "Geplant: Neu") != 20 || !strings.Contains(log, "Weitere Einzeländerungen ausgeblendet") || !strings.Contains(log, "25 Dateien würden") {
t.Fatal(log)
}
}
}
}
+59 -21
View File
@@ -1135,13 +1135,20 @@ func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model
if !ok {
return failed(run, "validation", "job no longer exists")
}
run.RsyncDryRun = job.Rsync.DryRun
defer func() { result = s.finishJob(run, job, result) }()
live := run
logRsyncSetup(&live, job)
defer func() {
if result.Status == "failed" || result.Status == "cancelled" {
appendLiveLog(&live, "Rsync abgebrochen/fehlgeschlagen: "+result.Message)
}
result.LiveLog = live.LiveLog
}()
s.updateActive(live)
if err := platform.ValidateRsyncPaths(job); err != nil {
return failedError(run, err)
}
live := run
appendLiveLog(&live, fmt.Sprintf("Rsync-Kopie wird vorbereitet: %d Quelle(n) nach %s", len(job.Sources), job.Rsync.Target))
s.updateActive(live)
for _, source := range job.Sources {
if _, err := os.Stat(source.Path); err != nil {
return failed(run, "source", "source unavailable: "+source.Path)
@@ -1154,7 +1161,7 @@ func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model
return failed(run, "environment", "rsync target unavailable: "+err.Error())
}
if !job.Rsync.DryRun && !job.Rsync.SkipSpaceCheck {
appendLiveLog(&live, "Rsync-Vorabprüfung ermittelt den tatsächlich zu übertragenden Datenumfang")
appendLiveLog(&live, "Speicherplatz-Vorabprüfung: rsync vergleicht Quellen und Ziel ohne Änderungen; bei großen Verzeichnissen kann dies dauern")
s.updateActive(live)
estimate, err := s.rsync.Estimate(ctx, run.ID, job)
if err != nil {
@@ -1170,6 +1177,14 @@ func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model
return failed(run, "environment", fmt.Sprintf("Rsync-Ziel hat nicht genügend freien Speicher: ungefähr %s für %d Dateien erforderlich, aber nur %s frei unter %s", formatBytes(estimate.Bytes), estimate.Files, formatBytes(available), job.Rsync.Target))
}
}
if job.Rsync.DryRun {
appendLiveLog(&live, "Speicherplatz-Vorabprüfung entfällt beim Testlauf")
} else if job.Rsync.SkipSpaceCheck {
appendLiveLog(&live, "Speicherplatz-Vorabprüfung laut Job-Einstellung übersprungen")
}
appendLiveLog(&live, "Rsync startet den Dateivergleich; Änderungen werden als Stichprobe protokolliert (max. 20 Einträge)")
s.updateActive(live)
changesLogged := 0
started := time.Now()
lastLoggedPercent := -10
summary, err := s.rsync.Run(ctx, run.ID, job, func(progress rsync.Progress) {
@@ -1184,28 +1199,37 @@ func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model
}
percent := int(progress.Percent)
if percent >= lastLoggedPercent+10 {
appendLiveLog(&live, fmt.Sprintf("Rsync-Fortschritt: %.0f%%, %s übertragen", progress.Percent, formatBytes(progress.Bytes)))
if job.Rsync.DryRun {
appendLiveLog(&live, fmt.Sprintf("Testlauf-Fortschritt: %.0f%% (keine Übertragung)", progress.Percent))
} else {
appendLiveLog(&live, fmt.Sprintf("Rsync-Fortschritt: %.0f%%, %s übertragen", progress.Percent, formatBytes(progress.Bytes)))
}
lastLoggedPercent = percent - percent%10
}
}
if progress.CurrentFile != "" {
live.CurrentFile = progress.CurrentFile
if changesLogged < 20 {
prefix := "Änderung: "
if job.Rsync.DryRun {
prefix = "Geplant: "
}
appendLiveLog(&live, prefix+rsyncChangeLabel(progress.Change)+" · "+progress.CurrentFile)
} else if changesLogged == 20 {
appendLiveLog(&live, "Weitere Einzeländerungen ausgeblendet; die Zusammenfassung zählt alle Dateien und Löschungen")
}
changesLogged++
}
s.updateActive(live)
})
if err != nil {
appendLiveLog(&live, "Rsync-Kopie fehlgeschlagen: "+err.Error())
failedRun := failedError(run, err)
failedRun.LiveLog = live.LiveLog
return failedRun
return failedError(run, err)
}
run.Status, run.Message = "success", "rsync completed"
run.BytesAdded, run.BytesProcessed = summary.Bytes, summary.Bytes
run.FilesNew, run.FilesProcessed = summary.Files, summary.Files
applyRsyncSummary(&run, summary)
run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, summary.Bytes, summary.Bytes
run.ProgressFiles, run.ProgressFileTotal = summary.Files, summary.Files
run.BytesPerSecond, run.CurrentFile = live.BytesPerSecond, live.CurrentFile
appendLiveLog(&live, fmt.Sprintf("Rsync-Kopie abgeschlossen: %d Dateien, %s", summary.Files, formatBytes(summary.Bytes)))
logRsyncSummary(&live, summary)
run.LiveLog = live.LiveLog
return run
}
@@ -1447,6 +1471,7 @@ func diffKindLabel(kind string) string {
func (s *Service) updateActive(run model.Run) {
s.queue.UpdateActive(run.ID, func(active *model.Run) {
active.RsyncDryRun = run.RsyncDryRun
active.ProgressPercent, active.ProgressBytes, active.ProgressTotal = run.ProgressPercent, run.ProgressBytes, run.ProgressTotal
active.ProgressFiles, active.ProgressFileTotal = run.ProgressFiles, run.ProgressFileTotal
active.BytesPerSecond, active.SecondsRemaining = run.BytesPerSecond, run.SecondsRemaining
@@ -1592,6 +1617,9 @@ func notificationMessage(name, repository string, run model.Run, now time.Time)
if status == "" {
status = run.Status
}
if run.TaskType == "rsync" && run.RsyncDryRun && run.Status == "success" {
status = "Testlauf erfolgreich – keine Änderungen vorgenommen"
}
summaryTitle := map[string]string{"backup": "📊 Backup-Zusammenfassung", "rsync": "📊 Rsync-Zusammenfassung", "restore": "📊 Restore-Zusammenfassung", "prune": "🧹 Prune-Zusammenfassung", "check": "🔎 Prüfungs-Zusammenfassung"}[run.TaskType]
if summaryTitle == "" {
summaryTitle = "📊 URBM-Zusammenfassung"
@@ -1615,19 +1643,29 @@ func notificationMessage(name, repository string, run model.Run, now time.Time)
lines = append(lines, "", "⏱️ Dauer: "+formatDuration(duration))
}
}
if (run.TaskType == "backup" || run.TaskType == "rsync") && (run.Status == "success" || run.Status == "warning") {
if run.TaskType == "backup" && (run.Status == "success" || run.Status == "warning") {
lines = append(lines,
fmt.Sprintf("📂 Dateien: %d verarbeitet", run.FilesProcessed),
"💾 Verarbeitet: "+formatBytes(run.BytesProcessed),
)
if run.TaskType == "backup" {
lines = append(lines,
fmt.Sprintf("➕ Neu: %d Dateien", run.FilesNew),
fmt.Sprintf("✏️ Geändert: %d Dateien", run.FilesChanged),
"📤 Neu gespeichert: "+formatBytes(run.BytesAdded),
)
}
if run.TaskType == "rsync" && (run.Status == "success" || run.Status == "warning") {
if run.RsyncDryRun {
lines = append(lines,
fmt.Sprintf("➕ Neu: %d Dateien", run.FilesNew),
fmt.Sprintf("✏️ Geändert: %d Dateien", run.FilesChanged),
"📤 Neu gespeichert: "+formatBytes(run.BytesAdded),
)
"🧪 Testlauf – keine Änderungen vorgenommen",
fmt.Sprintf("📂 Dateien würden kopiert/aktualisiert: %d", run.FilesProcessed),
"💾 Geplanter Dateiinhalt: "+formatBytes(run.BytesProcessed),
fmt.Sprintf("🗑️ Einträge würden gelöscht (inkl. Ordner/Symlinks): %d", run.EntriesDeleted))
} else {
lines = append(lines, "📤 Kopiert: "+formatBytes(run.BytesAdded))
lines = append(lines,
fmt.Sprintf("📂 Dateien kopiert/aktualisiert: %d", run.FilesProcessed),
"📤 Kopierter Dateiinhalt: "+formatBytes(run.BytesAdded),
fmt.Sprintf("🗑️ Einträge gelöscht (inkl. Ordner/Symlinks): %d", run.EntriesDeleted))
}
}
if run.TaskType == "backup" && run.RetentionAfter > 0 {
@@ -1646,7 +1684,7 @@ func notificationMessage(name, repository string, run model.Run, now time.Time)
)
}
lines = append(lines, "", "🔐 Ziel: "+notificationTargetLabel(run.TaskType), statusIcon(run.Status)+" Status: "+status)
if run.Message != "" && run.Message != run.TaskType+" completed" {
if run.Message != "" && run.Message != run.TaskType+" completed" && !(run.RsyncDryRun && run.Status == "success") {
lines = append(lines, "⚠️ Details: "+run.Message)
}
return strings.Join(lines, "\n")