diff --git a/dist/urbm-2026.06.15.r022-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r022-x86_64-1.txz.sha256 deleted file mode 100644 index 5889b6e..0000000 --- a/dist/urbm-2026.06.15.r022-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -ad334b4da853736a6583a0a96e11d3d196a4b22275b95dc0eb0e624a66f77bdb urbm-2026.06.15.r022-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r022-x86_64-1.txz b/dist/urbm-2026.06.15.r023-x86_64-1.txz similarity index 56% rename from dist/urbm-2026.06.15.r022-x86_64-1.txz rename to dist/urbm-2026.06.15.r023-x86_64-1.txz index 3f1c0ec..34d28f3 100644 Binary files a/dist/urbm-2026.06.15.r022-x86_64-1.txz and b/dist/urbm-2026.06.15.r023-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r023-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r023-x86_64-1.txz.sha256 new file mode 100644 index 0000000..e1afbc3 --- /dev/null +++ b/dist/urbm-2026.06.15.r023-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +e7a53aae8a052d41025771b79811176c9570f6ac767734e8ef77fa24fcea0bca urbm-2026.06.15.r023-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index eaab663..f94e8ee 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,17 @@ - + - + ]> +### 2026.06.15.r023 +- Add a per-job Restic file read concurrency setting for faster backups from SSD and NVMe sources. +- Keep zero as the backward-compatible Restic default and validate custom values from 1 through 64. + ### 2026.06.15.r022 - Replace the source folder table with a lazy-loaded expandable tree for Restic and Rsync jobs. - Add rotating disclosure triangles, nested indentation, and per-folder loading, empty, retry, and error states. diff --git a/internal/model/model.go b/internal/model/model.go index 8be0eb7..e7f3835 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -47,24 +47,25 @@ type Settings struct { } type Job struct { - SchemaVersion int `json:"schemaVersion"` - ID string `json:"id"` - Name string `json:"name"` - Type JobType `json:"type"` - Enabled bool `json:"enabled"` - RepositoryID string `json:"repositoryId"` - Sources []Source `json:"sources"` - Schedule Schedule `json:"schedule"` - Consistency Consistency `json:"consistency"` - Compression string `json:"compression"` - CPUCores int `json:"cpuCores,omitempty"` - Excludes []string `json:"excludes,omitempty"` - Tags []string `json:"tags,omitempty"` - FlashImage bool `json:"flashImage,omitempty"` - Retention Retention `json:"retention"` - NotifyOn []string `json:"notifyOn,omitempty"` - ShutdownSecs int `json:"shutdownTimeoutSeconds"` - Rsync RsyncOptions `json:"rsync,omitempty"` + SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + Name string `json:"name"` + Type JobType `json:"type"` + Enabled bool `json:"enabled"` + RepositoryID string `json:"repositoryId"` + Sources []Source `json:"sources"` + Schedule Schedule `json:"schedule"` + Consistency Consistency `json:"consistency"` + Compression string `json:"compression"` + CPUCores int `json:"cpuCores,omitempty"` + ReadConcurrency int `json:"readConcurrency,omitempty"` + Excludes []string `json:"excludes,omitempty"` + Tags []string `json:"tags,omitempty"` + FlashImage bool `json:"flashImage,omitempty"` + Retention Retention `json:"retention"` + NotifyOn []string `json:"notifyOn,omitempty"` + ShutdownSecs int `json:"shutdownTimeoutSeconds"` + Rsync RsyncOptions `json:"rsync,omitempty"` } type RsyncOptions struct { diff --git a/internal/model/validate.go b/internal/model/validate.go index c99dab4..0cca932 100644 --- a/internal/model/validate.go +++ b/internal/model/validate.go @@ -138,6 +138,9 @@ func ValidateJob(j Job) error { if j.CPUCores < 0 || j.CPUCores > 256 { return errors.New("cpuCores must be between 0 and 256") } + if j.ReadConcurrency < 0 || j.ReadConcurrency > 64 { + return errors.New("readConcurrency must be between 0 and 64") + } if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" { return errors.New("consistency mode must be live or stop") } diff --git a/internal/model/validate_test.go b/internal/model/validate_test.go index be21882..89e6643 100644 --- a/internal/model/validate_test.go +++ b/internal/model/validate_test.go @@ -92,6 +92,18 @@ func TestBackupCPUCoresRange(t *testing.T) { } } +func TestBackupReadConcurrencyRange(t *testing.T) { + job := Job{SchemaVersion: 1, ID: "job", Name: "Share", Type: JobShare, RepositoryID: "repo", Sources: []Source{{Path: "/mnt/user/data"}}, Consistency: Consistency{Mode: "live"}, Compression: "auto", Retention: Retention{KeepWithinDays: 30}} + job.ReadConcurrency = 8 + if err := ValidateJob(job); err != nil { + t.Fatalf("valid read concurrency rejected: %v", err) + } + job.ReadConcurrency = 65 + if err := ValidateJob(job); err == nil { + t.Fatal("excessive read concurrency 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}}} diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 13cde7e..5afc2e9 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -121,6 +121,9 @@ func (r *Runner) TestPassword(ctx context.Context, repo model.Repository, passwo func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Job, sources []string, progress ProgressCallback) (Summary, error) { args := []string{"backup", "--json", "--compression", job.Compression} + if job.ReadConcurrency > 0 { + args = append(args, "--read-concurrency", fmt.Sprint(job.ReadConcurrency)) + } for _, tag := range append([]string{"urbm", "job:" + job.ID}, job.Tags...) { args = append(args, "--tag", tag) } diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index e159392..9697565 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -110,6 +110,29 @@ func TestBackupLimitsCPUForThisJob(t *testing.T) { } } +func TestBackupSetsReadConcurrencyForThisJob(t *testing.T) { + dir := t.TempDir() + argsPath := filepath.Join(dir, "args") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"read123\"}'\n", argsPath) + if err := os.WriteFile(script, []byte(body), 0700); err != nil { + t.Fatal(err) + } + runner := &Runner{Binary: script, RuntimeDir: dir, Secrets: fakeSecrets{"password": "secret"}} + repo := model.Repository{Type: model.RepositoryLocal, Location: "/repo", PasswordRef: "password"} + job := model.Job{ID: "job", Compression: "auto", ReadConcurrency: 8} + if _, err := runner.Backup(context.Background(), repo, job, []string{"/source"}, nil); err != nil { + t.Fatal(err) + } + args, err := os.ReadFile(argsPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(args), "--read-concurrency\n8\n") { + t.Fatalf("read concurrency missing from arguments: %s", args) + } +} + func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") diff --git a/plugin/urbm.plg b/plugin/urbm.plg index bf94309..d3a3c8f 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,17 @@ - + ]> +### 2026.06.15.r023 +- Add a per-job Restic file read concurrency setting for faster backups from SSD and NVMe sources. +- Keep zero as the backward-compatible Restic default and validate custom values from 1 through 64. + ### 2026.06.15.r022 - Replace the source folder table with a lazy-loaded expandable tree for Restic and Rsync jobs. - Add rotating disclosure triangles, nested indentation, and per-folder loading, empty, retry, and error states. diff --git a/webgui/URBM.page b/webgui/URBM.page index 36749a2..772a046 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.15.r022

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r023

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 1151cd7..5b73f54 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -20,6 +20,7 @@ consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.', compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.', cpuCores: 'Begrenzt diesen Backup-Lauf über GOMAXPROCS auf die angegebene Anzahl CPU-Kerne. 0 verwendet wie bisher alle verfügbaren Kerne.', + readConcurrency: 'Anzahl der Dateien, die Restic gleichzeitig liest. Höhere Werte können SSD- und NVMe-Quellen mit vielen kleinen Dateien beschleunigen, bei HDDs aber langsamer sein. 0 verwendet den Restic-Standard.', sources: 'Optionale manuelle Quellen, eine pro Zeile. Ordner wählst du einfacher über die Checkbox-Liste. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.', excludes: 'Muster für Dateien, die nicht gesichert werden. Ein Muster pro Zeile. Beispiele: *.tmp, cache/** oder /mnt/user/appdata/plex/Cache/**.', shutdownSecs: 'Maximale Wartezeit für einen kontrollierten Stopp. Beispiel: 120 wartet bis zu zwei Minuten; danach wird das Backup ohne Force-Stop abgebrochen.', @@ -388,7 +389,7 @@ content.innerHTML = `

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

-
Restic-Einstellungen
+
Restic-Einstellungen
${scheduleFields(job)}
@@ -735,7 +736,7 @@ 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; if(type==='rsync'&&rsync.skipSpaceCheck&&!confirm('Die Kapazitätsprüfung wirklich abschalten? Der Rsync-Lauf kann dann das Ziel vollständig füllen und mit einem Schreibfehler abbrechen.')) 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)}; + 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),readConcurrency:type==='rsync'?0:Number(f.get('readConcurrency')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); } if (kind === 'submit-repo') {