Add scheduled rsync copy jobs
This commit is contained in:
@@ -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
@@ -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 := ¬ify.Sender{Secrets: secretStore}
|
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 {
|
if err != nil {
|
||||||
log.Error("initialize service", "error", err)
|
log.Error("initialize service", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ad6f334a1648fb9b9f37df61ab061da32605c539e3c1933aff6169115ed65fb5 urbm-2026.06.15.r016-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
c4a3955c26ca3e380ae25c93d9d3e5706c65f4457ec6d1418cc1ed72b485cb9b urbm-2026.06.15.r017-x86_64-1.txz
|
||||||
Vendored
+8
-2
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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(); }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user