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.
|
||||
|
||||
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.
|
||||
|
||||
## 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 [
|
||||
<!ENTITY name "urbm">
|
||||
<!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 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">
|
||||
<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
|
||||
- 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.
|
||||
|
||||
@@ -54,6 +54,7 @@ type Job struct {
|
||||
Compression string `json:"compression"`
|
||||
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"`
|
||||
|
||||
@@ -14,7 +14,12 @@ import (
|
||||
"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 {
|
||||
ID string `json:"id"`
|
||||
@@ -149,6 +154,54 @@ func (w *WorkloadManager) Prepare(ctx context.Context, job model.Job) (Prepared,
|
||||
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 {
|
||||
var first error
|
||||
for i := len(prepared.Stopped) - 1; i >= 0; i-- {
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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) {
|
||||
path := filepath.Join(t.TempDir(), "domain.cfg")
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
args := []string{"forget", "--tag", "job:" + job.ID}
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
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...)
|
||||
cmd := exec.CommandContext(ctx, r.Binary, fullArgs...)
|
||||
cmd.Stdin = input
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
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) {
|
||||
dir := t.TempDir()
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)))
|
||||
s.updateActive(live)
|
||||
lastLoggedPercent := -5
|
||||
@@ -364,8 +397,12 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
|
||||
} else {
|
||||
run.Status, run.Message = "success", "backup completed"
|
||||
}
|
||||
run.SnapshotID, run.BytesAdded, run.FilesNew, run.FilesChanged = summary.SnapshotID, summary.DataAdded, summary.FilesNew, summary.FilesChanged
|
||||
run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed
|
||||
run.SnapshotID = summary.SnapshotID
|
||||
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.ProgressFiles, run.ProgressFileTotal = live.ProgressFiles, live.ProgressFileTotal
|
||||
run.BytesPerSecond, run.SecondsRemaining, run.CurrentFile = live.BytesPerSecond, 0, live.CurrentFile
|
||||
|
||||
+6
-1
@@ -2,13 +2,18 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!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 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.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
|
||||
- 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.
|
||||
|
||||
+4
-4
@@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore"
|
||||
<?php
|
||||
$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">
|
||||
<header class="bu-header">
|
||||
<div class="bu-brand">
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r006" 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>
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r007" alt="URBM-Logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.15.r007</span></h1><p>Restic Backup Manager für Unraid</p></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>
|
||||
</header>
|
||||
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
|
||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||
</div>
|
||||
<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);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
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.');
|
||||
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();
|
||||
}
|
||||
if (kind === 'submit-repo') {
|
||||
|
||||
Reference in New Issue
Block a user