Release URBM 2026.06.15.r007

This commit is contained in:
Mikei386
2026-06-15 07:14:24 +02:00
parent 706f8b997b
commit b0fa11a40a
14 changed files with 214 additions and 13 deletions
+2
View File
@@ -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
View File
@@ -1 +0,0 @@
b5bcaefc48ac2258d036822d8e735d85f24384ec185720571c5a3a919c71fd70 urbm-2026.06.15.r006-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
61d61cf633884a645c15400665fc352637f641d93bd9a25594fb3e9659d72fe0 urbm-2026.06.15.r007-x86_64-1.txz
+7 -2
View File
@@ -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.
+1
View File
@@ -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"`
+54 -1
View File
@@ -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-- {
+40
View File
@@ -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)
}
}
+27
View File
@@ -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 {
+31
View File
@@ -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")
+39 -2
View File
@@ -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
View File
@@ -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
View File
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
<?php <?php
$pluginRoot = '/plugins/urbm'; $pluginRoot = '/plugins/urbm';
?> ?>
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=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>
+2 -2
View File
@@ -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') {