Release URBM 2026.06.15.r009
This commit is contained in:
@@ -1 +0,0 @@
|
||||
aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5 urbm-2026.06.15.r008-x86_64-1.txz
|
||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
648afa0c0b1348302f882422c5a199e56ecdcdc0f817bc9a402c1a73549bbeea urbm-2026.06.15.r009-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.r008">
|
||||
<!ENTITY version "2026.06.15.r009">
|
||||
<!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 "aa07788cc61822162b8caccd91b5b83afabf6dd770e4800c180dc6ce2b94abe5">
|
||||
<!ENTITY packageSHA256 "648afa0c0b1348302f882422c5a199e56ecdcdc0f817bc9a402c1a73549bbeea">
|
||||
]>
|
||||
<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.r009
|
||||
- Add a per-backup-job CPU core limit using Restic's GOMAXPROCS setting.
|
||||
- Apply the configured limit to normal backups and optional USB raw-image backups without affecting restore or maintenance tasks.
|
||||
- Preserve existing behavior with 0 meaning all available CPU cores.
|
||||
|
||||
### 2026.06.15.r008
|
||||
- Replace backup-run history graphs with actual per-repository Restic statistics.
|
||||
- Show deduplicated stored repository data and the total files referenced by all snapshots.
|
||||
|
||||
@@ -52,6 +52,7 @@ type Job struct {
|
||||
Schedule Schedule `json:"schedule"`
|
||||
Consistency Consistency `json:"consistency"`
|
||||
Compression string `json:"compression"`
|
||||
CPUCores int `json:"cpuCores,omitempty"`
|
||||
Excludes []string `json:"excludes,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
FlashImage bool `json:"flashImage,omitempty"`
|
||||
|
||||
@@ -127,6 +127,9 @@ func ValidateJob(j Job) error {
|
||||
if j.Compression != "auto" && j.Compression != "off" && j.Compression != "max" {
|
||||
return errors.New("compression must be auto, off, or max")
|
||||
}
|
||||
if j.CPUCores < 0 || j.CPUCores > 256 {
|
||||
return errors.New("cpuCores must be between 0 and 256")
|
||||
}
|
||||
if j.Consistency.Mode != "live" && j.Consistency.Mode != "stop" {
|
||||
return errors.New("consistency mode must be live or stop")
|
||||
}
|
||||
|
||||
@@ -79,3 +79,15 @@ func TestRetentionRequiresAtLeastOneRule(t *testing.T) {
|
||||
t.Fatalf("age retention rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupCPUCoresRange(t *testing.T) {
|
||||
job := Job{SchemaVersion: 1, ID: "job", Name: "Share", Type: JobShare, RepositoryID: "repo", Sources: []Source{{Path: "/mnt/user/data"}}, Consistency: Consistency{Mode: "live"}, Compression: "auto", Retention: Retention{KeepWithinDays: 30}}
|
||||
job.CPUCores = 2
|
||||
if err := ValidateJob(job); err != nil {
|
||||
t.Fatalf("valid CPU limit rejected: %v", err)
|
||||
}
|
||||
job.CPUCores = 257
|
||||
if err := ValidateJob(job); err == nil {
|
||||
t.Fatal("excessive CPU limit accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo
|
||||
}
|
||||
args = append(args, sources...)
|
||||
var summary Summary
|
||||
err := r.run(ctx, repo, args, func(line []byte) {
|
||||
err := r.runWithEnv(ctx, repo, args, func(line []byte) {
|
||||
var status Progress
|
||||
if json.Unmarshal(line, &status) == nil && status.MessageType == "status" {
|
||||
if progress != nil {
|
||||
@@ -127,7 +127,7 @@ func (r *Runner) Backup(ctx context.Context, repo model.Repository, job model.Jo
|
||||
if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" {
|
||||
summary = candidate
|
||||
}
|
||||
}, nil)
|
||||
}, nil, backupEnvironment(job))
|
||||
return summary, err
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func (r *Runner) BackupImage(ctx context.Context, repo model.Repository, job mod
|
||||
args = append(args, "--tag", tag)
|
||||
}
|
||||
var summary Summary
|
||||
err := r.runWithInput(ctx, repo, args, func(line []byte) {
|
||||
err := r.runWithInputAndEnv(ctx, repo, args, func(line []byte) {
|
||||
var status Progress
|
||||
if json.Unmarshal(line, &status) == nil && status.MessageType == "status" {
|
||||
if progress != nil {
|
||||
@@ -149,7 +149,7 @@ func (r *Runner) BackupImage(ctx context.Context, repo model.Repository, job mod
|
||||
if json.Unmarshal(line, &candidate) == nil && candidate.MessageType == "summary" {
|
||||
summary = candidate
|
||||
}
|
||||
}, nil, image)
|
||||
}, nil, image, backupEnvironment(job))
|
||||
return summary, err
|
||||
}
|
||||
|
||||
@@ -245,10 +245,14 @@ 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)
|
||||
return r.runWithInputAndEnv(ctx, repo, args, onLine, capture, nil, nil)
|
||||
}
|
||||
|
||||
func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, input io.Reader) error {
|
||||
func (r *Runner) runWithEnv(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, environment []string) error {
|
||||
return r.runWithInputAndEnv(ctx, repo, args, onLine, capture, nil, environment)
|
||||
}
|
||||
|
||||
func (r *Runner) runWithInputAndEnv(ctx context.Context, repo model.Repository, args []string, onLine func([]byte), capture *[]byte, input io.Reader, environment []string) error {
|
||||
password, err := r.Secrets.Get(repo.PasswordRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("authentication: load repository password: %w", err)
|
||||
@@ -319,7 +323,7 @@ func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args [
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.Env = []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME=/root", "RESTIC_PASSWORD_FILE=" + passwordPath}
|
||||
cmd.Env = append([]string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME=/root", "RESTIC_PASSWORD_FILE=" + passwordPath}, environment...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -367,6 +371,13 @@ func (r *Runner) runWithInput(ctx context.Context, repo model.Repository, args [
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupEnvironment(job model.Job) []string {
|
||||
if job.CPUCores <= 0 {
|
||||
return nil
|
||||
}
|
||||
return []string{"GOMAXPROCS=" + fmt.Sprint(job.CPUCores)}
|
||||
}
|
||||
|
||||
func resticError(message string, commandErr error) error {
|
||||
lower := strings.ToLower(message)
|
||||
switch {
|
||||
|
||||
@@ -51,6 +51,29 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupLimitsCPUForThisJob(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, "gomaxprocs")
|
||||
script := filepath.Join(dir, "restic")
|
||||
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s' \"$GOMAXPROCS\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"cpu123\"}'\n", envPath)
|
||||
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: "job", Compression: "auto", CPUCores: 2}
|
||||
if _, err := runner.Backup(context.Background(), repo, job, []string{"/source"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(value) != "2" {
|
||||
t.Fatalf("GOMAXPROCS = %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupImageStreamsRawDeviceWithStableFilename(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
argsPath := filepath.Join(dir, "args")
|
||||
|
||||
+6
-1
@@ -2,13 +2,18 @@
|
||||
<!DOCTYPE PLUGIN [
|
||||
<!ENTITY name "urbm">
|
||||
<!ENTITY author "Michael Roll">
|
||||
<!ENTITY version "2026.06.15.r008">
|
||||
<!ENTITY version "2026.06.15.r009">
|
||||
<!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.r009
|
||||
- Add a per-backup-job CPU core limit using Restic's GOMAXPROCS setting.
|
||||
- Apply the configured limit to normal backups and optional USB raw-image backups without affecting restore or maintenance tasks.
|
||||
- Preserve existing behavior with 0 meaning all available CPU cores.
|
||||
|
||||
### 2026.06.15.r008
|
||||
- Replace backup-run history graphs with actual per-repository Restic statistics.
|
||||
- Show deduplicated stored repository data and the total files referenced by all snapshots.
|
||||
|
||||
+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=20260615r008">
|
||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/urbm.css?v=20260615r009">
|
||||
<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=20260615r008" alt="URBM-Logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.15.r008</span></h1><p>Restic Backup Manager für Unraid</p></div>
|
||||
<img class="bu-logo" src="<?= $pluginRoot ?>/images/urbm.png?v=20260615r009" alt="URBM-Logo">
|
||||
<div><h1>URBM <span class="bu-version">2026.06.15.r009</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.r008.js"></script>
|
||||
<script src="<?= $pluginRoot ?>/assets/urbm-2026.06.15.r009.js"></script>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
scheduleDays: 'Wähle mindestens einen Wochentag. Beispiel: Montag, Mittwoch und Sonntag jeweils zur eingestellten Uhrzeit.',
|
||||
consistency: 'Live sichert ohne Unterbrechung, kann aber nur crash-konsistent sein. Controlled stop beendet Container oder VM vor dem Backup und startet sie danach wieder.',
|
||||
compression: 'Restic-Kompression. auto ist empfohlen; off spart CPU; max spart eher Speicher, benötigt aber mehr CPU-Zeit.',
|
||||
cpuCores: 'Begrenzt diesen Backup-Lauf über GOMAXPROCS auf die angegebene Anzahl CPU-Kerne. 0 verwendet wie bisher alle verfügbaren Kerne.',
|
||||
sources: 'Optionale manuelle Quellen, eine pro Zeile. Ordner wählst du einfacher über die Checkbox-Liste. Docker- oder VM-ID mit @ voranstellen, z. B. @HomeAssistant.',
|
||||
excludes: 'Muster für Dateien, die nicht gesichert werden. Ein Muster pro Zeile. Beispiele: *.tmp, cache/** oder /mnt/user/appdata/plex/Cache/**.',
|
||||
shutdownSecs: 'Maximale Wartezeit für einen kontrollierten Stopp. Beispiel: 120 wartet bis zu zwei Minuten; danach wird das Backup ohne Force-Stop abgebrochen.',
|
||||
@@ -365,6 +366,7 @@
|
||||
<input type="hidden" name="id" value="${esc(job.id || '')}"><label>Name<input name="name" required value="${esc(job.name || '')}"></label>
|
||||
<label>Typ<select name="type">${['share','appdata','docker','vm','flash'].map(v=>`<option value="${v}" ${v===job.type?'selected':''}>${esc(typeLabels[v])}</option>`).join('')}</select></label>
|
||||
<label>Repository<select name="repositoryId" required>${options}</select></label><label>Kompression<select name="compression"><option value="auto" ${(job.compression||'auto')==='auto'?'selected':''}>Automatisch</option><option value="off" ${job.compression==='off'?'selected':''}>Aus</option><option value="max" ${job.compression==='max'?'selected':''}>Maximal</option></select></label>
|
||||
<label>CPU-Kerne für dieses Backup<input name="cpuCores" type="number" min="0" max="256" value="${Number(job.cpuCores||0)}"><span class="bu-muted">0 = alle verfügbaren Kerne</span></label>
|
||||
${scheduleFields(job)}
|
||||
<div id="bu-job-specific" class="wide"></div>
|
||||
<label class="wide">Ausschlussmuster, eines pro Zeile<textarea name="excludes">${esc((job.excludes||[]).join('\n'))}</textarea></label>
|
||||
@@ -610,7 +612,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||[],flashImage:type==='flash'&&f.get('flashImage')==='on',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'),cpuCores:Number(f.get('cpuCores')||0),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