diff --git a/dist/urbm-2026.06.21.r012-x86_64-1.txz.sha256 b/dist/urbm-2026.06.21.r012-x86_64-1.txz.sha256 deleted file mode 100644 index 34b8861..0000000 --- a/dist/urbm-2026.06.21.r012-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -9c909e42cbe12ca04b7e27661f4f7c1152ca66904f39083d7782fd31c19701ef urbm-2026.06.21.r012-x86_64-1.txz diff --git a/dist/urbm-2026.06.21.r012-x86_64-1.txz b/dist/urbm-2026.06.21.r013-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.06.21.r012-x86_64-1.txz rename to dist/urbm-2026.06.21.r013-x86_64-1.txz index 9129b72..4df7b09 100644 Binary files a/dist/urbm-2026.06.21.r012-x86_64-1.txz and b/dist/urbm-2026.06.21.r013-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.21.r013-x86_64-1.txz.sha256 b/dist/urbm-2026.06.21.r013-x86_64-1.txz.sha256 new file mode 100644 index 0000000..3beab1b --- /dev/null +++ b/dist/urbm-2026.06.21.r013-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +d357fa422c688189bca32be22e1de05de15c079fdcaaa52b6315072322dbea6f urbm-2026.06.21.r013-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index 97b41ff..deacbdb 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,17 @@ - + - + ]> +### 2026.06.21.r013 +- Show Restic restore progress in activities with percent, transferred bytes, file counts, speed, ETA, and current file when Restic provides JSON status updates. +- Keep restore target details readable in the activity list instead of showing the internal restore payload. + ### 2026.06.21.r012 - Add emoji-labelled fields to Unraid and Gotify backup notifications. - Shorten long Restic snapshot IDs in notifications to keep mobile notification cards readable. diff --git a/internal/restic/restic.go b/internal/restic/restic.go index b7bd31b..25065b2 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -280,12 +280,17 @@ func (r *Runner) List(ctx context.Context, repo model.Repository, snapshot, path return items, scanner.Err() } -func (r *Runner) Restore(ctx context.Context, repo model.Repository, task model.RestoreTask) error { - args := []string{"restore", task.SnapshotID, "--target", task.Target} +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 { args = append(args, "--include", include) } - return r.run(ctx, repo, args, nil, nil) + return r.run(ctx, repo, args, func(line []byte) { + var status Progress + if json.Unmarshal(line, &status) == nil && status.MessageType == "status" && progress != nil { + progress(status) + } + }, nil) } func (r *Runner) run(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte) error { diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index 9b2557b..689d1e4 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -164,6 +164,32 @@ func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) { } } +func TestRestoreReportsProgress(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"status\",\"percent_done\":0.25,\"total_files\":8,\"files_done\":2,\"total_bytes\":4096,\"bytes_done\":1024,\"seconds_remaining\":30,\"current_files\":[\"/restore/file.txt\"]}'\n", 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"} + task := model.RestoreTask{SnapshotID: "snap123", Target: "/target", Includes: []string{"/source/file.txt"}} + var progress Progress + if err := runner.Restore(context.Background(), repo, task, func(value Progress) { progress = value }); err != nil { + t.Fatal(err) + } + if progress.PercentDone != 0.25 || progress.FilesDone != 2 || progress.BytesDone != 1024 || progress.SecondsRemaining != 30 || len(progress.CurrentFiles) != 1 { + t.Fatalf("unexpected progress: %+v", progress) + } + args, _ := os.ReadFile(argsPath) + for _, expected := range []string{"restore", "snap123", "--target", "/target", "--json", "--include", "/source/file.txt"} { + 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 a8469c4..65b10e4 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -732,13 +732,35 @@ func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run { defer s.mounts.Cleanup(context.Background(), mounted) appendLiveLog(&live, "Restic stellt den Snapshot wieder her") s.updateActive(live) - if err := s.restic.Restore(ctx, mounted.Repository, task); err != nil { + lastLoggedPercent := -10 + started := time.Now() + if err := s.restic.Restore(ctx, mounted.Repository, task, func(progress restic.Progress) { + live.ProgressPercent = progress.PercentDone * 100 + live.ProgressBytes, live.ProgressTotal = progress.BytesDone, progress.TotalBytes + live.ProgressFiles, live.ProgressFileTotal = progress.FilesDone, progress.TotalFiles + live.SecondsRemaining = progress.SecondsRemaining + if elapsed := time.Since(started).Seconds(); elapsed > 0 { + live.BytesPerSecond = float64(progress.BytesDone) / elapsed + } + if len(progress.CurrentFiles) > 0 { + live.CurrentFile = progress.CurrentFiles[len(progress.CurrentFiles)-1] + } + percent := int(live.ProgressPercent) + if percent >= lastLoggedPercent+10 { + appendLiveLog(&live, fmt.Sprintf("Restore-Fortschritt: %.1f%%, %d/%d Dateien, %s/%s", live.ProgressPercent, live.ProgressFiles, live.ProgressFileTotal, formatBytes(live.ProgressBytes), formatBytes(live.ProgressTotal))) + lastLoggedPercent = percent - percent%10 + } + s.updateActive(live) + }); err != nil { failedRun := failedError(run, err) appendLiveLog(&live, "Restore fehlgeschlagen: "+err.Error()) failedRun.LiveLog = live.LiveLog return failedRun } - run.Status, run.Message, run.JobID = "success", "restore completed", "" + run.Status, run.Message = "success", "restore completed" + run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, live.ProgressBytes, live.ProgressTotal + run.ProgressFiles, run.ProgressFileTotal = live.ProgressFiles, live.ProgressFileTotal + run.BytesPerSecond, run.SecondsRemaining, run.CurrentFile = live.BytesPerSecond, 0, live.CurrentFile appendLiveLog(&live, "Restore abgeschlossen") run.LiveLog = live.LiveLog return run diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 8a5388a..912ccba 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,17 @@ - + ]> +### 2026.06.21.r013 +- Show Restic restore progress in activities with percent, transferred bytes, file counts, speed, ETA, and current file when Restic provides JSON status updates. +- Keep restore target details readable in the activity list instead of showing the internal restore payload. + ### 2026.06.21.r012 - Add emoji-labelled fields to Unraid and Gotify backup notifications. - Shorten long Restic snapshot IDs in notifications to keep mobile notification cards readable. diff --git a/webgui/URBM.page b/webgui/URBM.page index d9613e4..ee86146 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.21.r012

