diff --git a/dist/urbm-2026.06.15.r003-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r003-x86_64-1.txz.sha256 deleted file mode 100644 index 0cbc1a7..0000000 --- a/dist/urbm-2026.06.15.r003-x86_64-1.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -87517e9e5e8d7b255576173f55c9092551c161e84ea2a95c0301ca06c5c5b519 urbm-2026.06.15.r003-x86_64-1.txz diff --git a/dist/urbm-2026.06.15.r003-x86_64-1.txz b/dist/urbm-2026.06.15.r005-x86_64-1.txz similarity index 55% rename from dist/urbm-2026.06.15.r003-x86_64-1.txz rename to dist/urbm-2026.06.15.r005-x86_64-1.txz index 6862e51..632eefc 100644 Binary files a/dist/urbm-2026.06.15.r003-x86_64-1.txz and b/dist/urbm-2026.06.15.r005-x86_64-1.txz differ diff --git a/dist/urbm-2026.06.15.r005-x86_64-1.txz.sha256 b/dist/urbm-2026.06.15.r005-x86_64-1.txz.sha256 new file mode 100644 index 0000000..91cafa1 --- /dev/null +++ b/dist/urbm-2026.06.15.r005-x86_64-1.txz.sha256 @@ -0,0 +1 @@ +8c6fc66fbe80ca1dc06e5bf445a794b749eaca212376e016160e24310deb5e5f urbm-2026.06.15.r005-x86_64-1.txz diff --git a/dist/urbm.plg b/dist/urbm.plg index acacbb8..d734de6 100644 --- a/dist/urbm.plg +++ b/dist/urbm.plg @@ -2,13 +2,21 @@ - + - + ]> +### 2026.06.15.r005 +- Truncate long activity job names cleanly while keeping the complete name available as a tooltip. +- Automatically clear safe stale Restic locks and retry retention once after a successful backup. + +### 2026.06.15.r004 +- Show readable job and repository names in activities instead of full internal UUIDs. +- Replace verbose Restic lock output with concise German recovery guidance and prevent status badges from overlapping adjacent columns. + ### 2026.06.15.r003 - Keep activity tables inside their cards by wrapping long messages, paths, and job IDs and removing oversized empty action columns. - Add controlled horizontal scrolling for activity tables on narrow displays. diff --git a/internal/restic/restic.go b/internal/restic/restic.go index 14ee956..3b2c633 100644 --- a/internal/restic/restic.go +++ b/internal/restic/restic.go @@ -139,6 +139,13 @@ func (r *Runner) Forget(ctx context.Context, repo model.Repository, job model.Jo if job.Retention.Monthly > 0 { args = append(args, "--keep-monthly", fmt.Sprint(job.Retention.Monthly)) } + err := r.run(ctx, repo, args, nil, nil) + if err == nil || !isRepositoryLocked(err) { + return err + } + if unlockErr := r.Unlock(ctx, repo); unlockErr != nil { + return fmt.Errorf("retention repository locked; remove stale lock: %w", unlockErr) + } return r.run(ctx, repo, args, nil, nil) } @@ -328,6 +335,15 @@ func resticError(message string, commandErr error) error { } } +func isRepositoryLocked(err error) bool { + if err == nil { + return false + } + lower := strings.ToLower(err.Error()) + return strings.Contains(lower, "repository is already locked") || + strings.Contains(lower, "unable to create lock in backend") +} + func repositoryLocation(repo model.Repository) string { if repo.Type == model.RepositorySFTP && !strings.HasPrefix(repo.Location, "sftp:") { return "sftp:" + repo.Location diff --git a/internal/restic/restic_test.go b/internal/restic/restic_test.go index a439039..977ddb7 100644 --- a/internal/restic/restic_test.go +++ b/internal/restic/restic_test.go @@ -76,6 +76,45 @@ func TestForgetUsesAgeAndCalendarRetention(t *testing.T) { } } +func TestForgetUnlocksStaleRepositoryLockAndRetries(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "commands") + countPath := filepath.Join(dir, "forget-count") + script := filepath.Join(dir, "restic") + body := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> '%s' +case " $* " in + *" forget "*) + count=0 + [ -f '%s' ] && count=$(cat '%s') + count=$((count + 1)) + printf '%%s' "$count" > '%s' + if [ "$count" -eq 1 ]; then + printf 'Fatal: unable to create lock in backend: repository is already locked\n' >&2 + exit 11 + fi + ;; +esac +`, logPath, countPath, countPath, countPath) + 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", Retention: model.Retention{KeepWithinDays: 30}} + if err := runner.Forget(context.Background(), repo, job); err != nil { + t.Fatal(err) + } + commands, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(commands)), "\n") + if len(lines) != 3 || !strings.Contains(lines[0], "forget") || !strings.Contains(lines[1], "unlock") || !strings.Contains(lines[2], "forget") { + t.Fatalf("expected forget, unlock, forget; got %q", lines) + } +} + func TestUnlockUsesSafeResticCommand(t *testing.T) { dir := t.TempDir() argsPath := filepath.Join(dir, "args") diff --git a/plugin/urbm.plg b/plugin/urbm.plg index 6ff637c..5968458 100644 --- a/plugin/urbm.plg +++ b/plugin/urbm.plg @@ -2,13 +2,21 @@ - + ]> +### 2026.06.15.r005 +- Truncate long activity job names cleanly while keeping the complete name available as a tooltip. +- Automatically clear safe stale Restic locks and retry retention once after a successful backup. + +### 2026.06.15.r004 +- Show readable job and repository names in activities instead of full internal UUIDs. +- Replace verbose Restic lock output with concise German recovery guidance and prevent status badges from overlapping adjacent columns. + ### 2026.06.15.r003 - Keep activity tables inside their cards by wrapping long messages, paths, and job IDs and removing oversized empty action columns. - Add controlled horizontal scrolling for activity tables on narrow displays. diff --git a/webgui/URBM.page b/webgui/URBM.page index 3641df1..06fc7a4 100644 --- a/webgui/URBM.page +++ b/webgui/URBM.page @@ -9,12 +9,12 @@ Tag="URBM Unraid Restic Backup Manager backup snapshots restore" - +
- -

