Release URBM 2026.06.15.r007
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
URBM is a native Unraid 7 plugin that manages encrypted, deduplicated and versioned backups through Restic. It supports shares, appdata, Docker metadata and data, VM definitions and disks, and the Unraid boot drive. Destinations can be local, SFTP, SMB or NFS.
|
URBM is a native Unraid 7 plugin that manages encrypted, deduplicated and versioned backups through Restic. It supports shares, appdata, Docker metadata and data, VM definitions and disks, and the Unraid boot drive. Destinations can be local, SFTP, SMB or NFS.
|
||||||
|
|
||||||
|
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 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
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
b5bcaefc48ac2258d036822d8e735d85f24384ec185720571c5a3a919c71fd70 urbm-2026.06.15.r006-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0 urbm-2026.06.15.r007-x86_64-1.txz
|
||||||
Vendored
+7
-2
@@ -2,13 +2,18 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.15.r006">
|
<!ENTITY version "2026.06.15.r007">
|
||||||
<!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 "b5bcaefc48ac2258d036822d8e735d85f24384ec185720571c5a3a919c71fd70">
|
<!ENTITY packageSHA256 "61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0">
|
||||||
]>
|
]>
|
||||||
<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.r007
|
||||||
|
- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup.
|
||||||
|
- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file.
|
||||||
|
- Detect the whole USB device behind the mounted /boot partition and tag image snapshots for retention and recovery.
|
||||||
|
|
||||||
### 2026.06.15.r006
|
### 2026.06.15.r006
|
||||||
- Place running backup controls directly beside progress instead of beyond an empty message column.
|
- Place running backup controls directly beside progress instead of beyond an empty message column.
|
||||||
- Remove the unnecessary activity-table action column and its horizontal overflow.
|
- Remove the unnecessary activity-table action column and its horizontal overflow.
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type Job struct {
|
|||||||
Compression string `json:"compression"`
|
Compression string `json:"compression"`
|
||||||
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"`
|
||||||
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"`
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import (
|
|||||||
"git.casaderoll.de/michael/urbm/internal/model"
|
"git.casaderoll.de/michael/urbm/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkloadManager struct{ RuntimeDir string }
|
type WorkloadManager struct {
|
||||||
|
RuntimeDir string
|
||||||
|
FindmntPath string
|
||||||
|
LsblkPath string
|
||||||
|
Stat func(string) (os.FileInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
type Workload struct {
|
type Workload struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -149,6 +154,54 @@ func (w *WorkloadManager) Prepare(ctx context.Context, job model.Job) (Prepared,
|
|||||||
return prepared, nil
|
return prepared, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *WorkloadManager) FlashDevice(ctx context.Context) (string, error) {
|
||||||
|
findmnt := w.FindmntPath
|
||||||
|
if findmnt == "" {
|
||||||
|
findmnt = "findmnt"
|
||||||
|
}
|
||||||
|
output, err := exec.CommandContext(ctx, findmnt, "--noheadings", "--output", "SOURCE", "--target", "/boot").Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("source: determine USB flash partition: %w", err)
|
||||||
|
}
|
||||||
|
partition := strings.TrimSpace(string(output))
|
||||||
|
if index := strings.IndexByte(partition, '['); index >= 0 {
|
||||||
|
partition = partition[:index]
|
||||||
|
}
|
||||||
|
partition = filepath.Clean(partition)
|
||||||
|
if !strings.HasPrefix(partition, "/dev/") || strings.ContainsAny(partition, "\r\n\x00") {
|
||||||
|
return "", fmt.Errorf("source: /boot is not backed by a device: %q", partition)
|
||||||
|
}
|
||||||
|
|
||||||
|
lsblk := w.LsblkPath
|
||||||
|
if lsblk == "" {
|
||||||
|
lsblk = "lsblk"
|
||||||
|
}
|
||||||
|
output, err = exec.CommandContext(ctx, lsblk, "--noheadings", "--output", "PKNAME", "--", partition).Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("source: determine USB flash device for %s: %w", partition, err)
|
||||||
|
}
|
||||||
|
parent := strings.TrimSpace(string(output))
|
||||||
|
device := partition
|
||||||
|
if parent != "" {
|
||||||
|
if strings.ContainsAny(parent, "/\r\n\x00") {
|
||||||
|
return "", fmt.Errorf("source: invalid USB flash parent device %q", parent)
|
||||||
|
}
|
||||||
|
device = filepath.Join("/dev", parent)
|
||||||
|
}
|
||||||
|
stat := w.Stat
|
||||||
|
if stat == nil {
|
||||||
|
stat = os.Stat
|
||||||
|
}
|
||||||
|
info, err := stat(device)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("source: access USB flash device %s: %w", device, err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeDevice == 0 {
|
||||||
|
return "", fmt.Errorf("source: USB flash source %s is not a block device", device)
|
||||||
|
}
|
||||||
|
return device, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error {
|
func (w *WorkloadManager) Cleanup(ctx context.Context, prepared Prepared) error {
|
||||||
var first error
|
var first error
|
||||||
for i := len(prepared.Stopped) - 1; i >= 0; i-- {
|
for i := len(prepared.Stopped) - 1; i >= 0; i-- {
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type blockDeviceInfo struct{ name string }
|
||||||
|
|
||||||
|
func (i blockDeviceInfo) Name() string { return i.name }
|
||||||
|
func (blockDeviceInfo) Size() int64 { return 0 }
|
||||||
|
func (blockDeviceInfo) Mode() os.FileMode { return os.ModeDevice }
|
||||||
|
func (blockDeviceInfo) ModTime() time.Time { return time.Time{} }
|
||||||
|
func (blockDeviceInfo) IsDir() bool { return false }
|
||||||
|
func (blockDeviceInfo) Sys() any { return nil }
|
||||||
|
|
||||||
func TestUnraidServiceEnabled(t *testing.T) {
|
func TestUnraidServiceEnabled(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "domain.cfg")
|
path := filepath.Join(t.TempDir(), "domain.cfg")
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
@@ -24,3 +35,32 @@ func TestUnraidServiceEnabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFlashDeviceResolvesWholeDiskBehindBootPartition(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
findmnt := filepath.Join(dir, "findmnt")
|
||||||
|
lsblk := filepath.Join(dir, "lsblk")
|
||||||
|
if err := os.WriteFile(findmnt, []byte("#!/bin/sh\nprintf '/dev/sda1\\n'\n"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(lsblk, []byte("#!/bin/sh\nprintf 'sda\\n'\n"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := &WorkloadManager{
|
||||||
|
FindmntPath: findmnt,
|
||||||
|
LsblkPath: lsblk,
|
||||||
|
Stat: func(path string) (os.FileInfo, error) {
|
||||||
|
if path != "/dev/sda" {
|
||||||
|
t.Fatalf("stat path = %q", path)
|
||||||
|
}
|
||||||
|
return blockDeviceInfo{name: "sda"}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
device, err := w.FlashDevice(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if device != "/dev/sda" {
|
||||||
|
t.Fatalf("device = %q", device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,6 +125,28 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo
|
|||||||
return summary, err
|
return summary, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Runner) BackupImage(ctx context.Context, repo model.Repository, job model.Job, image io.Reader, progress ProgressCallback) (Summary, error) {
|
||||||
|
args := []string{"backup", "--json", "--stdin", "--stdin-filename", "urbm-usb-flash.img", "--compression", job.Compression}
|
||||||
|
for _, tag := range append([]string{"urbm", "job:" + job.ID, "usb-image"}, job.Tags...) {
|
||||||
|
args = append(args, "--tag", tag)
|
||||||
|
}
|
||||||
|
var summary Summary
|
||||||
|
err := r.runWithInput(ctx, repo, args, func(line []byte) {
|
||||||
|
var status Progress
|
||||||
|
if json.Unmarshal(line, &status) == nil && status.MessageType == "status" {
|
||||||
|
if progress != nil {
|
||||||
|
progress(status)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var candidate Summary
|
||||||
|
if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" {
|
||||||
|
summary = candidate
|
||||||
|
}
|
||||||
|
}, nil, image)
|
||||||
|
return summary, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Runner) Forget(ctx context.Context, repo model.Repository, job model.Job) error {
|
func (r *Runner) Forget(ctx context.Context, repo model.Repository, job model.Job) error {
|
||||||
args := []string{"forget", "--tag", "job:" + job.ID}
|
args := []string{"forget", "--tag", "job:" + job.ID}
|
||||||
if job.Retention.KeepWithinDays > 0 {
|
if job.Retention.KeepWithinDays > 0 {
|
||||||
@@ -201,6 +223,10 @@ func (r *Runner) Restore(ctx context.Context, repo model.Repository, task model.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) run(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte) error {
|
func (r *Runner) run(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte) error {
|
||||||
|
return r.runWithInput(ctx, repo, args, onLine, capture, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, input io.Reader) error {
|
||||||
password, err := r.Secrets.Get(repo.PasswordRef)
|
password, err := r.Secrets.Get(repo.PasswordRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("authentication: load repository password: %w", err)
|
return fmt.Errorf("authentication: load repository password: %w", err)
|
||||||
@@ -257,6 +283,7 @@ func (r *Runner) run(ctx context.Context, repo model.Repository, args []string,
|
|||||||
}
|
}
|
||||||
fullArgs := append(globalArgs, args...)
|
fullArgs := append(globalArgs, args...)
|
||||||
cmd := exec.CommandContext(ctx, r.Binary, fullArgs...)
|
cmd := exec.CommandContext(ctx, r.Binary, fullArgs...)
|
||||||
|
cmd.Stdin = input
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
cmd.Cancel = func() error {
|
cmd.Cancel = func() error {
|
||||||
if cmd.Process == nil {
|
if cmd.Process == nil {
|
||||||
|
|||||||
@@ -51,6 +51,37 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
argsPath := filepath.Join(dir, "args")
|
||||||
|
imagePath := filepath.Join(dir, "image")
|
||||||
|
script := filepath.Join(dir, "restic")
|
||||||
|
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"image123\",\"data_added\":4,\"total_bytes_processed\":4,\"total_files_processed\":1}'\n", argsPath, imagePath)
|
||||||
|
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: "flash", Compression: "auto"}
|
||||||
|
summary, err := runner.BackupImage(context.Background(), repo, job, strings.NewReader("disk"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if summary.SnapshotID != "image123" || summary.TotalBytesProcessed != 4 {
|
||||||
|
t.Fatalf("unexpected summary: %+v", summary)
|
||||||
|
}
|
||||||
|
args, _ := os.ReadFile(argsPath)
|
||||||
|
for _, expected := range []string{"--stdin", "--stdin-filename", "urbm-usb-flash.img", "usb-image"} {
|
||||||
|
if !strings.Contains(string(args), expected) {
|
||||||
|
t.Fatalf("arguments missing %q: %s", expected, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image, _ := os.ReadFile(imagePath)
|
||||||
|
if string(image) != "disk" {
|
||||||
|
t.Fatalf("streamed image = %q", image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestForgetUsesAgeAndCalendarRetention(t *testing.T) {
|
func TestForgetUsesAgeAndCalendarRetention(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
argsPath := filepath.Join(dir, "args")
|
argsPath := filepath.Join(dir, "args")
|
||||||
|
|||||||
@@ -329,6 +329,39 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
|
|||||||
return failed(run, "source", "source unavailable: "+path)
|
return failed(run, "source", "source unavailable: "+path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var imageSummary restic.Summary
|
||||||
|
if job.Type == model.JobFlash && job.FlashImage {
|
||||||
|
device, err := s.workloads.FlashDevice(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return failedError(run, err)
|
||||||
|
}
|
||||||
|
image, err := os.Open(device)
|
||||||
|
if err != nil {
|
||||||
|
return failed(run, "source", "open USB flash device: "+err.Error())
|
||||||
|
}
|
||||||
|
appendLiveLog(&live, "USB-Rohabbild wird direkt aus "+device+" zu Restic übertragen")
|
||||||
|
s.updateActive(live)
|
||||||
|
imageSummary, err = s.restic.BackupImage(ctx, mounted.Repository, job, image, func(progress restic.Progress) {
|
||||||
|
live.ProgressPercent = progress.PercentDone * 100
|
||||||
|
live.ProgressBytes, live.ProgressTotal = progress.BytesDone, progress.TotalBytes
|
||||||
|
live.ProgressFiles, live.ProgressFileTotal = progress.FilesDone, progress.TotalFiles
|
||||||
|
live.SecondsRemaining = progress.SecondsRemaining
|
||||||
|
live.CurrentFile = "urbm-usb-flash.img"
|
||||||
|
s.updateActive(live)
|
||||||
|
})
|
||||||
|
closeErr := image.Close()
|
||||||
|
if err != nil {
|
||||||
|
appendLiveLog(&live, "USB-Rohabbild fehlgeschlagen: "+err.Error())
|
||||||
|
failedRun := failedError(run, err)
|
||||||
|
failedRun.LiveLog = live.LiveLog
|
||||||
|
return failedRun
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
return failed(run, "source", "close USB flash device: "+closeErr.Error())
|
||||||
|
}
|
||||||
|
appendLiveLog(&live, "USB-Rohabbild als urbm-usb-flash.img gesichert")
|
||||||
|
s.updateActive(live)
|
||||||
|
}
|
||||||
appendLiveLog(&live, fmt.Sprintf("Restic-Backup gestartet: %d Quelle(n)", len(prepared.Sources)))
|
appendLiveLog(&live, fmt.Sprintf("Restic-Backup gestartet: %d Quelle(n)", len(prepared.Sources)))
|
||||||
s.updateActive(live)
|
s.updateActive(live)
|
||||||
lastLoggedPercent := -5
|
lastLoggedPercent := -5
|
||||||
@@ -364,8 +397,12 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
|
|||||||
} else {
|
} else {
|
||||||
run.Status, run.Message = "success", "backup completed"
|
run.Status, run.Message = "success", "backup completed"
|
||||||
}
|
}
|
||||||
run.SnapshotID, run.BytesAdded, run.FilesNew, run.FilesChanged = summary.SnapshotID, summary.DataAdded, summary.FilesNew, summary.FilesChanged
|
run.SnapshotID = summary.SnapshotID
|
||||||
run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed
|
run.BytesAdded = summary.DataAdded + imageSummary.DataAdded
|
||||||
|
run.FilesNew = summary.FilesNew + imageSummary.FilesNew
|
||||||
|
run.FilesChanged = summary.FilesChanged + imageSummary.FilesChanged
|
||||||
|
run.BytesProcessed = summary.TotalBytesProcessed + imageSummary.TotalBytesProcessed
|
||||||
|
run.FilesProcessed = summary.TotalFilesProcessed + imageSummary.TotalFilesProcessed
|
||||||
run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, live.ProgressBytes, live.ProgressTotal
|
run.ProgressPercent, run.ProgressBytes, run.ProgressTotal = 100, live.ProgressBytes, live.ProgressTotal
|
||||||
run.ProgressFiles, run.ProgressFileTotal = live.ProgressFiles, live.ProgressFileTotal
|
run.ProgressFiles, run.ProgressFileTotal = live.ProgressFiles, live.ProgressFileTotal
|
||||||
run.BytesPerSecond, run.SecondsRemaining, run.CurrentFile = live.BytesPerSecond, 0, live.CurrentFile
|
run.BytesPerSecond, run.SecondsRemaining, run.CurrentFile = live.BytesPerSecond, 0, live.CurrentFile
|
||||||
|
|||||||
+6
-1
@@ -2,13 +2,18 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "urbm">
|
<!ENTITY name "urbm">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.15.r006">
|
<!ENTITY version "2026.06.15.r007">
|
||||||
<!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.r007
|
||||||
|
- Add an optional raw USB flash image to flash backup jobs while retaining the normal browsable /boot backup.
|
||||||
|
- Stream the physical boot device directly into Restic as urbm-usb-flash.img without a temporary full-size image file.
|
||||||
|
- Detect the whole USB device behind the mounted /boot partition and tag image snapshots for retention and recovery.
|
||||||
|
|
||||||
### 2026.06.15.r006
|
### 2026.06.15.r006
|
||||||
- Place running backup controls directly beside progress instead of beyond an empty message column.
|
- Place running backup controls directly beside progress instead of beyond an empty message column.
|
||||||
- Remove the unnecessary activity-table action column and its horizontal overflow.
|
- Remove the unnecessary activity-table action column and its horizontal overflow.
|
||||||
|
|||||||
+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=20260615r006">
|
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r007">
|
||||||
<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=20260615r006" alt="URBM-Logo">
|
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r007" alt="URBM-Logo">
|
||||||
<div><h1>URBM <span class="bu-version">2026.06.15.r006</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
<div><h1>URBM <span class="bu-version">2026.06.15.r007</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.r006.js"></script>
|
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r007.js"></script>
|
||||||
|
|||||||
@@ -368,7 +368,7 @@
|
|||||||
loadWorkloads(type);
|
loadWorkloads(type);
|
||||||
} 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 gesichert. Es ist keine zusätzliche Quellenauswahl erforderlich.</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>`;
|
||||||
}
|
}
|
||||||
enhanceTooltips(host);
|
enhanceTooltips(host);
|
||||||
}
|
}
|
||||||
@@ -590,7 +590,7 @@
|
|||||||
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(!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'),excludes:lines(f.get('excludes')),tags:existing?.tags||[],retention,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:f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:f.get('compression'),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)};
|
||||||
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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user