Restic Backup Manager für Unraid

+ +

URBM 2026.06.21.r013

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 39a016c..e05ae67 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -180,6 +180,13 @@ const runTarget = run => { if(run.taskType==='backup'||run.taskType==='rsync') return state.config.jobs.find(job=>job.id===run.jobId)?.name || 'Unbekannter Job'; if(['check','prune'].includes(run.taskType)) return state.config.repositories.find(repo=>repo.id===run.jobId)?.name || 'Unbekanntes Repository'; + if(run.taskType==='restore') { + const parts=String(run.jobId||'').split('\u0000'); + if(parts.length>=4) { + const repo=state.config.repositories.find(candidate=>candidate.id===parts[0])?.name || 'Repository'; + return `${repo} · ${parts[1].slice(0,12)} → ${parts[2]}`; + } + } return run.jobId || ''; }; const button = (label, action, kind = '') => ``; @@ -703,7 +710,7 @@ function renderActivity() { return `

Aktivitäten

Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.
${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}
${runsTable(newestRuns(state.runs))}
`; } function progressView(run) { if(run.status==='queued') return 'Wartet in der Warteschlange'; - if(run.taskType!=='backup'&&run.taskType!=='rsync'&&!run.progressPercent) return 'Kein Prozentwert verfügbar'; + if(!['backup','rsync','restore'].includes(run.taskType)&&!run.progressPercent) return 'Kein Prozentwert verfügbar'; const usbImage=run.status==='running'&&run.currentFile==='urbm-usb-flash.img'; if(usbImage) { const details=[]; @@ -713,12 +720,13 @@ } const percent=Math.max(0,Math.min(100,Number(run.progressPercent)||0)); const scanning=run.status==='running'&&percent===0; + const waitingLabel=run.taskType==='restore'?'Wiederherstellung läuft…':'Wird eingelesen…'; const details=[]; if(run.progressTotal) details.push(`${formatBytes(run.progressBytes)} / ${formatBytes(run.progressTotal)}`); if(run.progressFileTotal) details.push(`${Number(run.progressFiles||0).toLocaleString()} / ${Number(run.progressFileTotal).toLocaleString()} Dateien`); if(run.bytesPerSecond) details.push(`${formatBytes(run.bytesPerSecond)}/s`); if(run.secondsRemaining>0) details.push(`Restzeit ${formatEta(run.secondsRemaining)}`); - return `
${run.status==='paused'?'Pausiert':scanning?'Wird eingelesen…':`${percent.toFixed(1)}%`}${esc(details.join(' · '))}
${run.currentFile?`
${esc(run.currentFile)}
`:''}
`; + return `
${run.status==='paused'?'Pausiert':scanning?waitingLabel:`${percent.toFixed(1)}%`}${esc(details.join(' · '))}
${run.currentFile?`
${esc(run.currentFile)}
`:''}
`; } function liveLogView(run) { if(!(run.liveLog||[]).length) return '';