Release 2026.09.27.r001: support array disk rsync backups to mounted USB disks
This commit is contained in:
@@ -57,3 +57,19 @@ paused/restarting states fail safely. Each restart has a 120-second deadline and
|
||||
recovery continues after individual failures. Shutdown waits for cleanup; the rc
|
||||
script refuses a forced kill or overlapping restart if cleanup exceeds five minutes.
|
||||
Hard crashes or power loss still require checking workload state and backup results.
|
||||
|
||||
### Mirror an array disk to a mounted USB disk (2026.09.27.r001)
|
||||
|
||||
Create an Rsync job and select the mounted array disk, for example `/mnt/disk1`,
|
||||
as the source. Select the USB disk under `/mnt/disks`, for example
|
||||
`/mnt/disks/USB-Backup`, as the target. URBM copies the selected directory, so this
|
||||
creates `/mnt/disks/USB-Backup/disk1` and copies its contents there. Select the USB
|
||||
root, not its existing `disk1` subdirectory, to avoid nesting another `disk1`.
|
||||
|
||||
Enable overwrite to update existing files. Enable destination deletion for a
|
||||
mirror that also removes files deleted from the source; deletion is off by default.
|
||||
Use the dry-run option to preview a job without changing files. The USB disk must
|
||||
already be mounted, for example through Unassigned Devices. URBM checks array and
|
||||
USB mounts before starting the job and refuses leftover directories after an
|
||||
unmount. Keep the USB disk connected throughout the run. Reload the page after
|
||||
mounting a new array disk to refresh the available sources.
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
e66e3d540fba172a1e0b8e60f7b36e33b38ba8357e5a45a07879a32c2d655222 urbm-2026.09.27.r001-x86_64-1.txz
|
||||
Vendored
+8
-2
@@ -2,13 +2,19 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.09.21.r001">
|
||||
<!ENTITY version "2026.09.27.r001">
|
||||
<!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 packageSHA256 "55d48398ecd446793f51c0fcc987994ba77e52ac950dbc7f5a32f983449e5b83">
|
||||
<!ENTITY packageSHA256 "e66e3d540fba172a1e0b8e60f7b36e33b38ba8357e5a45a07879a32c2d655222">
|
||||
]>
|
||||
<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>
|
||||
### 2026.09.27.r001
|
||||
- Discover mounted array disks as selectable Rsync sources, including complete disks.
|
||||
- Show directory symlinks only when they resolve within allowed storage roots.
|
||||
- Refuse Rsync disk jobs when the array source or USB destination is not mounted.
|
||||
- Document array-to-USB mirroring and cover disk discovery, mount checks and safe browsing with regression tests.
|
||||
|
||||
### 2026.09.21.r001
|
||||
- Notify on successful and failed repository checks; complete notification attempts before finishing a task.
|
||||
- Preserve stopped Docker/VM workloads and refuse ambiguous paused or restarting states.
|
||||
|
||||
@@ -154,3 +154,10 @@ func TestValidateConfigRejectsUnsafeRestoreRoot(t *testing.T) {
|
||||
t.Fatalf("safe restoreRoot rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRsyncArrayDiskToUSB(t *testing.T) {
|
||||
job := Job{SchemaVersion: 1, ID: "disk-mirror", Name: "Array to USB", Type: JobRsync, Sources: []Source{{Path: "/mnt/disk1"}}, Rsync: RsyncOptions{Target: "/mnt/disks/USB Backup", Overwrite: true, Delete: true}}
|
||||
if err := ValidateJob(job); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,12 @@ type DirectoryEntry struct {
|
||||
var browseRoots = []string{"/mnt/user", "/mnt/disks", "/mnt/remotes", "/boot"}
|
||||
|
||||
func BrowseDirectories(path string) ([]DirectoryEntry, error) {
|
||||
clean, err := validateBrowsePath(path)
|
||||
mounts, _ := mountedPaths()
|
||||
return browseDirectories(path, storageBrowseRoots(mounts))
|
||||
}
|
||||
|
||||
func browseDirectories(path string, roots []string) ([]DirectoryEntry, error) {
|
||||
clean, err := validateBrowsePathWithRoots(path, roots)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -26,17 +31,29 @@ func BrowseDirectories(path string) ([]DirectoryEntry, error) {
|
||||
}
|
||||
result := make([]DirectoryEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
|
||||
result = append(result, DirectoryEntry{Name: entry.Name(), Path: filepath.Join(clean, entry.Name())})
|
||||
child := filepath.Join(clean, entry.Name())
|
||||
if entry.Type()&os.ModeSymlink != 0 {
|
||||
if _, err := validateBrowsePathWithRoots(child, roots); err != nil {
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(child)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
} else if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
result = append(result, DirectoryEntry{Name: entry.Name(), Path: child})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return strings.ToLower(result[i].Name) < strings.ToLower(result[j].Name) })
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BrowseRoots() []DirectoryEntry {
|
||||
result := make([]DirectoryEntry, 0, len(browseRoots))
|
||||
for _, root := range browseRoots {
|
||||
mounts, _ := mountedPaths()
|
||||
roots := storageBrowseRoots(mounts)
|
||||
result := make([]DirectoryEntry, 0, len(roots))
|
||||
for _, root := range roots {
|
||||
if info, err := os.Stat(root); err == nil && info.IsDir() {
|
||||
result = append(result, DirectoryEntry{Name: root, Path: root})
|
||||
}
|
||||
@@ -45,6 +62,11 @@ func BrowseRoots() []DirectoryEntry {
|
||||
}
|
||||
|
||||
func validateBrowsePath(path string) (string, error) {
|
||||
mounts, _ := mountedPaths()
|
||||
return validateBrowsePathWithRoots(path, storageBrowseRoots(mounts))
|
||||
}
|
||||
|
||||
func validateBrowsePathWithRoots(path string, roots []string) (string, error) {
|
||||
if path == "" || path == "/" {
|
||||
return "", errors.New("validation: select an allowed browse root")
|
||||
}
|
||||
@@ -53,7 +75,7 @@ func validateBrowsePath(path string) (string, error) {
|
||||
return "", errors.New("validation: browse path must be absolute")
|
||||
}
|
||||
allowed := false
|
||||
for _, root := range browseRoots {
|
||||
for _, root := range roots {
|
||||
if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||||
allowed = true
|
||||
break
|
||||
@@ -66,9 +88,9 @@ func validateBrowsePath(path string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, root := range browseRoots {
|
||||
for _, root := range roots {
|
||||
if resolved == root || strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
|
||||
return resolved, nil
|
||||
return clean, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("validation: browse path resolves outside allowed Unraid storage roots")
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package platform
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) {
|
||||
for _, path := range []string{"", "/", "/etc", "/mnt/user/../../etc", "relative"} {
|
||||
@@ -9,3 +14,69 @@ func TestValidateBrowsePathRejectsUnsafePaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseDirectoriesIncludesSafeLinks(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := t.TempDir()
|
||||
for _, name := range []string{"disk1", "USB"} {
|
||||
if err := os.Mkdir(filepath.Join(root, name), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "file"), nil, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, target := range map[string]string{"alias": "USB", "escape": outside, "broken": "missing", "file-link": "file"} {
|
||||
if err := os.Symlink(target, filepath.Join(root, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, err := browseDirectories(root, []string{root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, item := range items {
|
||||
names = append(names, item.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"alias", "disk1", "USB"}) {
|
||||
t.Fatalf("entries = %v", names)
|
||||
}
|
||||
if _, err := browseDirectories(filepath.Join(root, "alias"), []string{root}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := browseDirectories(filepath.Join(root, "escape"), []string{root}); err == nil {
|
||||
t.Fatal("escaped storage root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageBrowseRootsOnlyMountedArrayDisks(t *testing.T) {
|
||||
mounts := []string{"/mnt/disk1", "/mnt/disk12", "/mnt/disk1", "/mnt/disk0", "/mnt/disk01", "/mnt/disk1-extra", "/mnt/disk2/subdir", "/etc", "/mnt/disks/USB"}
|
||||
want := append(append([]string{}, browseRoots...), "/mnt/disk1", "/mnt/disk12")
|
||||
if got := storageBrowseRoots(mounts); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("roots = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMountPathsAndUnmountedDisks(t *testing.T) {
|
||||
mounts := parseMountPaths("36 25 8:1 / /mnt/disk1 rw - xfs /dev/md1 rw\n37 25 8:2 / /mnt/disks/USB\\040Backup rw - xfs /dev/sdb1 rw\n")
|
||||
if !reflect.DeepEqual(mounts, []string{"/mnt/disk1", "/mnt/disks/USB Backup"}) {
|
||||
t.Fatalf("mounts = %v", mounts)
|
||||
}
|
||||
for _, path := range []string{"/mnt/disk1", "/mnt/disk1/data", "/mnt/disks/USB Backup", "/mnt/disks/USB Backup/disk1"} {
|
||||
if !diskPathMounted(path, mounts) {
|
||||
t.Fatalf("mounted path rejected: %s", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/mnt/disk2", "/mnt/disk10", "/mnt/disks", "/mnt/disks/USB Backup-old", "/mnt/disks/unmounted"} {
|
||||
if diskPathMounted(path, mounts) {
|
||||
t.Fatalf("unmounted path accepted: %s", path)
|
||||
}
|
||||
}
|
||||
if diskPathMounted("/mnt/disks/USB", []string{"/", "/mnt", "/mnt/disks"}) {
|
||||
t.Fatal("parent mount accepted as USB mount")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,19 @@ import (
|
||||
)
|
||||
|
||||
func ValidateRsyncPaths(job model.Job) error {
|
||||
needsMountCheck := pathUnder(filepath.Clean(job.Rsync.Target), "/mnt/disks")
|
||||
for _, source := range job.Sources {
|
||||
needsMountCheck = needsMountCheck || isArrayDiskPath(source.Path) || pathUnder(filepath.Clean(source.Path), "/mnt/disks")
|
||||
}
|
||||
if needsMountCheck {
|
||||
mounts, err := mountedPaths()
|
||||
if err != nil {
|
||||
return errors.New("environment: cannot inspect rsync disk mounts: " + err.Error())
|
||||
}
|
||||
if err := validateRsyncMounts(job, mounts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
target, err := filepath.EvalSymlinks(job.Rsync.Target)
|
||||
if err != nil {
|
||||
return errors.New("validation: resolve rsync target: " + err.Error())
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.casaderoll.de/michael/urbm/internal/model"
|
||||
)
|
||||
|
||||
var arrayDiskName = regexp.MustCompile(`^disk[1-9][0-9]*$`)
|
||||
|
||||
func isArrayDiskPath(path string) bool {
|
||||
parts := strings.Split(strings.TrimPrefix(filepath.Clean(path), "/"), "/")
|
||||
return len(parts) >= 2 && parts[0] == "mnt" && arrayDiskName.MatchString(parts[1])
|
||||
}
|
||||
|
||||
func mountedPaths() ([]string, error) {
|
||||
data, err := os.ReadFile("/proc/self/mountinfo")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseMountPaths(string(data)), nil
|
||||
}
|
||||
|
||||
func parseMountPaths(data string) []string {
|
||||
var paths []string
|
||||
unescape := strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`)
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 10 {
|
||||
paths = append(paths, unescape.Replace(fields[4]))
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func storageBrowseRoots(mounts []string) []string {
|
||||
roots := append([]string{}, browseRoots...)
|
||||
seen := make(map[string]bool)
|
||||
for _, mount := range mounts {
|
||||
if filepath.Dir(mount) == "/mnt" && isArrayDiskPath(mount) && !seen[mount] {
|
||||
roots = append(roots, mount)
|
||||
seen[mount] = true
|
||||
}
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
func pathUnder(path, root string) bool {
|
||||
return path == root || strings.HasPrefix(path, root+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// Require an actual mount for removable disks and array sources. An empty
|
||||
// directory left behind by an unmount must never become a backup destination.
|
||||
func validateRsyncMounts(job model.Job, mounts []string) error {
|
||||
paths := []string{job.Rsync.Target}
|
||||
for _, source := range job.Sources {
|
||||
paths = append(paths, source.Path)
|
||||
}
|
||||
for _, path := range paths {
|
||||
clean := filepath.Clean(path)
|
||||
if !isArrayDiskPath(clean) && !pathUnder(clean, "/mnt/disks") {
|
||||
continue
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(clean)
|
||||
if err != nil {
|
||||
return fmt.Errorf("environment: rsync disk unavailable: %s: %w", clean, err)
|
||||
}
|
||||
if !diskPathMounted(resolved, mounts) {
|
||||
return fmt.Errorf("environment: rsync disk is not mounted: %s", clean)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func diskPathMounted(resolved string, mounts []string) bool {
|
||||
for _, mount := range mounts {
|
||||
if ((isArrayDiskPath(mount) && filepath.Dir(mount) == "/mnt") || strings.HasPrefix(mount, "/mnt/disks/")) && pathUnder(resolved, mount) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -23,3 +23,11 @@ func TestParseProgress(t *testing.T) {
|
||||
t.Fatalf("progress = %#v, %v", progress, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayDiskMirrorKeepsDiskDirectory(t *testing.T) {
|
||||
job := model.Job{Sources: []model.Source{{Path: "/mnt/disk1"}}, Rsync: model.RsyncOptions{Target: "/mnt/disks/USB Backup", Overwrite: true, Delete: true}}
|
||||
args := Arguments(job)
|
||||
if !slices.Equal(args[len(args)-3:], []string{"--", "/mnt/disk1", "/mnt/disks/USB Backup/"}) || !slices.Contains(args, "--delete-delay") {
|
||||
t.Fatalf("mirror arguments = %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -2,13 +2,19 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.09.21.r001">
|
||||
<!ENTITY version "2026.09.27.r001">
|
||||
<!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 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">
|
||||
<CHANGES>
|
||||
### 2026.09.27.r001
|
||||
- Discover mounted array disks as selectable Rsync sources, including complete disks.
|
||||
- Show directory symlinks only when they resolve within allowed storage roots.
|
||||
- Refuse Rsync disk jobs when the array source or USB destination is not mounted.
|
||||
- Document array-to-USB mirroring and cover disk discovery, mount checks and safe browsing with regression tests.
|
||||
|
||||
### 2026.09.21.r001
|
||||
- Notify on successful and failed repository checks; complete notification attempts before finishing a task.
|
||||
- Preserve stopped Docker/VM workloads and refuse ambiguous paused or restarting states.
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
||||
---
|
||||
<?php
|
||||
$pluginRoot = '/plugins/urbm';
|
||||
$urbmVersion = '2026.09.21.r001';
|
||||
$urbmVersion = '2026.09.27.r001';
|
||||
$urbmAssetVersion = preg_replace('/[^A-Za-z0-9]/', '', $urbmVersion);
|
||||
?>
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=<?= $urbmAssetVersion ?>">
|
||||
|
||||
@@ -406,9 +406,10 @@
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [configResult,runsResult,metricsResult,statusResult,daemonResult] = await Promise.allSettled([api('/v1/config'),api('/v1/runs'),api('/v1/backup-metrics'),api('/v1/dashboard-status'),api('/v1/health')]);
|
||||
const [configResult,runsResult,metricsResult,statusResult,daemonResult,rootsResult] = await Promise.allSettled([api('/v1/config'),api('/v1/runs'),api('/v1/backup-metrics'),api('/v1/dashboard-status'),api('/v1/health'),api('/v1/filesystem/directories')]);
|
||||
if(configResult.status!=='fulfilled') throw configResult.reason;
|
||||
state.config=configResult.value;
|
||||
if(rootsResult.status==='fulfilled' && Array.isArray(rootsResult.value)) sourceRoots.splice(0,sourceRoots.length,...rootsResult.value);
|
||||
if(runsResult.status==='fulfilled'){state.runs=newestRuns(Array.isArray(runsResult.value)?runsResult.value:[]);state.runsError='';}else state.runsError=runsResult.reason?.message||'Aktivitäten konnten nicht geladen werden.';
|
||||
if(metricsResult.status==='fulfilled'){state.backupMetrics=Array.isArray(metricsResult.value)?metricsResult.value:[];state.backupMetricsError='';}else state.backupMetricsError=metricsResult.reason?.message||'Diagrammdaten konnten nicht geladen werden.';
|
||||
if(statusResult.status==='fulfilled'){state.dashboardStatus=statusResult.value;state.dashboardStatusError='';lastDashboardStatusRefresh=Date.now();}else state.dashboardStatusError=statusResult.reason?.message||'Backup-Status konnte nicht geladen werden.';
|
||||
@@ -554,7 +555,7 @@
|
||||
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-dir-tree-rsyncTarget"></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>`;
|
||||
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 (z. B. /mnt/disk1 nach /mnt/disks/USB-Backup/disk1). Array-Disks erscheinen als eigene Quellen, gemountete USB-Disks unter /mnt/disks. 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-dir-tree-rsyncTarget"></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>`;
|
||||
host.querySelector('.bu-event-options').insertAdjacentHTML('beforeend', `<label><input name="rsyncSkipSpaceCheck" type="checkbox" ${o.skipSpaceCheck===true?'checked':''}><span>Kapazitätsprüfung überspringen (riskant; nur wenn vorhandene Zieldateien ausreichend Platz freigeben)</span></label>`);
|
||||
initSourceTree(sourceRoots.map(item=>item.path), false); initDirectoryTree('rsyncTarget','Rsync-Ziel auswählen','#bu-job-form [name="rsyncTarget"]');
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user