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
+17
View File
@@ -73,3 +73,20 @@ already be mounted, for example through Unassigned Devices. URBM checks array an
USB mounts before starting the job and refuses leftover directories after an USB mounts before starting the job and refuses leftover directories after an
unmount. Keep the USB disk connected throughout the run. Reload the page after unmount. Keep the USB disk connected throughout the run. Reload the page after
mounting a new array disk to refresh the available sources. mounting a new array disk to refresh the available sources.
### Rsync diagnostics (2026.09.27.r002)
Run logs start collapsed and retain your open/closed selection during refresh.
New Rsync runs log the source paths, target, dry-run mode, overwrite/delete and
comparison options, exclusions, execution phases, up to 20 itemized change
examples, and final file/byte/deletion totals. Deletion counts include directories
and symlinks. Byte totals describe the contents of transferred files, not protocol
traffic. A dry run reports planned changes and records zero bytes actually copied;
its notification explicitly states that no changes were made. Historical logs
cannot be reconstructed with these additional details.
For an integration test against Rsync 3.x, run
`URBM_TEST_RSYNC=/path/to/rsync go test ./internal/rsync -run TestRealRsync -v`.
The test uses temporary directories only and checks that a dry run changes nothing,
that a subsequent copy matches the planned counts, and that another comparison
then finds no changes.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
12bf23238112ee7ad9fe6c1102528cdd7baeb32a89378b7ac17621642b001b87 urbm-2026.09.27.r002-x86_64-1.txz
+9 -2
View File
@@ -2,13 +2,20 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.09.27.r001"> <!ENTITY version "2026.09.27.r002">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "e66e3d540fba172a1e0b8e60f7b36e33b38ba8357e5a45a07879a32c2d655222"> <!ENTITY packageSHA256 "12bf23238112ee7ad9fe6c1102528cdd7baeb32a89378b7ac17621642b001b87">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.09.27.r002
- Collapse run logs by default and retain manually opened logs during refresh.
- Log Rsync sources, destination, comparison and deletion options, execution phases, sample changes, errors and final totals.
- Clearly distinguish dry-run plans from actual copies and include deleted entries in logs and notifications.
- Parse exact byte statistics for progress, summaries and free-space checks; drain process output before closing pipes.
- Test dry runs, copies and deletions with real Rsync fixtures as well as regression tests.
### 2026.09.27.r001 ### 2026.09.27.r001
- Discover mounted array disks as selectable Rsync sources, including complete disks. - Discover mounted array disks as selectable Rsync sources, including complete disks.
- Show directory symlinks only when they resolve within allowed storage roots. - Show directory symlinks only when they resolve within allowed storage roots.
+2
View File
@@ -138,6 +138,8 @@ type NotificationTarget struct {
} }
type Run struct { type Run struct {
RsyncDryRun bool `json:"rsyncDryRun,omitempty"`
EntriesDeleted int64 `json:"entriesDeleted,omitempty"`
SchemaVersion int `json:"schemaVersion"` SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"` ID string `json:"id"`
JobID string `json:"jobId,omitempty"` JobID string `json:"jobId,omitempty"`
+37 -19
View File
@@ -40,16 +40,19 @@ type Progress struct {
Bytes int64 Bytes int64
Files int64 Files int64
CurrentFile string CurrentFile string
Change string
} }
type Summary struct { type Summary struct {
Bytes int64 Bytes int64
Files int64 Files int64
Deleted int64
} }
var ( var (
progressPattern = regexp.MustCompile(`^\s*([0-9,]+)\s+([0-9]+)%`) progressPattern = regexp.MustCompile(`^\s*([0-9,]+)\s+([0-9]+)%`)
filesPattern = regexp.MustCompile(`^Number of regular files transferred:\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`) 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{}) done := make(chan struct{})
go func() { go func() {
scanOutput(stdout, func(line string) { scanOutput(stdout, func(line string) {
if value, ok := parseCount(filesPattern, line); ok { summary.parseLine(line)
summary.Files = value
}
if value, ok := parseCount(bytesPattern, line); ok {
summary.Bytes = value
}
if callback != nil { if callback != nil {
if progress, ok := parseProgress(line); ok { if progress, ok := parseProgress(line); ok {
callback(progress) callback(progress)
} else if strings.HasPrefix(line, "FILE|") { } else if change, ok := parseChange(line); ok {
callback(Progress{CurrentFile: strings.TrimPrefix(line, "FILE|")}) callback(change)
} }
} }
}) })
@@ -106,9 +104,10 @@ func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback
}() }()
stderrDone := make(chan []byte, 1) stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }() go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
err = cmd.Wait() // Drain both pipes before Wait closes them, including the final statistics.
<-done <-done
errBytes := <-stderrDone errBytes := <-stderrDone
err = cmd.Wait()
if err != nil { if err != nil {
if errors.Is(ctx.Err(), context.Canceled) { if errors.Is(ctx.Err(), context.Canceled) {
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err()) 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{}) done := make(chan struct{})
go func() { go func() {
scanOutput(stdout, func(line string) { scanOutput(stdout, func(line string) {
if value, ok := parseCount(filesPattern, line); ok { summary.parseLine(line)
summary.Files = value
}
if value, ok := parseCount(bytesPattern, line); ok {
summary.Bytes = value
}
}) })
close(done) close(done)
}() }()
stderrDone := make(chan []byte, 1) stderrDone := make(chan []byte, 1)
go func() { stderrDone <- drainLimited(stderr, 1024*1024) }() go func() { stderrDone <- drainLimited(stderr, 1024*1024) }()
err = cmd.Wait() // Drain both pipes before Wait closes them, including the final statistics.
<-done <-done
errBytes := <-stderrDone errBytes := <-stderrDone
err = cmd.Wait()
if err != nil { if err != nil {
if errors.Is(ctx.Err(), context.Canceled) { if errors.Is(ctx.Err(), context.Canceled) {
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err()) 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 { func Arguments(job model.Job) []string {
options := job.Rsync 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 { if !options.Overwrite {
args = append(args, "--ignore-existing") 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) value, err := strconv.ParseInt(strings.ReplaceAll(match[1], ",", ""), 10, 64)
return value, err == nil 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 package rsync
import ( import (
"context"
"os"
"path/filepath"
"slices" "slices"
"strings"
"testing" "testing"
"git.casaderoll.de/michael/urbm/internal/model" "git.casaderoll.de/michael/urbm/internal/model"
@@ -31,3 +35,138 @@ func TestArrayDiskMirrorKeepsDiskDirectory(t *testing.T) {
t.Fatalf("mirror arguments = %v", args) 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 { if !ok {
return failed(run, "validation", "job no longer exists") return failed(run, "validation", "job no longer exists")
} }
run.RsyncDryRun = job.Rsync.DryRun
defer func() { result = s.finishJob(run, job, result) }() 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 { if err := platform.ValidateRsyncPaths(job); err != nil {
return failedError(run, err) 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 { for _, source := range job.Sources {
if _, err := os.Stat(source.Path); err != nil { if _, err := os.Stat(source.Path); err != nil {
return failed(run, "source", "source unavailable: "+source.Path) 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()) return failed(run, "environment", "rsync target unavailable: "+err.Error())
} }
if !job.Rsync.DryRun && !job.Rsync.SkipSpaceCheck { 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) s.updateActive(live)
estimate, err := s.rsync.Estimate(ctx, run.ID, job) estimate, err := s.rsync.Estimate(ctx, run.ID, job)
if err != nil { 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)) 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() started := time.Now()
lastLoggedPercent := -10 lastLoggedPercent := -10
summary, err := s.rsync.Run(ctx, run.ID, job, func(progress rsync.Progress) { 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) percent := int(progress.Percent)
if percent >= lastLoggedPercent+10 { 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 lastLoggedPercent = percent - percent%10
} }
} }
if progress.CurrentFile != "" { if progress.CurrentFile != "" {
live.CurrentFile = 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) s.updateActive(live)
}) })
if err != nil { if err != nil {
appendLiveLog(&live, "Rsync-Kopie fehlgeschlagen: "+err.Error()) return failedError(run, err)
failedRun := failedError(run, err)
failedRun.LiveLog = live.LiveLog
return failedRun
} }
run.Status, run.Message = "success", "rsync completed" applyRsyncSummary(&run, summary)
run.BytesAdded, run.BytesProcessed = summary.Bytes, summary.Bytes
run.FilesNew, run.FilesProcessed = summary.Files, summary.Files
run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, summary.Bytes, summary.Bytes run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, summary.Bytes, summary.Bytes
run.ProgressFiles, run.ProgressFileTotal = summary.Files, summary.Files run.ProgressFiles, run.ProgressFileTotal = summary.Files, summary.Files
run.BytesPerSecond, run.CurrentFile = live.BytesPerSecond, live.CurrentFile 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 run.LiveLog = live.LiveLog
return run return run
} }
@@ -1447,6 +1471,7 @@ func diffKindLabel(kind string) string {
func (s *Service) updateActive(run model.Run) { func (s *Service) updateActive(run model.Run) {
s.queue.UpdateActive(run.ID, func(active *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.ProgressPercent, active.ProgressBytes, active.ProgressTotal = run.ProgressPercent, run.ProgressBytes, run.ProgressTotal
active.ProgressFiles, active.ProgressFileTotal = run.ProgressFiles, run.ProgressFileTotal active.ProgressFiles, active.ProgressFileTotal = run.ProgressFiles, run.ProgressFileTotal
active.BytesPerSecond, active.SecondsRemaining = run.BytesPerSecond, run.SecondsRemaining 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 == "" { if status == "" {
status = run.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] summaryTitle := map[string]string{"backup": "📊 Backup-Zusammenfassung", "rsync": "📊 Rsync-Zusammenfassung", "restore": "📊 Restore-Zusammenfassung", "prune": "🧹 Prune-Zusammenfassung", "check": "🔎 Prüfungs-Zusammenfassung"}[run.TaskType]
if summaryTitle == "" { if summaryTitle == "" {
summaryTitle = "📊 URBM-Zusammenfassung" summaryTitle = "📊 URBM-Zusammenfassung"
@@ -1615,19 +1643,29 @@ func notificationMessage(name, repository string, run model.Run, now time.Time)
lines = append(lines, "", "⏱️ Dauer: "+formatDuration(duration)) 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, lines = append(lines,
fmt.Sprintf("📂 Dateien: %d verarbeitet", run.FilesProcessed), fmt.Sprintf("📂 Dateien: %d verarbeitet", run.FilesProcessed),
"💾 Verarbeitet: "+formatBytes(run.BytesProcessed), "💾 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, lines = append(lines,
fmt.Sprintf("➕ Neu: %d Dateien", run.FilesNew), "🧪 Testlauf – keine Änderungen vorgenommen",
fmt.Sprintf("✏️ Geändert: %d Dateien", run.FilesChanged), fmt.Sprintf("📂 Dateien würden kopiert/aktualisiert: %d", run.FilesProcessed),
"📤 Neu gespeichert: "+formatBytes(run.BytesAdded), "💾 Geplanter Dateiinhalt: "+formatBytes(run.BytesProcessed),
) fmt.Sprintf("🗑️ Einträge würden gelöscht (inkl. Ordner/Symlinks): %d", run.EntriesDeleted))
} else { } 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 { 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) 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) lines = append(lines, "⚠️ Details: "+run.Message)
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
+8 -1
View File
@@ -2,13 +2,20 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.09.27.r001"> <!ENTITY version "2026.09.27.r002">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE"> <!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.09.27.r002
- Collapse run logs by default and retain manually opened logs during refresh.
- Log Rsync sources, destination, comparison and deletion options, execution phases, sample changes, errors and final totals.
- Clearly distinguish dry-run plans from actual copies and include deleted entries in logs and notifications.
- Parse exact byte statistics for progress, summaries and free-space checks; drain process output before closing pipes.
- Test dry runs, copies and deletions with real Rsync fixtures as well as regression tests.
### 2026.09.27.r001 ### 2026.09.27.r001
- Discover mounted array disks as selectable Rsync sources, including complete disks. - Discover mounted array disks as selectable Rsync sources, including complete disks.
- Show directory symlinks only when they resolve within allowed storage roots. - Show directory symlinks only when they resolve within allowed storage roots.
+1 -1
View File
@@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
--- ---
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
$urbmVersion = '2026.09.27.r001'; $urbmVersion = '2026.09.27.r002';
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion); $urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
+2 -2
View File
@@ -813,7 +813,7 @@
const daemon=state.logs?.daemon||{}; const daemon=state.logs?.daemon||{};
const daemonLines=daemon.lines||[]; const daemonLines=daemon.lines||[];
const daemonHint=daemon.error?`<div class="bu-inline-error">${esc(daemon.error)}</div>`:`<div class="bu-muted">${esc(daemon.path||'/var/log/urbm.log')}${daemon.truncatedLines?` · ${daemon.truncatedLines} ältere Zeilen ausgeblendet`:''}${daemon.truncatedBytes?` · Anfang wegen Größe gekürzt`:''}</div>`; const daemonHint=daemon.error?`<div class="bu-inline-error">${esc(daemon.error)}</div>`:`<div class="bu-muted">${esc(daemon.path||'/var/log/urbm.log')}${daemon.truncatedLines?` · ${daemon.truncatedLines} ältere Zeilen ausgeblendet`:''}${daemon.truncatedBytes?` · Anfang wegen Größe gekürzt`:''}</div>`;
const runLogs=(state.logs?.runs||[]).map(run=>`<details class="bu-live-log" open><summary>${esc(taskLabels[run.taskType]||run.taskType)} · ${esc(runTarget(run))} · ${esc(statusLabels[run.status]||run.status)} · ${esc(new Date(run.createdAt).toLocaleString())}</summary><pre>${esc((run.liveLog||[]).join('\n'))}</pre></details>`).join(''); const runLogs=(state.logs?.runs||[]).map(run=>`<details class="bu-live-log" data-run-id="${esc(run.id)}" ${state.liveLogOpen[run.id]===true?'open':''}><summary>${esc(run.rsyncDryRun?'Rsync-Testlauf':taskLabels[run.taskType]||run.taskType)} · ${esc(runTarget(run))} · ${esc(statusLabels[run.status]||run.status)} · ${esc(new Date(run.createdAt).toLocaleString())}</summary><pre>${esc((run.liveLog||[]).join('\n'))}</pre></details>`).join('');
return `<div class="bu-toolbar"><div><h2>Protokolle</h2><div class="bu-muted">Nur lesbare Diagnoseansicht. Secrets und Passwörter werden von URBM nicht geloggt.</div></div><div class="bu-actions">${button('Aktualisieren','refresh-logs','primary')}</div></div>${state.logsLoading?'<section class="bu-card"><div class="bu-empty">Protokolle werden geladen…</div></section>':state.logsError?`<section class="bu-card"><div class="bu-inline-error">${esc(state.logsError)}</div></section>`:`<section class="bu-card"><h3>Daemon-Protokoll</h3>${daemonHint}<pre class="bu-log-pre">${esc(daemonLines.join('\n'))}</pre></section><section class="bu-card" style="margin-top:16px"><h3>Laufprotokolle</h3>${runLogs||'<div class="bu-empty">Keine gespeicherten Laufprotokolle vorhanden.</div>'}</section>`}`; return `<div class="bu-toolbar"><div><h2>Protokolle</h2><div class="bu-muted">Nur lesbare Diagnoseansicht. Secrets und Passwörter werden von URBM nicht geloggt.</div></div><div class="bu-actions">${button('Aktualisieren','refresh-logs','primary')}</div></div>${state.logsLoading?'<section class="bu-card"><div class="bu-empty">Protokolle werden geladen…</div></section>':state.logsError?`<section class="bu-card"><div class="bu-inline-error">${esc(state.logsError)}</div></section>`:`<section class="bu-card"><h3>Daemon-Protokoll</h3>${daemonHint}<pre class="bu-log-pre">${esc(daemonLines.join('\n'))}</pre></section><section class="bu-card" style="margin-top:16px"><h3>Laufprotokolle</h3>${runLogs||'<div class="bu-empty">Keine gespeicherten Laufprotokolle vorhanden.</div>'}</section>`}`;
} }
function progressView(run) { function progressView(run) {
@@ -838,7 +838,7 @@
} }
function liveLogView(run) { function liveLogView(run) {
if(!(run.liveLog||[]).length) return ''; if(!(run.liveLog||[]).length) return '';
const open=Object.prototype.hasOwnProperty.call(state.liveLogOpen,run.id)?state.liveLogOpen[run.id]:run.status==='running'; const open=Object.prototype.hasOwnProperty.call(state.liveLogOpen,run.id)?state.liveLogOpen[run.id]:false;
return `<details class="bu-live-log" data-run-id="${esc(run.id)}" ${open?'open':''}><summary>${run.status==='running'?'Live-Protokoll':'Laufprotokoll'} (${run.liveLog.length})</summary><pre>${esc(run.liveLog.join('\n'))}</pre></details>`; return `<details class="bu-live-log" data-run-id="${esc(run.id)}" ${open?'open':''}><summary>${run.status==='running'?'Live-Protokoll':'Laufprotokoll'} (${run.liveLog.length})</summary><pre>${esc(run.liveLog.join('\n'))}</pre></details>`;
} }
function runsTable(runs) { function runsTable(runs) {