URBM 2026.06.15.r003

Restic Backup Manager für Unraid

+ +

URBM 2026.06.15.r005

Restic Backup Manager für Unraid

Verbindung wird hergestellt...
@@ -32,4 +32,4 @@ $pluginRoot = '/plugins/urbm';
- + diff --git a/webgui/assets/urbm.css b/webgui/assets/urbm.css index 2938e3b..bab7e95 100644 --- a/webgui/assets/urbm.css +++ b/webgui/assets/urbm.css @@ -143,15 +143,19 @@ .bu-table th { color: var(--bu-text); font-size: 13px; font-weight: 700; letter-spacing: .03em; opacity: .76; text-transform: uppercase; } .bu-table-scroll { max-width: 100%; overflow-x: auto; } .bu-runs-table { table-layout: fixed; } -.bu-runs-table .bu-run-task { width: 18%; } -.bu-runs-table .bu-run-status { width: 8%; } -.bu-runs-table .bu-run-created { width: 11%; } -.bu-runs-table .bu-run-progress { width: 27%; } +.bu-runs-table .bu-run-task { width: 17%; } +.bu-runs-table .bu-run-status { width: 9%; } +.bu-runs-table .bu-run-created { width: 12%; } +.bu-runs-table .bu-run-progress { width: 26%; } .bu-runs-table .bu-run-message { width: 30%; } .bu-runs-table .bu-run-actions { width: 6%; } .bu-runs-table th, .bu-runs-table td { min-width: 0; overflow-wrap: anywhere; word-break: break-word; } .bu-runs-table .bu-run-message-cell { line-height: 1.45; white-space: normal; } .bu-runs-table .bu-table-actions { min-width: 0; } +.bu-runs-table .bu-run-task-cell { overflow: hidden; } +.bu-runs-table .bu-run-task-cell strong, .bu-runs-table .bu-run-task-cell span { display: block; } +.bu-runs-table .bu-run-task-cell span { color: var(--bu-muted); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bu-runs-table .bu-status { white-space: nowrap; } .bu-status { border: 1px solid currentColor; border-radius: 999px; display: inline-block; font-size: 12px; font-weight: 700; padding: 3px 8px; text-transform: uppercase; } .bu-status.success { background: rgba(8,122,85,.10); color: var(--bu-good); } .bu-status.failed, .bu-status.cancelled { background: rgba(180,35,47,.10); color: var(--bu-bad); } diff --git a/webgui/assets/urbm.js b/webgui/assets/urbm.js index 23bcdfe..40eeb78 100644 --- a/webgui/assets/urbm.js +++ b/webgui/assets/urbm.js @@ -150,7 +150,17 @@ const typeLabels = {share:'Freigaben',appdata:'Appdata',docker:'Docker',vm:'Virtuelle Maschinen',flash:'USB-Flash',local:'Lokal',smb:'SMB',nfs:'NFS',sftp:'SFTP'}; const messageLabels = {'backup completed':'Backup abgeschlossen','restore completed':'Wiederherstellung abgeschlossen','check completed':'Prüfung abgeschlossen','prune completed':'Bereinigung abgeschlossen','daemon restarted while task was active':'Daemon wurde während eines aktiven Vorgangs neu gestartet'}; const status = (value) => `${esc(statusLabels[value]||value)}`; - const displayMessage = value => messageLabels[value] || translateUserMessage(value); + const displayMessage = value => { + const text=String(value||''); + if(text.includes('backup succeeded but retention failed')&&text.includes('repository is already locked')) return 'Backup erfolgreich. Die Aufbewahrung konnte wegen einer Repository-Sperre nicht ausgeführt werden. Unter Repositorys „Entsperren“ und danach „Bereinigen“ wählen.'; + if(text.includes('repository is already locked')) return 'Repository ist gesperrt. Falls kein anderer Restic-Vorgang läuft, unter Repositorys „Entsperren“ wählen.'; + return messageLabels[text] || translateUserMessage(text); + }; + const runTarget = run => { + if(run.taskType==='backup') return state.config.jobs.find(job=>job.id===run.jobId)?.name || 'Unbekannter Job'; + if(['check','prune'].includes(run.taskType)) return state.config.repositories.find(repo=>repo.id===run.jobId)?.name || 'Unbekanntes Repository'; + return run.jobId || ''; + }; const button = (label, action, kind = '') => ``; const disabledButton = (label, help) => ``; const actionGroup = (buttons) => `
${buttons}
`; @@ -513,7 +523,7 @@ function runsTable(runs) { if(!runs.length) return '
Keine Aktivitäten vorhanden.
'; const showLiveLog=state.config?.settings?.showLiveLog===true; - return `
${runs.map(r=>{const controls=r.status==='running'?button('Pause',`pause-run:${r.id}`)+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='paused'?button('Fortsetzen',`resume-run:${r.id}`,'primary')+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='queued'?button('Abbrechen',`cancel-run:${r.id}`,'danger'):'';return `${showLiveLog&&(r.liveLog||[]).length?``:''}`;}).join('')}
AufgabeStatusErstelltFortschrittMeldung
${esc(taskLabels[r.taskType]||r.taskType)} ${esc(r.jobId||'')}${status(r.status)}${esc(new Date(r.createdAt).toLocaleString())}${progressView(r)}${esc(displayMessage(r.message))}${controls?actionGroup(controls):''}
${liveLogView(r)}
`; + return `
${runs.map(r=>{const controls=r.status==='running'?button('Pause',`pause-run:${r.id}`)+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='paused'?button('Fortsetzen',`resume-run:${r.id}`,'primary')+button('Abbrechen',`cancel-run:${r.id}`,'danger'):r.status==='queued'?button('Abbrechen',`cancel-run:${r.id}`,'danger'):'';const target=runTarget(r);return `${showLiveLog&&(r.liveLog||[]).length?``:''}`;}).join('')}
AufgabeStatusErstelltFortschrittMeldung
${esc(taskLabels[r.taskType]||r.taskType)}${esc(target)}${status(r.status)}${esc(new Date(r.createdAt).toLocaleString())}${progressView(r)}${esc(displayMessage(r.message))}${controls?actionGroup(controls):''}
${liveLogView(r)}
`; } function renderSettings() {