Include retention and prune details in notifications

This commit is contained in:
Mikei386
2026-07-10 11:42:06 +02:00
parent 5aa28cf43e
commit c2f0f0b277
9 changed files with 91 additions and 19 deletions
-1
View File
@@ -1 +0,0 @@
bc3619b1606e268b3e114e9ce68d6317dc7a3b17ddc5c0472cc31aec749edd39 urbm-2026.07.10.r001-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
ec72032752aaae7a2bde7587781f65b2b5b4d9ef74a6aad79a3e121491b30958 urbm-2026.07.10.r002-x86_64-1.txz
+6 -2
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.10.r001"> <!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 "bc3619b1606e268b3e114e9ce68d6317dc7a3b17ddc5c0472cc31aec749edd39"> <!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 ### 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. - 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. - Show up to the last 50 new, modified, or removed paths after successful Restic backups without failing the backup if diff collection is unavailable.
+6
View File
@@ -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"`
+4 -4
View File
@@ -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)
} }
@@ -31,9 +31,9 @@ func TestNotificationTitleIncludesRepositoryAndStatus(t *testing.T) {
func TestNotificationMessageIncludesPruneSummary(t *testing.T) { func TestNotificationMessageIncludesPruneSummary(t *testing.T) {
started := time.Date(2026, 6, 22, 3, 0, 0, 0, time.UTC) started := time.Date(2026, 6, 22, 3, 0, 0, 0, time.UTC)
finished := started.Add(42 * time.Second) finished := started.Add(42 * time.Second)
run := model.Run{TaskType: "prune", Status: "success", StartedAt: &started, FinishedAt: &finished, Message: "prune completed"} 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) message := notificationMessage("Repository-Bereinigung", "LV-426", run, finished)
for _, expected := range []string{"🧹 Prune-Zusammenfassung", "📦 Repository: LV-426", "🗂️ Job: Repository-Bereinigung", "⏱️ Dauer: 42 Sekunden", "🔐 Ziel: Restic Repository", "✅ Status: Erfolgreich"} { 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) { 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)
} }
+65 -7
View File
@@ -449,6 +449,8 @@ func (s *Service) execute(ctx context.Context, run model.Run) (result model.Run)
return failed(run, "validation", "repository no longer exists") return failed(run, "validation", "repository no longer exists")
} }
maintenanceRepoName = repo.Name 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)
@@ -465,6 +467,13 @@ func (s *Service) execute(ctx context.Context, run model.Run) (result 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 {
@@ -474,6 +483,18 @@ func (s *Service) execute(ctx context.Context, run model.Run) (result 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
@@ -586,7 +607,8 @@ 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()
} }
}() }()
previousSnapshot, previousSnapshotOK, previousSnapshotErr := s.latestJobSnapshot(ctx, mounted.Repository, job.ID) previousSnapshots, previousSnapshotErr := s.jobSnapshots(ctx, mounted.Repository, job.ID)
previousSnapshot, previousSnapshotOK := latestSnapshot(previousSnapshots)
if previousSnapshotErr != nil { if previousSnapshotErr != nil {
appendLiveLog(&live, "Änderungsliste: vorheriger Snapshot konnte nicht ermittelt werden: "+previousSnapshotErr.Error()) appendLiveLog(&live, "Änderungsliste: vorheriger Snapshot konnte nicht ermittelt werden: "+previousSnapshotErr.Error())
s.updateActive(live) s.updateActive(live)
@@ -686,6 +708,20 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
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
@@ -701,24 +737,31 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
return run return run
} }
func (s *Service) latestJobSnapshot(ctx context.Context, repo model.Repository, jobID string) (model.Snapshot, bool, error) { func (s *Service) jobSnapshots(ctx context.Context, repo model.Repository, jobID string) ([]model.Snapshot, error) {
snapshots, err := s.restic.Snapshots(ctx, repo) snapshots, err := s.restic.Snapshots(ctx, repo)
if err != nil { if err != nil {
return model.Snapshot{}, false, err return nil, err
} }
tag := "job:" + jobID 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 var latest model.Snapshot
found := false found := false
for _, snapshot := range snapshots { for _, snapshot := range snapshots {
if !hasTag(snapshot.Tags, tag) || hasTag(snapshot.Tags, "usb-image") {
continue
}
if !found || snapshot.Time.After(latest.Time) { if !found || snapshot.Time.After(latest.Time) {
latest = snapshot latest = snapshot
found = true found = true
} }
} }
return latest, found, nil return latest, found
} }
func hasTag(tags []string, wanted string) bool { func hasTag(tags []string, wanted string) bool {
@@ -934,6 +977,21 @@ 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 != run.TaskType+" completed" { if run.Message != "" && run.Message != run.TaskType+" completed" {
lines = append(lines, "⚠️ Details: "+run.Message) lines = append(lines, "⚠️ Details: "+run.Message)
+5 -1
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.07.10.r001"> <!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 ### 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. - 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. - Show up to the last 50 new, modified, or removed paths after successful Restic backups without failing the backup if diff collection is unavailable.
+4 -4
View File
@@ -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=20260710r001"> <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=20260710r001" 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.07.10.r001</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.07.10.r001.js"></script> <script src="<?= $pluginRoot ?>/assets/urbm-2026.07.10.r002.js"></script>