Compare commits
3
Commits
91089328ce
...
c2f0f0b277
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f0f0b277 | ||
|
|
5aa28cf43e | ||
|
|
99e2753c10 |
No files matched your search
@@ -1 +0,0 @@
|
|||||||
1fda0986918fa5838cbd8f56ce013e1c5a2add51c41fb5cee14fe77c0e33c0b4 urbm-2026.06.22.r002-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
ec72032752aaae7a2bde7587781f65b2b5b4d9ef74a6aad79a3e121491b30958 urbm-2026.07.10.r002-x86_64-1.txz
|
||||||
Vendored
+14
-2
@@ -2,13 +2,25 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.22.r002">
|
<!ENTITY version "2026.07.10.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 "1fda0986918fa5838cbd8f56ce013e1c5a2add51c41fb5cee14fe77c0e33c0b4">
|
<!ENTITY packageSHA256 "ec72032752aaae7a2bde7587781f65b2b5b4d9ef74a6aad79a3e121491b30958">
|
||||||
]>
|
]>
|
||||||
<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.07.10.r002
|
||||||
|
- Include retention results in backup notifications after Restic forget runs, including removed and remaining snapshot counts when available.
|
||||||
|
- Include repository size before, after, and freed space in prune notifications when Restic statistics are available.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
### 2026.06.22.r002
|
### 2026.06.22.r002
|
||||||
- Preserve page and tree-container scroll positions after expanding deeply nested folders in source, target, settings, repository, and snapshot trees.
|
- Preserve page and tree-container scroll positions after expanding deeply nested folders in source, target, settings, repository, and snapshot trees.
|
||||||
- Restore scroll positions after the browser has processed focus/layout changes to prevent jumps back to the root.
|
- Restore scroll positions after the browser has processed focus/layout changes to prevent jumps back to the root.
|
||||||
|
|||||||
@@ -155,6 +155,12 @@ type Run struct {
|
|||||||
FilesChanged int64 `json:"filesChanged,omitempty"`
|
FilesChanged int64 `json:"filesChanged,omitempty"`
|
||||||
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
|
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
|
||||||
FilesProcessed int64 `json:"filesProcessed,omitempty"`
|
FilesProcessed int64 `json:"filesProcessed,omitempty"`
|
||||||
|
RetentionBefore int `json:"retentionBefore,omitempty"`
|
||||||
|
RetentionAfter int `json:"retentionAfter,omitempty"`
|
||||||
|
RetentionRemoved int `json:"retentionRemoved,omitempty"`
|
||||||
|
RepositoryBefore int64 `json:"repositoryBefore,omitempty"`
|
||||||
|
RepositoryAfter int64 `json:"repositoryAfter,omitempty"`
|
||||||
|
RepositoryFreed int64 `json:"repositoryFreed,omitempty"`
|
||||||
ProgressPercent float64 `json:"progressPercent,omitempty"`
|
ProgressPercent float64 `json:"progressPercent,omitempty"`
|
||||||
ProgressBytes int64 `json:"progressBytes,omitempty"`
|
ProgressBytes int64 `json:"progressBytes,omitempty"`
|
||||||
ProgressTotal int64 `json:"progressTotal,omitempty"`
|
ProgressTotal int64 `json:"progressTotal,omitempty"`
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ type Stats struct {
|
|||||||
TotalBlobCount int64 `json:"total_blob_count"`
|
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 {
|
func (r *Runner) Init(ctx context.Context, repo model.Repository) error {
|
||||||
return r.run(ctx, repo, []string{"init"}, nil, nil)
|
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()
|
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 {
|
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"}
|
args := []string{"restore", task.SnapshotID, "--target", task.Target, "--json"}
|
||||||
for _, include := range task.Includes {
|
for _, include := range task.Includes {
|
||||||
|
|||||||
@@ -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) {
|
func TestStatsUsesRequestedRepositoryCountingMode(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
argsPath := filepath.Join(dir, "args")
|
argsPath := filepath.Join(dir, "args")
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
func TestNotificationMessageIncludesBackupStatistics(t *testing.T) {
|
func TestNotificationMessageIncludesBackupStatistics(t *testing.T) {
|
||||||
started := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
started := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
||||||
finished := started.Add(2*time.Minute + 8*time.Second)
|
finished := started.Add(2*time.Minute + 8*time.Second)
|
||||||
run := model.Run{TaskType: "backup", Status: "success", StartedAt: &started, FinishedAt: &finished, SnapshotID: "abc123456789abcdef", BytesAdded: 1536, FilesNew: 7, FilesChanged: 2, BytesProcessed: 5 * 1024 * 1024, FilesProcessed: 120}
|
run := model.Run{TaskType: "backup", Status: "success", StartedAt: &started, FinishedAt: &finished, SnapshotID: "abc123456789abcdef", BytesAdded: 1536, FilesNew: 7, FilesChanged: 2, BytesProcessed: 5 * 1024 * 1024, FilesProcessed: 120, RetentionRemoved: 3, RetentionAfter: 14}
|
||||||
message := notificationMessage("Freigaben", "LV-426", run, finished)
|
message := notificationMessage("Freigaben", "LV-426", run, finished)
|
||||||
for _, expected := range []string{"📊 Backup-Zusammenfassung", "📦 Repository: LV-426", "🗂️ Job: Freigaben", "📸 Snapshot: abc123456789", "⏱️ Dauer: 2 Min. 8 Sek.", "📂 Dateien: 120 verarbeitet", "💾 Verarbeitet: 5.0 MiB", "➕ Neu: 7 Dateien", "✏️ Geändert: 2 Dateien", "📤 Neu gespeichert: 1.5 KiB", "🔐 Ziel: Restic Repository", "✅ Status: Erfolgreich"} {
|
for _, expected := range []string{"📊 Backup-Zusammenfassung", "📦 Repository: LV-426", "🗂️ Job: Freigaben", "📸 Snapshot: abc123456789", "⏱️ Dauer: 2 Min. 8 Sek.", "📂 Dateien: 120 verarbeitet", "💾 Verarbeitet: 5.0 MiB", "➕ Neu: 7 Dateien", "✏️ Geändert: 2 Dateien", "📤 Neu gespeichert: 1.5 KiB", "🧹 Aufbewahrung: 3 Snapshot(s) entfernt", "📸 Verbleibende Snapshots: 14", "🔐 Ziel: Restic Repository", "✅ Status: Erfolgreich"} {
|
||||||
if !strings.Contains(message, expected) {
|
if !strings.Contains(message, expected) {
|
||||||
t.Errorf("message %q does not contain %q", message, expected)
|
t.Errorf("message %q does not contain %q", message, expected)
|
||||||
}
|
}
|
||||||
@@ -27,3 +27,18 @@ func TestNotificationTitleIncludesRepositoryAndStatus(t *testing.T) {
|
|||||||
t.Fatalf("title = %q", title)
|
t.Fatalf("title = %q", title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNotificationMessageIncludesPruneSummary(t *testing.T) {
|
||||||
|
started := time.Date(2026, 6, 22, 3, 0, 0, 0, time.UTC)
|
||||||
|
finished := started.Add(42 * time.Second)
|
||||||
|
run := model.Run{TaskType: "prune", Status: "success", StartedAt: &started, FinishedAt: &finished, Message: "prune completed", RepositoryBefore: 100 * 1024 * 1024, RepositoryAfter: 75 * 1024 * 1024, RepositoryFreed: 25 * 1024 * 1024}
|
||||||
|
message := notificationMessage("Repository-Bereinigung", "LV-426", run, finished)
|
||||||
|
for _, expected := range []string{"🧹 Prune-Zusammenfassung", "📦 Repository: LV-426", "🗂️ Job: Repository-Bereinigung", "⏱️ Dauer: 42 Sekunden", "📦 Vorher: 100.0 MiB", "📦 Nachher: 75.0 MiB", "🧹 Freigegeben: 25.0 MiB", "🔐 Ziel: Restic Repository", "✅ Status: Erfolgreich"} {
|
||||||
|
if !strings.Contains(message, expected) {
|
||||||
|
t.Errorf("message %q does not contain %q", message, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(message, "⚠️ Details: prune completed") {
|
||||||
|
t.Fatalf("message contains redundant prune completion details: %q", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
-3
@@ -427,8 +427,14 @@ func (s *Service) ForceUnlockRepository(ctx context.Context, repoID string) erro
|
|||||||
return s.restic.ForceUnlock(ctx, mounted.Repository)
|
return s.restic.ForceUnlock(ctx, mounted.Repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) execute(ctx context.Context, run model.Run) model.Run {
|
func (s *Service) execute(ctx context.Context, run model.Run) (result model.Run) {
|
||||||
ctx = restic.WithRunID(ctx, run.ID)
|
ctx = restic.WithRunID(ctx, run.ID)
|
||||||
|
maintenanceRepoName := ""
|
||||||
|
defer func() {
|
||||||
|
if result.TaskType == "prune" && (result.Status == "success" || result.Status == "warning" || result.Status == "failed") {
|
||||||
|
go s.notify("Repository-Bereinigung", maintenanceRepoName, result)
|
||||||
|
}
|
||||||
|
}()
|
||||||
if run.TaskType == "backup" {
|
if run.TaskType == "backup" {
|
||||||
return s.executeBackup(ctx, run)
|
return s.executeBackup(ctx, run)
|
||||||
}
|
}
|
||||||
@@ -442,6 +448,9 @@ func (s *Service) execute(ctx context.Context, run model.Run) model.Run {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return failed(run, "validation", "repository no longer exists")
|
return failed(run, "validation", "repository no longer exists")
|
||||||
}
|
}
|
||||||
|
maintenanceRepoName = repo.Name
|
||||||
|
var pruneStatsBefore restic.Stats
|
||||||
|
pruneStatsBeforeOK := false
|
||||||
live := run
|
live := run
|
||||||
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" wird vorbereitet")
|
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" wird vorbereitet")
|
||||||
s.updateActive(live)
|
s.updateActive(live)
|
||||||
@@ -458,6 +467,13 @@ func (s *Service) execute(ctx context.Context, run model.Run) model.Run {
|
|||||||
if run.TaskType == "check" {
|
if run.TaskType == "check" {
|
||||||
err = s.restic.Check(ctx, mounted.Repository)
|
err = s.restic.Check(ctx, mounted.Repository)
|
||||||
} else {
|
} else {
|
||||||
|
if stats, statsErr := s.restic.Stats(ctx, mounted.Repository, "raw-data"); statsErr == nil {
|
||||||
|
pruneStatsBefore, pruneStatsBeforeOK = stats, true
|
||||||
|
appendLiveLog(&live, "Repository-Größe vor Bereinigung: "+formatBytes(stats.TotalSize))
|
||||||
|
s.updateActive(live)
|
||||||
|
} else {
|
||||||
|
appendLiveLog(&live, "Repository-Größe vor Bereinigung konnte nicht ermittelt werden: "+statsErr.Error())
|
||||||
|
}
|
||||||
err = s.restic.Prune(ctx, mounted.Repository)
|
err = s.restic.Prune(ctx, mounted.Repository)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -467,6 +483,18 @@ func (s *Service) execute(ctx context.Context, run model.Run) model.Run {
|
|||||||
return failedRun
|
return failedRun
|
||||||
}
|
}
|
||||||
run.Status, run.Message = "success", run.TaskType+" completed"
|
run.Status, run.Message = "success", run.TaskType+" completed"
|
||||||
|
if run.TaskType == "prune" && pruneStatsBeforeOK {
|
||||||
|
run.RepositoryBefore = pruneStatsBefore.TotalSize
|
||||||
|
if stats, statsErr := s.restic.Stats(ctx, mounted.Repository, "raw-data"); statsErr == nil {
|
||||||
|
run.RepositoryAfter = stats.TotalSize
|
||||||
|
if run.RepositoryBefore > run.RepositoryAfter {
|
||||||
|
run.RepositoryFreed = run.RepositoryBefore - run.RepositoryAfter
|
||||||
|
}
|
||||||
|
appendLiveLog(&live, fmt.Sprintf("Repository-Größe nach Bereinigung: %s, freigegeben: %s", formatBytes(run.RepositoryAfter), formatBytes(run.RepositoryFreed)))
|
||||||
|
} else {
|
||||||
|
appendLiveLog(&live, "Repository-Größe nach Bereinigung konnte nicht ermittelt werden: "+statsErr.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" abgeschlossen")
|
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" abgeschlossen")
|
||||||
run.LiveLog = live.LiveLog
|
run.LiveLog = live.LiveLog
|
||||||
return run
|
return run
|
||||||
@@ -579,6 +607,12 @@ 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()
|
result.Status, result.ErrorCode, result.Message = "warning", "connectivity", result.Message+"; repository unmount failed: "+err.Error()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
previousSnapshots, previousSnapshotErr := s.jobSnapshots(ctx, mounted.Repository, job.ID)
|
||||||
|
previousSnapshot, previousSnapshotOK := latestSnapshot(previousSnapshots)
|
||||||
|
if previousSnapshotErr != nil {
|
||||||
|
appendLiveLog(&live, "Änderungsliste: vorheriger Snapshot konnte nicht ermittelt werden: "+previousSnapshotErr.Error())
|
||||||
|
s.updateActive(live)
|
||||||
|
}
|
||||||
prepared, err := s.workloads.Prepare(ctx, job)
|
prepared, err := s.workloads.Prepare(ctx, job)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failedError(run, err)
|
return failedError(run, err)
|
||||||
@@ -664,12 +698,30 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
|
|||||||
failedRun.LiveLog = live.LiveLog
|
failedRun.LiveLog = live.LiveLog
|
||||||
return failedRun
|
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")
|
appendLiveLog(&live, "Backup-Daten übertragen; Aufbewahrung wird angewendet")
|
||||||
s.updateActive(live)
|
s.updateActive(live)
|
||||||
if err := s.restic.Forget(ctx, mounted.Repository, job); err != nil {
|
if err := s.restic.Forget(ctx, mounted.Repository, job); err != nil {
|
||||||
run.Status, run.Message, run.ErrorCode = "warning", "backup succeeded but retention failed: "+err.Error(), "repository"
|
run.Status, run.Message, run.ErrorCode = "warning", "backup succeeded but retention failed: "+err.Error(), "repository"
|
||||||
} else {
|
} else {
|
||||||
run.Status, run.Message = "success", "backup completed"
|
run.Status, run.Message = "success", "backup completed"
|
||||||
|
if summary.SnapshotID != "" && previousSnapshotErr == nil {
|
||||||
|
run.RetentionBefore = len(previousSnapshots)
|
||||||
|
if remainingSnapshots, remainingErr := s.jobSnapshots(ctx, mounted.Repository, job.ID); remainingErr == nil {
|
||||||
|
run.RetentionAfter = len(remainingSnapshots)
|
||||||
|
expectedAfter := run.RetentionBefore + 1
|
||||||
|
if expectedAfter > run.RetentionAfter {
|
||||||
|
run.RetentionRemoved = expectedAfter - run.RetentionAfter
|
||||||
|
}
|
||||||
|
appendLiveLog(&live, fmt.Sprintf("Aufbewahrung angewendet: %d Snapshot(s) entfernt, %d bleiben", run.RetentionRemoved, run.RetentionAfter))
|
||||||
|
s.updateActive(live)
|
||||||
|
} else {
|
||||||
|
appendLiveLog(&live, "Aufbewahrung angewendet; verbleibende Snapshots konnten nicht ermittelt werden: "+remainingErr.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
run.SnapshotID = summary.SnapshotID
|
run.SnapshotID = summary.SnapshotID
|
||||||
run.BytesAdded = summary.DataAdded + imageSummary.DataAdded
|
run.BytesAdded = summary.DataAdded + imageSummary.DataAdded
|
||||||
@@ -685,6 +737,76 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
|
|||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) jobSnapshots(ctx context.Context, repo model.Repository, jobID string) ([]model.Snapshot, error) {
|
||||||
|
snapshots, err := s.restic.Snapshots(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tag := "job:" + jobID
|
||||||
|
filtered := make([]model.Snapshot, 0, len(snapshots))
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
if hasTag(snapshot.Tags, tag) && !hasTag(snapshot.Tags, "usb-image") {
|
||||||
|
filtered = append(filtered, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestSnapshot(snapshots []model.Snapshot) (model.Snapshot, bool) {
|
||||||
|
var latest model.Snapshot
|
||||||
|
found := false
|
||||||
|
for _, snapshot := range snapshots {
|
||||||
|
if !found || snapshot.Time.After(latest.Time) {
|
||||||
|
latest = snapshot
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest, found
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
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.ProgressPercent, active.ProgressBytes, active.ProgressTotal = run.ProgressPercent, run.ProgressBytes, run.ProgressTotal
|
active.ProgressPercent, active.ProgressBytes, active.ProgressTotal = run.ProgressPercent, run.ProgressBytes, run.ProgressTotal
|
||||||
@@ -817,7 +939,11 @@ func notificationMessage(name, repository string, run model.Run, now time.Time)
|
|||||||
if status == "" {
|
if status == "" {
|
||||||
status = run.Status
|
status = run.Status
|
||||||
}
|
}
|
||||||
lines := []string{"📊 Backup-Zusammenfassung", ""}
|
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"
|
||||||
|
}
|
||||||
|
lines := []string{summaryTitle, ""}
|
||||||
if repository != "" {
|
if repository != "" {
|
||||||
lines = append(lines, "📦 Repository: "+repository)
|
lines = append(lines, "📦 Repository: "+repository)
|
||||||
}
|
}
|
||||||
@@ -851,8 +977,23 @@ func notificationMessage(name, repository string, run model.Run, now time.Time)
|
|||||||
lines = append(lines, "📤 Kopiert: "+formatBytes(run.BytesAdded))
|
lines = append(lines, "📤 Kopiert: "+formatBytes(run.BytesAdded))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if run.TaskType == "backup" && run.RetentionAfter > 0 {
|
||||||
|
lines = append(lines,
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("🧹 Aufbewahrung: %d Snapshot(s) entfernt", run.RetentionRemoved),
|
||||||
|
fmt.Sprintf("📸 Verbleibende Snapshots: %d", run.RetentionAfter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if run.TaskType == "prune" && run.RepositoryBefore > 0 && run.RepositoryAfter > 0 {
|
||||||
|
lines = append(lines,
|
||||||
|
"",
|
||||||
|
"📦 Vorher: "+formatBytes(run.RepositoryBefore),
|
||||||
|
"📦 Nachher: "+formatBytes(run.RepositoryAfter),
|
||||||
|
"🧹 Freigegeben: "+formatBytes(run.RepositoryFreed),
|
||||||
|
)
|
||||||
|
}
|
||||||
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 != "backup completed" && run.Message != "rsync completed" {
|
if run.Message != "" && run.Message != run.TaskType+" completed" {
|
||||||
lines = append(lines, "⚠️ Details: "+run.Message)
|
lines = append(lines, "⚠️ Details: "+run.Message)
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
|
|||||||
+13
-1
@@ -2,13 +2,25 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.22.r002">
|
<!ENTITY version "2026.07.10.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.07.10.r002
|
||||||
|
- Include retention results in backup notifications after Restic forget runs, including removed and remaining snapshot counts when available.
|
||||||
|
- Include repository size before, after, and freed space in prune notifications when Restic statistics are available.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
### 2026.06.22.r002
|
### 2026.06.22.r002
|
||||||
- Preserve page and tree-container scroll positions after expanding deeply nested folders in source, target, settings, repository, and snapshot trees.
|
- Preserve page and tree-container scroll positions after expanding deeply nested folders in source, target, settings, repository, and snapshot trees.
|
||||||
- Restore scroll positions after the browser has processed focus/layout changes to prevent jumps back to the root.
|
- Restore scroll positions after the browser has processed focus/layout changes to prevent jumps back to the root.
|
||||||
|
|||||||
+4
-4
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
|||||||
<?php
|
<?php
|
||||||
$pluginRoot = '/plugins/urbm';
|
$pluginRoot = '/plugins/urbm';
|
||||||
?>
|
?>
|
||||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260622r002">
|
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260710r002">
|
||||||
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
|
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||||
<header class="bu-header">
|
<header class="bu-header">
|
||||||
<div class="bu-brand">
|
<div class="bu-brand">
|
||||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260622r002" alt="URBM-Logo">
|
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260710r002" alt="URBM-Logo">
|
||||||
<div><h1>URBM <span class="bu-version">2026.06.22.r002</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
<div><h1>URBM <span class="bu-version">2026.07.10.r002</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
|
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
|
|||||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||||
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.22.r002.js"></script>
|
<script src="<?= $pluginRoot ?>/assets/urbm-2026.07.10.r002.js"></script>
|
||||||
Reference in new issue
Block a user