Add scheduled rsync copy jobs

This commit is contained in:
Mikei386
2026-06-15 17:15:27 +02:00
parent 2abfc8eb26
commit 6c795fd97d
15 changed files with 524 additions and 59 deletions
+2
View File
@@ -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. 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. URBM is an independent community plugin and is not affiliated with or endorsed by Lime Technology, Inc.
## Architecture ## Architecture
+3 -1
View File
@@ -14,6 +14,7 @@ import (
"git.casaderoll.de/michael/urbm/internal/notify" "git.casaderoll.de/michael/urbm/internal/notify"
"git.casaderoll.de/michael/urbm/internal/platform" "git.casaderoll.de/michael/urbm/internal/platform"
"git.casaderoll.de/michael/urbm/internal/restic" "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/scheduler"
"git.casaderoll.de/michael/urbm/internal/secrets" "git.casaderoll.de/michael/urbm/internal/secrets"
"git.casaderoll.de/michael/urbm/internal/service" "git.casaderoll.de/michael/urbm/internal/service"
@@ -56,10 +57,11 @@ func main() {
os.Exit(1) os.Exit(1)
} }
rr := &restic.Runner{Binary: config.Settings.ResticPath, RuntimeDir: *runtimeDir, Secrets: secretStore, Log: log} 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} mounts := &platform.MountManager{RuntimeDir: *runtimeDir, Secrets: secretStore}
workloads := &platform.WorkloadManager{RuntimeDir: *runtimeDir} workloads := &platform.WorkloadManager{RuntimeDir: *runtimeDir}
notifier := &notify.Sender{Secrets: secretStore} notifier := &notify.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 { if err != nil {
log.Error("initialize service", "error", err) log.Error("initialize service", "error", err)
os.Exit(1) os.Exit(1)
-1
View File
@@ -1 +0,0 @@
ad6f334a1648fb9b9f37df61ab061da32605c539e3c1933aff6169115ed65fb5 urbm-2026.06.15.r016-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
c4a3955c26ca3e380ae25c93d9d3e5706c65f4457ec6d1418cc1ed72b485cb9b urbm-2026.06.15.r017-x86_64-1.txz
+8 -2
View File
@@ -2,13 +2,19 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r016"> <!ENTITY version "2026.06.15.r017">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "ad6f334a1648fb9b9f37df61ab061da32605c539e3c1933aff6169115ed65fb5"> <!ENTITY packageSHA256 "c4a3955c26ca3e380ae25c93d9d3e5706c65f4457ec6d1418cc1ed72b485cb9b">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.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 ### 2026.06.15.r016
- Keep long backup progress statistics inside the progress column and wrap them before they can overlap pause or abort controls. - 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. - Stack progress statistics on narrow displays for reliable activity-table layout.
+43 -18
View File
@@ -1,6 +1,9 @@
package model package model
import "time" import (
"strings"
"time"
)
const SchemaVersion = 1 const SchemaVersion = 1
@@ -12,6 +15,7 @@ const (
JobDocker JobType = "docker" JobDocker JobType = "docker"
JobVM JobType = "vm" JobVM JobType = "vm"
JobFlash JobType = "flash" JobFlash JobType = "flash"
JobRsync JobType = "rsync"
) )
type RepositoryType string type RepositoryType string
@@ -33,6 +37,7 @@ type Config struct {
type Settings struct { type Settings struct {
ResticPath string `json:"resticPath"` ResticPath string `json:"resticPath"`
RsyncPath string `json:"rsyncPath"`
RestoreRoot string `json:"restoreRoot"` RestoreRoot string `json:"restoreRoot"`
PersistentLogDir string `json:"persistentLogDir,omitempty"` PersistentLogDir string `json:"persistentLogDir,omitempty"`
ShowLiveLog bool `json:"showLiveLog"` ShowLiveLog bool `json:"showLiveLog"`
@@ -42,23 +47,39 @@ type Settings struct {
} }
type Job struct { type Job struct {
SchemaVersion int `json:"schemaVersion"` SchemaVersion int `json:"schemaVersion"`
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type JobType `json:"type"` Type JobType `json:"type"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
RepositoryID string `json:"repositoryId"` RepositoryID string `json:"repositoryId"`
Sources []Source `json:"sources"` Sources []Source `json:"sources"`
Schedule Schedule `json:"schedule"` Schedule Schedule `json:"schedule"`
Consistency Consistency `json:"consistency"` Consistency Consistency `json:"consistency"`
Compression string `json:"compression"` Compression string `json:"compression"`
CPUCores int `json:"cpuCores,omitempty"` CPUCores int `json:"cpuCores,omitempty"`
Excludes []string `json:"excludes,omitempty"` Excludes []string `json:"excludes,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
FlashImage bool `json:"flashImage,omitempty"` FlashImage bool `json:"flashImage,omitempty"`
Retention Retention `json:"retention"` Retention Retention `json:"retention"`
NotifyOn []string `json:"notifyOn,omitempty"` NotifyOn []string `json:"notifyOn,omitempty"`
ShutdownSecs int `json:"shutdownTimeoutSeconds"` 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 { 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"}}}, Notifications: []NotificationTarget{{SchemaVersion: SchemaVersion, ID: "unraid", Name: "Unraid", Type: "unraid", Enabled: true, Events: []string{"success", "warning", "failed"}}},
Settings: Settings{ Settings: Settings{
ResticPath: "/usr/local/libexec/urbm/restic", ResticPath: "/usr/local/libexec/urbm/restic",
RsyncPath: "/usr/bin/rsync",
RestoreRoot: "/mnt/user/urbm-restores", RestoreRoot: "/mnt/user/urbm-restores",
CatchUpWindowHrs: 24, CatchUpWindowHrs: 24,
CheckCron: "0 3 1 * *", CheckCron: "0 3 1 * *",
@@ -189,6 +211,9 @@ func DefaultConfig() Config {
} }
func NormalizeConfig(c Config) 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) notifications := make([]NotificationTarget, 0, len(c.Notifications)+1)
hasUnraid := false hasUnraid := false
for _, target := range c.Notifications { for _, target := range c.Notifications {
+41 -8
View File
@@ -34,8 +34,10 @@ func ValidateConfig(c Config) error {
return fmt.Errorf("duplicate job id %q", job.ID) return fmt.Errorf("duplicate job id %q", job.ID)
} }
jobs[job.ID] = struct{}{} jobs[job.ID] = struct{}{}
if _, exists := repos[job.RepositoryID]; !exists { if job.Type != JobRsync {
return fmt.Errorf("job %q references unknown repository %q", job.ID, job.RepositoryID) 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)) 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) { if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) {
return errors.New("invalid schemaVersion or id") return errors.New("invalid schemaVersion or id")
} }
if strings.TrimSpace(j.Name) == "" || strings.TrimSpace(j.RepositoryID) == "" { if strings.TrimSpace(j.Name) == "" {
return errors.New("name and repositoryId are required") return errors.New("name is required")
} }
switch j.Type { switch j.Type {
case JobShare, JobAppdata, JobDocker, JobVM, JobFlash: case JobShare, JobAppdata, JobDocker, JobVM, JobFlash, JobRsync:
default: default:
return fmt.Errorf("unsupported job type %q", j.Type) 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" { if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" {
return errors.New("compression must be auto, off, or 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 { 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") return errors.New("retention must keep at least one age or calendar policy")
} }
for _, exclude := range j.Excludes { return nil
if strings.ContainsAny(exclude, "\x00\r\n") { }
return errors.New("exclude contains forbidden control characters")
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 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 { func ValidateRepository(r Repository) error {
if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) { if r.SchemaVersion != SchemaVersion || !idPattern.MatchString(r.ID) {
return errors.New("invalid schemaVersion or id") return errors.New("invalid schemaVersion or id")
+15
View File
@@ -91,3 +91,18 @@ func TestBackupCPUCoresRange(t *testing.T) {
t.Fatal("excessive CPU limit accepted") 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")
}
}
+228
View File
@@ -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
}
+25
View File
@@ -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)
}
}
+96 -8
View File
@@ -18,6 +18,7 @@ import (
"git.casaderoll.de/michael/urbm/internal/platform" "git.casaderoll.de/michael/urbm/internal/platform"
"git.casaderoll.de/michael/urbm/internal/queue" "git.casaderoll.de/michael/urbm/internal/queue"
"git.casaderoll.de/michael/urbm/internal/restic" "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/scheduler"
"git.casaderoll.de/michael/urbm/internal/store" "git.casaderoll.de/michael/urbm/internal/store"
) )
@@ -32,6 +33,7 @@ type Service struct {
store *store.Store store *store.Store
secrets SecretStore secrets SecretStore
restic *restic.Runner restic *restic.Runner
rsync *rsync.Runner
mounts *platform.MountManager mounts *platform.MountManager
workloads *platform.WorkloadManager workloads *platform.WorkloadManager
notifier *notify.Sender notifier *notify.Sender
@@ -41,12 +43,12 @@ type Service struct {
config model.Config 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() config, err := st.LoadConfig()
if err != nil { if err != nil {
return nil, err 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) { s.queue = queue.New(s.execute, func(runs []model.Run) {
if err := st.SaveRuns(runs); err != nil { if err := st.SaveRuns(runs); err != nil {
log.Error("save run history", "error", err) log.Error("save run history", "error", err)
@@ -112,6 +114,7 @@ func (s *Service) SaveConfig(config model.Config) error {
s.config = config s.config = config
s.mu.Unlock() s.mu.Unlock()
s.restic.Binary = config.Settings.ResticPath s.restic.Binary = config.Settings.ResticPath
s.rsync.Binary = config.Settings.RsyncPath
return nil return nil
} }
@@ -129,7 +132,11 @@ func (s *Service) EnqueueJob(jobID string, priority int) (model.Run, error) {
if !ok { if !ok {
return model.Run{}, errors.New("validation: unknown job") 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) 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) ClearRunHistory() int { return s.queue.ClearHistory() }
func (s *Service) Pause(runID string) error { 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) return fmt.Errorf("pause: %w", err)
} }
if !s.queue.SetPaused(runID, true) { if !s.queue.SetPaused(runID, true) {
_ = s.restic.Resume(runID) _ = resume(runID)
return errors.New("pause: run is not currently running") return errors.New("pause: run is not currently running")
} }
return nil return nil
} }
func (s *Service) Resume(runID string) error { 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) return fmt.Errorf("resume: %w", err)
} }
if !s.queue.SetPaused(runID, false) { if !s.queue.SetPaused(runID, false) {
@@ -189,6 +205,15 @@ func (s *Service) Resume(runID string) error {
return nil 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) { func (s *Service) Snapshots(ctx context.Context, repoID string) ([]model.Snapshot, error) {
repo, ok := s.repository(repoID) repo, ok := s.repository(repoID)
if !ok { if !ok {
@@ -317,6 +342,9 @@ func (s *Service) execute(ctx context.Context, run model.Run) model.Run {
if run.TaskType == "backup" { if run.TaskType == "backup" {
return s.executeBackup(ctx, run) return s.executeBackup(ctx, run)
} }
if run.TaskType == "rsync" {
return s.executeRsync(ctx, run)
}
if run.TaskType == "restore" { if run.TaskType == "restore" {
return s.executeRestore(ctx, run) return s.executeRestore(ctx, run)
} }
@@ -354,6 +382,66 @@ func (s *Service) execute(ctx context.Context, run model.Run) model.Run {
return 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) { func (s *Service) executeBackup(ctx context.Context, run model.Run) (result model.Run) {
job, ok := s.job(run.JobID) job, ok := s.job(run.JobID)
if !ok { if !ok {
@@ -577,7 +665,7 @@ func notificationMessage(run model.Run, now time.Time) string {
lines = append(lines, "Dauer: "+formatDuration(duration)) 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, lines = append(lines,
"Neu gespeichert: "+formatBytes(run.BytesAdded), "Neu gespeichert: "+formatBytes(run.BytesAdded),
fmt.Sprintf("Neue Dateien: %d", run.FilesNew), 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) 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) lines = append(lines, "Details: "+run.Message)
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
+7 -1
View File
@@ -2,13 +2,19 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "urbm"> <!ENTITY name "urbm">
<!ENTITY author "Michael Roll"> <!ENTITY author "Michael Roll">
<!ENTITY version "2026.06.15.r016"> <!ENTITY version "2026.06.15.r017">
<!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg"> <!ENTITY pluginURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm.plg">
<!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz"> <!ENTITY packageURL "https://git.casaderoll.de/michael/URBM/raw/branch/main/dist/urbm-&version;-x86_64-1.txz">
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE"> <!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
]> ]>
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png"> <PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/URBM/issues" icon="urbm.png">
<CHANGES> <CHANGES>
### 2026.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 ### 2026.06.15.r016
- Keep long backup progress statistics inside the progress column and wrap them before they can overlap pause or abort controls. - 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. - Stack progress statistics on narrow displays for reliable activity-table layout.
+4 -4
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r016"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r017">
<div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php"> <div id="urbm-app" data-api="<?= $pluginRoot ?>/api.php">
<header class="bu-header"> <header class="bu-header">
<div class="bu-brand"> <div class="bu-brand">
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r016" alt="URBM-Logo"> <img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r017" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r016</span></h1><p>Restic Backup Manager für Unraid</p></div> <div><h1>URBM <span class="bu-version">2026.06.15.r017</span></h1><p>Restic Backup Manager für Unraid</p></div>
</div> </div>
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div> <div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die URBM-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Verbindung wird hergestellt...</div>
</header> </header>
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div> <div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
</div> </div>
<script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script> <script>window.URBM_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r016.js"></script> <script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r017.js"></script>
+51 -16
View File
@@ -8,7 +8,7 @@
const tooltip = document.getElementById('bu-tooltip'); const tooltip = document.getElementById('bu-tooltip');
const sourceRoots = ['/mnt/user','/mnt/disks','/mnt/remotes','/boot'].map(path=>({name:path,path})); 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 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 = { const fieldHelp = {
name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.', name: 'Frei wählbarer Anzeigename. Beispiel: Appdata täglich oder VM Home Assistant.',
@@ -162,9 +162,9 @@
return dynamic[name] || `${label} ausführen.`; return dynamic[name] || `${label} ausführen.`;
}; };
const statusLabels = {success:'Erfolgreich',warning:'Warnung',failed:'Fehlgeschlagen',running:'Läuft',paused:'Pausiert',queued:'Wartet',cancelled:'Abgebrochen'}; 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 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',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'}; 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','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 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) => `<span class="bu-status ${esc(value)}" tabindex="0"${tipAttr('Aktueller oder endgültiger Status dieses Vorgangs.')}>${esc(statusLabels[value]||value)}</span>`; const status = (value) => `<span class="bu-status ${esc(value)}" tabindex="0"${tipAttr('Aktueller oder endgültiger Status dieses Vorgangs.')}>${esc(statusLabels[value]||value)}</span>`;
const displayMessage = value => { const displayMessage = value => {
const text=String(value||''); const text=String(value||'');
@@ -173,7 +173,7 @@
return messageLabels[text] || translateUserMessage(text); return messageLabels[text] || translateUserMessage(text);
}; };
const runTarget = run => { 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'; if(['check','prune'].includes(run.taskType)) return state.config.repositories.find(repo=>repo.id===run.jobId)?.name || 'Unbekanntes Repository';
return run.jobId || ''; return run.jobId || '';
}; };
@@ -366,9 +366,9 @@
} }
function renderJobs() { function renderJobs() {
const rows = state.config.jobs.map(j => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(typeLabels[j.type]||j.type)}</td><td>${esc(repoName(j.repositoryId))}</td><td>${esc(scheduleLabel(j.schedule?.cron))}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td>${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'))}</td></tr>`).join(''); const rows = state.config.jobs.map(j => `<tr><td><strong>${esc(j.name)}</strong><br><span class="bu-muted">${esc(j.id)}</span></td><td>${esc(typeLabels[j.type]||j.type)}</td><td>${esc(j.type==='rsync'?(j.rsync?.target||'Kein Ziel'):repoName(j.repositoryId))}</td><td>${esc(scheduleLabel(j.schedule?.cron))}</td><td>${j.enabled ? status('success') : status('cancelled')}</td><td>${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'))}</td></tr>`).join('');
return `<div class="bu-toolbar"><div><h2>Backup-Jobs</h2><div class="bu-muted">Mehrere Jobs können dasselbe Restic-Repository verwenden; Snapshots und Aufbewahrung werden per Job-Tag getrennt.</div></div>${button('Neuer Job','new-job','primary')}</div> return `<div class="bu-toolbar"><div><h2>Backup-Jobs</h2><div class="bu-muted">Restic-Backups und direkte Rsync-Kopien werden gemeinsam geplant und überwacht.</div></div>${button('Neuer Job','new-job','primary')}</div>
<section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Job</th><th>Typ</th><th>Repository</th><th>Zeitplan</th><th>Aktiv</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Backup-Jobs konfiguriert.</div>'}</section>`; <section class="bu-card">${rows ? `<table class="bu-table"><thead><tr><th>Job</th><th>Typ</th><th>Repository / Ziel</th><th>Zeitplan</th><th>Aktiv</th><th>Aktionen</th></tr></thead><tbody>${rows}</tbody></table>` : '<div class="bu-empty">Keine Backup-Jobs konfiguriert.</div>'}</section>`;
} }
function jobForm(job = {}) { function jobForm(job = {}) {
@@ -376,16 +376,16 @@
state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId); state.workloadSelections = (job.sources || []).filter(s=>s.workloadId).map(s=>s.workloadId);
state.workloads = []; state.workloadAvailable = true; state.workloadError = ''; state.workloads = []; state.workloadAvailable = true; state.workloadError = '';
state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = ''; state.sourceBrowserPath = '/'; state.sourceDirectories = sourceRoots; state.sourceBrowserError = '';
state.rsyncTargetBrowserPath = '/'; state.rsyncTargetDirectories = repositoryRoots; state.rsyncTargetBrowserError = '';
const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join(''); const options = state.config.repositories.map(r => `<option value="${esc(r.id)}" ${r.id===job.repositoryId?'selected':''}>${esc(r.name)}</option>`).join('');
content.innerHTML = `<section class="bu-card"><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><form id="bu-job-form" class="bu-form"> content.innerHTML = `<section class="bu-card"><h2>Backup-Job ${job.id ? 'bearbeiten' : 'erstellen'}</h2><form id="bu-job-form" class="bu-form">
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label> <input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
<label>Typ<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label> <label>Typ<select name="type">${['share','appdata','docker','vm','flash','rsync'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
<label>Repository<select name="repositoryId" required>${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label> <fieldset id="bu-restic-job-settings" class="wide bu-fieldset"><legend>Restic-Einstellungen</legend><div class="bu-form-grid"><label>Repository<select name="repositoryId">${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label><label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label></div></fieldset>
<label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label>
${scheduleFields(job)} ${scheduleFields(job)}
<div id="bu-job-specific" class="wide"></div> <div id="bu-job-specific" class="wide"></div>
<label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label> <label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
<fieldset class="wide bu-fieldset"><legend>Backup-Aufbewahrung</legend><p class="bu-muted">Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.</p><div class="bu-retention-grid"><label>Alle Backups behalten für Tage<input name="keepWithinDays" type="number" min="0" value="${job.retention?.keepWithinDays ?? 30}"></label><label>Zusätzliche tägliche Backups<input name="keepDaily" type="number" min="0" value="${job.retention?.daily ?? 0}"></label><label>Zusätzliche wöchentliche Backups<input name="keepWeekly" type="number" min="0" value="${job.retention?.weekly ?? 0}"></label><label>Zusätzliche monatliche Backups<input name="keepMonthly" type="number" min="0" value="${job.retention?.monthly ?? 0}"></label></div></fieldset> <fieldset id="bu-retention-settings" class="wide bu-fieldset"><legend>Backup-Aufbewahrung</legend><p class="bu-muted">Snapshots bleiben erhalten, wenn mindestens eine aktivierte Regel zutrifft. Täglich, wöchentlich und monatlich auf 0 setzen, um ausschließlich nach Alter aufzubewahren.</p><div class="bu-retention-grid"><label>Alle Backups behalten für Tage<input name="keepWithinDays" type="number" min="0" value="${job.retention?.keepWithinDays ?? 30}"></label><label>Zusätzliche tägliche Backups<input name="keepDaily" type="number" min="0" value="${job.retention?.daily ?? 0}"></label><label>Zusätzliche wöchentliche Backups<input name="keepWeekly" type="number" min="0" value="${job.retention?.weekly ?? 0}"></label><label>Zusätzliche monatliche Backups<input name="keepMonthly" type="number" min="0" value="${job.retention?.monthly ?? 0}"></label></div></fieldset>
<label><span>Aktiviert</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label> <label><span>Aktiviert</span><input name="enabled" type="checkbox" ${job.enabled !== false?'checked':''}></label>
<div class="wide bu-actions">${button('Speichern','submit-job','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`; <div class="wide bu-actions">${button('Speichern','submit-job','primary')}${button('Abbrechen','cancel-form')}</div></form></section>`;
enhanceTooltips(content); enhanceTooltips(content);
@@ -395,6 +395,9 @@
function renderJobSpecific(type, job = {}) { function renderJobSpecific(type, job = {}) {
const host = document.getElementById('bu-job-specific'); const host = document.getElementById('bu-job-specific');
if (!host) return; 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') { if (type === 'share' || type === 'appdata') {
host.innerHTML = `<h3>${type==='appdata'?'Appdata-Ordner':'Freigabeordner'}</h3><div class="bu-muted">Wähle einen oder mehrere Ordner aus. Zusätzliche Dateifilter können darunter eingetragen werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Pfade<textarea name="sources"></textarea></label>`; host.innerHTML = `<h3>${type==='appdata'?'Appdata-Ordner':'Freigabeordner'}</h3><div class="bu-muted">Wähle einen oder mehrere Ordner aus. Zusätzliche Dateifilter können darunter eingetragen werden.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Pfade<textarea name="sources"></textarea></label>`;
state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user'; state.sourceBrowserPath = type === 'appdata' ? '/mnt/user/appdata' : '/mnt/user';
@@ -403,6 +406,10 @@
} else if (type === 'docker' || type === 'vm') { } else if (type === 'docker' || type === 'vm') {
host.innerHTML = `<h3>${type==='docker'?'Docker-Container':'Virtuelle Maschinen'}</h3><div class="bu-muted">Wähle die zu sichernden Systeme aus. Metadaten und erkannte Bind-Mounts oder virtuelle Datenträger werden automatisch einbezogen.</div><div id="bu-workload-browser" class="bu-browser-card"><div class="bu-empty">Wird geladen...</div></div><div class="bu-workload-options"><label>Konsistenz<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Kontrolliert stoppen</option></select></label><label>Stopp-Zeitlimit (Sekunden)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label></div>`; host.innerHTML = `<h3>${type==='docker'?'Docker-Container':'Virtuelle Maschinen'}</h3><div class="bu-muted">Wähle die zu sichernden Systeme aus. Metadaten und erkannte Bind-Mounts oder virtuelle Datenträger werden automatisch einbezogen.</div><div id="bu-workload-browser" class="bu-browser-card"><div class="bu-empty">Wird geladen...</div></div><div class="bu-workload-options"><label>Konsistenz<select name="consistency"><option value="live">Live</option><option value="stop" ${job.consistency?.mode==='stop'?'selected':''}>Kontrolliert stoppen</option></select></label><label>Stopp-Zeitlimit (Sekunden)<input name="shutdownSecs" type="number" min="1" value="${job.shutdownTimeoutSeconds || 120}"></label></div>`;
loadWorkloads(type); loadWorkloads(type);
} else if (type === 'rsync') {
const o=job.rsync||{};
host.innerHTML = `<h3>Direkte Rsync-Kopie</h3><div class="bu-inline-info">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.</div><div id="bu-source-browser"></div><label class="bu-manual-source">Zusätzliche manuelle Quellpfade<textarea name="sources"></textarea></label><h3>Zielordner</h3><label class="wide">Rsync-Ziel<input name="rsyncTarget" required value="${esc(o.target||'')}" placeholder="/mnt/remotes/backup"></label><div id="bu-rsync-target-browser"></div><fieldset class="wide bu-fieldset"><legend>Rsync-Optionen</legend><div class="bu-event-options"><label><input name="rsyncOverwrite" type="checkbox" ${o.overwrite!==false?'checked':''}><span>Vorhandene Dateien aktualisieren / überschreiben</span></label><label><input name="rsyncDelete" type="checkbox" ${o.delete===true?'checked':''}><span>Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind</span></label><label><input name="rsyncPermissions" type="checkbox" ${o.preservePermissions!==false?'checked':''}><span>Dateiberechtigungen übernehmen</span></label><label><input name="rsyncOwner" type="checkbox" ${o.preserveOwner===true?'checked':''}><span>Besitzer übernehmen</span></label><label><input name="rsyncGroup" type="checkbox" ${o.preserveGroup===true?'checked':''}><span>Gruppen übernehmen</span></label><label><input name="rsyncTimes" type="checkbox" ${o.preserveTimes!==false?'checked':''}><span>Zeitstempel übernehmen</span></label><label><input name="rsyncLinks" type="checkbox" ${o.preserveLinks!==false?'checked':''}><span>Symbolische Links als Links übernehmen</span></label><label><input name="rsyncACLs" type="checkbox" ${o.preserveAcls===true?'checked':''}><span>ACLs übernehmen</span></label><label><input name="rsyncXattrs" type="checkbox" ${o.preserveXattrs===true?'checked':''}><span>Erweiterte Attribute übernehmen</span></label><label><input name="rsyncChecksum" type="checkbox" ${o.checksum===true?'checked':''}><span>Dateien per Prüfsumme vergleichen (langsamer)</span></label><label><input name="rsyncDryRun" type="checkbox" ${o.dryRun===true?'checked':''}><span>Testlauf ohne Änderungen</span></label></div></fieldset>`;
state.sourceBrowserPath='/'; state.sourceDirectories=sourceRoots; state.sourceBrowserError=''; renderSourceBrowser(); renderRsyncTargetBrowser();
} else { } else {
state.backupSelections = ['/boot']; state.backupSelections = ['/boot'];
host.innerHTML = `<div class="bu-info-card"><h3>Unraid-USB-Flash-Backup</h3><p>Das vollständige Konfigurationslaufwerk <strong>/boot</strong> wird immer dateibasiert gesichert.</p><label class="bu-inline-check"><input name="flashImage" type="checkbox" ${job.flashImage===true?'checked':''}><span>Zusätzlich vollständiges USB-Rohabbild <strong>urbm-usb-flash.img</strong> sichern</span></label><p class="bu-muted">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.</p></div>`; host.innerHTML = `<div class="bu-info-card"><h3>Unraid-USB-Flash-Backup</h3><p>Das vollständige Konfigurationslaufwerk <strong>/boot</strong> wird immer dateibasiert gesichert.</p><label class="bu-inline-check"><input name="flashImage" type="checkbox" ${job.flashImage===true?'checked':''}><span>Zusätzlich vollständiges USB-Rohabbild <strong>urbm-usb-flash.img</strong> sichern</span></label><p class="bu-muted">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.</p></div>`;
@@ -454,6 +461,28 @@
} }
} }
function renderRsyncTargetBrowser() {
const host=document.getElementById('bu-rsync-target-browser');
if(!host) return;
const rows=state.rsyncTargetDirectories.map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.path)}</td><td>${button('Öffnen',`open-rsync-target:${encodeURIComponent(item.path)}`)}</td></tr>`).join('');
const error=state.rsyncTargetBrowserError?`<div class="bu-inline-error">${esc(state.rsyncTargetBrowserError)}</div>`:'';
host.innerHTML=`<section class="bu-browser-card"><div class="bu-toolbar"><div><h3>Rsync-Ziel auswählen</h3><span class="bu-muted">${esc(state.rsyncTargetBrowserPath)}</span></div><div class="bu-actions">${state.rsyncTargetBrowserPath!=='/'?button('Diesen Ordner verwenden','select-rsync-target','primary'):''}${state.rsyncTargetBrowserPath!=='/'?button('Nach oben','rsync-target-up'):''}</div></div>${error}<div class="bu-browser-scroll"><table class="bu-table"><thead><tr><th>Name</th><th>Pfad</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan="3" class="bu-empty">Keine Unterordner gefunden.</td></tr>'}</tbody></table></div></section>`;
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() { function renderRepositories() {
const rows = state.config.repositories.map(r => { 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'); 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 `<div class="bu-toolbar"><div><h2>Aktivitäten</h2><div class="bu-muted">Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.</div></div><div class="bu-actions">${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}</div></div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; } function renderActivity() { return `<div class="bu-toolbar"><div><h2>Aktivitäten</h2><div class="bu-muted">Laufende Aufgaben werden automatisch alle zwei Sekunden aktualisiert.</div></div><div class="bu-actions">${button('Aktualisieren','refresh','primary')}${button('Abgeschlossene Aktivitäten löschen','clear-activity','danger')}</div></div><section class="bu-card">${runsTable([...state.runs].reverse())}</section>`; }
function progressView(run) { function progressView(run) {
if(run.status==='queued') return '<span class="bu-muted">Wartet in der Warteschlange</span>'; if(run.status==='queued') return '<span class="bu-muted">Wartet in der Warteschlange</span>';
if(run.taskType!=='backup'&&!run.progressPercent) return '<span class="bu-muted">Kein Prozentwert verfügbar</span>'; if(run.taskType!=='backup'&&run.taskType!=='rsync'&&!run.progressPercent) return '<span class="bu-muted">Kein Prozentwert verfügbar</span>';
const usbImage=run.status==='running'&&run.currentFile==='urbm-usb-flash.img'; const usbImage=run.status==='running'&&run.currentFile==='urbm-usb-flash.img';
if(usbImage) { if(usbImage) {
const details=[]; const details=[];
@@ -613,6 +642,9 @@
if (name === 'open-source-dir') { return loadSourceDirectories(decodeURIComponent(a)); } 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 === '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 === '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 === '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 === '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; } 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 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); 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'))}; 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.'); if(type!=='rsync'&&!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)}; 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(); state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
} }
if (kind === 'submit-repo') { 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-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-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(); } 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(); }
} }