Check rsync destination capacity before copying

This commit is contained in:
Mikei386
2026-06-15 17:20:07 +02:00
parent 6c795fd97d
commit e66d89598d
10 changed files with 110 additions and 9 deletions
-1
View File
@@ -1 +0,0 @@
c4a3955c26ca3e380ae25c93d9d3e5706c65f4457ec6d1418cc1ed72b485cb9b urbm-2026.06.15.r017-x86_64-1.txz
+1
View File
@@ -0,0 +1 @@
56f07ac0a18bf9067c8cb38ef6e5cbf69d52a2313e37f32f87b619268a47d6e8 urbm-2026.06.15.r018-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.r017"> <!ENTITY version "2026.06.15.r018">
<!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 "c4a3955c26ca3e380ae25c93d9d3e5706c65f4457ec6d1418cc1ed72b485cb9b"> <!ENTITY packageSHA256 "56f07ac0a18bf9067c8cb38ef6e5cbf69d52a2313e37f32f87b619268a47d6e8">
]> ]>
<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.r018
- Run a write-free Rsync preflight to estimate the actual transfer size before copying.
- Abort Rsync jobs when the estimated transfer exceeds the free destination capacity.
- Add an explicitly confirmed override for advanced replacement scenarios where existing destination files will release enough space.
### 2026.06.15.r017 ### 2026.06.15.r017
- Add scheduled Rsync copy jobs with separate source and destination folder browsers. - Add scheduled Rsync copy jobs with separate source and destination folder browsers.
- Add explicit controls for overwrite behavior, destination deletion, permissions, ownership, timestamps, links, ACLs, extended attributes, checksums, and dry runs. - Add explicit controls for overwrite behavior, destination deletion, permissions, ownership, timestamps, links, ACLs, extended attributes, checksums, and dry runs.
+1
View File
@@ -80,6 +80,7 @@ type RsyncOptions struct {
PreserveXattrs bool `json:"preserveXattrs"` PreserveXattrs bool `json:"preserveXattrs"`
Checksum bool `json:"checksum"` Checksum bool `json:"checksum"`
DryRun bool `json:"dryRun"` DryRun bool `json:"dryRun"`
SkipSpaceCheck bool `json:"skipSpaceCheck"`
} }
type Source struct { type Source struct {
+62
View File
@@ -108,6 +108,68 @@ func (r *Runner) Run(ctx context.Context, runID string, job model.Job, callback
return summary, nil return summary, nil
} }
// Estimate performs rsync's complete comparison without writing destination data.
func (r *Runner) Estimate(ctx context.Context, runID string, job model.Job) (Summary, error) {
job.Rsync.DryRun = true
args := Arguments(job)
for index, arg := range args {
if strings.HasPrefix(arg, "--out-format=") {
args[index] = "--out-format="
}
}
cmd := exec.CommandContext(ctx, r.Binary, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process == nil {
return os.ErrProcessDone
}
if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, syscall.ESRCH) {
return err
}
return nil
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return Summary{}, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return Summary{}, err
}
if err := cmd.Start(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return Summary{}, fmt.Errorf("environment: rsync binary not found: %w", err)
}
return Summary{}, err
}
unregister := r.register(runID, cmd.Process)
defer unregister()
var summary Summary
done := make(chan struct{})
go func() {
scanOutput(stdout, func(line string) {
if value, ok := parseCount(filesPattern, line); ok {
summary.Files = value
}
if value, ok := parseCount(bytesPattern, line); ok {
summary.Bytes = value
}
})
close(done)
}()
errBytes, _ := io.ReadAll(io.LimitReader(stderr, 1024*1024))
err = cmd.Wait()
<-done
if err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
return Summary{}, fmt.Errorf("cancelled: %w", ctx.Err())
}
return Summary{}, fmt.Errorf("rsync preflight: %s", strings.TrimSpace(string(errBytes)))
}
return summary, nil
}
func Arguments(job model.Job) []string { func Arguments(job model.Job) []string {
options := job.Rsync options := job.Rsync
args := []string{"--recursive", "--human-readable", "--info=progress2", "--stats", "--partial", "--out-format=FILE|%n"} args := []string{"--recursive", "--human-readable", "--info=progress2", "--stats", "--partial", "--out-format=FILE|%n"}
+26
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
"git.casaderoll.de/michael/urbm/internal/model" "git.casaderoll.de/michael/urbm/internal/model"
@@ -402,6 +403,23 @@ func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model
} }
return failed(run, "environment", "rsync target unavailable: "+err.Error()) return failed(run, "environment", "rsync target unavailable: "+err.Error())
} }
if !job.Rsync.DryRun && !job.Rsync.SkipSpaceCheck {
appendLiveLog(&live, "Rsync-Vorabprüfung ermittelt den tatsächlich zu übertragenden Datenumfang")
s.updateActive(live)
estimate, err := s.rsync.Estimate(ctx, run.ID, job)
if err != nil {
return failedError(run, err)
}
available, err := availableBytes(job.Rsync.Target)
if err != nil {
return failed(run, "environment", "read rsync target capacity: "+err.Error())
}
appendLiveLog(&live, fmt.Sprintf("Rsync-Vorabprüfung: %s zu übertragen, %s am Ziel frei", formatBytes(estimate.Bytes), formatBytes(available)))
s.updateActive(live)
if estimate.Bytes > available {
return failed(run, "environment", fmt.Sprintf("Rsync-Ziel hat nicht genügend freien Speicher: ungefähr %s für %d Dateien erforderlich, aber nur %s frei unter %s", formatBytes(estimate.Bytes), estimate.Files, formatBytes(available), job.Rsync.Target))
}
}
started := time.Now() started := time.Now()
lastLoggedPercent := -10 lastLoggedPercent := -10
summary, err := s.rsync.Run(ctx, run.ID, job, func(progress rsync.Progress) { summary, err := s.rsync.Run(ctx, run.ID, job, func(progress rsync.Progress) {
@@ -442,6 +460,14 @@ func (s *Service) executeRsync(ctx context.Context, run model.Run) (result model
return run return run
} }
func availableBytes(path string) (int64, error) {
var stats syscall.Statfs_t
if err := syscall.Statfs(path, &stats); err != nil {
return 0, err
}
return int64(stats.Bavail) * int64(stats.Bsize), nil
}
func (s *Service) executeBackup(ctx context.Context, run model.Run) (result model.Run) { func (s *Service) executeBackup(ctx context.Context, run model.Run) (result model.Run) {
job, ok := s.job(run.JobID) job, ok := s.job(run.JobID)
if !ok { if !ok {
+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.r017"> <!ENTITY version "2026.06.15.r018">
<!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.r018
- Run a write-free Rsync preflight to estimate the actual transfer size before copying.
- Abort Rsync jobs when the estimated transfer exceeds the free destination capacity.
- Add an explicitly confirmed override for advanced replacement scenarios where existing destination files will release enough space.
### 2026.06.15.r017 ### 2026.06.15.r017
- Add scheduled Rsync copy jobs with separate source and destination folder browsers. - Add scheduled Rsync copy jobs with separate source and destination folder browsers.
- Add explicit controls for overwrite behavior, destination deletion, permissions, ownership, timestamps, links, ACLs, extended attributes, checksums, and dry runs. - Add explicit controls for overwrite behavior, destination deletion, permissions, ownership, timestamps, links, ACLs, extended attributes, checksums, and dry runs.
+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=20260615r017"> <link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r018">
<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=20260615r017" alt="URBM-Logo"> <img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r018" alt="URBM-Logo">
<div><h1>URBM <span class="bu-version">2026.06.15.r017</span></h1><p>Restic Backup Manager für Unraid</p></div> <div><h1>URBM <span class="bu-version">2026.06.15.r018</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.r017.js"></script> <script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r018.js"></script>
+3 -1
View File
@@ -409,6 +409,7 @@
} else if (type === 'rsync') { } else if (type === 'rsync') {
const o=job.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-rsync-target-browser"></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. 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-rsync-target-browser"></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>`);
state.sourceBrowserPath='/'; state.sourceDirectories=sourceRoots; state.sourceBrowserError=''; renderSourceBrowser(); renderRsyncTargetBrowser(); state.sourceBrowserPath='/'; state.sourceDirectories=sourceRoots; state.sourceBrowserError=''; renderSourceBrowser(); renderRsyncTargetBrowser();
} else { } else {
state.backupSelections = ['/boot']; state.backupSelections = ['/boot'];
@@ -668,9 +669,10 @@
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(type!=='rsync'&&!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.'); if(type!=='rsync'&&!retention.keepWithinDays&&!retention.daily&&!retention.weekly&&!retention.monthly) throw new Error('Die Aufbewahrung benötigt mindestens eine Alters- oder Kalenderregel.');
const rsync=type==='rsync'?{target:String(f.get('rsyncTarget')||'').trim(),overwrite:f.get('rsyncOverwrite')==='on',delete:f.get('rsyncDelete')==='on',preservePermissions:f.get('rsyncPermissions')==='on',preserveOwner:f.get('rsyncOwner')==='on',preserveGroup:f.get('rsyncGroup')==='on',preserveTimes:f.get('rsyncTimes')==='on',preserveLinks:f.get('rsyncLinks')==='on',preserveAcls:f.get('rsyncACLs')==='on',preserveXattrs:f.get('rsyncXattrs')==='on',checksum:f.get('rsyncChecksum')==='on',dryRun:f.get('rsyncDryRun')==='on'}:{}; const rsync=type==='rsync'?{target:String(f.get('rsyncTarget')||'').trim(),overwrite:f.get('rsyncOverwrite')==='on',delete:f.get('rsyncDelete')==='on',preservePermissions:f.get('rsyncPermissions')==='on',preserveOwner:f.get('rsyncOwner')==='on',preserveGroup:f.get('rsyncGroup')==='on',preserveTimes:f.get('rsyncTimes')==='on',preserveLinks:f.get('rsyncLinks')==='on',preserveAcls:f.get('rsyncACLs')==='on',preserveXattrs:f.get('rsyncXattrs')==='on',checksum:f.get('rsyncChecksum')==='on',dryRun:f.get('rsyncDryRun')==='on',skipSpaceCheck:f.get('rsyncSkipSpaceCheck')==='on'}:{};
if(type==='rsync'&&!rsync.target) throw new Error('Wähle einen Zielordner für die Rsync-Kopie.'); if(type==='rsync'&&!rsync.target) throw new Error('Wähle einen Zielordner für die Rsync-Kopie.');
if(type==='rsync'&&rsync.delete&&!confirm('Rsync darf Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind. Diese Option kann Daten am Ziel entfernen. Wirklich speichern?')) return; if(type==='rsync'&&rsync.delete&&!confirm('Rsync darf Dateien am Ziel löschen, die in der Quelle nicht mehr vorhanden sind. Diese Option kann Daten am Ziel entfernen. Wirklich speichern?')) return;
if(type==='rsync'&&rsync.skipSpaceCheck&&!confirm('Die Kapazitätsprüfung wirklich abschalten? Der Rsync-Lauf kann dann das Ziel vollständig füllen und mit einem Schreibfehler abbrechen.')) return;
const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:type==='rsync'?'':f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:type==='rsync'?'':f.get('compression'),cpuCores:type==='rsync'?0:Number(f.get('cpuCores')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)}; const job={schemaVersion:1,id:f.get('id')||id('job'),name:f.get('name'),type,enabled:f.get('enabled')==='on',repositoryId:type==='rsync'?'':f.get('repositoryId'),sources,schedule:{cron:scheduleCron(f),timezone:Intl.DateTimeFormat().resolvedOptions().timeZone},consistency:{mode:f.get('consistency')||'live'},compression:type==='rsync'?'':f.get('compression'),cpuCores:type==='rsync'?0:Number(f.get('cpuCores')||0),excludes:lines(f.get('excludes')),tags:existing?.tags||[],flashImage:type==='flash'&&f.get('flashImage')==='on',retention:type==='rsync'?{}:retention,rsync,notifyOn:existing?.notifyOn||['success','warning','failed'],shutdownTimeoutSeconds:Number(f.get('shutdownSecs')||120)};
state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render(); state.config.jobs=state.config.jobs.filter(j=>j.id!==job.id).concat(job); await saveConfig(); render();
} }