diff --git a/dist/urbm-2026.06.22.r003-x86_64-1.txz.sha256 b/dist/urbm-2026.06.22.r003-x86_64-1.txz.sha256 deleted file mode 100644 index 1aa95c3..0000000 --- a/dist/urbm-2026.06.22.r003-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -3459126382e7099035ffbdd3d39ca419e2531d0602d2eaac1ae0621d67ae5c91 urbm-2026.06.22.r003-x86_64-1.txz diff --git a/dist/urbm-2026.06.22.r003-x86_64-1.txz b/dist/urbm-2026.07.10.r001-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.06.22.r003-x86_64-1.txz rename to dist/urbm-2026.07.10.r001-x86_64-1.txz index d10d725..a1e2f9d 100644 Binary files a/dist/urbm-2026.06.22.r003-x86_64-1.txz and b/dist/urbm-2026.07.10.r001-x86_64-1.txz differ diff --git a/dist/urbm-2026.07.10.r001-x86_64-1.txz.sha256 b/dist/urbm-2026.07.10.r001-x86_64-1.txz.sha256 new file mode 100644 index 0000000..70f9408 --- /dev/null +++ b/dist/urbm-2026.07.10.r001-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +bc3619b1606e268b3e114e9ce68d6317dc7a3b17ddc5c0472cc31aec749edd39 urbm-2026.07.10.r001-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 25846f9..76faf77 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,17 @@ - + - + ]> +### 2026.07.10.r001 +- Add a bounded backup change list to the run log by diffing the previous job snapshot against the newly created snapshot. +- Show up to the last 50 new, modified, or removed paths after successful Restic backups without failing the backup if diff collection is unavailable. + ### 2026.06.22.r003 - Send Unraid and Gotify notifications after scheduled or manual repository prune tasks finish. - Use task-specific notification summary headings for backup, rsync, restore, prune, and check messages. diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 25065b2..fdef455 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -98,6 +98,16 @@ type Stats struct { TotalBlobCount int64 `json:"total_blob_count"` } +type DiffEntry struct { + Kind string + Path string +} + +type DiffResult struct { + Entries []DiffEntry + Total int +} + func (r *Runner) Init(ctx context.Context, repo model.Repository) error { return r.run(ctx, repo, []string{"init"}, nil, nil) } @@ -280,6 +290,58 @@ func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path return items, scanner.Err() } +func (r *Runner) Diff(ctx context.Context, repo model.Repository, before, after string, limit int) (DiffResult, error) { + var output []byte + if err := r.run(ctx, repo, []string{"diff", "--metadata", before, after}, nil, &output); err != nil { + return DiffResult{}, err + } + return parseDiffOutput(output, limit), nil +} + +func parseDiffOutput(output []byte, limit int) DiffResult { + if limit <= 0 { + limit = 50 + } + result := DiffResult{} + scanner := bufio.NewScanner(bytes.NewReader(output)) + scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024) + for scanner.Scan() { + line := scanner.Text() + if len(line) < 2 { + continue + } + kind := diffKind(line[0]) + if kind == "" { + continue + } + path := strings.TrimSpace(line[1:]) + if path == "" || !strings.HasPrefix(path, "/") { + continue + } + result.Total++ + if len(result.Entries) == limit { + copy(result.Entries, result.Entries[1:]) + result.Entries[len(result.Entries)-1] = DiffEntry{Kind: kind, Path: path} + continue + } + result.Entries = append(result.Entries, DiffEntry{Kind: kind, Path: path}) + } + return result +} + +func diffKind(marker byte) string { + switch marker { + case '+': + return "new" + case '-': + return "removed" + case 'M': + return "modified" + default: + return "" + } +} + func (r *Runner) Restore(ctx context.Context, repo model.Repository, task model.RestoreTask, progress ProgressCallback) error { args := []string{"restore", task.SnapshotID, "--target", task.Target, "--json"} for _, include := range task.Includes { diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index 689d1e4..b82c1d0 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -190,6 +190,49 @@ func TestRestoreReportsProgress(t *testing.T) { } } +func TestDiffReportsLastChangedPaths(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$@" > '%s' +printf '%%s\n' 'comparing snapshot before to after:' +printf '%%s\n' '+ /mnt/user/new.txt' +printf '%%s\n' 'M /mnt/user/modified-a.txt' +printf '%%s\n' '- /mnt/user/removed.txt' +printf '%%s\n' 'M /mnt/user/modified-b.txt' +printf '%%s\n' 'Files: 1 new, 1 removed, 2 changed' +`, argsPath) + if err := os.WriteFile(script, []byte(body), 0700); err != nil { + t.Fatal(err) + } + runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} + repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} + diff, err := runner.Diff(context.Background(), repo, "before", "after", 3) + if err != nil { + t.Fatal(err) + } + if diff.Total != 4 || len(diff.Entries) != 3 { + t.Fatalf("unexpected diff result: %+v", diff) + } + expected := []DiffEntry{ + {Kind: "modified", Path: "/mnt/user/modified-a.txt"}, + {Kind: "removed", Path: "/mnt/user/removed.txt"}, + {Kind: "modified", Path: "/mnt/user/modified-b.txt"}, + } + for index, entry := range expected { + if diff.Entries[index] != entry { + t.Fatalf("entry %d = %+v, want %+v", index, diff.Entries[index], entry) + } + } + args, _ := os.ReadFile(argsPath) + for _, expected := range []string{"diff", "--metadata", "before", "after"} { + if !strings.Contains(string(args), expected) { + t.Fatalf("arguments missing %q: %s", expected, args) + } + } +} + func TestStatsUsesRequestedRepositoryCountingMode(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") diff --git a/internal/service/service.go b/internal/service/service.go index 635eab2..7cff52c 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -586,6 +586,11 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode result.Status, result.ErrorCode, result.Message = "warning", "connectivity", result.Message+"; repository unmount failed: "+err.Error() } }() + previousSnapshot, previousSnapshotOK, previousSnapshotErr := s.latestJobSnapshot(ctx, mounted.Repository, job.ID) + if previousSnapshotErr != nil { + appendLiveLog(&live, "Änderungsliste: vorheriger Snapshot konnte nicht ermittelt werden: "+previousSnapshotErr.Error()) + s.updateActive(live) + } prepared, err := s.workloads.Prepare(ctx, job) if err != nil { return failedError(run, err) @@ -671,6 +676,10 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode failedRun.LiveLog = live.LiveLog return failedRun } + if previousSnapshotOK && summary.SnapshotID != "" { + s.appendBackupDiffLog(ctx, &live, mounted.Repository, previousSnapshot, summary.SnapshotID) + s.updateActive(live) + } appendLiveLog(&live, "Backup-Daten übertragen; Aufbewahrung wird angewendet") s.updateActive(live) if err := s.restic.Forget(ctx, mounted.Repository, job); err != nil { @@ -692,6 +701,69 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode return run } +func (s *Service) latestJobSnapshot(ctx context.Context, repo model.Repository, jobID string) (model.Snapshot, bool, error) { + snapshots, err := s.restic.Snapshots(ctx, repo) + if err != nil { + return model.Snapshot{}, false, err + } + tag := "job:" + jobID + var latest model.Snapshot + found := false + for _, snapshot := range snapshots { + if !hasTag(snapshot.Tags, tag) || hasTag(snapshot.Tags, "usb-image") { + continue + } + if !found || snapshot.Time.After(latest.Time) { + latest = snapshot + found = true + } + } + return latest, found, nil +} + +func hasTag(tags []string, wanted string) bool { + for _, tag := range tags { + if tag == wanted { + return true + } + } + return false +} + +func (s *Service) appendBackupDiffLog(ctx context.Context, run *model.Run, repo model.Repository, previous model.Snapshot, currentSnapshotID string) { + const limit = 50 + diff, err := s.restic.Diff(ctx, repo, previous.ID, currentSnapshotID, limit) + if err != nil { + appendLiveLog(run, "Änderungsliste konnte nicht erstellt werden: "+err.Error()) + return + } + if diff.Total == 0 { + appendLiveLog(run, "Änderungsliste gegenüber "+shortID(previous.ID)+": keine geänderten Pfade erkannt") + return + } + if diff.Total > len(diff.Entries) { + appendLiveLog(run, fmt.Sprintf("Änderungsliste gegenüber %s: letzte %d von %d geänderten Pfaden", shortID(previous.ID), len(diff.Entries), diff.Total)) + } else { + appendLiveLog(run, fmt.Sprintf("Änderungsliste gegenüber %s: %d geänderte Pfade", shortID(previous.ID), diff.Total)) + } + for _, entry := range diff.Entries { + appendLiveLog(run, fmt.Sprintf("%s %s", diffKindLabel(entry.Kind), entry.Path)) + } +} + +func diffKindLabel(kind string) string { + switch kind { + case "new": + return "NEU" + case "removed": + return "GELÖSCHT" + case "modified": + return "GEÄNDERT" + default: + return strings.ToUpper(kind) + } +} + func (s *Service) updateActive(run model.Run) { s.queue.UpdateActive(run.ID, func(active *model.Run) { active.ProgressPercent, active.ProgressBytes, active.ProgressTotal = run.ProgressPercent, run.ProgressBytes, run.ProgressTotal diff --git a/plugin/urbm.plg b/plugin/urbm.plg index aae58c8..a465c31 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,17 @@ - + ]> +### 2026.07.10.r001 +- Add a bounded backup change list to the run log by diffing the previous job snapshot against the newly created snapshot. +- Show up to the last 50 new, modified, or removed paths after successful Restic backups without failing the backup if diff collection is unavailable. + ### 2026.06.22.r003 - Send Unraid and Gotify notifications after scheduled or manual repository prune tasks finish. - Use task-specific notification summary headings for backup, rsync, restore, prune, and check messages. diff --git a/webgui/URBM.page b/webgui/URBM.page index f457d7e..ef94415 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.22.r003

Restic Backup Manager für Unraid

+ +

URBM 2026.07.10.r001

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- +