Log changed files after backup
This commit is contained in:
@@ -1 +0,0 @@
|
||||
3459126382e7099035ffbdd3d39ca419e2531d0602d2eaac1ae0621d67ae5c91 urbm-2026.06.22.r003-x86_64-1.txz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
bc3619b1606e268b3e114e9ce68d6317dc7a3b17ddc5c0472cc31aec749edd39 urbm-2026.07.10.r001-x86_64-1.txz
|
||||
Vendored
+6
-2
@@ -2,13 +2,17 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.22.r003">
|
||||
<!ENTITY version "2026.07.10.r001">
|
||||
<!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 packageSHA256 "3459126382e7099035ffbdd3d39ca419e2531d0602d2eaac1ae0621d67ae5c91">
|
||||
<!ENTITY packageSHA256 "bc3619b1606e268b3e114e9ce68d6317dc7a3b17ddc5c0472cc31aec749edd39">
|
||||
]>
|
||||
<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>
|
||||
### 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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -2,13 +2,17 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.22.r003">
|
||||
<!ENTITY version "2026.07.10.r001">
|
||||
<!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 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">
|
||||
<CHANGES>
|
||||
### 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.
|
||||
|
||||
+4
-4
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
||||
<?php
|
||||
$pluginRoot = '/plugins/urbm';
|
||||
?>
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260622r003">
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260710r001">
|
||||
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||
<header class="bu-header">
|
||||
<div class="bu-brand">
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260622r003" alt="URBM-Logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.22.r003</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260710r001" 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>
|
||||
<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>
|
||||
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
|
||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||
</div>
|
||||
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.22.r003.js"></script>
|
||||
<script src="<?= $pluginRoot ?>/assets/urbm-2026.07.10.r001.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user