diff --git a/README.md b/README.md index 444269c..13c2126 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ URBM is a native Unraid 7 plugin that manages encrypted, deduplicated and versio USB flash jobs always back up the browsable `/boot` file tree and can optionally stream the complete physical boot device into Restic as `urbm-usb-flash.img` for block-level recovery to an equal-sized or larger replacement device. +URBM also supports scheduled direct Rsync copies. Sources and the destination are selected with folder browsers; overwrite, destination deletion, permissions, ownership, timestamps, links, ACLs, extended attributes, checksum comparison, and dry-run behavior are configured without free-form shell arguments. + URBM is an independent community plugin and is not affiliated with or endorsed by Lime Technology, Inc. ## Architecture diff --git a/cmd/urbmd/main.go b/cmd/urbmd/main.go index 36fa688..05ab503 100644 --- a/cmd/urbmd/main.go +++ b/cmd/urbmd/main.go @@ -14,6 +14,7 @@ import ( "git.casaderoll.de/michael/urbm/internal/notify" "git.casaderoll.de/michael/urbm/internal/platform" "git.casaderoll.de/michael/urbm/internal/restic" + "git.casaderoll.de/michael/urbm/internal/rsync" "git.casaderoll.de/michael/urbm/internal/scheduler" "git.casaderoll.de/michael/urbm/internal/secrets" "git.casaderoll.de/michael/urbm/internal/service" @@ -56,10 +57,11 @@ func main() { os.Exit(1) } rr := &restic.Runner{Binary: config.Settings.ResticPath, RuntimeDir: *runtimeDir, Secrets: secretStore, Log: log} + rs := &rsync.Runner{Binary: config.Settings.RsyncPath} mounts := &platform.MountManager{RuntimeDir: *runtimeDir, Secrets: secretStore} workloads := &platform.WorkloadManager{RuntimeDir: *runtimeDir} notifier := ¬ify.Sender{Secrets: secretStore} - svc, err := service.New(configStore, secretStore, rr, mounts, workloads, notifier, log) + svc, err := service.New(configStore, secretStore, rr, rs, mounts, workloads, notifier, log) if err != nil { log.Error("initialize service", "error", err) os.Exit(1) diff --git a/dist/urbm-2026.06.15.r016-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r016-x86_64-1.txz.sha256 deleted file mode 100644 index 46ae8e9..0000000 --- a/dist/urbm-2026.06.15.r016-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -ad6f334a1648fb9b9f37df61ab061da32605c539e3c1933aff6169115ed65fb5 urbm-2026.06.15.r016-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r016-x86_64-1.txz b/dist/urbm-2026.06.15.r017-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.06.15.r016-x86_64-1.txz rename to dist/urbm-2026.06.15.r017-x86_64-1.txz index 81227d5..0624917 100644 Binary files a/dist/urbm-2026.06.15.r016-x86_64-1.txz and b/dist/urbm-2026.06.15.r017-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r017-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r017-x86_64-1.txz.sha256 new file mode 100644 index 0000000..29a023e --- /dev/null +++ b/dist/urbm-2026.06.15.r017-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +c4a3955c26ca3e380ae25c93d9d3e5706c65f4457ec6d1418cc1ed72b485cb9b urbm-2026.06.15.r017-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index ade27c5..bc501d0 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,19 @@ - + - + ]> +### 2026.06.15.r017 +- Add scheduled Rsync copy jobs with separate source and destination folder browsers. +- Add explicit controls for overwrite behavior, destination deletion, permissions, ownership, timestamps, links, ACLs, extended attributes, checksums, and dry runs. +- Integrate Rsync progress, pause, resume, cancellation, activity history, and notifications into the existing global queue. +- Reject overlapping source and destination paths and require an extra confirmation before enabling destination deletion. + ### 2026.06.15.r016 - Keep long backup progress statistics inside the progress column and wrap them before they can overlap pause or abort controls. - Stack progress statistics on narrow displays for reliable activity-table layout. diff --git a/internal/model/model.go b/internal/model/model.go index 6ed3510..69790a2 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "strings" + "time" +) const SchemaVersion = 1 @@ -12,6 +15,7 @@ const ( JobDocker JobType = "docker" JobVM JobType = "vm" JobFlash JobType = "flash" + JobRsync JobType = "rsync" ) type RepositoryType string @@ -33,6 +37,7 @@ type Config struct { type Settings struct { ResticPath string `json:"resticPath"` + RsyncPath string `json:"rsyncPath"` RestoreRoot string `json:"restoreRoot"` PersistentLogDir string `json:"persistentLogDir,omitempty"` ShowLiveLog bool `json:"showLiveLog"` @@ -42,23 +47,39 @@ type Settings struct { } type Job struct { - SchemaVersion int `json:"schemaVersion"` - ID string `json:"id"` - Name string `json:"name"` - Type JobType `json:"type"` - Enabled bool `json:"enabled"` - RepositoryID string `json:"repositoryId"` - Sources []Source `json:"sources"` - Schedule Schedule `json:"schedule"` - Consistency Consistency `json:"consistency"` - Compression string `json:"compression"` - CPUCores int `json:"cpuCores,omitempty"` - Excludes []string `json:"excludes,omitempty"` - Tags []string `json:"tags,omitempty"` - FlashImage bool `json:"flashImage,omitempty"` - Retention Retention `json:"retention"` - NotifyOn []string `json:"notifyOn,omitempty"` - ShutdownSecs int `json:"shutdownTimeoutSeconds"` + SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + Name string `json:"name"` + Type JobType `json:"type"` + Enabled bool `json:"enabled"` + RepositoryID string `json:"repositoryId"` + Sources []Source `json:"sources"` + Schedule Schedule `json:"schedule"` + Consistency Consistency `json:"consistency"` + Compression string `json:"compression"` + CPUCores int `json:"cpuCores,omitempty"` + Excludes []string `json:"excludes,omitempty"` + Tags []string `json:"tags,omitempty"` + FlashImage bool `json:"flashImage,omitempty"` + Retention Retention `json:"retention"` + NotifyOn []string `json:"notifyOn,omitempty"` + ShutdownSecs int `json:"shutdownTimeoutSeconds"` + Rsync RsyncOptions `json:"rsync,omitempty"` +} + +type RsyncOptions struct { + Target string `json:"target"` + Overwrite bool `json:"overwrite"` + Delete bool `json:"delete"` + PreservePermissions bool `json:"preservePermissions"` + PreserveOwner bool `json:"preserveOwner"` + PreserveGroup bool `json:"preserveGroup"` + PreserveTimes bool `json:"preserveTimes"` + PreserveLinks bool `json:"preserveLinks"` + PreserveACLs bool `json:"preserveAcls"` + PreserveXattrs bool `json:"preserveXattrs"` + Checksum bool `json:"checksum"` + DryRun bool `json:"dryRun"` } type Source struct { @@ -180,6 +201,7 @@ func DefaultConfig() Config { Notifications: []NotificationTarget{{SchemaVersion: SchemaVersion, ID: "unraid", Name: "Unraid", Type: "unraid", Enabled: true, Events: []string{"success", "warning", "failed"}}}, Settings: Settings{ ResticPath: "/usr/local/libexec/urbm/restic", + RsyncPath: "/usr/bin/rsync", RestoreRoot: "/mnt/user/urbm-restores", CatchUpWindowHrs: 24, CheckCron: "0 3 1 * *", @@ -189,6 +211,9 @@ func DefaultConfig() Config { } func NormalizeConfig(c Config) Config { + if strings.TrimSpace(c.Settings.RsyncPath) == "" { + c.Settings.RsyncPath = "/usr/bin/rsync" + } notifications := make([]NotificationTarget, 0, len(c.Notifications)+1) hasUnraid := false for _, target := range c.Notifications { diff --git a/internal/model/validate.go b/internal/model/validate.go index 95fa02d..c99dab4 100644 --- a/internal/model/validate.go +++ b/internal/model/validate.go @@ -34,8 +34,10 @@ func ValidateConfig(c Config) error { return fmt.Errorf("duplicate job id %q", job.ID) } jobs[job.ID] = struct{}{} - if _, exists := repos[job.RepositoryID]; !exists { - return fmt.Errorf("job %q references unknown repository %q", job.ID, job.RepositoryID) + if job.Type != JobRsync { + if _, exists := repos[job.RepositoryID]; !exists { + return fmt.Errorf("job %q references unknown repository %q", job.ID, job.RepositoryID) + } } } notifications := make(map[string]struct{}, len(c.Notifications)) @@ -90,11 +92,11 @@ func ValidateJob(j Job) error { if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) { return errors.New("invalid schemaVersion or id") } - if strings.TrimSpace(j.Name) == "" || strings.TrimSpace(j.RepositoryID) == "" { - return errors.New("name and repositoryId are required") + if strings.TrimSpace(j.Name) == "" { + return errors.New("name is required") } switch j.Type { - case JobShare, JobAppdata, JobDocker, JobVM, JobFlash: + case JobShare, JobAppdata, JobDocker, JobVM, JobFlash, JobRsync: default: return fmt.Errorf("unsupported job type %q", j.Type) } @@ -119,6 +121,17 @@ func ValidateJob(j Job) error { } } } + for _, exclude := range j.Excludes { + if strings.ContainsAny(exclude, "\x00\r\n") { + return errors.New("exclude contains forbidden control characters") + } + } + if j.Type == JobRsync { + return validateRsyncJob(j) + } + if strings.TrimSpace(j.RepositoryID) == "" { + return errors.New("repositoryId is required") + } if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" { return errors.New("compression must be auto, off, or max") } @@ -134,14 +147,34 @@ func ValidateJob(j Job) error { if j.Retention.KeepWithinDays == 0 && j.Retention.Daily == 0 && j.Retention.Weekly == 0 && j.Retention.Monthly == 0 { return errors.New("retention must keep at least one age or calendar policy") } - for _, exclude := range j.Excludes { - if strings.ContainsAny(exclude, "\x00\r\n") { - return errors.New("exclude contains forbidden control characters") + return nil +} + +func validateRsyncJob(j Job) error { + target := filepath.Clean(strings.TrimSpace(j.Rsync.Target)) + if !filepath.IsAbs(target) { + return errors.New("rsync target must be absolute") + } + if !pathWithin(target, "/mnt/user") && !pathWithin(target, "/mnt/disks") && !pathWithin(target, "/mnt/remotes") { + return errors.New("rsync target must be below /mnt/user, /mnt/disks, or /mnt/remotes") + } + for _, source := range j.Sources { + if source.WorkloadID != "" || source.Path == "" { + return errors.New("rsync jobs require filesystem paths") + } + cleanSource := filepath.Clean(source.Path) + if pathWithin(target, cleanSource) || pathWithin(cleanSource, target) { + return fmt.Errorf("rsync source %q and target %q must not contain each other", cleanSource, target) } } return nil } +func pathWithin(path, root string) bool { + rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + func ValidateRepository(r Repository) error { if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) { return errors.New("invalid schemaVersion or id") diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go index 2afe12b..be21882 100644 --- a/internal/model/validate_test.go +++ b/internal/model/validate_test.go @@ -91,3 +91,18 @@ func TestBackupCPUCoresRange(t *testing.T) { t.Fatal("excessive CPU limit accepted") } } + +func TestRsyncJobDoesNotRequireRepository(t *testing.T) { + c := DefaultConfig() + c.Jobs = []Job{{SchemaVersion: 1, ID: "rsync-job", Name: "Mirror", Type: JobRsync, Enabled: true, Sources: []Source{{Path: "/mnt/user/data"}}, Rsync: RsyncOptions{Target: "/mnt/remotes/backup", Overwrite: true}}} + if err := ValidateConfig(c); err != nil { + t.Fatalf("valid rsync job rejected: %v", err) + } +} + +func TestRsyncJobRejectsOverlappingPaths(t *testing.T) { + job := Job{SchemaVersion: 1, ID: "rsync-job", Name: "Mirror", Type: JobRsync, Sources: []Source{{Path: "/mnt/user/data"}}, Rsync: RsyncOptions{Target: "/mnt/user/data/copy"}} + if err := ValidateJob(job); err == nil { + t.Fatal("rsync target inside source accepted") + } +} diff --git a/internal/rsync/rsync.go b/internal/rsync/rsync.go new file mode 100644 index 0000000..eaa7d57 --- /dev/null +++ b/internal/rsync/rsync.go @@ -0,0 +1,228 @@ +package rsync + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "syscall" + + "git.casaderoll.de/michael/urbm/internal/model" +) + +type Runner struct { + Binary string + mu sync.Mutex + processes map[string]*os.Process +} + +type Progress struct { + Percent float64 + Bytes int64 + Files int64 + CurrentFile string +} + +type Summary struct { + Bytes int64 + Files int64 +} + +var ( + progressPattern = regexp.MustCompile(`^\s*([0-9,]+)\s+([0-9]+)%`) + filesPattern = regexp.MustCompile(`^Number of regular files transferred:\s*([0-9,]+)`) + bytesPattern = regexp.MustCompile(`^Total transferred file size:\s*([0-9,]+) bytes`) +) + +func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback func(Progress)) (Summary, error) { + args := Arguments(job) + cmd := exec.CommandContext(ctx, r.Binary, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return os.ErrProcessDone + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } + return nil + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return Summary{}, err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return Summary{}, err + } + if err := cmd.Start(); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return Summary{}, fmt.Errorf("environment: rsync binary not found: %w", err) + } + return Summary{}, err + } + unregister := r.register(runID, cmd.Process) + defer unregister() + + var summary Summary + done := make(chan struct{}) + go func() { + scanOutput(stdout, func(line string) { + if value, ok := parseCount(filesPattern, line); ok { + summary.Files = value + } + if value, ok := parseCount(bytesPattern, line); ok { + summary.Bytes = value + } + if callback != nil { + if progress, ok := parseProgress(line); ok { + callback(progress) + } else if strings.HasPrefix(line, "FILE|") { + callback(Progress{CurrentFile: strings.TrimPrefix(line, "FILE|")}) + } + } + }) + close(done) + }() + errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024)) + err = cmd.Wait() + <-done + if err != nil { + if errors.Is(ctx.Err(), context.Canceled) { + return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err()) + } + message := strings.TrimSpace(string(errBytes)) + if message == "" { + message = err.Error() + } + return Summary{}, fmt.Errorf("rsync: %s", message) + } + return summary, nil +} + +func Arguments(job model.Job) []string { + options := job.Rsync + args := []string{"--recursive", "--human-readable", "--info=progress2", "--stats", "--partial", "--out-format=FILE|%n"} + if !options.Overwrite { + args = append(args, "--ignore-existing") + } + if options.Delete { + args = append(args, "--delete-delay") + } + if options.PreservePermissions { + args = append(args, "--perms") + } + if options.PreserveOwner { + args = append(args, "--owner") + } + if options.PreserveGroup { + args = append(args, "--group") + } + if options.PreserveTimes { + args = append(args, "--times") + } + if options.PreserveLinks { + args = append(args, "--links") + } + if options.PreserveACLs { + args = append(args, "--acls") + } + if options.PreserveXattrs { + args = append(args, "--xattrs") + } + if options.Checksum { + args = append(args, "--checksum") + } + if options.DryRun { + args = append(args, "--dry-run") + } + for _, exclude := range job.Excludes { + args = append(args, "--exclude", exclude) + } + args = append(args, "--") + for _, source := range job.Sources { + args = append(args, source.Path) + } + args = append(args, strings.TrimRight(options.Target, "/")+"/") + return args +} + +func (r *Runner) Pause(runID string) error { return r.signal(runID, syscall.SIGSTOP) } +func (r *Runner) Resume(runID string) error { return r.signal(runID, syscall.SIGCONT) } + +func (r *Runner) signal(runID string, signal syscall.Signal) error { + r.mu.Lock() + process := r.processes[runID] + r.mu.Unlock() + if process == nil { + return errors.New("rsync process is not active yet") + } + if err := syscall.Kill(-process.Pid, signal); err != nil { + return fmt.Errorf("signal rsync process group: %w", err) + } + return nil +} + +func (r *Runner) register(runID string, process *os.Process) func() { + r.mu.Lock() + if r.processes == nil { + r.processes = make(map[string]*os.Process) + } + r.processes[runID] = process + r.mu.Unlock() + return func() { + r.mu.Lock() + delete(r.processes, runID) + r.mu.Unlock() + } +} + +func scanOutput(reader io.Reader, callback func(string)) { + scanner := bufio.NewScanner(reader) + scanner.Split(splitLinesAndCarriageReturns) + scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) + for scanner.Scan() { + if line := strings.TrimSpace(scanner.Text()); line != "" { + callback(line) + } + } +} + +func splitLinesAndCarriageReturns(data []byte, atEOF bool) (advance int, token []byte, err error) { + for i, value := range data { + if value == '\n' || value == '\r' { + return i + 1, data[:i], nil + } + } + if atEOF && len(data) > 0 { + return len(data), data, nil + } + return 0, nil, nil +} + +func parseProgress(line string) (Progress, bool) { + match := progressPattern.FindStringSubmatch(line) + if len(match) != 3 { + return Progress{}, false + } + bytes, _ := strconv.ParseInt(strings.ReplaceAll(match[1], ",", ""), 10, 64) + percent, _ := strconv.ParseFloat(match[2], 64) + return Progress{Bytes: bytes, Percent: percent}, true +} + +func parseCount(pattern *regexp.Regexp, line string) (int64, bool) { + match := pattern.FindStringSubmatch(line) + if len(match) != 2 { + return 0, false + } + value, err := strconv.ParseInt(strings.ReplaceAll(match[1], ",", ""), 10, 64) + return value, err == nil +} diff --git a/internal/rsync/rsync_test.go b/internal/rsync/rsync_test.go new file mode 100644 index 0000000..034fabc --- /dev/null +++ b/internal/rsync/rsync_test.go @@ -0,0 +1,25 @@ +package rsync + +import ( + "slices" + "testing" + + "git.casaderoll.de/michael/urbm/internal/model" +) + +func TestArgumentsUseExplicitSafeOptions(t *testing.T) { + job := model.Job{Sources: []model.Source{{Path: "/mnt/user/data"}}, Excludes: []string{"*.tmp"}, Rsync: model.RsyncOptions{Target: "/mnt/remotes/backup", Delete: true, PreservePermissions: true}} + args := Arguments(job) + for _, expected := range []string{"--recursive", "--delete-delay", "--perms", "--exclude", "*.tmp", "--", "/mnt/user/data", "/mnt/remotes/backup/"} { + if !slices.Contains(args, expected) { + t.Fatalf("missing %q in %#v", expected, args) + } + } +} + +func TestParseProgress(t *testing.T) { + progress, ok := parseProgress(" 1,234,567 42% 12.34MB/s 0:00:05") + if !ok || progress.Bytes != 1234567 || progress.Percent != 42 { + t.Fatalf("progress = %#v, %v", progress, ok) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index fa97d19..8042077 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -18,6 +18,7 @@ import ( "git.casaderoll.de/michael/urbm/internal/platform" "git.casaderoll.de/michael/urbm/internal/queue" "git.casaderoll.de/michael/urbm/internal/restic" + "git.casaderoll.de/michael/urbm/internal/rsync" "git.casaderoll.de/michael/urbm/internal/scheduler" "git.casaderoll.de/michael/urbm/internal/store" ) @@ -32,6 +33,7 @@ type Service struct { store *store.Store secrets SecretStore restic *restic.Runner + rsync *rsync.Runner mounts *platform.MountManager workloads *platform.WorkloadManager notifier *notify.Sender @@ -41,12 +43,12 @@ type Service struct { config model.Config } -func New(st *store.Store, secrets SecretStore, rr *restic.Runner, mounts *platform.MountManager, workloads *platform.WorkloadManager, notifier *notify.Sender, log *slog.Logger) (*Service, error) { +func New(st *store.Store, secrets SecretStore, rr *restic.Runner, rs *rsync.Runner, mounts *platform.MountManager, workloads *platform.WorkloadManager, notifier *notify.Sender, log *slog.Logger) (*Service, error) { config, err := st.LoadConfig() if err != nil { return nil, err } - s := &Service{store: st, secrets: secrets, restic: rr, mounts: mounts, workloads: workloads, notifier: notifier, log: log, config: config} + s := &Service{store: st, secrets: secrets, restic: rr, rsync: rs, mounts: mounts, workloads: workloads, notifier: notifier, log: log, config: config} s.queue = queue.New(s.execute, func(runs []model.Run) { if err := st.SaveRuns(runs); err != nil { log.Error("save run history", "error", err) @@ -112,6 +114,7 @@ func (s *Service) SaveConfig(config model.Config) error { s.config = config s.mu.Unlock() s.restic.Binary = config.Settings.ResticPath + s.rsync.Binary = config.Settings.RsyncPath return nil } @@ -129,7 +132,11 @@ func (s *Service) EnqueueJob(jobID string, priority int) (model.Run, error) { if !ok { return model.Run{}, errors.New("validation: unknown job") } - run := model.Run{SchemaVersion: model.SchemaVersion, ID: newID(), JobID: job.ID, TaskType: "backup", Priority: priority, CreatedAt: time.Now().UTC()} + taskType := "backup" + if job.Type == model.JobRsync { + taskType = "rsync" + } + run := model.Run{SchemaVersion: model.SchemaVersion, ID: newID(), JobID: job.ID, TaskType: taskType, Priority: priority, CreatedAt: time.Now().UTC()} return run, s.queue.Enqueue(run) } @@ -169,18 +176,27 @@ func (s *Service) Cancel(runID string) bool { return s.queue.Cancel(runID) } func (s *Service) ClearRunHistory() int { return s.queue.ClearHistory() } func (s *Service) Pause(runID string) error { - if err := s.restic.Pause(runID); err != nil { + runner := s.restic.Pause + resume := s.restic.Resume + if s.runTaskType(runID) == "rsync" { + runner, resume = s.rsync.Pause, s.rsync.Resume + } + if err := runner(runID); err != nil { return fmt.Errorf("pause: %w", err) } if !s.queue.SetPaused(runID, true) { - _ = s.restic.Resume(runID) + _ = resume(runID) return errors.New("pause: run is not currently running") } return nil } func (s *Service) Resume(runID string) error { - if err := s.restic.Resume(runID); err != nil { + runner := s.restic.Resume + if s.runTaskType(runID) == "rsync" { + runner = s.rsync.Resume + } + if err := runner(runID); err != nil { return fmt.Errorf("resume: %w", err) } if !s.queue.SetPaused(runID, false) { @@ -189,6 +205,15 @@ func (s *Service) Resume(runID string) error { return nil } +func (s *Service) runTaskType(runID string) string { + for _, run := range s.queue.Snapshot() { + if run.ID == runID { + return run.TaskType + } + } + return "" +} + func (s *Service) Snapshots(ctx context.Context, repoID string) ([]model.Snapshot, error) { repo, ok := s.repository(repoID) if !ok { @@ -317,6 +342,9 @@ func (s *Service) execute(ctx context.Context, run model.Run) model.Run { if run.TaskType == "backup" { return s.executeBackup(ctx, run) } + if run.TaskType == "rsync" { + return s.executeRsync(ctx, run) + } if run.TaskType == "restore" { return s.executeRestore(ctx, run) } @@ -354,6 +382,66 @@ func (s *Service) execute(ctx context.Context, run model.Run) model.Run { return run } +func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model.Run) { + job, ok := s.job(run.JobID) + if !ok { + return failed(run, "validation", "job no longer exists") + } + defer func() { result = s.finishJob(run, job, result) }() + live := run + appendLiveLog(&live, fmt.Sprintf("Rsync-Kopie wird vorbereitet: %d Quelle(n) nach %s", len(job.Sources), job.Rsync.Target)) + s.updateActive(live) + for _, source := range job.Sources { + if _, err := os.Stat(source.Path); err != nil { + return failed(run, "source", "source unavailable: "+source.Path) + } + } + if info, err := os.Stat(job.Rsync.Target); err != nil || !info.IsDir() { + if err == nil { + err = errors.New("target is not a directory") + } + return failed(run, "environment", "rsync target unavailable: "+err.Error()) + } + started := time.Now() + lastLoggedPercent := -10 + summary, err := s.rsync.Run(ctx, run.ID, job, func(progress rsync.Progress) { + if progress.Percent > 0 || progress.Bytes > 0 { + live.ProgressPercent = progress.Percent + live.ProgressBytes = progress.Bytes + if progress.Percent > 0 { + live.ProgressTotal = int64(float64(progress.Bytes) / (progress.Percent / 100)) + } + if elapsed := time.Since(started).Seconds(); elapsed > 0 { + live.BytesPerSecond = float64(progress.Bytes) / elapsed + } + percent := int(progress.Percent) + if percent >= lastLoggedPercent+10 { + appendLiveLog(&live, fmt.Sprintf("Rsync-Fortschritt: %.0f%%, %s übertragen", progress.Percent, formatBytes(progress.Bytes))) + lastLoggedPercent = percent - percent%10 + } + } + if progress.CurrentFile != "" { + live.CurrentFile = progress.CurrentFile + } + s.updateActive(live) + }) + if err != nil { + appendLiveLog(&live, "Rsync-Kopie fehlgeschlagen: "+err.Error()) + failedRun := failedError(run, err) + failedRun.LiveLog = live.LiveLog + return failedRun + } + run.Status, run.Message = "success", "rsync completed" + run.BytesAdded, run.BytesProcessed = summary.Bytes, summary.Bytes + run.FilesNew, run.FilesProcessed = summary.Files, summary.Files + run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, summary.Bytes, summary.Bytes + run.ProgressFiles, run.ProgressFileTotal = summary.Files, summary.Files + run.BytesPerSecond, run.CurrentFile = live.BytesPerSecond, live.CurrentFile + appendLiveLog(&live, fmt.Sprintf("Rsync-Kopie abgeschlossen: %d Dateien, %s", summary.Files, formatBytes(summary.Bytes))) + run.LiveLog = live.LiveLog + return run +} + func (s *Service) executeBackup(ctx context.Context, run model.Run) (result model.Run) { job, ok := s.job(run.JobID) if !ok { @@ -577,7 +665,7 @@ func notificationMessage(run model.Run, now time.Time) string { lines = append(lines, "Dauer: "+formatDuration(duration)) } } - if run.TaskType == "backup" && (run.Status == "success" || run.Status == "warning") { + if (run.TaskType == "backup" || run.TaskType == "rsync") && (run.Status == "success" || run.Status == "warning") { lines = append(lines, "Neu gespeichert: "+formatBytes(run.BytesAdded), fmt.Sprintf("Neue Dateien: %d", run.FilesNew), @@ -589,7 +677,7 @@ func notificationMessage(run model.Run, now time.Time) string { lines = append(lines, "Snapshot: "+run.SnapshotID) } } - if run.Message != "" && run.Message != "backup completed" { + if run.Message != "" && run.Message != "backup completed" && run.Message != "rsync completed" { lines = append(lines, "Details: "+run.Message) } return strings.Join(lines, "\n") diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 056b025..4dd6fb8 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,19 @@ - + ]> +### 2026.06.15.r017 +- Add scheduled Rsync copy jobs with separate source and destination folder browsers. +- Add explicit controls for overwrite behavior, destination deletion, permissions, ownership, timestamps, links, ACLs, extended attributes, checksums, and dry runs. +- Integrate Rsync progress, pause, resume, cancellation, activity history, and notifications into the existing global queue. +- Reject overlapping source and destination paths and require an extra confirmation before enabling destination deletion. + ### 2026.06.15.r016 - Keep long backup progress statistics inside the progress column and wrap them before they can overlap pause or abort controls. - Stack progress statistics on narrow displays for reliable activity-table layout. diff --git a/webgui/URBM.page b/webgui/URBM.page index e192c58..264cac3 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.15.r016

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r017

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 d85c378..99917bb 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -8,7 +8,7 @@ const tooltip = document.getElementById('bu-tooltip'); const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); const repositoryRoots = ['/mnt/user','/mnt/disks','/mnt/remotes'].map(path=>({name:path,path})); - const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} }; + const state = { config: null, runs: [], repositoryStats: [], repositoryStatsLoading: false, view: 'dashboard', snapshots: [], selectedSnapshot: null, snapshotRepo: null, files: [], browserPath: '/', restoreIncludes: [], backupSelections: [], workloadSelections: [], workloads: [], workloadAvailable: true, workloadError: '', sourceBrowserPath: '/', sourceDirectories: sourceRoots, sourceBrowserError: '', rsyncTargetBrowserPath: '/', rsyncTargetDirectories: repositoryRoots, rsyncTargetBrowserError: '', repositoryBrowserPath: '/', repositoryDirectories: repositoryRoots, repositoryBrowserError: '', liveLogOpen: {} }; const fieldHelp = { name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', @@ -162,9 +162,9 @@ return dynamic[name] || `${label} ausführen.`; }; const statusLabels = {success:'Erfolgreich',warning:'Warnung',failed:'Fehlgeschlagen',running:'Läuft',paused:'Pausiert',queued:'Wartet',cancelled:'Abgebrochen'}; - const taskLabels = {backup:'Backup',restore:'Wiederherstellung',check:'Prüfung',prune:'Bereinigung'}; - const typeLabels = {share:'Freigaben',appdata:'Appdata',docker:'Docker',vm:'Virtuelle Maschinen',flash:'USB-Flash',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'}; - const messageLabels = {'backup completed':'Backup abgeschlossen','restore completed':'Wiederherstellung abgeschlossen','check completed':'Prüfung abgeschlossen','prune completed':'Bereinigung abgeschlossen','daemon restarted while task was active':'Daemon wurde während eines aktiven Vorgangs neu gestartet'}; + const taskLabels = {backup:'Backup',rsync:'Rsync-Kopie',restore:'Wiederherstellung',check:'Prüfung',prune:'Bereinigung'}; + const typeLabels = {share:'Freigaben',appdata:'Appdata',docker:'Docker',vm:'Virtuelle Maschinen',flash:'USB-Flash',rsync:'Rsync-Kopie',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'}; + const messageLabels = {'backup completed':'Backup abgeschlossen','rsync completed':'Rsync-Kopie abgeschlossen','restore completed':'Wiederherstellung abgeschlossen','check completed':'Prüfung abgeschlossen','prune completed':'Bereinigung abgeschlossen','daemon restarted while task was active':'Daemon wurde während eines aktiven Vorgangs neu gestartet'}; const status = (value) => `${esc(statusLabels[value]||value)}`; const displayMessage = value => { const text=String(value||''); @@ -173,7 +173,7 @@ return messageLabels[text] || translateUserMessage(text); }; const runTarget = run => { - if(run.taskType==='backup') return state.config.jobs.find(job=>job.id===run.jobId)?.name || 'Unbekannter Job'; + 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'; return run.jobId || ''; }; @@ -366,9 +366,9 @@ } function renderJobs() { - const rows = state.config.jobs.map(j => `${esc(j.name)}
${esc(j.id)}${esc(typeLabels[j.type]||j.type)}${esc(repoName(j.repositoryId))}${esc(scheduleLabel(j.schedule?.cron))}${j.enabled ? status('success') : status('cancelled')}${actionGroup(button('Starten',`run-job:${j.id}`,'primary')+button('Bearbeiten',`edit-job:${j.id}`)+button('Duplizieren',`duplicate-job:${j.id}`)+button('Löschen',`delete-job:${j.id}`,'danger'))}`).join(''); - return `

Backup-Jobs

Mehrere Jobs können dasselbe Restic-Repository verwenden; Snapshots und Aufbewahrung werden per Job-Tag getrennt.
${button('Neuer Job','new-job','primary')}
-
${rows ? `${rows}
JobTypRepositoryZeitplanAktivAktionen
` : '
Keine Backup-Jobs konfiguriert.
'}
`; + const rows = state.config.jobs.map(j => `${esc(j.name)}
${esc(j.id)}${esc(typeLabels[j.type]||j.type)}${esc(j.type==='rsync'?(j.rsync?.target||'Kein Ziel'):repoName(j.repositoryId))}${esc(scheduleLabel(j.schedule?.cron))}${j.enabled ? status('success') : status('cancelled')}${actionGroup(button('Starten',`run-job:${j.id}`,'primary')+button('Bearbeiten',`edit-job:${j.id}`)+button('Duplizieren',`duplicate-job:${j.id}`)+button('Löschen',`delete-job:${j.id}`,'danger'))}`).join(''); + return `

Backup-Jobs

Restic-Backups und direkte Rsync-Kopien werden gemeinsam geplant und überwacht.
${button('Neuer Job','new-job','primary')}
+
${rows ? `${rows}
JobTypRepository / ZielZeitplanAktivAktionen
` : '
Keine Backup-Jobs konfiguriert.
'}
`; } function jobForm(job = {}) { @@ -376,16 +376,16 @@ state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId); state.workloads = []; state.workloadAvailable = true; state.workloadError = ''; state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = ''; + state.rsyncTargetBrowserPath = '/'; state.rsyncTargetDirectories = repositoryRoots; state.rsyncTargetBrowserError = ''; const options = state.config.repositories.map(r => ``).join(''); content.innerHTML = `

Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}

- - - + +
Restic-Einstellungen
${scheduleFields(job)}
-
Backup-Aufbewahrung

Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.

+
Backup-Aufbewahrung

Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.

${button('Speichern','submit-job','primary')}${button('Abbrechen','cancel-form')}
`; enhanceTooltips(content); @@ -395,6 +395,9 @@ function renderJobSpecific(type, job = {}) { const host = document.getElementById('bu-job-specific'); if (!host) return; + const isRsync=type==='rsync'; + document.getElementById('bu-restic-job-settings').hidden=isRsync; + document.getElementById('bu-retention-settings').hidden=isRsync; if (type === 'share' || type === 'appdata') { host.innerHTML = `

${type==='appdata'?'Appdata-Ordner':'Freigabeordner'}

Wähle einen oder mehrere Ordner aus. Zusätzliche Dateifilter können darunter eingetragen werden.
`; state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user'; @@ -403,6 +406,10 @@ } else if (type === 'docker' || type === 'vm') { host.innerHTML = `

${type==='docker'?'Docker-Container':'Virtuelle Maschinen'}

Wähle die zu sichernden Systeme aus. Metadaten und erkannte Bind-Mounts oder virtuelle Datenträger werden automatisch einbezogen.
Wird geladen...
`; loadWorkloads(type); + } else if (type === 'rsync') { + const o=job.rsync||{}; + host.innerHTML = `

Direkte Rsync-Kopie

Rsync erstellt keine verschlüsselten Snapshots. Die ausgewählten Ordner werden direkt in das Ziel kopiert. Die Option „Am Ziel löschen“ spiegelt Löschungen und sollte bewusst aktiviert werden.

Zielordner

Rsync-Optionen
`; + state.sourceBrowserPath='/'; state.sourceDirectories=sourceRoots; state.sourceBrowserError=''; renderSourceBrowser(); renderRsyncTargetBrowser(); } else { state.backupSelections = ['/boot']; host.innerHTML = `

Unraid-USB-Flash-Backup

Das vollständige Konfigurationslaufwerk /boot wird immer dateibasiert gesichert.

Das Abbild wird ohne Zwischenkopie direkt zu Restic übertragen und kann nach einer Wiederherstellung mit einem Image-Programm auf einen mindestens gleich großen Stick geschrieben werden. Die an die USB-GUID gebundene Unraid-Lizenz muss bei einem Stickwechsel separat übertragen werden.

`; @@ -454,6 +461,28 @@ } } + function renderRsyncTargetBrowser() { + const host=document.getElementById('bu-rsync-target-browser'); + if(!host) return; + const rows=state.rsyncTargetDirectories.map(item=>`${esc(item.name)}${esc(item.path)}${button('Öffnen',`open-rsync-target:${encodeURIComponent(item.path)}`)}`).join(''); + const error=state.rsyncTargetBrowserError?`
${esc(state.rsyncTargetBrowserError)}
`:''; + host.innerHTML=`

Rsync-Ziel auswählen

${esc(state.rsyncTargetBrowserPath)}
${state.rsyncTargetBrowserPath!=='/'?button('Diesen Ordner verwenden','select-rsync-target','primary'):''}${state.rsyncTargetBrowserPath!=='/'?button('Nach oben','rsync-target-up'):''}
${error}
${rows||''}
NamePfad
Keine Unterordner gefunden.
`; + enhanceTooltips(host); + } + + async function loadRsyncTargetDirectories(path) { + if(path==='/') { + state.rsyncTargetBrowserPath='/'; state.rsyncTargetDirectories=repositoryRoots; state.rsyncTargetBrowserError=''; renderRsyncTargetBrowser(); return; + } + try { + state.rsyncTargetDirectories=await api(`/v1/filesystem/directories?path=${encodeURIComponent(path)}`); + state.rsyncTargetBrowserPath=path; state.rsyncTargetBrowserError=''; + } catch(error) { + state.rsyncTargetBrowserError=`Zielordner konnte nicht geladen werden: ${error.message}`; + } + renderRsyncTargetBrowser(); + } + function renderRepositories() { const rows = state.config.repositories.map(r => { const initialize = volatileLocation(r) ? disabledButton('Initialisieren','Dieses Ziel liegt im flüchtigen RAM. Zuerst über Bearbeiten einen dauerhaften Pfad wie /mnt/user/Backups/HomeServer eintragen.') : button('Initialisieren',`init-repo:${r.id}`,'primary'); @@ -543,7 +572,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([...state.runs].reverse())}
`; } function progressView(run) { if(run.status==='queued') return 'Wartet in der Warteschlange'; - if(run.taskType!=='backup'&&!run.progressPercent) return 'Kein Prozentwert verfügbar'; + if(run.taskType!=='backup'&&run.taskType!=='rsync'&&!run.progressPercent) return 'Kein Prozentwert verfügbar'; const usbImage=run.status==='running'&&run.currentFile==='urbm-usb-flash.img'; if(usbImage) { const details=[]; @@ -613,6 +642,9 @@ if (name === 'open-source-dir') { return loadSourceDirectories(decodeURIComponent(a)); } if (name === 'source-up') { const parent=state.sourceBrowserPath==='/'?'/':state.sourceBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadSourceDirectories(parent==='/mnt'?'/':parent); } if (name === 'select-current-source') { if(!state.backupSelections.includes(state.sourceBrowserPath)) state.backupSelections.push(state.sourceBrowserPath); return renderSourceBrowser(); } + if (name === 'open-rsync-target') { return loadRsyncTargetDirectories(decodeURIComponent(a)); } + if (name === 'rsync-target-up') { const parent=state.rsyncTargetBrowserPath==='/'?'/':state.rsyncTargetBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadRsyncTargetDirectories(parent==='/mnt'?'/':parent); } + if (name === 'select-rsync-target') { const target=document.querySelector('#bu-job-form [name="rsyncTarget"]'); if(target) target.value=state.rsyncTargetBrowserPath; return; } if (name === 'open-repository-dir') { return loadRepositoryDirectories(decodeURIComponent(a)); } if (name === 'repository-up') { const parent=state.repositoryBrowserPath==='/'?'/':state.repositoryBrowserPath.split('/').slice(0,-1).join('/')||'/'; return loadRepositoryDirectories(parent==='/mnt'?'/':parent); } if (name === 'select-repository-folder') { const location=document.querySelector('#bu-repo-form [name="location"]'); if(location) location.value=state.repositoryBrowserPath; return; } @@ -635,8 +667,11 @@ else if(type==='flash') sources=[]; else sources=[...state.backupSelections.map(path=>({path})),...lines(f.get('sources')||'').map(path=>({path}))].filter((item,index,all)=>all.findIndex(other=>other.path===item.path)===index); const retention={keepWithinDays:Number(f.get('keepWithinDays')),daily:Number(f.get('keepDaily')),weekly:Number(f.get('keepWeekly')),monthly:Number(f.get('keepMonthly'))}; - if(!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.'); - const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),cpuCores:Number(f.get('cpuCores')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; + if(type!=='rsync'&&!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.'); + const rsync=type==='rsync'?{target:String(f.get('rsyncTarget')||'').trim(),overwrite:f.get('rsyncOverwrite')==='on',delete:f.get('rsyncDelete')==='on',preservePermissions:f.get('rsyncPermissions')==='on',preserveOwner:f.get('rsyncOwner')==='on',preserveGroup:f.get('rsyncGroup')==='on',preserveTimes:f.get('rsyncTimes')==='on',preserveLinks:f.get('rsyncLinks')==='on',preserveAcls:f.get('rsyncACLs')==='on',preserveXattrs:f.get('rsyncXattrs')==='on',checksum:f.get('rsyncChecksum')==='on',dryRun:f.get('rsyncDryRun')==='on'}:{}; + if(type==='rsync'&&!rsync.target) throw new Error('Wähle einen Zielordner für die Rsync-Kopie.'); + if(type==='rsync'&&rsync.delete&&!confirm('Rsync darf Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind. Diese Option kann Daten am Ziel entfernen. Wirklich speichern?')) return; + const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:type==='rsync'?'':f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:type==='rsync'?'':f.get('compression'),cpuCores:type==='rsync'?0:Number(f.get('cpuCores')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); } if (kind === 'submit-repo') { @@ -666,7 +701,7 @@ } } if (kind === 'submit-restore') { const f=new FormData(document.getElementById('bu-restore-form')); await api('/v1/restores',{method:'POST',body:JSON.stringify({schemaVersion:1,id:'',repositoryId:f.get('repositoryId'),snapshotId:f.get('snapshotId'),includes:lines(f.get('includes')),target:f.get('target'),inPlace:f.get('inPlace')==='on',confirmed:f.get('confirmed')==='on'})}); notify('Wiederherstellung wurde eingereiht'); state.view='activity'; render(); } - if (kind === 'submit-settings') { const f=new FormData(document.getElementById('bu-settings-form')); state.config.settings={resticPath:f.get('resticPath'),restoreRoot:f.get('restoreRoot'),persistentLogDir:f.get('persistentLogDir'),showLiveLog:f.get('showLiveLog')==='on',catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); render(); } + if (kind === 'submit-settings') { const f=new FormData(document.getElementById('bu-settings-form')); state.config.settings={resticPath:f.get('resticPath'),rsyncPath:state.config.settings.rsyncPath||'/usr/bin/rsync',restoreRoot:f.get('restoreRoot'),persistentLogDir:f.get('persistentLogDir'),showLiveLog:f.get('showLiveLog')==='on',catchUpWindowHours:Number(f.get('catchUp')),checkCron:f.get('checkCron'),pruneCron:f.get('pruneCron')}; await saveConfig(); render(); } if (kind === 'submit-unraid-notify') { const f=new FormData(document.getElementById('bu-unraid-notify-form')); const target={schemaVersion:1,id:f.get('id')||'unraid',name:'Unraid',type:'unraid',enabled:f.get('enabled')==='on',events:f.getAll('events')}; if(target.enabled&&!target.events.length) throw new Error('Wähle mindestens ein Ereignis für Unraid-Benachrichtigungen aus.'); state.config.notifications=state.config.notifications.filter(n=>n.type!=='unraid'&&n.type!=='ntfy').concat(target); await saveConfig(); render(); } if (kind === 'submit-gotify-notify') { const f=new FormData(document.getElementById('bu-gotify-notify-form')); const existing=state.config.notifications.find(n=>n.type==='gotify'); const target={schemaVersion:1,id:f.get('id')||'gotify',name:'Gotify',type:'gotify',enabled:f.get('enabled')==='on',url:String(f.get('url')||'').trim(),tokenRef:String(f.get('tokenRef')||'').trim(),events:f.getAll('events')}; if(target.enabled&&!target.url) throw new Error('Die Gotify-Server-URL ist erforderlich.'); if(target.enabled&&!target.events.length) throw new Error('Wähle mindestens ein Ereignis für Gotify-Benachrichtigungen aus.'); if(target.enabled&&!existing&&!f.get('token')) throw new Error('Ein Gotify-Anwendungs-Token ist erforderlich.'); if(f.get('token')) await api(`/v1/secrets/${encodeURIComponent(target.tokenRef)}`,{method:'PUT',body:JSON.stringify({type:'gotify-token',value:f.get('token')})}); state.config.notifications=state.config.notifications.filter(n=>n.type!=='gotify'&&n.type!=='ntfy'); if(target.enabled||target.url) state.config.notifications.push(target); await saveConfig(); render(); } }