Add live activity progress and logs

This commit is contained in:
Mikei386
2026-06-14 14:26:13 +02:00
parent 5a1b9588e1
commit 3e3c63070e
14 changed files with 254 additions and 38 deletions
-1
View File
@@ -1 +0,0 @@
f1cd8773608d0e117ff2a71d5552dcc21b38259061d4399c8d4fa1f168c0373c backupper-2026.06.14.9.4-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
587fc8645d2a5271c4d40105e7209cda16636afd9be2613f83eb92c9208ad8b5 backupper-2026.06.14.9.5-x86_64-1.txz
+6 -2
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "backupper"> <!ENTITY name "backupper">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.14.9.4"> <!ENTITY version "2026.06.14.9.5">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "f1cd8773608d0e117ff2a71d5552dcc21b38259061d4399c8d4fa1f168c0373c"> <!ENTITY packageSHA256 "587fc8645d2a5271c4d40105e7209cda16636afd9be2613f83eb92c9208ad8b5">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
<CHANGES> <CHANGES>
### 2026.06.14.9.5
- Add live backup progress with percentage, file and byte counters, transfer rate, ETA, and current file.
- Add a sanitized in-memory live log for backup, restore, check, and prune activities.
### 2026.06.14.9.4 ### 2026.06.14.9.4
- Make the repository form type-aware for local, SMB, NFS, and SFTP destinations. - Make the repository form type-aware for local, SMB, NFS, and SFTP destinations.
- Add a folder browser for local repository paths. - Add a folder browser for local repository paths.
+26 -17
View File
@@ -112,23 +112,32 @@ type NotificationTarget struct {
} }
type Run struct { type Run struct {
SchemaVersion int `json:"schemaVersion"` SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"` ID string `json:"id"`
JobID string `json:"jobId,omitempty"` JobID string `json:"jobId,omitempty"`
TaskType string `json:"taskType"` TaskType string `json:"taskType"`
Status string `json:"status"` Status string `json:"status"`
Priority int `json:"priority"` Priority int `json:"priority"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
StartedAt *time.Time `json:"startedAt,omitempty"` StartedAt *time.Time `json:"startedAt,omitempty"`
FinishedAt *time.Time `json:"finishedAt,omitempty"` FinishedAt *time.Time `json:"finishedAt,omitempty"`
SnapshotID string `json:"snapshotId,omitempty"` SnapshotID string `json:"snapshotId,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
ErrorCode string `json:"errorCode,omitempty"` ErrorCode string `json:"errorCode,omitempty"`
BytesAdded int64 `json:"bytesAdded,omitempty"` BytesAdded int64 `json:"bytesAdded,omitempty"`
FilesNew int64 `json:"filesNew,omitempty"` FilesNew int64 `json:"filesNew,omitempty"`
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"`
ProgressPercent float64 `json:"progressPercent,omitempty"`
ProgressBytes int64 `json:"progressBytes,omitempty"`
ProgressTotal int64 `json:"progressTotal,omitempty"`
ProgressFiles int64 `json:"progressFiles,omitempty"`
ProgressFileTotal int64 `json:"progressFileTotal,omitempty"`
BytesPerSecond float64 `json:"bytesPerSecond,omitempty"`
SecondsRemaining int64 `json:"secondsRemaining,omitempty"`
CurrentFile string `json:"currentFile,omitempty"`
LiveLog []string `json:"liveLog,omitempty"`
} }
type RestoreTask struct { type RestoreTask struct {
+47
View File
@@ -0,0 +1,47 @@
package queue
import (
"context"
"testing"
"time"
"github.com/backupper-unraid/backupper/internal/model"
)
func TestUpdateActiveIsVisibleWithoutPersistingIntermediateState(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
persistCalls := 0
q := New(func(_ context.Context, run model.Run) model.Run {
close(started)
<-release
run.Status = "success"
return run
}, func([]model.Run) { persistCalls++ })
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go q.Run(ctx)
if err := q.Enqueue(model.Run{ID: "run", JobID: "job"}); err != nil {
t.Fatal(err)
}
<-started
before := persistCalls
if !q.UpdateActive("run", func(run *model.Run) { run.ProgressPercent = 42 }) {
t.Fatal("active run was not updated")
}
if runs := q.Snapshot(); len(runs) != 1 || runs[0].ProgressPercent != 42 {
t.Fatalf("runs = %#v", runs)
}
if persistCalls != before {
t.Fatal("intermediate progress was persisted")
}
close(release)
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if runs := q.Snapshot(); len(runs) == 1 && runs[0].Status == "success" {
return
}
time.Sleep(time.Millisecond)
}
t.Fatal("run did not finish")
}
+10
View File
@@ -141,6 +141,16 @@ func (q *Queue) Snapshot() []model.Run {
return q.snapshotLocked() return q.snapshotLocked()
} }
func (q *Queue) UpdateActive(id string, update func(*model.Run)) bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.active == nil || q.active.ID != id {
return false
}
update(q.active)
return true
}
func (q *Queue) snapshotLocked() []model.Run { func (q *Queue) snapshotLocked() []model.Run {
result := append([]model.Run{}, q.history...) result := append([]model.Run{}, q.history...)
if q.active != nil { if q.active != nil {
+21 -1
View File
@@ -35,11 +35,24 @@ type Summary struct {
TotalFilesProcessed int64 `json:"total_files_processed"` TotalFilesProcessed int64 `json:"total_files_processed"`
} }
type Progress struct {
MessageType string `json:"message_type"`
PercentDone float64 `json:"percent_done"`
TotalFiles int64 `json:"total_files"`
FilesDone int64 `json:"files_done"`
TotalBytes int64 `json:"total_bytes"`
BytesDone int64 `json:"bytes_done"`
SecondsRemaining int64 `json:"seconds_remaining"`
CurrentFiles []string `json:"current_files"`
}
type ProgressCallback func(Progress)
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)
} }
func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string) (Summary, error) { func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string, progress ProgressCallback) (Summary, error) {
args := []string{"backup", "--json", "--compression", job.Compression} args := []string{"backup", "--json", "--compression", job.Compression}
for _, tag := range append([]string{"backupper", "job:" + job.ID}, job.Tags...) { for _, tag := range append([]string{"backupper", "job:" + job.ID}, job.Tags...) {
args = append(args, "--tag", tag) args = append(args, "--tag", tag)
@@ -50,6 +63,13 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo
args = append(args, sources...) args = append(args, sources...)
var summary Summary var summary Summary
err := r.run(ctx, repo, args, func(line []byte) { err := r.run(ctx, repo, args, func(line []byte) {
var status Progress
if json.Unmarshal(line, &status) == nil && status.MessageType == "status" {
if progress != nil {
progress(status)
}
return
}
var candidate Summary var candidate Summary
if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" { if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" {
summary = candidate summary = candidate
+6 -2
View File
@@ -20,20 +20,24 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) {
argsPath := filepath.Join(dir, "args") argsPath := filepath.Join(dir, "args")
passwordPath := filepath.Join(dir, "password") passwordPath := filepath.Join(dir, "password")
script := filepath.Join(dir, "restic") script := filepath.Join(dir, "restic")
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"files_changed\":2,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath) body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"status\",\"percent_done\":0.5,\"total_files\":12,\"files_done\":6,\"total_bytes\":1024,\"bytes_done\":512,\"seconds_remaining\":4,\"current_files\":[\"/source path/file.txt\"]}'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"files_changed\":2,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath)
if err := os.WriteFile(script, []byte(body), 0700); err != nil { if err := os.WriteFile(script, []byte(body), 0700); err != nil {
t.Fatal(err) t.Fatal(err)
} }
runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "top-secret"}} runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "top-secret"}}
repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo path", PasswordRef: "password"} repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo path", PasswordRef: "password"}
job := model.Job{ID: "job", Compression: "auto", Tags: []string{"daily"}, Excludes: []string{"*.tmp"}} job := model.Job{ID: "job", Compression: "auto", Tags: []string{"daily"}, Excludes: []string{"*.tmp"}}
summary, err := runner.Backup(context.Background(), repo, job, []string{"/source path"}) var progress Progress
summary, err := runner.Backup(context.Background(), repo, job, []string{"/source path"}, func(value Progress) { progress = value })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if summary.SnapshotID != "abc123" || summary.DataAdded != 42 || summary.FilesChanged != 2 || summary.TotalBytesProcessed != 1024 || summary.TotalFilesProcessed != 12 { if summary.SnapshotID != "abc123" || summary.DataAdded != 42 || summary.FilesChanged != 2 || summary.TotalBytesProcessed != 1024 || summary.TotalFilesProcessed != 12 {
t.Fatalf("unexpected summary: %+v", summary) t.Fatalf("unexpected summary: %+v", summary)
} }
if progress.PercentDone != 0.5 || progress.FilesDone != 6 || progress.BytesDone != 512 || len(progress.CurrentFiles) != 1 {
t.Fatalf("unexpected progress: %+v", progress)
}
args, _ := os.ReadFile(argsPath) args, _ := os.ReadFile(argsPath)
for _, expected := range []string{"/repo path", "backup", "--json", "/source path"} { for _, expected := range []string{"/repo path", "backup", "--json", "/source path"} {
if !strings.Contains(string(args), expected) { if !strings.Contains(string(args), expected) {
+88 -7
View File
@@ -221,20 +221,33 @@ 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")
} }
live := run
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" wird vorbereitet")
s.updateActive(live)
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
return failedError(run, err) failedRun := failedError(run, err)
appendLiveLog(&live, "Vorbereitung fehlgeschlagen: "+err.Error())
failedRun.LiveLog = live.LiveLog
return failedRun
} }
defer s.mounts.Cleanup(context.Background(), mounted) defer s.mounts.Cleanup(context.Background(), mounted)
appendLiveLog(&live, "Repository bereit; Restic "+run.TaskType+" läuft")
s.updateActive(live)
if run.TaskType == "check" { if run.TaskType == "check" {
err = s.restic.Check(ctx, mounted.Repository) err = s.restic.Check(ctx, mounted.Repository)
} else { } else {
err = s.restic.Prune(ctx, mounted.Repository) err = s.restic.Prune(ctx, mounted.Repository)
} }
if err != nil { if err != nil {
return failedError(run, err) failedRun := failedError(run, err)
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" fehlgeschlagen: "+err.Error())
failedRun.LiveLog = live.LiveLog
return failedRun
} }
run.Status, run.Message = "success", run.TaskType+" completed" run.Status, run.Message = "success", run.TaskType+" completed"
appendLiveLog(&live, strings.ToUpper(run.TaskType)+" abgeschlossen")
run.LiveLog = live.LiveLog
return run return run
} }
@@ -248,6 +261,9 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
if !ok { if !ok {
return failed(run, "validation", "repository no longer exists") return failed(run, "validation", "repository no longer exists")
} }
live := run
appendLiveLog(&live, "Backup wird vorbereitet")
s.updateActive(live)
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
return failedError(run, err) return failedError(run, err)
@@ -277,10 +293,36 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
return failed(run, "source", "source unavailable: "+path) return failed(run, "source", "source unavailable: "+path)
} }
} }
summary, err := s.restic.Backup(ctx, mounted.Repository, job, prepared.Sources) appendLiveLog(&live, fmt.Sprintf("Restic-Backup gestartet: %d Quelle(n)", len(prepared.Sources)))
s.updateActive(live)
lastLoggedPercent := -5
started := time.Now()
summary, err := s.restic.Backup(ctx, mounted.Repository, job, prepared.Sources, 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+5 {
appendLiveLog(&live, fmt.Sprintf("Fortschritt: %.1f%%, %d/%d Dateien, %s/%s", live.ProgressPercent, live.ProgressFiles, live.ProgressFileTotal, formatBytes(live.ProgressBytes), formatBytes(live.ProgressTotal)))
lastLoggedPercent = percent - percent%5
}
s.updateActive(live)
})
if err != nil { if err != nil {
return failedError(run, err) appendLiveLog(&live, "Backup fehlgeschlagen: "+err.Error())
failedRun := failedError(run, err)
failedRun.LiveLog = live.LiveLog
return failedRun
} }
appendLiveLog(&live, "Backup-Daten übertragen; Aufbewahrung wird angewendet")
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 {
@@ -288,10 +330,36 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
} }
run.SnapshotID, run.BytesAdded, run.FilesNew, run.FilesChanged = summary.SnapshotID, summary.DataAdded, summary.FilesNew, summary.FilesChanged run.SnapshotID, run.BytesAdded, run.FilesNew, run.FilesChanged = summary.SnapshotID, summary.DataAdded, summary.FilesNew, summary.FilesChanged
run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed
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, "Backup abgeschlossen")
run.LiveLog = live.LiveLog
return run return run
} }
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
active.ProgressFiles, active.ProgressFileTotal = run.ProgressFiles, run.ProgressFileTotal
active.BytesPerSecond, active.SecondsRemaining = run.BytesPerSecond, run.SecondsRemaining
active.CurrentFile = run.CurrentFile
active.LiveLog = append([]string(nil), run.LiveLog...)
})
}
func appendLiveLog(run *model.Run, message string) {
line := time.Now().Format("15:04:05") + " " + strings.ReplaceAll(strings.TrimSpace(message), "\n", " ")
run.LiveLog = append(run.LiveLog, line)
if len(run.LiveLog) > 100 {
run.LiveLog = run.LiveLog[len(run.LiveLog)-100:]
}
}
func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run { func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run {
live := run
appendLiveLog(&live, "Restore wird vorbereitet")
s.updateActive(live)
parts := strings.Split(run.JobID, "\x00") parts := strings.Split(run.JobID, "\x00")
if len(parts) < 4 { if len(parts) < 4 {
return failed(run, "internal", "invalid restore payload") return failed(run, "internal", "invalid restore payload")
@@ -302,17 +370,30 @@ func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run {
} }
task := model.RestoreTask{SchemaVersion: model.SchemaVersion, ID: run.ID, RepositoryID: parts[0], SnapshotID: parts[1], Target: parts[2], InPlace: parts[3] == "true", Confirmed: true, Includes: parts[4:]} task := model.RestoreTask{SchemaVersion: model.SchemaVersion, ID: run.ID, RepositoryID: parts[0], SnapshotID: parts[1], Target: parts[2], InPlace: parts[3] == "true", Confirmed: true, Includes: parts[4:]}
if err := os.MkdirAll(task.Target, 0750); err != nil { if err := os.MkdirAll(task.Target, 0750); err != nil {
return failedError(run, err) failedRun := failedError(run, err)
appendLiveLog(&live, "Restore-Ziel konnte nicht vorbereitet werden: "+err.Error())
failedRun.LiveLog = live.LiveLog
return failedRun
} }
mounted, err := s.mounts.Prepare(ctx, repo) mounted, err := s.mounts.Prepare(ctx, repo)
if err != nil { if err != nil {
return failedError(run, err) failedRun := failedError(run, err)
appendLiveLog(&live, "Repository konnte nicht vorbereitet werden: "+err.Error())
failedRun.LiveLog = live.LiveLog
return failedRun
} }
defer s.mounts.Cleanup(context.Background(), mounted) 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 { if err := s.restic.Restore(ctx, mounted.Repository, task); err != nil {
return failedError(run, err) 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, run.JobID = "success", "restore completed", ""
appendLiveLog(&live, "Restore abgeschlossen")
run.LiveLog = live.LiveLog
return run return run
} }
+5 -1
View File
@@ -2,13 +2,17 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "backupper"> <!ENTITY name "backupper">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.14.9.4"> <!ENTITY version "2026.06.14.9.5">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&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/BackUpper/issues" icon="backupper.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues" icon="backupper.png">
<CHANGES> <CHANGES>
### 2026.06.14.9.5
- Add live backup progress with percentage, file and byte counters, transfer rate, ETA, and current file.
- Add a sanitized in-memory live log for backup, restore, check, and prune activities.
### 2026.06.14.9.4 ### 2026.06.14.9.4
- Make the repository form type-aware for local, SMB, NFS, and SFTP destinations. - Make the repository form type-aware for local, SMB, NFS, and SFTP destinations.
- Add a folder browser for local repository paths. - Add a folder browser for local repository paths.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php <?php
$pluginRoot = '/plugins/backupper'; $pluginRoot = '/plugins/backupper';
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=2026061494"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=2026061495">
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php"> <div id="backupper-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/backupper.png?v=2026061494" alt="URBM logo"> <img class="bu-logo" src="<?= $pluginRoot ?>/images/backupper.png?v=2026061495" alt="URBM logo">
<div><h1>URBM <span class="bu-version">2026.06.14.9.4</span></h1><p>Restic Backup Manager for Unraid</p></div> <div><h1>URBM <span class="bu-version">2026.06.14.9.5</span></h1><p>Restic Backup Manager for 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.">Connecting...</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.">Connecting...</div>
</header> </header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/backupper';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div> <div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div> </div>
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script> <script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.9.4.js"></script> <script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.9.5.js"></script>
+12
View File
@@ -97,6 +97,18 @@
#backupper-app .bu-weekdays input:checked + span { background: var(--bu-accent) !important; border-color: var(--bu-accent) !important; color: #fff !important; } #backupper-app .bu-weekdays input:checked + span { background: var(--bu-accent) !important; border-color: var(--bu-accent) !important; color: #fff !important; }
.bu-custom-cron { margin-top: 14px; } .bu-custom-cron { margin-top: 14px; }
.bu-event-options { display: flex; flex-wrap: wrap; gap: 10px; } .bu-event-options { display: flex; flex-wrap: wrap; gap: 10px; }
.bu-progress-wrap { min-width: 280px; }
.bu-progress-label { align-items: center; display: flex; font-size: 12px; gap: 8px; justify-content: space-between; margin-bottom: 5px; }
.bu-progress-label span { color: var(--bu-muted); }
.bu-progress { background: var(--bu-panel-alt); border: 1px solid var(--bu-border); border-radius: 999px; height: 12px; overflow: hidden; }
.bu-progress span { background: var(--bu-accent); display: block; height: 100%; min-width: 0; transition: width .35s ease; }
.bu-progress.indeterminate span { animation: bu-progress-scan 1.2s ease-in-out infinite alternate; }
.bu-current-file { color: var(--bu-muted); font-size: 12px; margin-top: 5px; max-width: 440px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bu-log-row td { border-top: 0 !important; padding-top: 0 !important; }
.bu-live-log { background: #11161a; border: 1px solid var(--bu-border); border-radius: 6px; color: #e8edf1; margin: 2px 0 8px; padding: 9px 12px; }
.bu-live-log summary { cursor: pointer; font-weight: 700; }
.bu-live-log pre { color: #e8edf1; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; margin: 10px 0 0; max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-word; }
@keyframes bu-progress-scan { from { transform: translateX(-20%); } to { transform: translateX(250%); } }
.bu-form-grid { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); } .bu-form-grid { display: grid; gap: 15px; grid-template-columns: repeat(2, minmax(220px, 1fr)); }
.bu-managed-mount, .bu-external-mount { margin-top: 14px; } .bu-managed-mount, .bu-external-mount { margin-top: 14px; }
.bu-managed-mount[hidden], .bu-external-mount[hidden] { display: none; } .bu-managed-mount[hidden], .bu-external-mount[hidden] { display: none; }
+28 -3
View File
@@ -126,6 +126,12 @@
while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; } while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; }
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`; return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`;
}; };
const formatEta = value => {
let seconds=Math.max(0,Math.round(Number(value)||0));
const hours=Math.floor(seconds/3600); seconds%=3600;
const minutes=Math.floor(seconds/60); seconds%=60;
return hours?`${hours}h ${minutes}m`:minutes?`${minutes}m ${seconds}s`:`${seconds}s`;
};
const weekDays = [{value:'1',short:'Mo',name:'Montag'},{value:'2',short:'Di',name:'Dienstag'},{value:'3',short:'Mi',name:'Mittwoch'},{value:'4',short:'Do',name:'Donnerstag'},{value:'5',short:'Fr',name:'Freitag'},{value:'6',short:'Sa',name:'Samstag'},{value:'0',short:'So',name:'Sonntag'}]; const weekDays = [{value:'1',short:'Mo',name:'Montag'},{value:'2',short:'Di',name:'Dienstag'},{value:'3',short:'Mi',name:'Mittwoch'},{value:'4',short:'Do',name:'Donnerstag'},{value:'5',short:'Fr',name:'Freitag'},{value:'6',short:'Sa',name:'Samstag'},{value:'0',short:'So',name:'Sonntag'}];
function parseSchedule(cron = '0 2 * * *') { function parseSchedule(cron = '0 2 * * *') {
const match = String(cron).trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(.+)$/); const match = String(cron).trim().match(/^(\d{1,2})\s+(\d{1,2})\s+\*\s+\*\s+(.+)$/);
@@ -451,8 +457,27 @@
return `<section class="bu-card"><h2>Restore</h2><form id="bu-restore-form" class="bu-form"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Include paths, one per line<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Target<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><label>In-place restore<input name="inPlace" type="checkbox"></label><label>Confirm overwrite risk<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Queue restore','submit-restore','primary')}</div></form></section>`; return `<section class="bu-card"><h2>Restore</h2><form id="bu-restore-form" class="bu-form"><label>Repository<select name="repositoryId">${options}</select></label><label>Snapshot ID<input name="snapshotId" required value="${esc(state.selectedSnapshot||'')}"></label><label class="wide">Include paths, one per line<textarea name="includes">${esc((state.restoreIncludes||[]).join('\n'))}</textarea></label><label class="wide">Target<input name="target" required value="${esc(state.config.settings.restoreRoot)}/${esc(id('restore'))}"></label><label>In-place restore<input name="inPlace" type="checkbox"></label><label>Confirm overwrite risk<input name="confirmed" type="checkbox"></label><div class="wide bu-actions">${button('Queue restore','submit-restore','primary')}</div></form></section>`;
} }
function renderActivity() { return `<div class="bu-toolbar"><h2>Activity</h2>${button('Refresh','refresh','primary')}</div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; } function renderActivity() { return `<div class="bu-toolbar"><div><h2>Activity</h2><div class="bu-muted">Running tasks update automatically every two seconds.</div></div>${button('Refresh','refresh','primary')}</div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; }
function runsTable(runs) { return runs.length ? `<table class="bu-table"><thead><tr><th>Task</th><th>Status</th><th>Created</th><th>Message</th><th></th></tr></thead><tbody>${runs.map(r=>`<tr><td>${esc(r.taskType)} ${esc(r.jobId||'')}</td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${esc(r.message||'')}</td><td>${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}</td></tr>`).join('')}</tbody></table>` : '<div class="bu-empty">No activity recorded.</div>'; } function progressView(run) {
if(run.status==='queued') return '<span class="bu-muted">Waiting in queue</span>';
if(run.taskType!=='backup'&&!run.progressPercent) return '<span class="bu-muted">No percentage available</span>';
const percent=Math.max(0,Math.min(100,Number(run.progressPercent)||0));
const scanning=run.status==='running'&&percent===0;
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()} files`);
if(run.bytesPerSecond) details.push(`${formatBytes(run.bytesPerSecond)}/s`);
if(run.secondsRemaining>0) details.push(`ETA ${formatEta(run.secondsRemaining)}`);
return `<div class="bu-progress-wrap"><div class="bu-progress-label"><strong>${scanning?'Scanning…':`${percent.toFixed(1)}%`}</strong><span>${esc(details.join(' · '))}</span></div><div class="bu-progress ${scanning?'indeterminate':''}"><span style="width:${scanning?30:percent}%"></span></div>${run.currentFile?`<div class="bu-current-file" title="${esc(run.currentFile)}">${esc(run.currentFile)}</div>`:''}</div>`;
}
function liveLogView(run) {
if(!(run.liveLog||[]).length) return '';
return `<details class="bu-live-log" ${run.status==='running'?'open':''}><summary>${run.status==='running'?'Live log':'Run log'} (${run.liveLog.length})</summary><pre>${esc(run.liveLog.join('\n'))}</pre></details>`;
}
function runsTable(runs) {
if(!runs.length) return '<div class="bu-empty">No activity recorded.</div>';
return `<table class="bu-table bu-runs-table"><thead><tr><th>Task</th><th>Status</th><th>Created</th><th>Progress</th><th>Message</th><th></th></tr></thead><tbody>${runs.map(r=>`<tr><td>${esc(r.taskType)} ${esc(r.jobId||'')}</td><td>${status(r.status)}</td><td>${esc(new Date(r.createdAt).toLocaleString())}</td><td>${progressView(r)}</td><td>${esc(r.message||'')}</td><td>${['queued','running'].includes(r.status)?button('Cancel',`cancel-run:${r.id}`,'danger'):''}</td></tr>${(r.liveLog||[]).length?`<tr class="bu-log-row"><td colspan="6">${liveLogView(r)}</td></tr>`:''}`).join('')}</tbody></table>`;
}
function renderSettings() { function renderSettings() {
const s = state.config.settings; const s = state.config.settings;
@@ -600,6 +625,6 @@
window.addEventListener('scroll', hideTooltip, true); window.addEventListener('scroll', hideTooltip, true);
window.addEventListener('resize', hideTooltip); window.addEventListener('resize', hideTooltip);
enhanceTooltips(root); enhanceTooltips(root);
setInterval(async()=>{ if(!state.config)return; try { state.runs=await api('/v1/runs'); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },10000); setInterval(async()=>{ if(!state.config)return; try { state.runs=await api('/v1/runs'); if(['dashboard','activity'].includes(state.view))render(); }catch(_){ } },2000);
load(); load();
})(); })();